diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a445f873..f2ff8120 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ jobs: compile_plugin: strategy: matrix: - maya: [2022, 2023, 2024, 2025, 2026] + maya: [2022, 2023, 2024, 2025, 2026, 2027] os: [macos-13, macos-latest, ubuntu-latest, windows-latest] include: # Add the maya update versions here @@ -26,6 +26,8 @@ jobs: update: 3 - maya: 2026 update: 2 + - maya: 2027 + update: 2 # cross-compiling is annoying so just fall back to macos-13 exclude: @@ -33,12 +35,18 @@ jobs: maya: 2022 - os: macos-latest maya: 2023 + - os: macos-13 + maya: 2022 + - os: macos-13 + maya: 2023 - os: macos-13 maya: 2024 - os: macos-13 maya: 2025 - os: macos-13 maya: 2026 + - os: macos-13 + maya: 2027 fail-fast: false diff --git a/meson.build b/meson.build index 767f3972..f741af89 100644 --- a/meson.build +++ b/meson.build @@ -17,22 +17,34 @@ project( maya_build = get_option('maya_build') python_build = get_option('python_build') +imath_bridge_build = get_option('imath_bridge_build') -if not maya_build and not python_build +if not maya_build and not python_build and not imath_bridge_build error('No builds requested') endif conf_data = configuration_data() conf_data.set('VCS_TAG', meson.project_version()) -subdir('src/simplexlib') -if maya_build - maya_dep = dependency('maya') - maya_name_suffix = maya_dep.get_variable('name_suffix') - maya_version = maya_dep.get_variable('maya_version') - subdir('src/maya') + +if maya_build or imath_bridge_build + subproject('maya') +endif + +if imath_bridge_build + subdir('src/imathbridge') endif -if python_build - subdir('src/python') +if maya_build or python_build + subdir('src/simplexlib') + if maya_build + subdir('src/maya') + endif + + if python_build + subdir('src/python') + endif endif + + + diff --git a/meson.options b/meson.options index 36793e6c..0a4986f8 100644 --- a/meson.options +++ b/meson.options @@ -1,4 +1,5 @@ option('maya_build', type : 'boolean', value : true) option('python_build', type : 'boolean', value : true) +option('imath_bridge_build', type : 'boolean', value : false) option('python_wheel_build', type : 'boolean', value : false) option('python_script_build', type : 'boolean', value : false) diff --git a/quick_compile.bat b/quick_compile.bat index 9bade032..0ef31eb9 100644 --- a/quick_compile.bat +++ b/quick_compile.bat @@ -1,6 +1,6 @@ setlocal -SET MAYA_VERSION=2024 +SET MAYA_VERSION=2027 REM "vs" "ninja" REM use VS for the debugger, otherwise use NINJA REM Until I figure out how to debug using nvim @@ -13,8 +13,8 @@ if not exist %BUILDDIR%\ ( meson setup %BUILDDIR% ^ -Dmaya:maya_version=%MAYA_VERSION% ^ -Dmaya_build=true ^ - -Dpython_build=false ^ - -Dpython_script_build=false ^ + -Dpython_build=true ^ + -Dpython_script_build=true ^ --buildtype %BUILDTYPE% --vsenv --backend %BACKEND% ) diff --git a/src/maya/meson.build b/src/maya/meson.build index 36fac10b..46d45606 100644 --- a/src/maya/meson.build +++ b/src/maya/meson.build @@ -1,6 +1,7 @@ -maya_dep = dependency('maya') -maya_name_suffix = maya_dep.get_variable('name_suffix') -maya_version = maya_dep.get_variable('maya_version') +maya_plugin_dep = dependency('maya-plugin') +maya_core_dep = dependency('maya-core') +maya_name_suffix = maya_plugin_dep.get_variable('name_suffix') +maya_version = maya_core_dep.get_variable('maya_version') simplex_maya_files = files([ 'src/basicBlendShape.cpp', @@ -29,7 +30,7 @@ simplex_maya = shared_library( install: true, install_dir : meson.global_source_root() / 'output_Maya' + maya_version, include_directories : simplex_maya_inc, - dependencies : [maya_dep, simplexlib_dep], + dependencies : [maya_core_dep, maya_plugin_dep, simplexlib_dep], name_prefix : '', name_suffix : maya_name_suffix, ) diff --git a/src/python/pysimplex.cpp b/src/python/pysimplex.cpp index 3b5cbd7c..31b81776 100644 --- a/src/python/pysimplex.cpp +++ b/src/python/pysimplex.cpp @@ -1,30 +1,29 @@ #include #include -#include "simplex.h" -#include #include -#include #include +#include +#include + +#include "simplex.h" typedef struct { - PyObject_HEAD // No Semicolon for this Macro; - PyObject *definition; - simplex::Simplex *sPointer; + PyObject_HEAD // No Semicolon for this Macro; + PyObject* definition; + simplex::Simplex* sPointer; } PySimplex; -static void -PySimplex_dealloc(PySimplex* self) { +static void PySimplex_dealloc(PySimplex* self) { Py_XDECREF(self->definition); - if (self->sPointer != NULL) - delete self->sPointer; + if (self->sPointer != NULL) { + delete self->sPointer; + } PyObject_Del(self); } -static PyObject * -PySimplex_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { - - PySimplex *self = PyObject_New(PySimplex, type); +static PyObject* PySimplex_new(PyTypeObject* type, PyObject* args, PyObject* kwds) { + PySimplex* self = PyObject_New(PySimplex, type); if (self != NULL) { self->definition = PyUnicode_FromString(""); if (self->definition == NULL) { @@ -34,27 +33,25 @@ PySimplex_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { self->sPointer = new simplex::Simplex(); } - return (PyObject *)self; + return (PyObject*)self; } -static PyObject * -PySimplex_getdefinition(PySimplex* self, void* closure){ +static PyObject* PySimplex_getdefinition(PySimplex* self, void* closure) { Py_INCREF(self->definition); return self->definition; } -static int -PySimplex_setdefinition(PySimplex* self, PyObject* jsValue, void* closure){ - if (jsValue == NULL || jsValue == Py_None){ +static int PySimplex_setdefinition(PySimplex* self, PyObject* jsValue, void* closure) { + if (jsValue == NULL || jsValue == Py_None) { jsValue = PyUnicode_FromString(""); } - if (! PyUnicode_Check(jsValue)) { + if (!PyUnicode_Check(jsValue)) { PyErr_SetString(PyExc_TypeError, "The simplex definition must be a string"); return -1; } - PyObject *tmp = self->definition; + PyObject* tmp = self->definition; Py_INCREF(jsValue); self->definition = jsValue; Py_DECREF(tmp); @@ -72,18 +69,16 @@ PySimplex_setdefinition(PySimplex* self, PyObject* jsValue, void* closure){ return 0; } -static PyObject * -PySimplex_getexactsolve(PySimplex* self, void* closure){ - if (self->sPointer->getExactSolve()){ +static PyObject* PySimplex_getexactsolve(PySimplex* self, void* closure) { + if (self->sPointer->getExactSolve()) { Py_RETURN_TRUE; } Py_RETURN_FALSE; } -static int -PySimplex_setexactsolve(PySimplex* self, PyObject* exact, void* closure){ +static int PySimplex_setexactsolve(PySimplex* self, PyObject* exact, void* closure) { int truthy = PyObject_IsTrue(exact); - if (truthy == -1){ + if (truthy == -1) { PyErr_SetString(PyExc_TypeError, "The value passed cannot be cast to boolean"); return -1; } @@ -92,31 +87,30 @@ PySimplex_setexactsolve(PySimplex* self, PyObject* exact, void* closure){ return 0; } -static int -PySimplex_init(PySimplex *self, PyObject *args, PyObject *kwds) { - PyObject *jsValue=NULL, *tmp=NULL; +static int PySimplex_init(PySimplex* self, PyObject* args, PyObject* kwds) { + PyObject *jsValue = NULL, *tmp = NULL; char jsValueLiteral[] = "jsValue"; - static char *kwlist[] = {jsValueLiteral, NULL}; + static char* kwlist[] = {jsValueLiteral, NULL}; - if (! PyArg_ParseTupleAndKeywords(args, kwds, "|O", kwlist, &jsValue)) + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|O", kwlist, &jsValue)) { return -1; + } return PySimplex_setdefinition(self, jsValue, NULL); } -static PyObject * -PySimplex_solve(PySimplex* self, PyObject* vec){ - if (! PySequence_Check(vec)){ +static PyObject* PySimplex_solve(PySimplex* self, PyObject* vec) { + if (!PySequence_Check(vec)) { PyErr_SetString(PyExc_TypeError, "Input must be a list or tuple"); return NULL; } - PyObject *item; + PyObject* item; std::vector stdVec, outVec; - for (Py_ssize_t i=0; isPointer->clearValues(); + self->sPointer->clearValues(); outVec = self->sPointer->solve(stdVec); - PyObject *out = PyList_New(outVec.size()); - for (size_t i=0; i. +from __future__ import annotations + from ._version import __version__ # noqa: F401 SIMPLEX_UI = None SIMPLEX_UI_ROOT = None -def runSimplexUI(): +def runSimplexUI() -> None: from .interface import DISPATCH, rootWindow from .simplexDialog import SimplexDialog @@ -35,7 +37,7 @@ def runSimplexUI(): SIMPLEX_UI.show() -def tool_paths(): +def tool_paths() -> tuple[list[str], list[str]]: import os path = os.path.dirname(__file__) diff --git a/src/python/simplexui/channelBox.py b/src/python/simplexui/channelBox.py index 5633c9ea..8912da38 100644 --- a/src/python/simplexui/channelBox.py +++ b/src/python/simplexui/channelBox.py @@ -1,858 +1,867 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -"""The ChannelBox -A Super-minimal ui for interacting with a Simplex System -Currently VERY WIP. Probably shouldn't have committed it to master, but whatever -""" - -# pylint:disable=unused-import,relative-import,missing-docstring,unused-argument,no-self-use -import os -import sys - -from .interfaceModel import Group, Simplex, SimplexModel, Slider -from Qt.QtCore import ( - QAbstractItemModel, - QEvent, - QModelIndex, - QObject, - QRectF, - Qt, - QTimer, - Signal, -) -from Qt.QtGui import QBrush, QColor, QCursor, QPainter, QPainterPath, QPen, QTextOption -from Qt.QtWidgets import QApplication, QListView, QStyledItemDelegate, QTreeView - - -class SlideFilter(QObject): - """A simplified drag filter, specialized for this purpose""" - - SLIDE_ENABLED = 0 - - slideTick = Signal(float, float, float) # AbsValue, OffsetValue, Multiplier - slidePressed = Signal() - slideReleased = Signal() - - def __init__(self, parent): - super(SlideFilter, self).__init__(parent) - - self.slideCursor = Qt.CursorShape.SizeHorCursor - self.slideButton = Qt.MouseButton.LeftButton - - self.fastModifier = Qt.KeyboardModifier.ControlModifier - self.slowModifier = Qt.KeyboardModifier.ShiftModifier - - self.fastMultiplier = 5.0 - self.slowDivisor = 5.0 - - # private vars - self._slideStart = True - self._overridden = False - self._pressed = True - self._prevValue = None - - def doOverrideCursor(self): - """Override the cursor""" - if self._overridden: - return - QApplication.setOverrideCursor(self.slideCursor) - self._overridden = True - - def restoreOverrideCursor(self): - """Restore the overridden cursor""" - if not self._overridden: - return - QApplication.restoreOverrideCursor() - self._overridden = False - - def eventFilter(self, obj, event): - """Event filter override - - Parameters - ---------- - obj : QObject - The object to get events for - event : QEvent - The event being filtered - - Returns - ------- - - """ - if hasattr(self, "SLIDE_ENABLED"): - if event.type() == QEvent.Type.MouseButtonPress: - if event.button() & self.slideButton: - self.startSlide(obj, event) - self.doSlide(obj, event) - self._slideStart = True - - elif event.type() == QEvent.Type.MouseMove: - if self._slideStart: - try: - self.doSlide(obj, event) - except Exception: - # fix the cursor if there's an error during slideging - self.restoreOverrideCursor() - raise # re-raise the exception - return True - - elif event.type() == QEvent.Type.MouseButtonRelease: - if event.button() & self.slideButton: - self._pressed = False - self._slideStart = False - self.myendSlide(obj, event) - return True - - return super(SlideFilter, self).eventFilter(obj, event) - - def startSlide(self, obj, event): - """Start the slide operation - - Parameters - ---------- - obj : QObject - The object to get events for - event : QEvent - The event being filtered - - Returns - ------- - - """ - self.slidePressed.emit() - self.doOverrideCursor() - - def doSlide(self, obj, event): - """Do a slide tick - - Parameters - ---------- - obj : QObject - The object to get events for - event : QEvent - The event being filtered - - Returns - ------- - - """ - width = obj.width() - click = event.pos() - perc = click.x() / float(width) - - mul = 1.0 - if event.modifiers() & self.fastModifier: - mul = self.fastMultiplier - elif event.modifiers() & self.slowModifier: - mul = 1.0 / self.slowDivisor - - if self._prevValue is None: - offset = 0.0 - else: - offset = perc - self._prevValue - self._prevValue = perc - - self.slideTick.emit(perc, offset, mul) - - def myendSlide(self, obj, event): - """End the slide operation - - Parameters - ---------- - obj : QObject - The object to get events for - event : QEvent - The event being filtered - - Returns - ------- - - """ - self.restoreOverrideCursor() - self._slideStart = None - self.slideReleased.emit() - - -class ChannelBoxDelegate(QStyledItemDelegate): - """Delegate to draw the slider items""" - - def __init__(self, parent=None): - super(ChannelBoxDelegate, self).__init__(parent) - self.store = {} - - def paint(self, painter, opt, index): - """Overridden paint function""" - item = index.model().itemFromIndex(index) - if isinstance(item, Slider): - self.paintSlider(self, item, painter, opt.rect, opt.palette) - else: - super(ChannelBoxDelegate, self).paint(painter, opt, index) - - def roundedPath(self, width, height, left=True, right=True): - """Get a path with rounded corners for drawing - - Parameters - ---------- - width : float - The width of the rectangle - height : float - The height of the rectangle - left : bool - Round the left side of the rectangle (Default value = True) - right : bool - Round the right side of the rectangle (Default value = True) - - Returns - ------- - QPainterPath - The requested path - - """ - key = (round(width, 2), round(height, 2), round(left, 2), round(right, 2)) - if key in self.store: - return self.store[key] - - # off = 0.5 - off = 1.0 - ew = height - off # ellipse width - eh = height - 2 * off # ellipse height - ts = 0.0 + off # topside - bs = height - off # bottomside - ls = 0.0 + off # left side - rs = width - off # righSide - lc = height + off # left corner - rc = width - height - off # left corner - - # If we're too narrow then flatten the points - if left and right: - if width < 2 * ew: - lc = width * 0.5 - rc = lc - ew = lc - else: - if left: - if width < ew: - lc = rs - ew = width - elif right: - if width < ew: - rc = ls - ew = width - - bgPath = QPainterPath() - if left: - bgPath.moveTo(lc, ts) - else: - bgPath.moveTo(ls, ts) - - if right: - bgPath.lineTo(rc, ts) - bgPath.arcTo(rc, ts, ew, eh, 90, -180) - else: - bgPath.lineTo(rs, ts) - bgPath.lineTo(rs, bs) - - if left: - bgPath.lineTo(lc, bs) - bgPath.arcTo(ls, ts, ew, eh, -90, -180) - else: - bgPath.lineTo(ls, bs) - bgPath.lineTo(ls, ts) - - bgPath.closeSubpath() - self.store[key] = bgPath - return bgPath - - def paintSlider(self, delegate, slider, painter, rect, palette): - """Paint a slider - - Parameters - ---------- - delegate : QStyledItemDelegate - The paint delegate - slider : Slider - The slider to paint - painter : QPainter - The painter to paint with - rect : QRectF - The rectangle to fill - palette : QPalette - The palette to use - - Returns - ------- - - """ - painter.save() - try: - painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) - - fgColor = slider.color - bgColor = QColor(slider.color) - bgColor.setAlpha(128) - - fgBrush = QBrush(fgColor) - bgBrush = QBrush(bgColor) - painter.setPen(QPen(palette.foreground().color())) - - rx = rect.x() - ry = rect.y() - rw = rect.width() - rh = rect.height() - - bgLeft = slider.minValue != 0.0 - bgPath = self.roundedPath(rw, rh, left=bgLeft) - bgPath = bgPath.translated(rx, ry) - painter.fillPath(bgPath, bgBrush) - if bgLeft: - # Double sided slider - perc = slider.value - right = perc >= 0.0 - fgPath = self.roundedPath( - abs(perc) * rw * 0.5, rh, left=not right, right=right - ) - if right: - fgPath = fgPath.translated(rx + rw * 0.5, ry) - else: - fgPath = fgPath.translated(rx + rw * 0.5 * (1 + perc), ry) - painter.fillPath(fgPath, fgBrush) - - else: - # Positive only slider - perc = slider.value - perc = max(min(perc, 1.0), 0.0) # clamp between 0 and 1 - fgPath = self.roundedPath(rw * perc, rh, left=False) - fgPath = fgPath.translated(rx, ry) - painter.fillPath(fgPath, fgBrush) - - opts = QTextOption(Qt.AlignmentFlag.AlignCenter) - frect = QRectF(rx, ry, rw, rh) - painter.drawText(frect, slider.name, opts) - # painter.drawPath(bgPath) - finally: - painter.restore() - - -class ChannelListModel(QAbstractItemModel): - """A model to handle a list of sliders - Many functions will be un-documented. They're just overrides - for the QAbstractItemModel. Look at the Qt docs if you really - want to know - - Parameters - ---------- - simplex : Simplex - The simplex system - parent : QObject - The parent of this model - """ - - def __init__(self, simplex, parent): - super(ChannelListModel, self).__init__(parent) - self.simplex = simplex - self.simplex.models.append(self) - self.channels = [] - - def setChannels(self, channels): - """Set the channels to display in this model - - Parameters - ---------- - channels : [object, ...] - A list of tree objects to show in Channel Box - - Returns - ------- - - """ - self.beginResetModel() - self.channels = channels - self.endResetModel() - - def index(self, row, column=0, parIndex=None): - if parIndex is None: - parIndex = QModelIndex() - - try: - item = self.channels[row] - except IndexError: - return QModelIndex() - return self.createIndex(row, column, item) - - def parent(self, index): - return QModelIndex() - - def rowCount(self, parent): - return len(self.channels) - - def columnCount(self, parent): - return 1 - - def data(self, index, role): - if not index.isValid(): - return None - item = index.internalPointer() - - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - if isinstance(item, (Group, Slider)): - return item.name - - elif role == Qt.ItemDataRole.TextAlignmentRole: - return Qt.AlignmentFlag.AlignCenter - - return None - - def flags(self, index): - return ( - Qt.ItemFlag.ItemIsEnabled - | Qt.ItemFlag.ItemIsEditable - | Qt.ItemFlag.ItemIsSelectable - ) - - def itemFromIndex(self, index): - return index.internalPointer() - - def indexFromItem(self, item): - try: - row = self.channels.index(item) - except ValueError: - return QModelIndex() - return self.index(row) - - def typeHandled(self, item): - if isinstance(item, Group): - return item.groupType == Slider - return isinstance(item, Slider) - - def itemDataChanged(self, item): - if self.typeHandled(item): - idx = self.indexFromItem(item) - if idx.isValid(): - self.dataChanged.emit(idx, idx) - - -class ChannelList(QListView): - """A list to display the chosen channels""" - - def __init__(self, parent=None): - super(ChannelList, self).__init__(parent) - self.slider = None - self._nxt = 0.0 - self.start = False - self.residual = 0.0 - - def slideStart(self): - """Handle user sliding values""" - p = self.mapFromGlobal(QCursor.pos()) - item = self.indexAt(p).internalPointer() - if isinstance(item, Slider): - self.slider = item - self.start = True - - def slideStop(self): - """End the user sliding values""" - self.slider = None - - def slideTick(self, val, offset, mul): - """Handle the ticks from the slider Filter""" - if self.slider is not None: - mx = self.slider.maxValue - mn = self.slider.minValue - - tick = 20.0 / mul - - if self.start or mul == 1.0: - self.start = False - val = (val * (mx - mn)) + mn - rn = round(val * tick) / tick - else: - # When working in relative mode, we keep track of the - # unused residual value and add it to the next tick. - # Because, unless each mouse move refresh is more than - # one full tick from the previous, we get no movement - val = offset * (mx - mn) - val = self.slider.value + (val * mul) - val += self.residual - rn = round(val * tick) / tick - self.residual = val - rn - - rn = min(max(rn, mn), mx) - # Do this to keep the ui snappy - self._nxt = rn - QTimer.singleShot(0, self.setval) - - def setval(self): - """Set the value of a slider""" - if self.slider is not None and self._nxt is not None: - self.slider.value = self._nxt - self._nxt = None - - -class ChannelTreeModel(SimplexModel): - """A model to handle a tree of sliders from a simplex system - Many functions will be un-documented. They're just overrides - for the QAbstractItemModel or the SimplexModel. - """ - - def getChildItem(self, parent, row): - try: - if isinstance(parent, Group): - return parent.items[row] - elif parent is None: - return self.simplex.sliderGroups[row] - except IndexError: - pass - return None - - def getItemRow(self, item): - row = None - if isinstance(item, Group): - row = item.simplex.sliderGroups.index(item) - elif isinstance(item, Slider): - row = item.group.items.index(item) - return row - - def getParentItem(self, item): - par = None - if isinstance(item, Slider): - par = item.group - return par - - def columnCount(self, parent): - return 1 - - def getItemRowCount(self, item): - if isinstance(item, Group): - return len(item.items) - elif item is None: - return len(self.simplex.sliderGroups) - return 0 - - def getItemAppendRow(self, item): - return self.getItemRowCount(item) - - def getItemData(self, item, column, role): - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - if column == 0: - if isinstance(item, Group): - return item.name - elif isinstance(item, Slider): - return item.name - # return "{0}: {1:.2f}".format(item.name, item.value) - print("BAD ICR", item, column, role) - return "BAD" - return None - - def typeHandled(self, item): - if isinstance(item, Group): - return item.groupType == Slider - return isinstance(item, Slider) - - -class ChannelTree(QTreeView): - """Display the channels in a Tree form""" - - def __init__(self, parent=None): - super(ChannelTree, self).__init__(parent) - self.slider = None - self._nxt = 0.0 - self.start = False - self.residual = 0.0 - - def slideStart(self): - """Handle starting a slide drag operation""" - p = self.mapFromGlobal(QCursor.pos()) - item = self.indexAt(p).internalPointer() - if isinstance(item, Slider): - self.slider = item - self.start = True - - def slideStop(self): - """Handle ending a slide drag operation""" - self.slider = None - - def slideTick(self, val, offset, mul): - """Handle the ticks from the SliderFilter""" - if self.slider is not None: - mx = self.slider.maxValue - mn = self.slider.minValue - - tick = 20.0 / mul - - if self.start or mul == 1.0: - self.start = False - val = (val * (mx - mn)) + mn - rn = round(val * tick) / tick - else: - # When working in relative mode, we keep track of the - # unused residual value and add it to the next tick. - # Because, unless each mouse move refresh is more than - # one full tick from the previous, we get no movement - val = offset * (mx - mn) - val = self.slider.value + (val * mul) - val += self.residual - rn = round(val * tick) / tick - self.residual = val - rn - - rn = min(max(rn, mn), mx) - # Do this to keep the ui snappy - self._nxt = rn - QTimer.singleShot(0, self.setval) - - def setval(self): - """Set the value of a slider""" - if self.slider is not None and self._nxt is not None: - self.slider.value = self._nxt - self._nxt = None - - -# DISPLAY TESTS -def testSliderListDisplay(smpxPath): - """ - - Parameters - ---------- - smpxPath : - - - Returns - ------- - - """ - simp = Simplex.buildSystemFromSmpx(smpxPath) - channels = [] - - redAttrs = { - "lowerLipDepressor_X", - "stretcher_X", - "platysmaFlex_X", - "cheekRaiser_X", - "jawOpen", - "lidTightener_X", - "outerBrowRaiser_X", - "eyesClosed_X", - "cornerPuller_X", - "noseWrinkler_X", - "lipsBlow_X", - "cornerDepressor_X", - "funneler", - "browLateral_X", - "innerBrowRaiser_X", - "upperLipRaiser_X", - "chinRaiser", - "cheek_SuckBlow_X", - "pucker", - "eyeGaze_DownUp_X", - "eyeGaze_RightLeft_X", - "upperLidTweak_X", - "lowerLidTweak_X", - } - greenAttrs = { - "nasolabialDeepener_X", - "neckStretcher_X", - "lipsPressed_T", - "lipsPressed_B", - "throatCompress", - "lipsRolled_InOut_B", - "lipsRolled_InOut_T", - "sharpCornerPuller_X", - "dimpler_X", - "eyeBlink_X", - "scalpSlide_BackFwd", - "browDown_X", - "mouthSwing_RightLeft", - "sternoFlex_X", - "throatOpen", - } - blueAttrs = { - "adamsApple", - "noseSwing_RightLeft", - "nostrilCompress_X", - "jawThrust_BackFwd", - "eyesWide_X", - "lipsVerticalT_X", - "lipsVerticalB_X", - "earPull_X", - "lipsTighten_T", - "lipsTighten_B", - "lipsCompress_T", - "lipsCompress_B", - "lipsShift_RightLeft_B", - "lipsShift_RightLeft_T", - "lipsNarrowT_X", - "lipsNarrowB_X", - "jawSwing_RightLeft", - "nostril_SuckFlare_X", - "lipsCorner_DownUp_X", - "jawClench", - } - greyAttrs = {"lipsTogether"} - - app = QApplication(sys.argv) - tv = ChannelList() - model = ChannelListModel(simp, None) - delegate = ChannelBoxDelegate() - - slideFilter = SlideFilter(tv.viewport()) - slideFilter.slideButton = Qt.MouseButton.LeftButton - tv.viewport().installEventFilter(slideFilter) - slideFilter.slidePressed.connect(tv.slideStart) - slideFilter.slideReleased.connect(tv.slideStop) - slideFilter.slideTick.connect(tv.slideTick) - - for g in simp.sliderGroups: - channels.append(g) - for item in g.items: - if item.name in redAttrs: - item.color = QColor(178, 103, 103) - elif item.name in greenAttrs: - item.color = QColor(90, 161, 27) - elif item.name in blueAttrs: - item.color = QColor(103, 141, 178) - elif item.name in greyAttrs: - item.color = QColor(130, 130, 130) - channels.append(item) - - model.setChannels(channels) - - tv.setModel(model) - tv.setItemDelegate(delegate) - - tv.show() - sys.exit(app.exec_()) - - -def testSliderTreeDisplay(smpxPath): - """ - - Parameters - ---------- - smpxPath : - - - Returns - ------- - - """ - simp = Simplex.buildSystemFromSmpx(smpxPath) - - _redAttrs = { - "lowerLipDepressor_X", - "stretcher_X", - "platysmaFlex_X", - "cheekRaiser_X", - "jawOpen", - "lidTightener_X", - "outerBrowRaiser_X", - "eyesClosed_X", - "cornerPuller_X", - "noseWrinkler_X", - "lipsBlow_X", - "cornerDepressor_X", - "funneler", - "browLateral_X", - "innerBrowRaiser_X", - "upperLipRaiser_X", - "chinRaiser", - "cheek_SuckBlow_X", - "pucker", - "eyeGaze_DownUp_X", - "eyeGaze_RightLeft_X", - "upperLidTweak_X", - "lowerLidTweak_X", - } - - _greenAttrs = { - "nasolabialDeepener_X", - "neckStretcher_X", - "lipsPressed_T", - "lipsPressed_B", - "throatCompress", - "lipsRolled_InOut_B", - "lipsRolled_InOut_T", - "sharpCornerPuller_X", - "dimpler_X", - "eyeBlink_X", - "scalpSlide_BackFwd", - "browDown_X", - "mouthSwing_RightLeft", - "sternoFlex_X", - "throatOpen", - } - - _blueAttrs = { - "adamsApple", - "noseSwing_RightLeft", - "nostrilCompress_X", - "jawThrust_BackFwd", - "eyesWide_X", - "lipsVerticalT_X", - "lipsVerticalB_X", - "earPull_X", - "lipsTighten_T", - "lipsTighten_B", - "lipsCompress_T", - "lipsCompress_B", - "lipsShift_RightLeft_B", - "lipsShift_RightLeft_T", - "lipsNarrowT_X", - "lipsNarrowB_X", - "jawSwing_RightLeft", - "nostril_SuckFlare_X", - "lipsCorner_DownUp_X", - "jawClench", - } - - _greyAttrs = {"lipsTogether"} - - app = QApplication(sys.argv) - # tv = ChannelTree() - tv = QTreeView() - model = ChannelTreeModel(simp, None) - # delegate = ChannelBoxDelegate() - - # slideFilter = SlideFilter(tv.viewport()) - # slideFilter.slideButton = Qt.MouseButton.LeftButton - # tv.viewport().installEventFilter(slideFilter) - # slideFilter.slidePressed.connect(tv.slideStart) - # slideFilter.slideReleased.connect(tv.slideStop) - # slideFilter.slideTick.connect(tv.slideTick) - - # for g in simp.sliderGroups: - # for item in g.items: - # if item.name in redAttrs: - # item.color = QColor(178, 103, 103) - # elif item.name in greenAttrs: - # item.color = QColor(90, 161, 27) - # elif item.name in blueAttrs: - # item.color = QColor(103, 141, 178) - # elif item.name in greyAttrs: - # item.color = QColor(130, 130, 130) - - tv.setModel(model) - # tv.setItemDelegate(delegate) - - tv.show() - sys.exit(app.exec_()) - - -if __name__ == "__main__": - basePath = r"D:\Users\tyler\Documents\GitHub\Simplex\Useful" - smpxPath = os.path.join(basePath, "HeadMaleStandard_High_Unsplit.smpx") - - testSliderTreeDisplay(smpxPath) +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +"""The ChannelBox +A Super-minimal ui for interacting with a Simplex System +Currently VERY WIP. Probably shouldn't have committed it to master, but whatever +""" + +from __future__ import annotations + +import os +import sys + +from Qt.QtCore import ( + QAbstractItemModel, + QEvent, + QModelIndex, + QObject, + QRectF, + Qt, + QTimer, + Signal, +) +from Qt.QtGui import QBrush, QColor, QCursor, QPainter, QPainterPath, QPen, QTextOption +from Qt.QtWidgets import ( + QApplication, + QListView, + QStyledItemDelegate, + QTreeView, + QWidget, +) + +from .interfaceModel import Group, SimplexModel, Slider +from .items.simplex import Simplex + + +class SlideFilter(QObject): + """A simplified drag filter, specialized for this purpose""" + + SLIDE_ENABLED = 0 + + slideTick = Signal(float, float, float) # AbsValue, OffsetValue, Multiplier + slidePressed = Signal() + slideReleased = Signal() + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + + self.slideCursor = Qt.CursorShape.SizeHorCursor + self.slideButton = Qt.MouseButton.LeftButton + + self.fastModifier = Qt.KeyboardModifier.ControlModifier + self.slowModifier = Qt.KeyboardModifier.ShiftModifier + + self.fastMultiplier = 5.0 + self.slowDivisor = 5.0 + + # private vars + self._slideStart = True + self._overridden = False + self._pressed = True + self._prevValue = None + + def doOverrideCursor(self) -> None: + """Override the cursor""" + if self._overridden: + return + QApplication.setOverrideCursor(self.slideCursor) + self._overridden = True + + def restoreOverrideCursor(self) -> None: + """Restore the overridden cursor""" + if not self._overridden: + return + QApplication.restoreOverrideCursor() + self._overridden = False + + def eventFilter(self, obj, event) -> bool: + """Event filter override + + Parameters + ---------- + obj : QObject + The object to get events for + event : QEvent + The event being filtered + + Returns + ------- + + """ + if hasattr(self, "SLIDE_ENABLED"): + if event.type() == QEvent.Type.MouseButtonPress: + if event.button() & self.slideButton: + self.startSlide(obj, event) + self.doSlide(obj, event) + self._slideStart = True + + elif event.type() == QEvent.Type.MouseMove: + if self._slideStart: + try: + self.doSlide(obj, event) + except Exception: + # fix the cursor if there's an error during slideging + self.restoreOverrideCursor() + raise # re-raise the exception + return True + + elif event.type() == QEvent.Type.MouseButtonRelease: + if event.button() & self.slideButton: + self._pressed = False + self._slideStart = False + self.myendSlide(obj, event) + return True + + return super().eventFilter(obj, event) + + def startSlide(self, obj, event) -> None: + """Start the slide operation + + Parameters + ---------- + obj : QObject + The object to get events for + event : QEvent + The event being filtered + + Returns + ------- + + """ + self.slidePressed.emit() + self.doOverrideCursor() + + def doSlide(self, obj, event) -> None: + """Do a slide tick + + Parameters + ---------- + obj : QObject + The object to get events for + event : QEvent + The event being filtered + + Returns + ------- + + """ + width = obj.width() + click = event.pos() + perc = click.x() / float(width) + + mul = 1.0 + if event.modifiers() & self.fastModifier: + mul = self.fastMultiplier + elif event.modifiers() & self.slowModifier: + mul = 1.0 / self.slowDivisor + + if self._prevValue is None: + offset = 0.0 + else: + offset = perc - self._prevValue + self._prevValue = perc + + self.slideTick.emit(perc, offset, mul) + + def myendSlide(self, obj, event) -> None: + """End the slide operation + + Parameters + ---------- + obj : QObject + The object to get events for + event : QEvent + The event being filtered + + Returns + ------- + + """ + self.restoreOverrideCursor() + self._slideStart = None + self.slideReleased.emit() + + +class ChannelBoxDelegate(QStyledItemDelegate): + """Delegate to draw the slider items""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.store = {} + + def paint(self, painter, opt, index) -> None: + """Overridden paint function""" + item = index.model().itemFromIndex(index) + if isinstance(item, Slider): + self.paintSlider(self, item, painter, opt.rect, opt.palette) + else: + super().paint(painter, opt, index) + + def roundedPath(self, width, height, left=True, right=True): + """Get a path with rounded corners for drawing + + Parameters + ---------- + width : float + The width of the rectangle + height : float + The height of the rectangle + left : bool + Round the left side of the rectangle (Default value = True) + right : bool + Round the right side of the rectangle (Default value = True) + + Returns + ------- + QPainterPath + The requested path + + """ + key = (round(width, 2), round(height, 2), round(left, 2), round(right, 2)) + if key in self.store: + return self.store[key] + + # off = 0.5 + off = 1.0 + ew = height - off # ellipse width + eh = height - 2 * off # ellipse height + ts = 0.0 + off # topside + bs = height - off # bottomside + ls = 0.0 + off # left side + rs = width - off # righSide + lc = height + off # left corner + rc = width - height - off # left corner + + # If we're too narrow then flatten the points + if left and right: + if width < 2 * ew: + lc = width * 0.5 + rc = lc + ew = lc + else: + if left: + if width < ew: + lc = rs + ew = width + elif right: + if width < ew: + rc = ls + ew = width + + bgPath = QPainterPath() + if left: + bgPath.moveTo(lc, ts) + else: + bgPath.moveTo(ls, ts) + + if right: + bgPath.lineTo(rc, ts) + bgPath.arcTo(rc, ts, ew, eh, 90, -180) + else: + bgPath.lineTo(rs, ts) + bgPath.lineTo(rs, bs) + + if left: + bgPath.lineTo(lc, bs) + bgPath.arcTo(ls, ts, ew, eh, -90, -180) + else: + bgPath.lineTo(ls, bs) + bgPath.lineTo(ls, ts) + + bgPath.closeSubpath() + self.store[key] = bgPath + return bgPath + + def paintSlider(self, delegate, slider: Slider, painter, rect, palette) -> None: + """Paint a slider + + Parameters + ---------- + delegate : QStyledItemDelegate + The paint delegate + slider : Slider + The slider to paint + painter : QPainter + The painter to paint with + rect : QRectF + The rectangle to fill + palette : QPalette + The palette to use + + Returns + ------- + + """ + painter.save() + try: + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + fgColor = slider.color + bgColor = QColor(slider.color) + bgColor.setAlpha(128) + + fgBrush = QBrush(fgColor) + bgBrush = QBrush(bgColor) + painter.setPen(QPen(palette.foreground().color())) + + rx = rect.x() + ry = rect.y() + rw = rect.width() + rh = rect.height() + + bgLeft = slider.minValue != 0.0 + bgPath = self.roundedPath(rw, rh, left=bgLeft) + bgPath = bgPath.translated(rx, ry) + painter.fillPath(bgPath, bgBrush) + if bgLeft: + # Double sided slider + perc = slider.value + right = perc >= 0.0 + fgPath = self.roundedPath( + abs(perc) * rw * 0.5, rh, left=not right, right=right + ) + if right: + fgPath = fgPath.translated(rx + rw * 0.5, ry) + else: + fgPath = fgPath.translated(rx + rw * 0.5 * (1 + perc), ry) + painter.fillPath(fgPath, fgBrush) + + else: + # Positive only slider + perc = slider.value + perc = max(min(perc, 1.0), 0.0) # clamp between 0 and 1 + fgPath = self.roundedPath(rw * perc, rh, left=False) + fgPath = fgPath.translated(rx, ry) + painter.fillPath(fgPath, fgBrush) + + opts = QTextOption(Qt.AlignmentFlag.AlignCenter) + frect = QRectF(rx, ry, rw, rh) + painter.drawText(frect, slider.name, opts) + # painter.drawPath(bgPath) + finally: + painter.restore() + + +class ChannelListModel(QAbstractItemModel): + """A model to handle a list of sliders + Many functions will be un-documented. They're just overrides + for the QAbstractItemModel. Look at the Qt docs if you really + want to know + + Parameters + ---------- + simplex : Simplex + The simplex system + parent : QObject + The parent of this model + """ + + def __init__(self, simplex: Simplex, parent) -> None: + super().__init__(parent) + self.simplex = simplex + # self.simplex.models.append(self) + self.channels = [] + + def setChannels(self, channels) -> None: + """Set the channels to display in this model + + Parameters + ---------- + channels : [object, ...] + A list of tree objects to show in Channel Box + + Returns + ------- + + """ + self.beginResetModel() + self.channels = channels + self.endResetModel() + + def index(self, row: int, column=0, parIndex=None) -> QModelIndex: + if parIndex is None: + parIndex = QModelIndex() + + try: + item = self.channels[row] + except IndexError: + return QModelIndex() + return self.createIndex(row, column, item) + + def parent(self, index) -> QModelIndex: + return QModelIndex() + + def rowCount(self, parent) -> int: + return len(self.channels) + + def columnCount(self, parent) -> int: + return 1 + + def data(self, index, role): + if not index.isValid(): + return None + item = index.internalPointer() + + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + if isinstance(item, (Group, Slider)): + return item.name + + elif role == Qt.ItemDataRole.TextAlignmentRole: + return Qt.AlignmentFlag.AlignCenter + + return None + + def flags(self, index) -> Qt.ItemFlag: + return ( + Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsEditable + | Qt.ItemFlag.ItemIsSelectable + ) + + def itemFromIndex(self, index): + return index.internalPointer() + + def indexFromItem(self, item) -> QModelIndex: + try: + row = self.channels.index(item) + except ValueError: + return QModelIndex() + return self.index(row) + + def typeHandled(self, item) -> bool: + if isinstance(item, Group): + return item.groupType == Slider + return isinstance(item, Slider) + + def itemDataChanged(self, item) -> None: + if self.typeHandled(item): + idx = self.indexFromItem(item) + if idx.isValid(): + self.dataChanged.emit(idx, idx) + + +class ChannelList(QListView): + """A list to display the chosen channels""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.slider = None + self._nxt = 0.0 + self.start = False + self.residual = 0.0 + + def slideStart(self) -> None: + """Handle user sliding values""" + p = self.mapFromGlobal(QCursor.pos()) + item = self.indexAt(p).internalPointer() + if isinstance(item, Slider): + self.slider = item + self.start = True + + def slideStop(self) -> None: + """End the user sliding values""" + self.slider = None + + def slideTick(self, val, offset, mul) -> None: + """Handle the ticks from the slider Filter""" + if self.slider is not None: + mx = self.slider.maxValue + mn = self.slider.minValue + + tick = 20.0 / mul + + if self.start or mul == 1.0: + self.start = False + val = (val * (mx - mn)) + mn + rn = round(val * tick) / tick + else: + # When working in relative mode, we keep track of the + # unused residual value and add it to the next tick. + # Because, unless each mouse move refresh is more than + # one full tick from the previous, we get no movement + val = offset * (mx - mn) + val = self.slider.value + (val * mul) + val += self.residual + rn = round(val * tick) / tick + self.residual = val - rn + + rn = min(max(rn, mn), mx) + # Do this to keep the ui snappy + self._nxt = rn + QTimer.singleShot(0, self.setval) + + def setval(self) -> None: + """Set the value of a slider""" + if self.slider is not None and self._nxt is not None: + self.slider.value = self._nxt + self._nxt = None + + +class ChannelTreeModel(SimplexModel): + """A model to handle a tree of sliders from a simplex system + Many functions will be un-documented. They're just overrides + for the QAbstractItemModel or the SimplexModel. + """ + + def getChildItem(self, parent, row): + try: + if isinstance(parent, Group): + return parent.items[row] + elif parent is None: + return self.simplex.sliderGroups[row] + except IndexError: + pass + return None + + def getItemRow(self, item) -> int | None: + row = None + if isinstance(item, Group): + row = item.simplex.sliderGroups.index(item) + elif isinstance(item, Slider): + row = item.group.items.index(item) + return row + + def getParentItem(self, item) -> Group | None: + par = None + if isinstance(item, Slider): + par = item.group + return par + + def columnCount(self, parent) -> int: + return 1 + + def getItemRowCount(self, item) -> int: + if isinstance(item, Group): + return len(item.items) + elif item is None: + return len(self.simplex.sliderGroups) + return 0 + + def getItemAppendRow(self, item) -> int: + return self.getItemRowCount(item) + + def getItemData(self, item, column, role): + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + if column == 0: + if isinstance(item, Group): + return item.name + elif isinstance(item, Slider): + return item.name + # return "{0}: {1:.2f}".format(item.name, item.value) + print("BAD ICR", item, column, role) + return "BAD" + return None + + def typeHandled(self, item) -> bool: + if isinstance(item, Group): + return item.groupType == Slider + return isinstance(item, Slider) + + +class ChannelTree(QTreeView): + """Display the channels in a Tree form""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.slider = None + self._nxt = 0.0 + self.start = False + self.residual = 0.0 + + def slideStart(self) -> None: + """Handle starting a slide drag operation""" + p = self.mapFromGlobal(QCursor.pos()) + item = self.indexAt(p).internalPointer() + if isinstance(item, Slider): + self.slider = item + self.start = True + + def slideStop(self) -> None: + """Handle ending a slide drag operation""" + self.slider = None + + def slideTick(self, val, offset, mul) -> None: + """Handle the ticks from the SliderFilter""" + if self.slider is not None: + mx = self.slider.maxValue + mn = self.slider.minValue + + tick = 20.0 / mul + + if self.start or mul == 1.0: + self.start = False + val = (val * (mx - mn)) + mn + rn = round(val * tick) / tick + else: + # When working in relative mode, we keep track of the + # unused residual value and add it to the next tick. + # Because, unless each mouse move refresh is more than + # one full tick from the previous, we get no movement + val = offset * (mx - mn) + val = self.slider.value + (val * mul) + val += self.residual + rn = round(val * tick) / tick + self.residual = val - rn + + rn = min(max(rn, mn), mx) + # Do this to keep the ui snappy + self._nxt = rn + QTimer.singleShot(0, self.setval) + + def setval(self) -> None: + """Set the value of a slider""" + if self.slider is not None and self._nxt is not None: + self.slider.value = self._nxt + self._nxt = None + + +# DISPLAY TESTS +def testSliderListDisplay(smpxPath): + """ + + Parameters + ---------- + smpxPath : + + + Returns + ------- + + """ + simp = Simplex.buildSystemFromSmpx(smpxPath) + channels = [] + + redAttrs = { + "lowerLipDepressor_X", + "stretcher_X", + "platysmaFlex_X", + "cheekRaiser_X", + "jawOpen", + "lidTightener_X", + "outerBrowRaiser_X", + "eyesClosed_X", + "cornerPuller_X", + "noseWrinkler_X", + "lipsBlow_X", + "cornerDepressor_X", + "funneler", + "browLateral_X", + "innerBrowRaiser_X", + "upperLipRaiser_X", + "chinRaiser", + "cheek_SuckBlow_X", + "pucker", + "eyeGaze_DownUp_X", + "eyeGaze_RightLeft_X", + "upperLidTweak_X", + "lowerLidTweak_X", + } + greenAttrs = { + "nasolabialDeepener_X", + "neckStretcher_X", + "lipsPressed_T", + "lipsPressed_B", + "throatCompress", + "lipsRolled_InOut_B", + "lipsRolled_InOut_T", + "sharpCornerPuller_X", + "dimpler_X", + "eyeBlink_X", + "scalpSlide_BackFwd", + "browDown_X", + "mouthSwing_RightLeft", + "sternoFlex_X", + "throatOpen", + } + blueAttrs = { + "adamsApple", + "noseSwing_RightLeft", + "nostrilCompress_X", + "jawThrust_BackFwd", + "eyesWide_X", + "lipsVerticalT_X", + "lipsVerticalB_X", + "earPull_X", + "lipsTighten_T", + "lipsTighten_B", + "lipsCompress_T", + "lipsCompress_B", + "lipsShift_RightLeft_B", + "lipsShift_RightLeft_T", + "lipsNarrowT_X", + "lipsNarrowB_X", + "jawSwing_RightLeft", + "nostril_SuckFlare_X", + "lipsCorner_DownUp_X", + "jawClench", + } + greyAttrs = {"lipsTogether"} + + app = QApplication(sys.argv) + tv = ChannelList() + model = ChannelListModel(simp, None) + delegate = ChannelBoxDelegate() + + slideFilter = SlideFilter(tv.viewport()) + slideFilter.slideButton = Qt.MouseButton.LeftButton + tv.viewport().installEventFilter(slideFilter) + slideFilter.slidePressed.connect(tv.slideStart) + slideFilter.slideReleased.connect(tv.slideStop) + slideFilter.slideTick.connect(tv.slideTick) + + for g in simp.sliderGroups: + channels.append(g) + for item in g.items: + if item.name in redAttrs: + item.color = QColor(178, 103, 103) + elif item.name in greenAttrs: + item.color = QColor(90, 161, 27) + elif item.name in blueAttrs: + item.color = QColor(103, 141, 178) + elif item.name in greyAttrs: + item.color = QColor(130, 130, 130) + channels.append(item) + + model.setChannels(channels) + + tv.setModel(model) + tv.setItemDelegate(delegate) + + tv.show() + sys.exit(app.exec_()) + + +def testSliderTreeDisplay(smpxPath: str): + """ + + Parameters + ---------- + smpxPath : + + + Returns + ------- + + """ + simp = Simplex.buildSystemFromSmpx(smpxPath) + + _redAttrs = { + "lowerLipDepressor_X", + "stretcher_X", + "platysmaFlex_X", + "cheekRaiser_X", + "jawOpen", + "lidTightener_X", + "outerBrowRaiser_X", + "eyesClosed_X", + "cornerPuller_X", + "noseWrinkler_X", + "lipsBlow_X", + "cornerDepressor_X", + "funneler", + "browLateral_X", + "innerBrowRaiser_X", + "upperLipRaiser_X", + "chinRaiser", + "cheek_SuckBlow_X", + "pucker", + "eyeGaze_DownUp_X", + "eyeGaze_RightLeft_X", + "upperLidTweak_X", + "lowerLidTweak_X", + } + + _greenAttrs = { + "nasolabialDeepener_X", + "neckStretcher_X", + "lipsPressed_T", + "lipsPressed_B", + "throatCompress", + "lipsRolled_InOut_B", + "lipsRolled_InOut_T", + "sharpCornerPuller_X", + "dimpler_X", + "eyeBlink_X", + "scalpSlide_BackFwd", + "browDown_X", + "mouthSwing_RightLeft", + "sternoFlex_X", + "throatOpen", + } + + _blueAttrs = { + "adamsApple", + "noseSwing_RightLeft", + "nostrilCompress_X", + "jawThrust_BackFwd", + "eyesWide_X", + "lipsVerticalT_X", + "lipsVerticalB_X", + "earPull_X", + "lipsTighten_T", + "lipsTighten_B", + "lipsCompress_T", + "lipsCompress_B", + "lipsShift_RightLeft_B", + "lipsShift_RightLeft_T", + "lipsNarrowT_X", + "lipsNarrowB_X", + "jawSwing_RightLeft", + "nostril_SuckFlare_X", + "lipsCorner_DownUp_X", + "jawClench", + } + + _greyAttrs = {"lipsTogether"} + + app = QApplication(sys.argv) + # tv = ChannelTree() + tv = QTreeView() + model = ChannelTreeModel(simp, None) + # delegate = ChannelBoxDelegate() + + # slideFilter = SlideFilter(tv.viewport()) + # slideFilter.slideButton = Qt.MouseButton.LeftButton + # tv.viewport().installEventFilter(slideFilter) + # slideFilter.slidePressed.connect(tv.slideStart) + # slideFilter.slideReleased.connect(tv.slideStop) + # slideFilter.slideTick.connect(tv.slideTick) + + # for g in simp.sliderGroups: + # for item in g.items: + # if item.name in redAttrs: + # item.color = QColor(178, 103, 103) + # elif item.name in greenAttrs: + # item.color = QColor(90, 161, 27) + # elif item.name in blueAttrs: + # item.color = QColor(103, 141, 178) + # elif item.name in greyAttrs: + # item.color = QColor(130, 130, 130) + + tv.setModel(model) + # tv.setItemDelegate(delegate) + + tv.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + basePath = r"D:\Users\tyler\Documents\GitHub\Simplex\Useful" + smpxPath = os.path.join(basePath, "HeadMaleStandard_High_Unsplit.smpx") + + testSliderTreeDisplay(smpxPath) diff --git a/src/python/simplexui/comboCheckDialog.py b/src/python/simplexui/comboCheckDialog.py index ce330848..97e05fdc 100644 --- a/src/python/simplexui/comboCheckDialog.py +++ b/src/python/simplexui/comboCheckDialog.py @@ -1,343 +1,396 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -from itertools import combinations, product - -from .dragFilter import DragFilter -from .items import Combo, Slider -from Qt import QtCompat -from Qt.QtCore import Qt -from Qt.QtGui import QBrush, QColor -from Qt.QtWidgets import QDialog, QListWidgetItem, QTreeWidgetItem -from .utils import getUiFile - - -class TooManyPossibilitiesError(Exception): - """Error raised when there are too many possibilities - Basically used as a stop-iteration - """ - - pass - - -def buildPossibleCombos( - simplex, sliders, minDepth, maxDepth, lockDict=None, maxPoss=100 -): - """Build a list of possible combos - - Parameters - ---------- - simplex : Simplex - The simplex system to check - sliders : Slider - The sliders to check - minDepth : int - The minimum number of sliders that will go into any combo - maxDepth : int - The maximum number of sliders that will go into any combo - lockDict : {Slider: (float, ...), ...} - An optional per-slider dict of possible values - maxPoss : float - The Maximum number of possibilities to return.(Default value = 100) - - Returns - ------- - bool - True if the maximum number of possibilities was exceeded - [([(Slider, float), ...], Combo), ...] - Grouped slider/value pairs to existing (or None) Combos - - """ - # Get the range values for each slider - allRanges = {} - sliderDict = {} - lockDict = lockDict or {} - - for slider in sliders: - rng = set(lockDict.get(slider, slider.prog.getRange())) - rng.discard(0) # ignore the zeros - allRanges[slider] = sorted(rng) - sliderDict[slider.name] = slider - - poss = [] - tooMany = False - try: - for size in range(minDepth, maxDepth + 1): - for grp in combinations(sliders, size): - names = [i.name for i in grp] - ranges = [allRanges[s] for s in grp] - for vals in product(*ranges): - poss.append(frozenset(list(zip(names, vals)))) - if len(poss) > maxPoss: - raise TooManyPossibilitiesError("Don't melt your computer") - except TooManyPossibilitiesError: - tooMany = True - - # Get the "only" combo sets - onlys = {} - for combo in simplex.combos: - sls = [i.slider for i in combo.pairs] - if all(r in sliders for r in sls): - key = frozenset([(i.slider.name, i.value) for i in combo.pairs]) - onlys[key] = combo - - toAdd = [] - for p in poss: - truePairs = [(sliderDict[n], v) for n, v in p] - toAdd.append((truePairs, onlys.get(p))) - return tooMany, toAdd - - -class ComboCheckItem(QListWidgetItem): - def __init__(self, pairs, combo, *args, **kwargs): - super(ComboCheckItem, self).__init__(*args, **kwargs) - self.pairs = pairs - self.combo = combo - - if self.combo is None: - # We can create it!! - sliders, vals = list(zip(*self.pairs)) - newName = Combo.buildComboName(sliders, vals) - self.setText(newName) - else: - self.setText(self.combo.name) - self.setForeground(QBrush(QColor(128, 128, 128))) - - -class ComboCheckDialog(QDialog): - """Dialog for checking what possible combos exist, and picking new combos - - This dialog displays the available combos for a number of input sliders - - In 'Create' mode, it provides a quick way of choosing the one specific combo - that the user is looking for - - In 'Check' mode, it provides a convenient way to explore the possibilites - and create any missing combos directly - - Parameters - ---------- - sliders : [Slider, ...] - A list of sliders to check - values : {Slider: (float, ...), ...} - A dictionary of values to use per slider - mode : str - The mode to display the dialog. Defaults to 'create' - parent : QObject - The Parent of the dialog. Must be a SimplexDialog - - Returns - ------- - - """ - - def __init__(self, sliders, values=None, mode="create", parent=None): - super(ComboCheckDialog, self).__init__(parent) - - uiPath = getUiFile(__file__) - QtCompat.loadUi(uiPath, self) - self.mode = mode.lower() - - # Store the Parent UI rather than relying on Qt's .parent() - # Could cause crashes otherwise - self.parUI = parent - - self.uiCreateSelectedBTN.clicked.connect(self.createMissing) - self.uiMinLimitSPIN.valueChanged.connect(self.populateWithoutUpdate) - self.uiMaxLimitSPIN.valueChanged.connect(self.populateWithoutUpdate) - self.uiCancelBTN.clicked.connect(self.close) - self.uiManualUpdateBTN.clicked.connect(self.populateWithUpdate) - self.uiEditTREE.itemChanged.connect(self.populateWithoutUpdate) - - self.dragFilter = DragFilter(self) - self.uiEditTREE.viewport().installEventFilter(self.dragFilter) - self.dragFilter.dragTick.connect(self.dragTick) - - self.parUI.uiSliderTREE.selectionModel().selectionChanged.connect( - self.populateWithCheck - ) - - self.valueDict = values or {} - self.setSliders(sliders) - if self.mode == "create": - self.uiAutoUpdateCHK.setCheckState(Qt.CheckState.Unchecked) - self.uiAutoUpdateCHK.hide() - self.uiManualUpdateBTN.hide() - - self.uiMaxLimitSPIN.setValue(max(len(sliders), 2)) - self.uiMinLimitSPIN.setValue(max(len(sliders) - 2, 2)) - - if sliders is None: - self.populateWithUpdate() - else: - self._populate() - - def dragTick(self, ticks, mul): - """Deal with the ticks coming from the drag handler - - Parameters - ---------- - ticks : int - The number of ticks since the last update - mul : float - The multiplier value from the drag handler - - Returns - ------- - - """ - items = self.uiEditTREE.selectedItems() - for item in items: - val = item.data(3, Qt.ItemDataRole.EditRole) - val += (0.05) * ticks * mul - if abs(val) < 1.0e-5: - val = 0.0 - val = max(min(val, 1.0), -1.0) - item.setData(3, Qt.ItemDataRole.EditRole, val) - self.uiEditTREE.viewport().update() - - def setSliders(self, val): - """Set the sliders displayed in this UI - - Parameters - ---------- - val : [Slider, ...] - The sliders to be displayed - - Returns - ------- - - """ - self.uiEditTREE.clear() - dvs = [None, -1.0, 1.0, 0.5] - roles = [ - Qt.ItemDataRole.UserRole, - Qt.ItemDataRole.UserRole, - Qt.ItemDataRole.UserRole, - Qt.ItemDataRole.EditRole, - ] - val = val or [] - for slider in val: - item = QTreeWidgetItem(self.uiEditTREE, [slider.name]) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) - vvv = self.valueDict.get(slider, [-1.0, 1.0]) - mvs = [i for i in vvv if abs(i) != 1.0] - mvs = mvs[0] if mvs else 0.5 - - item.setData(0, Qt.ItemDataRole.UserRole, slider) - for col in range(1, 4): - val = mvs if col == 3 else dvs[col] - item.setData(col, roles[col], val) - rng = slider.prog.getRange() - if val in rng or col == 3: - chk = ( - Qt.CheckState.Checked if val in vvv else Qt.CheckState.Unchecked - ) - item.setCheckState(col, chk) - - for col in reversed(list(range(4))): - self.uiEditTREE.resizeColumnToContents(col) - - def closeEvent(self, event): - """Override the Qt close event""" - self.parUI.uiSliderTREE.selectionModel().selectionChanged.disconnect( - self.populateWithCheck - ) - super(ComboCheckDialog, self).closeEvent(event) - - def populateWithUpdate(self): - """Populate the list from the main dialog selection""" - self.setSliders(self.parUI.uiSliderTREE.getSelectedItems(typ=Slider)) - self._populate() - - def populateWithoutUpdate(self): - """Populate the list and but don't look at the main dialog""" - self._populate() - - def populateWithCheck(self): - """Populate the list from the main dialog selection, only if the AutoUpdate checkbox is checked""" - if self.uiAutoUpdateCHK.isChecked(): - self.setSliders(self.parUI.uiSliderTREE.getSelectedItems(typ=Slider)) - self._populate() - - def _populate(self): - """Populate the list widgets in the UI""" - minDepth = self.uiMinLimitSPIN.value() - maxDepth = self.uiMaxLimitSPIN.value() - maxPoss = 100 - - root = self.uiEditTREE.invisibleRootItem() - lockDict = {} - sliderList = [] - roles = [ - Qt.ItemDataRole.UserRole, - Qt.ItemDataRole.UserRole, - Qt.ItemDataRole.UserRole, - Qt.ItemDataRole.EditRole, - ] - for row in range(root.childCount()): - item = root.child(row) - slider = item.data(0, Qt.ItemDataRole.UserRole) - if slider is not None: - sliderList.append(slider) - lv = [ - item.data(col, roles[col]) - for col in range(1, 4) - if item.checkState(col) - ] - lockDict[slider] = lv - - tooMany, toAdd = buildPossibleCombos( - self.parUI.simplex, - sliderList, - minDepth, - maxDepth, - lockDict=lockDict, - maxPoss=maxPoss, - ) - - lbl = ( - "Too many possibilities. Limiting to {0}".format(maxPoss) if tooMany else "" - ) - self.uiWarningLBL.setText(lbl) - - self.uiComboCheckLIST.clear() - for pairs, combo in reversed(toAdd): - item = ComboCheckItem(pairs, combo) - self.uiComboCheckLIST.addItem(item) - - if self.mode == "create": - if self.uiComboCheckLIST.count() > 0: - self.uiComboCheckLIST.item(0).setSelected(True) - - def createMissing(self): - """Create selected combos if they don't already exist""" - simplex = self.parUI.simplex - created = [] - for item in self.uiComboCheckLIST.selectedItems(): - name = item.text() - sliders, vals = list(zip(*item.pairs)) - # Double check that the user didn't create any extra sliders - if Combo.comboAlreadyExists(simplex, sliders, vals) is None: - c = Combo.createCombo(name, simplex, sliders, vals) - created.append(c) - - self.parUI.uiComboTREE.setItemSelection(created) - if self.mode == "create": - self.close() - else: - self.populateWithoutUpdate() +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +from itertools import combinations, product +from typing import TYPE_CHECKING + +from Qt import QtCompat +from Qt.QtCore import Qt +from Qt.QtGui import QBrush, QColor +from Qt.QtWidgets import ( + QCheckBox, + QDialog, + QGroupBox, + QLabel, + QListWidget, + QListWidgetItem, + QPushButton, + QSpinBox, + QTreeWidget, + QTreeWidgetItem, + QWidget, +) + +from .dragFilter import DragFilter +from .items import Combo, Slider +from .utils import getUiFile + +if TYPE_CHECKING: + from Qt.QtGui import QCloseEvent + + from .items import Simplex + from .simplexDialog import SimplexDialog + + +class TooManyPossibilitiesError(Exception): + """Error raised when there are too many possibilities + Basically used as a stop-iteration + """ + + pass + + +def buildPossibleCombos( + simplex: Simplex, + sliders: list[Slider], + minDepth: int, + maxDepth: int, + lockDict: dict[Slider, tuple[float, ...]] | None = None, + maxPoss: int = 100, +) -> tuple[bool, list[tuple[list[tuple[Slider, float]], Combo]]]: + """Build a list of possible combos + + Parameters + ---------- + simplex : Simplex + The simplex system to check + sliders : Slider + The sliders to check + minDepth : int + The minimum number of sliders that will go into any combo + maxDepth : int + The maximum number of sliders that will go into any combo + lockDict : {Slider: (float, ...), ...} + An optional per-slider dict of possible values + maxPoss : float + The Maximum number of possibilities to return.(Default value = 100) + + Returns + ------- + bool + True if the maximum number of possibilities was exceeded + [([(Slider, float), ...], Combo), ...] + Grouped slider/value pairs to existing (or None) Combos + """ + # Get the range values for each slider + allRanges = {} + sliderDict = {} + lockDict = lockDict or {} + + for slider in sliders: + rng = set(lockDict.get(slider, slider.prog.getRange())) + rng.discard(0) # ignore the zeros + allRanges[slider] = sorted(rng) + sliderDict[slider.name] = slider + + poss = [] + tooMany = False + try: + for size in range(minDepth, maxDepth + 1): + for grp in combinations(sliders, size): + names = [i.name for i in grp] + ranges = [allRanges[s] for s in grp] + for vals in product(*ranges): + poss.append(frozenset(list(zip(names, vals)))) + if len(poss) > maxPoss: + raise TooManyPossibilitiesError("Don't melt your computer") + except TooManyPossibilitiesError: + tooMany = True + + # Get the "only" combo sets + onlys = {} + for combo in simplex.combos: + sls = [i.slider for i in combo.pairs] + if all(r in sliders for r in sls): + key = frozenset([(i.slider.name, i.value) for i in combo.pairs]) + onlys[key] = combo + + toAdd = [] + for p in poss: + truePairs = [(sliderDict[n], v) for n, v in p] + toAdd.append((truePairs, onlys.get(p))) + + return tooMany, toAdd + + +class ComboCheckItem(QListWidgetItem): + def __init__( + self, pairs: list[tuple[Slider, float]], combo: Combo, *args, **kwargs + ) -> None: + super().__init__(*args, **kwargs) + self.pairs = pairs + self.combo = combo + + if self.combo is None: + # We can create it!! + sliders, vals = list(zip(*self.pairs)) + newName = Combo.buildComboName(sliders, vals) + self.setText(newName) + else: + self.setText(self.combo.name) + self.setForeground(QBrush(QColor(128, 128, 128))) + + +class ComboCheckDialog(QDialog): + """Dialog for checking what possible combos exist, and picking new combos + + This dialog displays the available combos for a number of input sliders + + In 'Create' mode, it provides a quick way of choosing the one specific combo + that the user is looking for + + In 'Check' mode, it provides a convenient way to explore the possibilites + and create any missing combos directly + + Parameters + ---------- + sliders : [Slider, ...] + A list of sliders to check + values : {Slider: (float, ...), ...} + A dictionary of values to use per slider + mode : str + The mode to display the dialog. Defaults to 'create' + parent : QObject + The Parent of the dialog. Must be a SimplexDialog + + Returns + ------- + + """ + + uiHeaderWID: QWidget + uiLimitGRP: QGroupBox + uiMinLimitSPIN: QSpinBox + uiMaxLimitSPIN: QSpinBox + uiAutoUpdateCHK: QCheckBox + uiManualUpdateBTN: QPushButton + uiEditTREE: QTreeWidget + uiComboCheckLIST: QListWidget + uiWarningLBL: QLabel + uiCancelBTN: QPushButton + uiCreateSelectedBTN: QPushButton + + def __init__( + self, + sliders: list[Slider], + values: dict[Slider, list[float]] | None = None, + mode: str = "create", + parent: SimplexDialog | None = None, + ) -> None: + if parent is None: + raise ValueError("Parent must not be None") + super().__init__(parent) + + uiPath = getUiFile(__file__) + QtCompat.loadUi(uiPath, self) + self.mode = mode.lower() + + # Store the Parent UI rather than relying on Qt's .parent() + # Could cause crashes otherwise + self.parUI = parent + if self.parUI.simplex is None: + self.setDisabled(True) + return + + self.uiCreateSelectedBTN.clicked.connect(self.createMissing) + self.uiMinLimitSPIN.valueChanged.connect(self.populateWithoutUpdate) + self.uiMaxLimitSPIN.valueChanged.connect(self.populateWithoutUpdate) + self.uiCancelBTN.clicked.connect(self.close) + self.uiManualUpdateBTN.clicked.connect(self.populateWithUpdate) + self.uiEditTREE.itemChanged.connect(self.populateWithoutUpdate) + + self.dragFilter = DragFilter(self) + self.uiEditTREE.viewport().installEventFilter(self.dragFilter) + self.dragFilter.dragTick.connect(self.dragTick) + + self.parUI.uiSliderTREE.selectionModel().selectionChanged.connect( + self.populateWithCheck + ) + + self.valueDict = values or {} + self.setSliders(sliders) + if self.mode == "create": + self.uiAutoUpdateCHK.setCheckState(Qt.CheckState.Unchecked) + self.uiAutoUpdateCHK.hide() + self.uiManualUpdateBTN.hide() + + self.uiMaxLimitSPIN.setValue(max(len(sliders), 2)) + self.uiMinLimitSPIN.setValue(max(len(sliders) - 2, 2)) + + if sliders is None: + self.populateWithUpdate() + else: + self._populate() + + def dragTick(self, ticks: int, mul: float) -> None: + """Deal with the ticks coming from the drag handler + + Parameters + ---------- + ticks : int + The number of ticks since the last update + mul : float + The multiplier value from the drag handler + """ + items = self.uiEditTREE.selectedItems() + for item in items: + val = item.data(3, Qt.ItemDataRole.EditRole) + val += (0.05) * ticks * mul + if abs(val) < 1.0e-5: + val = 0.0 + val = max(min(val, 1.0), -1.0) + item.setData(3, Qt.ItemDataRole.EditRole, val) + self.uiEditTREE.viewport().update() + + def setSliders(self, sliders: list[Slider]) -> None: + """Set the sliders displayed in this UI + + Parameters + ---------- + sliders : [Slider, ...] + The sliders to be displayed + """ + self.uiEditTREE.clear() + defaultVals = [-1.0, 1.0, 0.5] + roles = [ + Qt.ItemDataRole.UserRole, + Qt.ItemDataRole.UserRole, + Qt.ItemDataRole.UserRole, + Qt.ItemDataRole.EditRole, + ] + + sliders = sliders or [] + for slider in sliders: + # item = QTreeWidgetItem(self.uiEditTREE, [slider.name]) + item = QTreeWidgetItem([slider.name]) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) + + valRange = self.valueDict.get(slider, [-1.0, 1.0]) + midVals = [i for i in valRange if abs(i) != 1.0] + slidef = defaultVals[:] + slidef[-1] = midVals[0] if midVals else 0.5 + + item.setData(0, Qt.ItemDataRole.UserRole, slider) + for col in range(1, 4): + slival = slidef[col - 1] + item.setData(col, roles[col], slival) + rng = slider.prog.getRange() + if slival in rng or col == 3: + chk = ( + Qt.CheckState.Checked + if slival in valRange + else Qt.CheckState.Unchecked + ) + item.setCheckState(col, chk) + self.uiEditTREE.addTopLevelItem(item) + + for col in reversed(list(range(4))): + self.uiEditTREE.resizeColumnToContents(col) + + def closeEvent(self, event: QCloseEvent) -> None: + """Override the Qt close event""" + if self.isEnabled(): + self.parUI.uiSliderTREE.selectionModel().selectionChanged.disconnect( + self.populateWithCheck + ) + super().closeEvent(event) + + def populateWithUpdate(self) -> None: + """Populate the list from the main dialog selection""" + self.setSliders(self.parUI.uiSliderTREE.getSelectedItems(typ=Slider)) + self._populate() + + def populateWithoutUpdate(self) -> None: + """Populate the list and but don't look at the main dialog""" + self._populate() + + def populateWithCheck(self) -> None: + """Populate the list from the main dialog selection, only if the AutoUpdate checkbox is checked""" + if self.uiAutoUpdateCHK.isChecked(): + self.setSliders(self.parUI.uiSliderTREE.getSelectedItems(typ=Slider)) + self._populate() + + def _populate(self) -> None: + """Populate the list widgets in the UI""" + if self.parUI.simplex is None: + return + + minDepth = self.uiMinLimitSPIN.value() + maxDepth = self.uiMaxLimitSPIN.value() + maxPoss = 100 + + root = self.uiEditTREE.invisibleRootItem() + lockDict = {} + sliderList = [] + roles = [ + Qt.ItemDataRole.UserRole, + Qt.ItemDataRole.UserRole, + Qt.ItemDataRole.UserRole, + Qt.ItemDataRole.EditRole, + ] + + for row in range(root.childCount()): + item = root.child(row) + slider = item.data(0, Qt.ItemDataRole.UserRole) + if slider is not None: + sliderList.append(slider) + lv = [ + item.data(col, roles[col]) + for col in range(1, 4) + if item.checkState(col) == Qt.CheckState.Checked + ] + lockDict[slider] = lv + + tooMany, toAdd = buildPossibleCombos( + self.parUI.simplex, + sliderList, + minDepth, + maxDepth, + lockDict=lockDict, + maxPoss=maxPoss, + ) + + lbl = f"Too many possibilities. Limiting to {maxPoss}" if tooMany else "" + self.uiWarningLBL.setText(lbl) + + self.uiComboCheckLIST.clear() + for pairs, combo in reversed(toAdd): + item = ComboCheckItem(pairs, combo) + self.uiComboCheckLIST.addItem(item) + + if self.mode == "create": + if self.uiComboCheckLIST.count() > 0: + self.uiComboCheckLIST.item(0).setSelected(True) + + def createMissing(self) -> None: + """Create selected combos if they don't already exist""" + simplex = self.parUI.simplex + created = [] + for item in self.uiComboCheckLIST.selectedItems(): + name = item.text() + sliders, vals = list(zip(*item.pairs)) + # Double check that the user didn't create any extra sliders + if Combo.comboAlreadyExists(simplex, sliders, vals) is None: + c = Combo.createCombo(name, simplex, sliders, vals) + created.append(c) + + self.parUI.uiComboTREE.setItemSelection(created) + if self.mode == "create": + self.close() + else: + self.populateWithoutUpdate() diff --git a/src/python/simplexui/commands/alembicCommon.py b/src/python/simplexui/commands/alembicCommon.py index 30302e54..479fdbbb 100644 --- a/src/python/simplexui/commands/alembicCommon.py +++ b/src/python/simplexui/commands/alembicCommon.py @@ -1,991 +1,1148 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -"""Alembic files can be difficult to work with, and can be *very* slow in Python -This is a library of convenience functions with the numpy speed optimizations -""" - -import os - -from alembic.Abc import IArchive, OArchive, OStringProperty -from alembic.AbcGeom import ( - GeometryScope, - IPolyMesh, - IXform, - ON3fGeomParamSample, - OPolyMesh, - OPolyMeshSchemaSample, - OV2fGeomParamSample, - OXform, -) -from imath import IntArray, UnsignedIntArray, V2f, V2fArray, V3fArray - -try: - import numpy as np - from .numpytoimath import imathToNumpy, numpyToImath -except ImportError: - np = None - numpyToImath = None - imathToNumpy = None - - -def pbPrint(pBar, message=None, val=None, maxVal=None, _pbPrintLastComma=None): - """A function that handles displaying messages in a QProgressDialog or printing to stdout - - Don't forget to call QApplication.processEvents() after using this function - - Parameters - ---------- - pBar : QProgressDialog or None - An optional progress bar - message : str or None - An optional message to display - val : int or None - An optional progress value to display - maxVal : int or None - An optional maximum value to display - _pbPrintLastComma: object - INTERNAL USE ONLY - """ - _pbPrintLastComma = [] if _pbPrintLastComma is None else _pbPrintLastComma - - if pBar is not None: - if val is not None: - pBar.setValue(val) - if message is not None: - pBar.setLabelText(message) - else: - if message is not None: - if val is not None: - if maxVal is not None: - print( - message, "{0: <4} of {1: <4}\r".format(val + 1, maxVal), end=" " - ) - else: - print(message, "{0: <4}\r".format(val + 1), end=" ") - # This is the ugliest, most terrible thing I think I've ever written - # Abusing the static default object to check if the last time - # this function was used, there was a trailing comma - # But damn if it doesn't make me laugh - if not _pbPrintLastComma: - _pbPrintLastComma.append("") - else: - if _pbPrintLastComma: - print(_pbPrintLastComma.pop()) - print(message) - - -def mkArray(aType, iList): - """Makes the alembic-usable c++ typed 2-d arrays - - Parameters - ---------- - aType : imath type - The type of the output array - iList : list or np.array - The input iterable. - - Returns - ------- - : aType - The input list translated into an aType array - """ - if isinstance(iList, aType): - return iList - - if np is None or numpyToImath is None: - array = aType(len(iList)) - for i in range(len(iList)): - array[i] = tuple(iList[i]) - return array - return numpyToImath(iList, aType) - - -def mk1dArray(aType, iList): - """Makes the alembic-usable c++ typed 1-d arrays - - Parameters - ---------- - aType : imath type - The type of the output array - iList : list or np.array - The input iterable. - - Returns - ------- - : aType - The input list translated into an aType array - """ - if isinstance(iList, aType): - return iList - if np is None or numpyToImath is None: - array = aType(len(iList)) - for i in range(len(iList)): - # Gotta cast to int because an "int" from numpy has - # the type np.int32, which makes this conversion angry - array[i] = int(iList[i]) - return array - return numpyToImath(iList, aType) - - -def mkSampleVertexPoints(pts): - """Make an imath array of vertices - - Parameters - ---------- - pts : list or np.array - The input points - - Returns - ------- - : V3fArray - The output list - """ - return mkArray(V3fArray, pts) - - -def mkSampleIntArray(vals): - """Make an imath array of integers - - Parameters - ---------- - pts : list or np.array - The input integers - - Returns - ------- - : IntArray - The output list - """ - return mk1dArray(IntArray, vals) - - -def mkSampleUIntArray(vals): - """Make an imath array of unsigned integers - - Parameters - ---------- - pts : list or np.array - The input unsigned integers - - Returns - ------- - : UnsignedIntArray - The output list - """ - return mk1dArray(UnsignedIntArray, vals) - - -def mkSampleUvArray(uvs): - """Make an imath array of uvs - - Parameters - ---------- - uvs : list or np.array - The input uvs - - Returns - ------- - : V2fArray - The output list - """ - array = V2fArray(len(uvs)) - setter = V2f(0, 0) - for i in range(len(uvs)): - setter.setValue(float(uvs[i][0]), float(uvs[i][1])) - array[i] = setter - return array - - -def mkUvSample(uvs, indexes=None): - """Take an array, and make a poly mesh sample of the uvs - - Parameters - ---------- - uvs : list or np.array - The input uvs - indexes : list or np.array or None - The optional face indices of the uvs - - Returns - ------- - : OV2fGeomParamSample - The UV sample - """ - ary = mkSampleUvArray(uvs) - if indexes is None: - return OV2fGeomParamSample(ary, GeometryScope.kFacevaryingScope) - idxs = mkSampleUIntArray(indexes) - return OV2fGeomParamSample(ary, idxs, GeometryScope.kFacevaryingScope) - - -def mkNormalSample(norms, indexes=None): - """Take an array, and make a poly mesh sample of the normals - - Parameters - ---------- - norms : list or np.array - The input normals - indexes : list or np.array or None - The optional face indices of the normals - - Returns - ------- - : ON3fGeomParamSample - The Normal sample - """ - ary = mkArray(V3fArray, norms) - if indexes is None: - return ON3fGeomParamSample(ary, GeometryScope.kFacevaryingScope) - idxs = mkSampleUIntArray(indexes) - return ON3fGeomParamSample(ary, idxs, GeometryScope.kFacevaryingScope) - - -def setAlembicSample( - omeshSch, points, faceCount, faceIndex, bounds=None, uvs=None, normals=None -): - """Set an alembic sample to the output mesh with the given properties""" - # Do it this way because the defaults for these arguments are some value other than None - kwargs = {} - if uvs is not None: - kwargs["iUVs"] = uvs - if normals is not None: - kwargs["iNormals"] = normals - - s = OPolyMeshSchemaSample(points, faceIndex, faceCount, **kwargs) - if bounds is not None: - omeshSch.getChildBoundsProperty().setValue(bounds) - omeshSch.set(s) - - -def getSampleArray(imesh, pBar=None): - """Get the per-frame vertex positions for a mesh - - Parameters - ---------- - imesh : IPolyMesh - The input alembic mesh object - - Returns - ------- - : np.array or list - The per-frame vertex positions - """ - meshSchema = imesh.getSchema() - posProp = meshSchema.getPositionsProperty() - numShapes = len(posProp.samples) - if imathToNumpy is not None and np is not None: - shapes = np.empty((len(posProp.samples), len(posProp.samples[0]), 3)) - for i, s in enumerate(posProp.samples): - pbPrint(pBar, message="Reading Shape", val=i, maxVal=numShapes) - shapes[i] = imathToNumpy(s) - else: - shapes = [] - for i, s in enumerate(posProp.samples): - pbPrint(pBar, message="Reading Shape", val=i, maxVal=numShapes) - shapes.append(s) - pbPrint(pBar, message="Done Reading") - return shapes - - -def getStaticMeshData(imesh): - """Get all the generally non-changing data for a mesh - - Parameters - ---------- - imesh : IPolyMesh - The input alembic mesh object - - Returns - ------- - : IntArray - A flat alembic array of vertex indices for the faces - : IntArray - The number of vertices per face - """ - sch = imesh.getSchema() - faces = sch.getFaceIndicesProperty().samples[0] - counts = sch.getFaceCountsProperty().samples[0] - return faces, counts - - -def getStaticMeshArrays(imesh): - """Get all the generally non-changing data for a mesh as numpy arrays - - Parameters - ---------- - imesh : IPolyMesh - The input alembic mesh object - - Returns - ------- - : np.array or list - A flat of vertex indices for the faces as np.array if possible - : np.array or list - The number of vertices per face as np.array if possible - """ - faces, counts = getStaticMeshData(imesh) - if np is None or imathToNumpy is None: - faces, counts = list(faces), list(counts) - else: - faces = imathToNumpy(faces) - counts = imathToNumpy(counts) - return faces, counts - - -def getUvSample(imesh): - """Get the UV's for a mesh - - Parameters - ---------- - imesh : IPolyMesh - The input alembic mesh object - - Returns - ------- - : OV2fGeomParamSample - The UV Sample - """ - imeshsch = imesh.getSchema() - uvParam = imeshsch.getUVsParam() - - if not uvParam.valid(): - return None - - uvValue = uvParam.getValueProperty().getValue() - if uvParam.isIndexed(): - idxValue = uvParam.getIndexProperty().getValue() - uv = OV2fGeomParamSample(uvValue, idxValue, GeometryScope.kFacevaryingScope) - else: - uv = OV2fGeomParamSample(uvValue, GeometryScope.kFacevaryingScope) - return uv - - -def getUvArray(imesh): - """Get the uv positions for a mesh - - Parameters - ---------- - imesh : IPolyMesh - The input alembic mesh object - - Returns - ------- - : list or np.array or None - The UVs if they exist as a numpy array if possible - """ - imeshsch = imesh.getSchema() - uvParam = imeshsch.getUVsParam() - if uvParam.valid(): - uvProp = uvParam.getValueProperty() - uvVals = uvProp.getValue() - # imathNumpy doesn't work on V2f arrays - # so I have to use one of the slow ways - uv = list(zip(uvVals.x, uvVals.y)) - if np is not None: - uv = np.array(uv) - else: - uv = None - return uv - - -def getFlatUvFaces(imesh): - """Get the UV structure for a mesh if it's indexed. If un-indexed, return None - This means that if we have valid UVs, but invalid uvFaces, then we're un-indexed - and can handle the data appropriately for export without keeping track of index-ness - - Parameters - ---------- - imesh : IPolyMesh - The input alembic mesh object - - Returns - ------- - : [int, ...] or np.array - The UVFace structure - : bool - Whether we share uvs between uvFaces (True), or we have a uv per face-vertex (False) - """ - sch = imesh.getSchema() - iuvs = sch.getUVsParam() - idxs = None - indexed = None - if iuvs.valid(): - if iuvs.isIndexed(): - indexed = True - idxs = iuvs.getIndexProperty().getValue() - if imathToNumpy is None or np is None: - idxs = list(idxs) - else: - idxs = imathToNumpy(idxs) - else: - indexed = False - rawCount = sum(list(sch.getFaceCountsProperty().samples[0])) - if np is None: - idxs = list(range(rawCount)) - else: - idxs = np.arange(rawCount) - - return idxs, indexed - - -def getUvFaces(imesh): - """Get the UV structure for a mesh if it's indexed. If un-indexed, return None - This means that if we have valid UVs, but invalid uvFaces, then we're un-indexed - and can handle the data appropriately for export without keeping track of index-ness - - Parameters - ---------- - imesh : IPolyMesh - The input alembic mesh object - - Returns - ------- - : [[int, ...], ...] - The UVFace structure - """ - sch = imesh.getSchema() - rawCounts = sch.getFaceCountsProperty().samples[0] - iuvs = sch.getUVsParam() - uvFaces = None - if iuvs.valid(): - uvFaces = [] - uvCounter = 0 - if iuvs.isIndexed(): - idxs = list(iuvs.getIndexProperty().getValue()) - for count in rawCounts: - uvFaces.append(list(idxs[uvCounter : uvCounter + count])) - uvCounter += count - return uvFaces - - -def getMeshFaces(imesh): - """Get The vertex indices used per face - - Parameters - ---------- - imesh : IPolyMesh - The input alembic mesh object - - Returns - ------- - : [[int, ...], ...] - The UVFace structure - """ - rawFaces, rawCounts = getStaticMeshData(imesh) - faces = [] - ptr = 0 - for count in rawCounts: - faces.append(list(rawFaces[ptr : ptr + count])) - ptr += count - return faces - - -def getPointCount(imesh): - """Get the number of vertices in a mesh - - Parameters - ---------- - imesh : IPolyMesh - The input alembic mesh object - - Returns - ------- - : int - The number of vertices in the mesh - """ - meshSchema = imesh.getSchema() - posProp = meshSchema.getPositionsProperty() - return len(posProp.samples[0]) - - -def findAlembicObject(obj, abcType=None, name=None): - """ - Finds a single object in an alembic archive by name and/or type - If only type is specified, then the first object of that type - encountered will be returned - """ - md = obj.getMetaData() - if abcType is None: - if name is None or obj.getName() == name: - return obj - elif abcType.matches(md): - if name is None or obj.getName() == name: - return abcType(obj.getParent(), obj.getName()) - for child in obj.children: - out = findAlembicObject(child, abcType, name) - if out is not None: - return out - return None - - -def findAllAlembicObjects(obj, abcType=None, out=None): - """Finds all objects of a type in an alembic archive""" - md = obj.getMetaData() - out = [] if out is None else out - if abcType is None: - out.append(obj) - elif abcType.matches(md): - out.append(abcType(obj.getParent(), obj.getName())) - for child in obj.children: - findAllAlembicObjects(child, abcType, out) - return out - - -def getTypedIObject(obj): - from alembic.AbcGeom import ( - ICamera, - ICurves, - ILight, - INuPatch, - IPoints, - IPolyMesh, - ISubD, - IXform, - ) - - md = obj.getMetaData() - for abcType in ( - IXform, - IPolyMesh, - ICamera, - ICurves, - ILight, - INuPatch, - IPoints, - ISubD, - ): - if abcType.matches(md): - return abcType(obj.getParent(), obj.getName()) - return None - - -def getMesh(infile): - """Get the first found mesh object from the alembic filepath""" - iarch = IArchive(infile) - ipolymsh = findAlembicObject(iarch.getTop(), abcType=IPolyMesh) - return ipolymsh - - -def writeStringProperty(props, key, value, ogawa=True): - """Write the definition string to an alembic OObject - - HDF5 (which we must still support) has a character limit - to string properties. Splitting the string must be handled - in a uniform way, so this function must be used - - Parameters - ---------- - props : OCompoundProperty - The alembic OObject properties - value : str - The simplex definition string - ogawa : bool - If the output is ogawa - - """ - if len(value) > 65000 and not ogawa: - value = str(value) - numChunks = (len(value) // 65000) + 1 - chunkSize = (len(value) // numChunks) + 1 - for c in range(numChunks): - prop = OStringProperty(props, "{0}{1}".format(key, c)) - prop.setValue(value[chunkSize * c : chunkSize * (c + 1)]) - else: - prop = OStringProperty(props, str(key)) - prop.setValue(str(value)) - - -def readStringProperty(props, key): - """Read the definition string from an alembic OObject - - HDF5 (which we must still support) has a character limit - to string properties. Splitting the string must be handled - in a uniform way, so this function must be used - - Parameters - ---------- - props : ICompoundProperty - The alembic IObject properties - - Returns - ------- - : str - The simplex definition string - """ - if not props.valid(): - raise ValueError(".smpx file is missing the alembic user properties") - - try: - prop = props.getProperty(key) - except KeyError: - parts = [] - for c in range(10): - try: - prop = props.getProperty("{0}{1}".format(key, c)) - except KeyError: - if c == 0: - raise - break - else: - parts.append(prop.getValue()) - else: - raise ValueError("That is a HELL of a long simplex definition") - jsString = "".join(parts) - else: - jsString = prop.getValue() - - return jsString - - -def flattenFaces(faces): - """Take a nested list representation of faces - and turn it into a flat face/count representation - - Parameters - ---------- - faces : [[int, ...], ...] - The nested list representation - - Returns - ------- - : np.array or list - The flat list of face connectivity - : np.array or list - The flat list of vertices per face - - """ - faceCounts, faceIdxs = [], [] - for f in faces: - faceCounts.append(len(f)) - faceIdxs.extend(f) - if np is not None: - return np.array(faceCounts), np.array(faceIdxs) - return faceCounts, faceIdxs - - -def buildAbc( - outPath, - points, - faces, - faceCounts=None, - uvs=None, - uvFaces=None, - normals=None, - normFaces=None, - name="polymsh", - shapeSuffix="Shape", - transformSuffix="", - propDict=None, - ogawa=True, - pBar=None, -): - """ - Build a single-mesh alembic file from all of the non-alembic raw data - - Parameters - ---------- - outPath: str - The output path for the alembic file - points: list or ndarray - The list or array of points. Single multiple frames supported - faces: list - A list of lists of face indices, or a flattened list of indices. - If flat, then faceCounts must be provided - faceCounts: list - A list of the number of vertices per face. Defaults to None - uvs: list or ndarray - The Uvs for this mesh. Defaults to None - uvFaces: list - A list of lists of face indices, or a flattened list of indices. - If flat, then faceCounts must be provided. Defaults to None - normals: list or ndarray - The Normals for this mesh. Defaults to None - normFaces: list - A list of lists of face indices, or a flattened list of indices. - If flat, then faceCounts must be provided. Defaults to None - name: str - The name to give this mesh. Defaults to "polymsh" - shapeSuffix: str - The suffix to add to the shape of this mesh. Defaults to "Shape" - transformSuffix: str - The suffix to add to the transform of this mesh. Defaults to "" - propDict: dict - A dictionary of properties to add to the xform object - ogawa : bool - Whether to write to the Ogawa (True) or HDF5 (False) backend - pBar : QProgressDialog, optional - An optional progress dialog - """ - if faceCounts is None: - # All the faces are in list-of-list format - # put them in index-count format - faceCounts, faces = flattenFaces(faces) - if uvFaces is not None: - _, uvFaces = flattenFaces(uvFaces) - if normFaces is not None: - _, normFaces = flattenFaces(normFaces) - - faceCounts = mkSampleIntArray(faceCounts) - faces = mkSampleIntArray(faces) - - if not isinstance(uvs, OV2fGeomParamSample): - if uvFaces is not None and uvs is not None: - uvs = mkUvSample(uvs, indexes=uvFaces) - - if not isinstance(normals, ON3fGeomParamSample): - if normFaces is not None and normals is not None: - normals = mkNormalSample(normals, indexes=normFaces) - - oarch = OArchive(str(outPath), ogawa) - parent, opar, props, omesh, sch = None, None, None, None, None - try: - parent = oarch.getTop() - opar = OXform(parent, str(name + transformSuffix)) - if propDict: - props = opar.getSchema().getUserProperties() - for k, v in propDict.items(): - writeStringProperty(props, str(k), str(v), ogawa=ogawa) - - omesh = OPolyMesh(opar, str(name + shapeSuffix)) - - if np is not None: - points = np.array(points) - if len(points.shape) == 2: - points = points[None, ...] - else: - if not isinstance(points[0][0], (list, tuple)): - points = [points] - - sch = omesh.getSchema() - for i, frame in enumerate(points): - pbPrint(pBar, message="Exporting Shape", val=i, maxVal=len(points)) - abcFrame = mkSampleVertexPoints(frame) - setAlembicSample(sch, abcFrame, faceCounts, faces, uvs=uvs, normals=normals) - - pbPrint(pBar, message="Done Exporting") - finally: - # Make sure all this gets deleted so the file is freed - del parent, opar, props, omesh, sch - - -# Simplex format specific stuff -def getSmpxArchiveData(abcPath): - """Read and return the low level relevant data from a simplex alembic - - Parameters - ---------- - abcPath : str - The path to the .smpx file - - Returns - ------- - : IArchive - An opened Alembic IArchive object handle - : IPolyMesh - An Alembic Mesh handle - : str - The json definition string - """ - if not os.path.isfile(str(abcPath)): - raise IOError("File does not exist: " + str(abcPath)) - iarch = IArchive(str(abcPath)) # because alembic hates unicode - top, par, abcMesh = [None] * 3 - try: - top = iarch.getTop() - par = top.children[0] - par = IXform(top, par.getName()) - abcMesh = par.children[0] - abcMesh = IPolyMesh(par, abcMesh.getName()) - # I *could* come up with a generic property reader - # but it's useless for me at this time - sch = par.getSchema() - props = sch.getUserProperties() - jsString = readStringProperty(props, "simplex") - - except Exception: # pylint: disable=broad-except - # ensure that the .smpx file is released - iarch, top, par, abcMesh = [None] * 4 - raise - - # Must return the archive, otherwise it gets GC'd - return iarch, abcMesh, jsString - - -def readSmpx(path, pBar=None): - """Read and return the raw alembic vertex/face data in the flat alembic style - - Parameters - ---------- - abcPath : str - The path to the .smpx file - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - : str - The simplex definition string - : [int, ...] or np.array - The number of vertices per face - : [[(float*3), ...], ...] or np.array - The vertex positions per shape - : [int, ...] or np.array - The flat indexes per face - : [(float*2), ...] or np.array or None - The UV's - : [int, ...] or np.array - The flat indexes per uv-face - """ - iarch, abcMesh, jsString = getSmpxArchiveData(path) - try: - faces, counts = getStaticMeshArrays(abcMesh) - verts = getSampleArray(abcMesh, pBar=pBar) - uvs = getUvArray(abcMesh) - uvFaces, _ = getFlatUvFaces(abcMesh) - finally: - del iarch, abcMesh - return jsString, counts, verts, faces, uvs, uvFaces - - -def buildSmpx( - outPath, - points, - faces, - jsString, - name, - faceCounts=None, - uvs=None, - uvFaces=None, - ogawa=True, - pBar=None, -): - """ - Build a simplex output from raw data - - Parameters - ---------- - outPath: str - The output path for the alembic file - points: list or ndarray - The list or array of points. Single multiple frames supported - faces: list - A list of lists of face indices, or a flattened list of indices. - If flat, then faceCounts must be provided - jsString : str - The simplex definition string - name: str - The name to give this mesh - faceCounts: list - A list of the number of vertices per face. Defaults to None - uvs: list or ndarray - The Uvs for this mesh. Defaults to None - uvFaces: list - A list of lists of face indices, or a flattened list of indices. - If flat, then faceCounts must be provided. Defaults to None - ogawa : bool - Whether to write to the Ogawa (True) or HDF5 (False) backend - pBar : QProgressDialog, optional - An optional progress dialog - """ - buildAbc( - outPath, - points, - faces, - faceCounts=faceCounts, - uvs=uvs, - uvFaces=uvFaces, - name=name, - shapeSuffix="", - transformSuffix="", - propDict={"simplex": jsString}, - ogawa=ogawa, - pBar=pBar, - ) - - -def buildAlembicArchiveData(path, name, jsString, ogawa): - """Set up an output alembic archive with a mesh ready for writing - - Parameters - ---------- - path : str - The output file path - name : str - The name of the system - jsString : str - The simplex definition string - ogawa : bool - Whether to open in Ogawa (True) or HDF5 (False) mode - - Returns - ------- - : OArchive - The opened alembic output archive - : OPolyMesh - The mesh to write the shape data to - - """ - arch = OArchive(str(path), ogawa) - par, props, abcMesh = [None] * 3 - try: - par = OXform(arch.getTop(), str(name)) - props = par.getSchema().getUserProperties() - writeStringProperty(props, "simplex", jsString, ogawa=ogawa) - abcMesh = OPolyMesh(par, str(name)) - except Exception: - arch, par, props, abcMesh = [None] * 4 - raise - return arch, abcMesh - - -def readFalloffData(abcPath): - """Load the relevant data from a simplex alembic - - Parameters - ---------- - abcPath : str - Path to the .smpx file - - """ - if not os.path.isfile(str(abcPath)): - raise IOError("File does not exist: " + str(abcPath)) - iarch = IArchive(str(abcPath)) # because alembic hates unicode - top, par, systemSchema, foPropPar, foProp = [None] * 5 - try: - top = iarch.getTop() - par = top.children[0] - par = IXform(top, par.getName()) - systemSchema = par.getSchema() - props = systemSchema.getUserProperties() - foDict = {} - try: - foPropPar = props.getProperty("falloffs") - except KeyError: - pass - else: - nps = foPropPar.getNumProperties() - for i in range(nps): - foProp = foPropPar.getProperty(i) - fon = foProp.getName() - fov = foProp.getValue() # imath.FloatArray - fov = list(fov) if np is None else np.array(fov) - foDict[fon] = fov - finally: - iarch, top, par, systemSchema, foPropPar, foProp = [None] * 6 - - return foDict +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +"""Alembic files can be difficult to work with, and can be *very* slow in Python +This is a library of convenience functions with the numpy speed optimizations +""" + +from __future__ import annotations + +import os + +import numpy as np +import numpy.typing as npt +from alembic.Abc import ( + IArchive, + ICompoundProperty, + IObject, + OArchive, + OCompoundProperty, + OStringProperty, +) +from alembic.AbcGeom import ( + GeometryScope, + IPolyMesh, + IXform, + ON3fGeomParamSample, + OPolyMesh, + OPolyMeshSchema, + OPolyMeshSchemaSample, + OV2fGeomParamSample, + OXform, +) +from imath import Box3d, IntArray, UnsignedIntArray, V2f, V2fArray, V3fArray + +try: + from imathnumpy import arrayToNumpy +except ImportError: + arrayToNumpy = None + + +from typing import ( + TYPE_CHECKING, + Any, + TypeVar, + Union, + overload, +) + +if TYPE_CHECKING: + from alembic.AbcGeom import _IBase # A helper typing-only class + from Qt.QtWidgets import QProgressDialog +else: + _IBase = QProgressDialog = Any + +npfloat = npt.NDArray[Union[np.float32, np.float64]] +npint = npt.NDArray[Union[np.int32, np.int64]] + +T = TypeVar("T", bound=Union[IntArray, UnsignedIntArray, V2fArray, V3fArray]) + + +def pbPrint( + pBar: QProgressDialog | None, + message: str | None = None, + val: int | None = None, + maxVal: int | None = None, + _pbPrintLastComma=None, +) -> None: + """A function that handles displaying messages in a QProgressDialog or printing to stdout + + Don't forget to call QApplication.processEvents() after using this function + + Parameters + ---------- + pBar : QProgressDialog or None + An optional progress bar + message : str or None + An optional message to display + val : int or None + An optional progress value to display + maxVal : int or None + An optional maximum value to display + _pbPrintLastComma: object + INTERNAL USE ONLY + """ + _pbPrintLastComma = [] if _pbPrintLastComma is None else _pbPrintLastComma + + if pBar is not None: + if val is not None: + pBar.setValue(val) + if message is not None: + pBar.setLabelText(message) + else: + if message is not None: + if val is not None: + if maxVal is not None: + print(message, f"{val + 1: <4} of {maxVal: <4}\r", end=" ") + else: + print(message, f"{val + 1: <4}\r", end=" ") + # This is the ugliest, most terrible thing I think I've ever written + # Abusing the static default object to check if the last time + # this function was used, there was a trailing comma + # But damn if it doesn't make me laugh + if not _pbPrintLastComma: + _pbPrintLastComma.append("") + else: + if _pbPrintLastComma: + print(_pbPrintLastComma.pop()) + print(message) + + +def mkArray(aType: type[T], iList: npt.NDArray) -> T: + """Makes the alembic-usable c++ typed 2-d arrays + + Parameters + ---------- + aType : imath type + The type of the output array + iList : list or np.array + The input iterable. + + Returns + ------- + : aType + The input list translated into an aType array + """ + if isinstance(iList, aType): + return iList + + size = len(iList) + array = aType(size) + if arrayToNumpy is None: + if isinstance(iList, np.ndarray): + for i in range(size): + array[i] = tuple(iList[i].tolist()) + else: + for i in range(size): + array[i] = tuple(iList[i]) + else: + nplist = np.array(iList) + memView = arrayToNumpy(array) + np.copyto(memView, nplist) + return array + + +def mk1dArray(aType: type[T], iList: npt.NDArray) -> T: + """Makes the alembic-usable c++ typed 1-d arrays + + Parameters + ---------- + aType : imath type + The type of the output array + iList : list or np.array + The input iterable. + + Returns + ------- + : aType + The input list translated into an aType array + """ + if isinstance(iList, aType): + return iList + + array = aType(len(iList)) + if arrayToNumpy is None or aType is UnsignedIntArray: + for i in range(len(iList)): + # Gotta cast to int because an "int" from numpy has + # the type np.int32, which makes this conversion angry + array[i] = int(iList[i]) + return array + else: + nplist = np.array(iList) + memView = arrayToNumpy(array) + np.copyto(memView, nplist) + return array + + +def mkSampleVertexPoints(pts: npfloat) -> V3fArray: + """Make an imath array of vertices + + Parameters + ---------- + pts : list or np.array + The input points + + Returns + ------- + : V3fArray + The output list + """ + return mkArray(V3fArray, pts) + + +def mkSampleIntArray(vals: npint) -> IntArray: + """Make an imath array of integers + + Parameters + ---------- + pts : list or np.array + The input integers + + Returns + ------- + : IntArray + The output list + """ + return mk1dArray(IntArray, vals) + + +def mkSampleUIntArray(vals: npint) -> UnsignedIntArray: + """Make an imath array of unsigned integers + + Parameters + ---------- + pts : list or np.array + The input unsigned integers + + Returns + ------- + : UnsignedIntArray + The output list + """ + return mk1dArray(UnsignedIntArray, vals) + + +def mkSampleUvArray(uvs: npfloat) -> V2fArray: + """Make an imath array of uvs + + Parameters + ---------- + uvs : list or np.array + The input uvs + + Returns + ------- + : V2fArray + The output list + """ + array = V2fArray(len(uvs)) + setter = V2f(0, 0) + for i in range(len(uvs)): + setter.setValue(float(uvs[i][0]), float(uvs[i][1])) + array[i] = setter + return array + + +def mkUvSample(uvs: npfloat, indexes: npint | None = None) -> OV2fGeomParamSample: + """Take an array, and make a poly mesh sample of the uvs + + Parameters + ---------- + uvs : list or np.array + The input uvs + indexes : list or np.array or None + The optional face indices of the uvs + + Returns + ------- + : OV2fGeomParamSample + The UV sample + """ + ary = mkSampleUvArray(uvs) + if indexes is None: + return OV2fGeomParamSample(ary, GeometryScope.kFacevaryingScope) + idxs = mkSampleUIntArray(indexes) + return OV2fGeomParamSample(ary, idxs, GeometryScope.kFacevaryingScope) + + +def mkNormalSample(norms: npfloat, indexes: npint | None = None) -> ON3fGeomParamSample: + """Take an array, and make a poly mesh sample of the normals + + Parameters + ---------- + norms : list or np.array + The input normals + indexes : list or np.array or None + The optional face indices of the normals + + Returns + ------- + : ON3fGeomParamSample + The Normal sample + """ + ary = mkArray(V3fArray, norms) + if indexes is None: + return ON3fGeomParamSample(ary, GeometryScope.kFacevaryingScope) + idxs = mkSampleUIntArray(indexes) + return ON3fGeomParamSample(ary, idxs, GeometryScope.kFacevaryingScope) + + +def setAlembicSample( + omeshSch: OPolyMeshSchema, + points: V3fArray, + faceCount: IntArray, + faceIndex: IntArray, + bounds: Box3d | None = None, + uvs: OV2fGeomParamSample | None = None, + normals: ON3fGeomParamSample | None = None, +) -> None: + """Set an alembic sample to the output mesh with the given properties""" + # Do it this way because the defaults for these arguments are some value other than None + kwargs = {} + if uvs is not None: + kwargs["iUVs"] = uvs + if normals is not None: + kwargs["iNormals"] = normals + + s = OPolyMeshSchemaSample(points, faceIndex, faceCount, **kwargs) + if bounds is not None: + omeshSch.getChildBoundsProperty().setValue(bounds) + omeshSch.set(s) + + +def getSampleArrayIndex( + imesh: IPolyMesh, index: int = 0, pBar: QProgressDialog | None = None +) -> npfloat: + """Get the per-frame vertex positions for a mesh + + Parameters + ---------- + imesh : IPolyMesh + The input alembic mesh object + index: int + The sample index to retrieve + + Returns + ------- + : np.array or list + The per-frame vertex positions + """ + meshSchema = imesh.getSchema() + posProp = meshSchema.getPositionsProperty() + s = posProp.samples[index] + if arrayToNumpy is not None: + return arrayToNumpy(s).copy() + return np.array([list(s.x), list(s.y), list(s.z)]).T + + +def getSampleArray(imesh: IPolyMesh, pBar: QProgressDialog | None = None) -> npfloat: + """Get the per-frame vertex positions for a mesh + + Parameters + ---------- + imesh : IPolyMesh + The input alembic mesh object + + Returns + ------- + : np.array or list + The per-frame vertex positions + """ + meshSchema = imesh.getSchema() + posProp = meshSchema.getPositionsProperty() + numShapes = len(posProp.samples) + if arrayToNumpy is not None: + shapes = np.empty((len(posProp.samples), len(posProp.samples[0]), 3)) + for i, s in enumerate(posProp.samples): + pbPrint(pBar, message="Reading Shape", val=i, maxVal=numShapes) + shapes[i] = arrayToNumpy(s) + return shapes + else: + shapes = [] + for i, s in enumerate(posProp.samples): + pbPrint(pBar, message="Reading Shape", val=i, maxVal=numShapes) + shapes.append((list(s.x), list(s.y), list(s.z))) + shapes = np.array(shapes) + shapes = shapes.transpose((0, 2, 1)) + return shapes + + +def getStaticMeshData(imesh: IPolyMesh) -> tuple[IntArray, IntArray]: + """Get all the generally non-changing data for a mesh + + Parameters + ---------- + imesh : IPolyMesh + The input alembic mesh object + + Returns + ------- + : IntArray + A flat alembic array of vertex indices for the faces + : IntArray + The number of vertices per face + """ + sch = imesh.getSchema() + faces = sch.getFaceIndicesProperty().samples[0] + counts = sch.getFaceCountsProperty().samples[0] + return faces, counts + + +def getStaticMeshArrays(imesh: IPolyMesh) -> tuple[npint, npint]: + """Get all the generally non-changing data for a mesh as numpy arrays + + Parameters + ---------- + imesh : IPolyMesh + The input alembic mesh object + + Returns + ------- + : np.array or list + A flat of vertex indices for the faces as np.array if possible + : np.array or list + The number of vertices per face as np.array if possible + """ + faces, counts = getStaticMeshData(imesh) + if arrayToNumpy is not None: + faces = arrayToNumpy(faces).copy() + counts = arrayToNumpy(counts).copy() + else: + faces, counts = np.array(faces), np.array(counts) + return faces, counts + + +def getUvSample(imesh: IPolyMesh) -> OV2fGeomParamSample | None: + """Get the UV's for a mesh + + Parameters + ---------- + imesh : IPolyMesh + The input alembic mesh object + + Returns + ------- + : OV2fGeomParamSample + The UV Sample + """ + imeshsch = imesh.getSchema() + uvParam = imeshsch.getUVsParam() + + if not uvParam.valid(): + return None + + uvValue = uvParam.getValueProperty().getValue() + if uvParam.isIndexed(): + idxValue = uvParam.getIndexProperty().getValue() + uv = OV2fGeomParamSample(uvValue, idxValue, GeometryScope.kFacevaryingScope) + else: + uv = OV2fGeomParamSample(uvValue, GeometryScope.kFacevaryingScope) + return uv + + +def getUvArray(imesh: IPolyMesh) -> npfloat | None: + """Get the uv positions for a mesh + + Parameters + ---------- + imesh : IPolyMesh + The input alembic mesh object + + Returns + ------- + : list or np.array or None + The UVs if they exist as a numpy array if possible + """ + imeshsch = imesh.getSchema() + uvParam = imeshsch.getUVsParam() + if uvParam.valid(): + uvProp = uvParam.getValueProperty() + uvVals = uvProp.getValue() + if arrayToNumpy is None: + uv = list(zip(uvVals.x, uvVals.y)) + uv = np.array(uv) + else: + uv = arrayToNumpy(uvVals) + else: + uv = None + return uv + + +def getFlatUvFaces(imesh: IPolyMesh) -> tuple[npint | None, bool | None]: + """Get the UV structure for a mesh if it's indexed. If un-indexed, return None + This means that if we have valid UVs, but invalid uvFaces, then we're un-indexed + and can handle the data appropriately for export without keeping track of index-ness + + Parameters + ---------- + imesh : IPolyMesh + The input alembic mesh object + + Returns + ------- + : [int, ...] or np.array + The UVFace structure + : bool + Whether we share uvs between uvFaces (True), or we have a uv per face-vertex (False) + """ + sch = imesh.getSchema() + iuvs = sch.getUVsParam() + idxs = None + indexed = None + if iuvs.valid(): + if iuvs.isIndexed(): + indexed = True + idxs = iuvs.getIndexProperty().getValue() + idxs = np.array(idxs) + else: + indexed = False + rawCount = sum(list(sch.getFaceCountsProperty().samples[0])) + idxs = np.arange(rawCount) + + return idxs, indexed + + +def getUvFaces(imesh: IPolyMesh) -> list[list[int]] | None: + """Get the UV structure for a mesh if it's indexed. If un-indexed, return None + This means that if we have valid UVs, but invalid uvFaces, then we're un-indexed + and can handle the data appropriately for export without keeping track of index-ness + + Parameters + ---------- + imesh : IPolyMesh + The input alembic mesh object + + Returns + ------- + : [[int, ...], ...] + The UVFace structure + """ + sch = imesh.getSchema() + rawCounts = sch.getFaceCountsProperty().samples[0] + iuvs = sch.getUVsParam() + uvFaces = None + if iuvs.valid(): + uvFaces = [] + uvCounter = 0 + if iuvs.isIndexed(): + idxs = list(iuvs.getIndexProperty().getValue()) + for count in rawCounts: + uvFaces.append(list(idxs[uvCounter : uvCounter + count])) + uvCounter += count + return uvFaces + + +def getMeshFaces(imesh: IPolyMesh) -> list[list[int]]: + """Get The vertex indices used per face + + Parameters + ---------- + imesh : IPolyMesh + The input alembic mesh object + + Returns + ------- + : [[int, ...], ...] + The UVFace structure + """ + rawFaces, rawCounts = getStaticMeshData(imesh) + faces = [] + ptr = 0 + for count in rawCounts: + chunk = rawFaces[ptr : ptr + count] + faces.append(list(chunk)) + ptr += count + return faces + + +def getPointCount(imesh: IPolyMesh) -> int: + """Get the number of vertices in a mesh + + Parameters + ---------- + imesh : IPolyMesh + The input alembic mesh object + + Returns + ------- + : int + The number of vertices in the mesh + """ + meshSchema = imesh.getSchema() + posProp = meshSchema.getPositionsProperty() + return len(posProp.samples[0]) + + +B = TypeVar("B", bound=_IBase) + + +@overload +def findAlembicObject( + obj: IObject, abcType: None = None, name: str | None = None +) -> IObject | None: ... + + +@overload +def findAlembicObject( + obj: IObject, abcType: type[B], name: str | None = None +) -> B | None: ... + + +def findAlembicObject( + obj: IObject, abcType: type[B] | None = None, name: str | None = None +) -> IObject | B | None: + """Finds a single object in an alembic archive by name and/or type + If only type is specified, then the first object of that type + encountered will be returned + """ + md = obj.getMetaData() + if abcType is None: + if name is None or obj.getName() == name: + return obj + elif abcType.matches(md): + if name is None or obj.getName() == name: + return abcType(obj.getParent(), obj.getName()) + for child in obj.children: + out = findAlembicObject(child, abcType, name) + if out is not None: + return out + return None + + +def findAllAlembicObjects( + obj: IObject, + abcType: type[_IBase] | None = None, + out: list[IObject] | None = None, +) -> list[IObject]: + """Finds all objects of a type in an alembic archive""" + md = obj.getMetaData() + out = [] if out is None else out + if abcType is None: + out.append(obj) + elif abcType.matches(md): + out.append(abcType(obj.getParent(), obj.getName())) + for child in obj.children: + findAllAlembicObjects(child, abcType, out) + return out + + +def getTypedIObject(obj: IObject) -> _IBase | None: + from alembic.AbcGeom import ( + ICamera, + ICurves, + ILight, + INuPatch, + IPoints, + IPolyMesh, + ISubD, + IXform, + ) + + md = obj.getMetaData() + for abcType in ( + IXform, + IPolyMesh, + ICamera, + ICurves, + ILight, + INuPatch, + IPoints, + ISubD, + ): + if abcType.matches(md): + return abcType(obj.getParent(), obj.getName()) + return None + + +def getMesh(infile: str) -> IPolyMesh | None: + """Get the first found mesh object from the alembic filepath""" + iarch = IArchive(str(infile)) + ipolymsh = findAlembicObject(iarch.getTop(), abcType=IPolyMesh) + return ipolymsh + + +def writeStringProperty( + props: OCompoundProperty, key: str, value: str, ogawa: bool = True +) -> None: + """Write the definition string to an alembic OObject + + HDF5 (which we must still support) has a character limit + to string properties. Splitting the string must be handled + in a uniform way, so this function must be used + + Parameters + ---------- + props : OCompoundProperty + The alembic OObject properties + value : str + The simplex definition string + ogawa : bool + If the output is ogawa + + """ + if len(value) > 65000 and not ogawa: + value = str(value) + numChunks = (len(value) // 65000) + 1 + chunkSize = (len(value) // numChunks) + 1 + for c in range(numChunks): + prop = OStringProperty(props, f"{key}{c}") + prop.setValue(value[chunkSize * c : chunkSize * (c + 1)]) + else: + prop = OStringProperty(props, str(key)) + prop.setValue(str(value)) + + +def readStringProperty(props: ICompoundProperty, key: str) -> str: + """Read the definition string from an alembic OObject + + HDF5 (which we must still support) has a character limit + to string properties. Splitting the string must be handled + in a uniform way, so this function must be used + + Parameters + ---------- + props : ICompoundProperty + The alembic IObject properties + + Returns + ------- + : str + The simplex definition string + """ + if not props.valid(): + raise ValueError(".smpx file is missing the alembic user properties") + + try: + prop = props.getProperty(key) + except KeyError: + parts = [] + for c in range(10): + try: + prop = props.getProperty(f"{key}{c}") + except KeyError: + if c == 0: + raise + break + else: + parts.append(prop.getValue()) + else: + raise ValueError("That is a HELL of a long simplex definition") + jsString = "".join(parts) + else: + jsString = prop.getValue() + + return jsString + + +def flattenFaces(faces: list[list[int]]) -> tuple[npint, npint]: + """Take a nested list representation of faces + and turn it into a flat face/count representation + + Parameters + ---------- + faces : [[int, ...], ...] + The nested list representation + + Returns + ------- + : np.array or list + The flat list of face connectivity + : np.array or list + The flat list of vertices per face + + """ + faceCounts, faceIdxs = [], [] + for f in faces: + faceCounts.append(len(f)) + faceIdxs.extend(f) + return np.array(faceCounts), np.array(faceIdxs) + + +def unflattenFaces(faces: npint, counts: npint) -> list[list[int]]: + """Take a flat face/count representation of faces + and turn it into a nested list representation + + Parameters + ---------- + faces : np.array + The flat list of face connectivity + counts : np.array + The flat list of vertices per face + + Returns + ------- + : [[int, ...], ...] + The nested list representation + """ + out, ptr = [], 0 + for c in counts: + out.append(faces[ptr : ptr + c].tolist()) + ptr += c + return out + + +def buildAbc( + outPath: str, + points: npfloat, + faces: npint, + faceCounts: npint | None = None, + uvs: npfloat | None = None, + uvFaces: npint | None = None, + normals: npfloat | None = None, + normFaces: npint | None = None, + name: str = "polymsh", + shapeSuffix: str = "Shape", + transformSuffix: str = "", + propDict: dict[str, str] | None = None, + ogawa: bool = True, + pBar: QProgressDialog | None = None, +) -> None: + """ + Build a single-mesh alembic file from all of the non-alembic raw data + + Parameters + ---------- + outPath: str + The output path for the alembic file + points: list or ndarray + The list or array of points. Single multiple frames supported + faces: list + A list of lists of face indices, or a flattened list of indices. + If flat, then faceCounts must be provided + faceCounts: list + A list of the number of vertices per face. Defaults to None + uvs: list or ndarray + The Uvs for this mesh. Defaults to None + uvFaces: list + A list of lists of face indices, or a flattened list of indices. + If flat, then faceCounts must be provided. Defaults to None + normals: list or ndarray + The Normals for this mesh. Defaults to None + normFaces: list + A list of lists of face indices, or a flattened list of indices. + If flat, then faceCounts must be provided. Defaults to None + name: str + The name to give this mesh. Defaults to "polymsh" + shapeSuffix: str + The suffix to add to the shape of this mesh. Defaults to "Shape" + transformSuffix: str + The suffix to add to the transform of this mesh. Defaults to "" + propDict: dict + A dictionary of properties to add to the xform object + ogawa : bool + Whether to write to the Ogawa (True) or HDF5 (False) backend + pBar : QProgressDialog, optional + An optional progress dialog + """ + if faceCounts is None: + # All the faces are in list-of-list format + # put them in index-count format + faceCounts, faces = flattenFaces(faces) + if uvFaces is not None: + _, uvFaces = flattenFaces(uvFaces) + if normFaces is not None: + _, normFaces = flattenFaces(normFaces) + + faceCounts = mkSampleIntArray(faceCounts) + faces = mkSampleIntArray(faces) + + if not isinstance(uvs, OV2fGeomParamSample): + if uvFaces is not None and uvs is not None: + uvs = mkUvSample(uvs, indexes=uvFaces) + + if not isinstance(normals, ON3fGeomParamSample): + if normFaces is not None and normals is not None: + normals = mkNormalSample(normals, indexes=normFaces) + + oarch = OArchive(str(outPath), ogawa) + parent, opar, props, omesh, sch = None, None, None, None, None + try: + parent = oarch.getTop() + opar = OXform(parent, str(name + transformSuffix)) + if propDict: + props = opar.getSchema().getUserProperties() + for k, v in propDict.items(): + writeStringProperty(props, str(k), str(v), ogawa=ogawa) + + omesh = OPolyMesh(opar, str(name + shapeSuffix)) + + points = np.array(points) + if len(points.shape) == 2: + points = points[None, ...] + + sch = omesh.getSchema() + for i, frame in enumerate(points): + pbPrint(pBar, message="Exporting Shape", val=i, maxVal=len(points)) + abcFrame = mkSampleVertexPoints(frame) + setAlembicSample(sch, abcFrame, faceCounts, faces, uvs=uvs, normals=normals) + + pbPrint(pBar, message="Done Exporting") + finally: + # Make sure all this gets deleted so the file is freed + del parent, opar, props, omesh, sch + + +# Simplex format specific stuff +def getSmpxArchiveData(abcPath: str) -> tuple[IArchive, IPolyMesh, str]: + """Read and return the low level relevant data from a simplex alembic + + Parameters + ---------- + abcPath : str + The path to the .smpx file + + Returns + ------- + : IArchive + An opened Alembic IArchive object handle + : IPolyMesh + An Alembic Mesh handle + : str + The json definition string + """ + if not os.path.isfile(str(abcPath)): + raise OSError("File does not exist: " + str(abcPath)) + iarch = IArchive(str(abcPath)) # because alembic hates unicode + top, par, abcMesh = [None] * 3 + try: + top = iarch.getTop() + par = top.children[0] + par = IXform(top, par.getName()) + abcMesh = par.children[0] + abcMesh = IPolyMesh(par, abcMesh.getName()) + # I *could* come up with a generic property reader + # but it's useless for me at this time + sch = par.getSchema() + props = sch.getUserProperties() + jsString = readStringProperty(props, "simplex") + + except Exception: + # ensure that the .smpx file is released + iarch, top, par, abcMesh = [None] * 4 + raise + + # Must return the archive, otherwise it gets GC'd + return iarch, abcMesh, jsString + + +def readSmpx( + path: str, pBar: QProgressDialog | None = None +) -> tuple[str, npint, npfloat, npint, npfloat | None, npint | None]: + """Read and return the raw alembic vertex/face data in the flat alembic style + + Parameters + ---------- + abcPath : str + The path to the .smpx file + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + : str + The simplex definition string + : [int, ...] or np.array + The number of vertices per face + : [[(float*3), ...], ...] or np.array + The vertex positions per shape + : [int, ...] or np.array + The flat indexes per face + : [(float*2), ...] or np.array or None + The UV's + : [int, ...] or np.array + The flat indexes per uv-face + """ + iarch, abcMesh, jsString = getSmpxArchiveData(path) + try: + faces, counts = getStaticMeshArrays(abcMesh) + verts = getSampleArray(abcMesh, pBar=pBar) + uvs = getUvArray(abcMesh) + uvFaces, _ = getFlatUvFaces(abcMesh) + finally: + del iarch, abcMesh + return jsString, counts, verts, faces, uvs, uvFaces + + +def buildSmpx( + outPath: str, + points: npfloat, + faces: npint, + jsString: str, + name: str, + faceCounts: npint | None = None, + uvs: npfloat | None = None, + uvFaces: npint | None = None, + ogawa: bool = True, + pBar: QProgressDialog | None = None, +) -> None: + """ + Build a simplex output from raw data + + Parameters + ---------- + outPath: str + The output path for the alembic file + points: list or ndarray + The list or array of points. Single multiple frames supported + faces: list + A list of lists of face indices, or a flattened list of indices. + If flat, then faceCounts must be provided + jsString : str + The simplex definition string + name: str + The name to give this mesh + faceCounts: list + A list of the number of vertices per face. Defaults to None + uvs: list or ndarray + The Uvs for this mesh. Defaults to None + uvFaces: list + A list of lists of face indices, or a flattened list of indices. + If flat, then faceCounts must be provided. Defaults to None + ogawa : bool + Whether to write to the Ogawa (True) or HDF5 (False) backend + pBar : QProgressDialog, optional + An optional progress dialog + """ + buildAbc( + outPath, + points, + faces, + faceCounts=faceCounts, + uvs=uvs, + uvFaces=uvFaces, + name=name, + shapeSuffix="", + transformSuffix="", + propDict={"simplex": jsString}, + ogawa=ogawa, + pBar=pBar, + ) + + +def buildAlembicArchiveData( + path: str, name: str, jsString: str, ogawa: bool +) -> tuple[OArchive | None, OPolyMesh | None]: + """Set up an output alembic archive with a mesh ready for writing + + Parameters + ---------- + path : str + The output file path + name : str + The name of the system + jsString : str + The simplex definition string + ogawa : bool + Whether to open in Ogawa (True) or HDF5 (False) mode + + Returns + ------- + : OArchive + The opened alembic output archive + : OPolyMesh + The mesh to write the shape data to + + """ + arch = OArchive(str(path), ogawa) + par, props, abcMesh = [None] * 3 + try: + par = OXform(arch.getTop(), str(name)) + props = par.getSchema().getUserProperties() + writeStringProperty(props, "simplex", jsString, ogawa=ogawa) + abcMesh = OPolyMesh(par, str(name)) + except Exception: + arch, par, props, abcMesh = [None] * 4 + raise + return arch, abcMesh + + +def readFalloffData(abcPath: str) -> dict[str, npfloat]: + """Load the relevant data from a simplex alembic + + Parameters + ---------- + abcPath : str + Path to the .smpx file + + """ + if not os.path.isfile(str(abcPath)): + raise OSError("File does not exist: " + str(abcPath)) + iarch = IArchive(str(abcPath)) # because alembic hates unicode + top, par, systemSchema, foPropPar, foProp = [None] * 5 + try: + top = iarch.getTop() + par = top.children[0] + par = IXform(top, par.getName()) + systemSchema = par.getSchema() + props = systemSchema.getUserProperties() + foDict = {} + try: + foPropPar = props.getProperty("falloffs") + except KeyError: + pass + else: + nps = foPropPar.getNumProperties() + for i in range(nps): + foProp = foPropPar.getProperty(i) + foDict[foProp.getName()] = np.array(foProp.getValue()) + finally: + iarch, top, par, systemSchema, foPropPar, foProp = [None] * 6 + + return foDict + + +def getIArchive(filepath: str | list[str]) -> IArchive: + """Get the IArchive from the given path, but check if the file exists + first and raise an appropriate error if it doesn't + + Parameters + ---------- + abcPath : str + Path to the .smpx file + + Returns + ------- + IArchive : + The opened IArchive + """ + if not filepath: + raise ValueError("Invalid Filepath: {0}".format(filepath)) + + # Alembic 1.7+ allows for opening multiple caches at the same time + # since I use that, I will just *always* use a list for consistency + if isinstance(filepath, str): + filepath = [filepath] + + if not isinstance(filepath, (list, tuple)): + raise TypeError("Invalid Filepath Type: {0}".format(filepath)) + + # Make sure all the paths are stringified so alembic doesn't complain + # Alembic crashes with unicode filepaths in py2 + filepath = [str(i) for i in filepath] + + # Check for file existence + for fp in filepath: + if not os.path.exists(str(fp)): + raise OSError("Filepath does not exist: {0}".format(fp)) + + try: + return IArchive(filepath) + except Exception as e: + # Make sure to include the filepath list in any other errors that alembic throws + errArgs = list(e.args) + errArgs[0] = f"{errArgs[0]}: {filepath}" + raise type(e)(*errArgs) from e diff --git a/src/python/simplexui/commands/alembic_walker.py b/src/python/simplexui/commands/alembic_walker.py new file mode 100644 index 00000000..4329ee6f --- /dev/null +++ b/src/python/simplexui/commands/alembic_walker.py @@ -0,0 +1,299 @@ +from __future__ import annotations +from typing import cast, Iterator + +from .alembicCommon import getIArchive + +from alembic.Abc import ( + IArchive, + OArchive, + IObject, + OObject, + IProperty, + OProperty, + OCompoundProperty, + OScalarProperty, + OArrayProperty, + ICompoundProperty, +) + + +class AlembicWalker(object): + """This class recursively walks an alembic hierarchy, copying + all objects and properties along the way. + + It's written as a class to allow for overriding methods used in the + copying process. To add extra decision making and archive mutation + + copy_alembic Takes input and output filepaths and kicks off the copy process + copy_hierarchy takes input and output alembic objects and copies the hierarchy + from the input to the output + copy_object_properties copies the property data from one object to another. + copy_property_data copies the the data from input to output + + make_obj_walk is the recursive generator function that walks the object hierarchy + depth-first, and creates the empty output objects with the correct type metadata + that copy_object_properties will fill + make_prop_walk is the recursive generator function that walks the property hierarchy + depth-first, and creates the empty output properties of the correct type that + copy_property_data fills + """ + + @classmethod + def make_prop_walk( + cls, + iPar: ICompoundProperty, + oPar: OCompoundProperty, + depth: int = 0, + name: list[str] | None = None, + debug: bool = False, + *args, + **kwargs, + ) -> Iterator[tuple[IProperty, OProperty, int, list[str]]]: + """Generator for depth-first iteration and duplication of the + properties of an alembic object. Every input property is recreated with the + same name and type as an output object, then yielded from the method + + You would override this method if you needed to add or remove certain properties + from the objects when processing the input archive + + Arguments: + iPar (ICompoundProperty): The parent input property whose sub-properties we will + iterate over + oPar (OCompoundProperty): The output property that will act as the container + for all the newly created properties + depth (int): The current depth of the hierarchy + name (list): The full path-name of the current property. This is the name + of all parent container properties as a list + debug (bool): Whether to print the debug messages + args/kwargs : Additional arguments that will get passed down the stack + This saves you from having to override all methods to pass a single + argument to the bottom of the call stack + + Yields: + iProperty: An input property to copy sub-properties and values from + oProperty: An output property to copy sub-properties and values to + int: The current depth of the objects in the hierarchy + list: The current path-name of the properties to be copied + """ + name = name or [] + num = iPar.getNumProperties() + pfx = " " * depth + for i in range(num): + iProp = iPar.getProperty(i) + nextName = name + [iProp.getName()] + + if iProp.isCompound(): + if debug: + print(pfx, iProp.getName()) + oProp = OCompoundProperty(oPar, iProp.getName(), iProp.getMetaData()) + elif iProp.isArray(): + if debug: + print(pfx, iProp.getName()) + oProp = OArrayProperty( + oPar, iProp.getName(), iProp.getDataType(), iProp.getMetaData() + ) + else: + if debug: + print(pfx, iProp.getName()) + oProp = OScalarProperty( + oPar, iProp.getName(), iProp.getDataType(), iProp.getMetaData() + ) + + yield iProp, oProp, depth, nextName + if iProp.isCompound(): + iProp = cast(ICompoundProperty, iProp) + oProp = cast(OCompoundProperty, oProp) + + for ip, op, d, nn in cls.make_prop_walk( + iProp, oProp, depth + 1, nextName, *args, debug=debug, **kwargs + ): + yield ip, op, d, nn + + @classmethod + def make_obj_walk( + cls, + iPar: IObject, + oPar: OObject, + depth: int = 0, + debug: bool = False, + *args, + **kwargs, + ) -> Iterator[tuple[IObject, OObject, int]]: + """Generator for depth-first iteration and duplication over the + objects in an alembic file. Every input object is recreated with the same + name and metadata as an output object, then yielded from the method + + You would override this method if you needed to change the type of an + object, change the output hierarchy, or skip certain objects when + processing the input archive + + Arguments: + iPar (iObject): The parent input object whose children we will + iterate over + oPar (oObject): The output object that will act as the parent + for all the newly created duplicates of the iPar's children + depth (int): The current depth of the hierarchy + debug (bool): Whether to print the debug messages + args/kwargs : Additional arguments that will get passed down the stack + This saves you from having to override all methods to pass a single + argument to the bottom of the call stack + + Yields: + iObject: An object to copy properties from + oObject: An object to copy properties to + int: The current depth of the objects in the hierarchy + """ + num = iPar.getNumChildren() + pfx = " " * depth + for i in range(num): + iChild = iPar.getChild(i) + if debug: + print(pfx, iChild.getName()) + oChild = OObject(oPar, iChild.getName(), iChild.getMetaData()) + + yield iChild, oChild, depth + for ic, oc, d in cls.make_obj_walk( + iChild, oChild, depth + 1, *args, debug=debug, **kwargs + ): + yield ic, oc, d + + @classmethod + def copy_time_sampling( + cls, inProp: IProperty, outProp: OProperty, *args, **kwargs + ) -> None: + """Choose the output timeSampling corresponding to the input one + + Arguments: + inProp (IProperty): The property to copy the time sampling from + outProp (OProperty): The property to copy the time sampling to + args/kwargs : Additional arguments that will get passed down the stack + This saves you from having to override all methods to pass a single + argument to the bottom of the call stack + """ + iSampling = inProp.getTimeSampling() + outProp.setTimeSampling(iSampling) + + @classmethod + def copy_property_data( + cls, + inProp: IProperty, + outProp: OProperty, + name: list[str], + objDepth: int, + propDepth: int, + *args, + **kwargs, + ) -> None: + """Copy the data from an input property to an output property + + Override this method if you need to change the data that gets stored + on a specific property + + Arguments: + inProp (iProperty): The input property to copy data from + outProp (oProperty): The output property to copy data to + name (list): The full path-name of the property being copied + objDepth (int): The depth in the *object* hierarchy that the parent + object of this property is + propDepth (int): The depth of the *property* hierarchy that this + property is + """ + if not inProp.isCompound(): + cls.copy_time_sampling(inProp, outProp, *args, **kwargs) + for s in inProp.samples: + outProp.setValue(s) + + @classmethod + def copy_object_properties( + cls, + inObj: IObject, + outObj: OObject, + objDepth: int, + debug: bool = False, + *args, + **kwargs, + ) -> None: + """Copy an input object and all of its properties to an output object + + Override this method if you need to change the structure of the properties on an object + + Arguments: + inObj (iObject): The input object whose properties to copy from + outObj (oObject): The output object to recieve the copied properties + objDepth (int): The depth in the hierarchy these objects are + debug (bool): Whether to print debug messages + args/kwargs : Additional arguments that will get passed down the stack + This saves you from having to override all methods to pass a single + argument to the bottom of the call stack + """ + + iProps = inObj.getProperties() + oProps = outObj.getProperties() + for ip, op, propDepth, name in cls.make_prop_walk( + iProps, oProps, *args, debug=debug, **kwargs + ): + cls.copy_property_data(ip, op, name, objDepth, propDepth, *args, **kwargs) + + @classmethod + def copy_hierarchy( + cls, inPar: IObject, outPar: OObject, debug: bool = False, *args, **kwargs + ) -> None: + """Start off the copying of an object hierarchy + + Arguments: + inPar (iObject): The top-level input object to copy the hierarchy from + outPar (oObject): The top-level output object to copy the hierarchy to + debug (bool): Whether to print debug messages + args/kwargs : Additional arguments that will get passed down the stack + This saves you from having to override all methods to pass a single + argument to the bottom of the call stack + """ + for iChild, oChild, depth in cls.make_obj_walk( + inPar, outPar, *args, debug=debug, **kwargs + ): + cls.copy_object_properties( + iChild, oChild, depth, *args, debug=debug, **kwargs + ) + + @classmethod + def copy_archive_time_samplings( + cls, iArch: IArchive, oArch: OArchive, debug: bool = False, *args, **kwargs + ) -> None: + """Copy the time samplings from the iArch to the oArch + + Arguments: + iArch (IArchive): The input archive object + oArch (OArchive): The output archive object + debug (bool): Whether to print debug messages + args/kwargs : Additional arguments that will get passed down the stack + This saves you from having to override all methods to pass a single + argument to the bottom of the call stack + """ + for idx in range(iArch.getNumTimeSamplings()): + ts = iArch.getTimeSampling(idx) + oArch.addTimeSampling(ts) + + @classmethod + def copy_alembic( + cls, inPath: str, outPath: str, debug: bool = False, *args, **kwargs + ): + """Start off the copying of an alembic archive + + Override this method if you need to get extra data about the + archives before starting the copy process + + Arguments: + inPath (str): The path to the input archive to copy + outPath (str): The path where to create a the copied archive + debug (bool): Whether to print debug messages + args/kwargs : Additional arguments that will get passed down the stack + This saves you from having to override all methods to pass a single + argument to the bottom of the call stack + """ + iArch = getIArchive(inPath) + oArch = OArchive(str(outPath)) + cls.copy_archive_time_samplings(iArch, oArch, *args, debug=debug, **kwargs) + + iTop = iArch.getTop() + oTop = oArch.getTop() + cls.copy_hierarchy(iTop, oTop, *args, debug=debug, **kwargs) diff --git a/src/python/simplexui/commands/applyCorrectives.py b/src/python/simplexui/commands/applyCorrectives.py index 226d408f..8755e340 100644 --- a/src/python/simplexui/commands/applyCorrectives.py +++ b/src/python/simplexui/commands/applyCorrectives.py @@ -1,451 +1,452 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# pylint:disable=unused-variable -import itertools -import os - -from pysimplex import PySimplex - -from ..items import Combo, Simplex, Slider -from Qt.QtWidgets import QApplication -from .alembicCommon import ( - buildSmpx, - getSmpxArchiveData, - getStaticMeshArrays, - getUvSample, - readSmpx, -) - -try: - import numpy as np -except ImportError: - pass - - -def invertAll(matrixArray): - """Invert all the square sub-matrices in a numpy array - - Parameters - ---------- - matrixArray : np.array - An M*N*N numpy array - - Returns - ------- - : np.array - An M*N*N numpy array - """ - # Look into numpy to see if there is a way to ignore - # all the repeated sanity checks, and do them ourselves, once - return np.array([np.linalg.inv(a) for a in matrixArray]) - - -def applyReference(pts, restPts, restDelta, inv): - """Given a shape and an array of pre-inverted - per-point matrices return the deltas - - Parameters - ---------- - pts : np.array - Deformed point positions - restPts : np.array - Rest point positions - restDelta : np.array - The delta from rest - inv : np.array - An M*4*4 array of matrices - - Returns - ------- - : np.array - The new point positions - - """ - pts = pts + restPts + restDelta - preSize = pts.shape[-1] - if inv.shape[-2] > pts.shape[-1]: - oneShape = list(pts.shape) - oneShape[-1] = inv.shape[-2] - pts.shape[-1] - pts = np.concatenate((pts, np.ones(oneShape)), axis=-1) - - # Return the 3d points - return np.einsum("ij,ijk->ik", pts, inv)[..., :preSize] - - -def loadSimplex(shapePath): - """Load and parse all the data from a simplex file - - Parameters - ---------- - shapePath : str - The path to the .smpx file - - Returns - ------- - : str - The simplex JSON string - : Simplex - The simplex system - : pySimplex - The instantiated simplex solver - : np.array - A Numpy array of the shape point positions - : np.array - A Numpy array of the rest pose of the system - - """ - if not os.path.isfile(str(shapePath)): - raise IOError("File does not exist: " + str(shapePath)) - - jsString, counts, verts, faces, uvs, uvFaces = readSmpx(shapePath) - - simplex = Simplex.buildSystemFromJsonString(jsString, None, forceDummy=True) - solver = PySimplex(jsString) - - # return as delta shapes - restIdx = simplex.shapes.index(simplex.restShape) - restPts = verts[restIdx] - verts = verts - restPts[None, ...] # reshape for broadcasting - - return jsString, simplex, solver, verts, restPts - - -def writeSimplex(inPath, outPath, newShapes, name="Face", pBar=None): - """Write a simplex file with new shapes - - Parameters - ---------- - inPath : str - The input .smpx file path - outPath : str - The output .smpx file path - newShapes : np.array - A numpy array of shapes to write - name : str - The name of the new system - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - - """ - if not os.path.isfile(str(inPath)): - raise IOError("File does not exist: " + str(inPath)) - - iarch, abcMesh, jsString = getSmpxArchiveData(inPath) - faces, counts = getStaticMeshArrays(abcMesh) - uvs = getUvSample(abcMesh) - del iarch, abcMesh - - buildSmpx( - outPath, - newShapes, - faces, - jsString, - name, - faceCounts=counts, - uvs=uvs, - ) - - -######################################################################### -#### Deform Reference #### -######################################################################### - - -def _buildSolverInputs(simplex, item, value, indexBySlider): - """Build an input vector for the solver that will - produce a required progression value on an item - """ - inVec = [0.0] * len(simplex.sliders) - if isinstance(item, Slider): - inVec[indexBySlider[item]] = value - return inVec - elif isinstance(item, Combo): - for pair in item.pairs: - inVec[indexBySlider[pair.slider]] = pair.value * abs(value) - return inVec - else: - raise ValueError( - "Not a slider or combo. Got type {0}: {1}".format(type(item), item) - ) - - -def buildFullShapes(simplex, shapeObjs, shapes, solver, pBar=None): - """Given shape inputs, build the full output shape from the deltas - We use shapes here because a shape implies both the progression - and the value of the inputs (with a little figuring) - - Parameters - ---------- - simplex : Simplex - A Simplex system - shapeObjs : [Shape, ...] - The Simplex system Shape objects - shapes : np.array - A numpy array of the shapes - solver : PySimplex - An instantiated simplex solver - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - : {Shape: np.array, ...} - A dictionary of point positions indexed by the shape - : {Shape: [float, ...], ...} - A dictionary of solver inputs indexed by the shape - """ - ########################################### - # Manipulate all the input lists and caches - indexBySlider = {s: i for i, s in enumerate(simplex.sliders)} - indexByShape = {s: i for i, s in enumerate(simplex.shapes)} - floaters = set(simplex.getFloatingShapes()) - floatIdxs = {indexByShape[s] for s in floaters} - - shapeDict = {} - for item in itertools.chain(simplex.sliders, simplex.combos): - for pair in item.prog.pairs: - if not pair.shape.isRest: - shapeDict[pair.shape] = (item, pair.value) - - ###################### - # Actually do the work - vecByShape = {} # store this for later use - ptsByShape = {} - - if pBar is not None: - pBar.setMaximum(len(shapeObjs)) - pBar.setValue(0) - QApplication.processEvents() - - flatShapes = shapes.reshape((len(shapes), -1)) - for i, shape in enumerate(shapeObjs): - if pBar is not None: - pBar.setValue(i) - QApplication.processEvents() - else: - print("Building {0} of {1}\r".format(i + 1, len(shapeObjs)), end=" ") - - item, value = shapeDict[shape] - inVec = _buildSolverInputs(simplex, item, value, indexBySlider) - outVec = solver.solve(inVec) - if shape not in floaters: - for fi in floatIdxs: - outVec[fi] = 0.0 - outVec = np.array(outVec) - outVec[np.where(np.isclose(outVec, 0))] = 0 - outVec[np.where(np.isclose(outVec, 1))] = 1 - vecByShape[shape] = outVec - pts = np.dot(outVec, flatShapes) - pts = pts.reshape((-1, 3)) - ptsByShape[shape] = pts - if pBar is None: - print() - - return ptsByShape, vecByShape - - -def collapseFullShapes(simplex, allPts, ptsByShape, vecByShape, pBar=None): - """Given a set of shapes that are full-on shapes (not just deltas) - Collapse them back into deltas in the simplex shape list - - Parameters - ---------- - simplex : Simplex - A simplex system - allPts : np.array - All the point positions - ptsByShape : {Shape: np.array, ...} - A dictionary of point positions indexed by the shape - vecByShape : {Shape: [float, ...], ...} - A dictionary of solver inputs indexed by the shape - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - : np.array - The collapsed shapes - - """ - # Figure out what order to build the deltas - # so that the deltas exist when I try to combine them - ctrlOrder = simplex.controllersByDepth() - shapeOrder = [pp.shape for ctrl in ctrlOrder for pp in ctrl.prog.pairs] - shapeOrder = [i for i in shapeOrder if not i.isRest] - - # Incrementally Build the numpy array of delta shapes - # build deltaShapeArray as a 2d array because numpy is like 10x faster on 2d arrays - indexByShape = {v: k for k, v in enumerate(simplex.shapes)} - deltaShapeArray = np.zeros((len(simplex.shapes), allPts.shape[1] * 3)) - - if pBar is not None: - pBar.setValue(0) - pBar.setMaximum(len(shapeOrder)) - pBar.setLabelText("Building Corrected Deltas") - QApplication.processEvents() - - for shpOrderIdx, shape in enumerate(shapeOrder): - if pBar is not None: - pBar.setValue(shpOrderIdx) - pBar.setLabelText("Building Corrected Deltas\n{}".format(shape.name)) - QApplication.processEvents() - else: - print( - "Collapsing {0} of {1}\r".format(shpOrderIdx + 1, len(shapeOrder)), - end=" ", - ) - - shpIdx = indexByShape[shape] - if shape in ptsByShape: - base = np.dot(vecByShape[shape], deltaShapeArray) - deltaShapeArray[shpIdx] = ( - ptsByShape[shape] - base.reshape((-1, 3)) - ).flatten() - else: - deltaShapeArray[shpIdx] = allPts[shpIdx].flatten() - - return deltaShapeArray.reshape((len(deltaShapeArray), -1, 3)) - - -def applyCorrectives( - simplex, allShapePts, restPts, solver, shapes, refIdxs, references, pBar=None -): - """Loop over the shapes and references, apply them, and return a new np.array - of shape points - - Parameters - ---------- - simplex : Simplex - Simplex system - allShapePts : np.array - deltas per shape - restPts : np.array - The rest point positions - solver : PySimplex - The Python Simplex solver object - shapes : [Shape, ...] - The simplex shape objects we care about - refIdxs : [int, ...] - The reference index per shape - references : np.array - A list of matrix-per-points - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - : np.array - The new shape points with correctives applied - - """ - # The rule of thumb is "THE SHAPE IS ALWAYS A DELTA" - - if pBar is not None: - pBar.setLabelText("Inverting References") - pBar.setValue(0) - pBar.setMaximum(len(references)) - QApplication.processEvents() - else: - print("Inverting References") - - # The initial reference is the rig rest shape - # This way we can handle a difference between - # The .smpx rest shape, and the rig rest shape - - # shape 0, all points, the tranform row of the matrix, the first 3 values in that row - rigRest = references[0, :, 3, :3] - restDelta = rigRest - restPts - - inverses = [] - for i, r in enumerate(references): - if pBar is not None: - pBar.setValue(i) - QApplication.processEvents() - inverses.append(invertAll(r)) - - if pBar is not None: - pBar.setLabelText("Building Full Shapes") - QApplication.processEvents() - else: - print("Building Full Shapes") - ptsByShape, vecByShape = buildFullShapes(simplex, shapes, allShapePts, solver, pBar) - - if pBar is not None: - pBar.setLabelText("Correcting Shapes") - pBar.setValue(0) - pBar.setMaximum(len(shapes)) - newPtsByShape = {} - for i, (shape, refIdx) in enumerate(zip(shapes, refIdxs)): - if pBar is not None: - pBar.setValue(i) - QApplication.processEvents() - else: - print("Correcting {0} of {1}: {2}".format(i + 1, len(shapes), shape.name)) - - inv = inverses[refIdx] - pts = ptsByShape[shape] - newPts = applyReference(pts, restPts, restDelta, inv) - newPtsByShape[shape] = newPts - - newShapePts = collapseFullShapes( - simplex, allShapePts, newPtsByShape, vecByShape, pBar - ) - newShapePts = newShapePts + restPts[None, ...] - - return newShapePts - - -def readAndApplyCorrectives(inPath, namePath, refPath, outPath, pBar=None): - """Read the provided files, apply the correctives, then output a new file - - Parameters - ---------- - inPath : str - The input path - namePath : str - A file correlating the shape names and indices - refPath : str - The reference matrices per point of deformation - outPath : str - The output path - pBar : QProgressDialog, optional - An optional progress dialog - """ - - if pBar is not None: - pBar.setLabelText("Reading reference data") - QApplication.processEvents() - - jsString, simplex, solver, allShapePts, restPts = loadSimplex(inPath) - with open(namePath, "r") as f: - nr = f.read() - nr = [i.split(";") for i in nr.split("\n") if i] - nr = nr[1:] # ignore the rest shape for this stuff - names, refIdxs = list(zip(*nr)) - refIdxs = list(map(int, refIdxs)) - refs = np.load(refPath, allow_pickle=True) - shapeByName = {i.name: i for i in simplex.shapes} - shapes = [shapeByName[n] for n in names] - newPts = applyCorrectives( - simplex, allShapePts, restPts, solver, shapes, refIdxs, refs, pBar - ) - writeSimplex(inPath, outPath, newPts, pBar=pBar) - print("DONE") +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +import itertools +import os + +import numpy as np +from pysimplex import PySimplex +from Qt.QtWidgets import QApplication + +from ..items import Combo, Simplex, Slider +from .alembicCommon import ( + buildSmpx, + getSmpxArchiveData, + getStaticMeshArrays, + getUvSample, + readSmpx, +) + + +def invertAll(matrixArray): + """Invert all the square sub-matrices in a numpy array + + Parameters + ---------- + matrixArray : np.array + An M*N*N numpy array + + Returns + ------- + : np.array + An M*N*N numpy array + """ + # Look into numpy to see if there is a way to ignore + # all the repeated sanity checks, and do them ourselves, once + return np.array([np.linalg.inv(a) for a in matrixArray]) + + +def applyReference(pts, restPts, restDelta, inv): + """Given a shape and an array of pre-inverted + per-point matrices return the deltas + + Parameters + ---------- + pts : np.array + Deformed point positions + restPts : np.array + Rest point positions + restDelta : np.array + The delta from rest + inv : np.array + An M*4*4 array of matrices + + Returns + ------- + : np.array + The new point positions + + """ + pts = pts + restPts + restDelta + preSize = pts.shape[-1] + if inv.shape[-2] > pts.shape[-1]: + oneShape = list(pts.shape) + oneShape[-1] = inv.shape[-2] - pts.shape[-1] + pts = np.concatenate((pts, np.ones(oneShape)), axis=-1) + + # Return the 3d points + return np.einsum("ij,ijk->ik", pts, inv)[..., :preSize] + + +def loadSimplex(shapePath): + """Load and parse all the data from a simplex file + + Parameters + ---------- + shapePath : str + The path to the .smpx file + + Returns + ------- + : str + The simplex JSON string + : Simplex + The simplex system + : pySimplex + The instantiated simplex solver + : np.array + A Numpy array of the shape point positions + : np.array + A Numpy array of the rest pose of the system + + """ + if not os.path.isfile(str(shapePath)): + raise OSError("File does not exist: " + str(shapePath)) + + jsString, counts, verts, faces, uvs, uvFaces = readSmpx(shapePath) + + simplex = Simplex.buildSystemFromJsonString(jsString, None, forceDummy=True) + solver = PySimplex(jsString) + + # return as delta shapes + restIdx = simplex.shapes.index(simplex.restShape) + restPts = verts[restIdx] + verts = verts - restPts[None, ...] # reshape for broadcasting + + return jsString, simplex, solver, verts, restPts + + +def writeSimplex(inPath, outPath, newShapes, name="Face", pBar=None) -> None: + """Write a simplex file with new shapes + + Parameters + ---------- + inPath : str + The input .smpx file path + outPath : str + The output .smpx file path + newShapes : np.array + A numpy array of shapes to write + name : str + The name of the new system + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + + """ + if not os.path.isfile(str(inPath)): + raise OSError("File does not exist: " + str(inPath)) + + iarch, abcMesh, jsString = getSmpxArchiveData(inPath) + faces, counts = getStaticMeshArrays(abcMesh) + uvs = getUvSample(abcMesh) + del iarch, abcMesh + + buildSmpx( + outPath, + newShapes, + faces, + jsString, + name, + faceCounts=counts, + uvs=uvs, + ) + + +######################################################################### +#### Deform Reference #### +######################################################################### + + +def _buildSolverInputs(simplex, item, value, indexBySlider) -> list[float]: + """Build an input vector for the solver that will + produce a required progression value on an item + """ + inVec = [0.0] * len(simplex.sliders) + if isinstance(item, Slider): + inVec[indexBySlider[item]] = value + return inVec + elif isinstance(item, Combo): + for pair in item.pairs: + inVec[indexBySlider[pair.slider]] = pair.value * abs(value) + return inVec + else: + raise ValueError(f"Not a slider or combo. Got type {type(item)}: {item}") + + +def buildFullShapes(simplex: Simplex, shapeObjs, shapes, solver, pBar=None): + """Given shape inputs, build the full output shape from the deltas + We use shapes here because a shape implies both the progression + and the value of the inputs (with a little figuring) + + Parameters + ---------- + simplex : Simplex + A Simplex system + shapeObjs : [Shape, ...] + The Simplex system Shape objects + shapes : np.array + A numpy array of the shapes + solver : PySimplex + An instantiated simplex solver + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + : {Shape: np.array, ...} + A dictionary of point positions indexed by the shape + : {Shape: [float, ...], ...} + A dictionary of solver inputs indexed by the shape + """ + ########################################### + # Manipulate all the input lists and caches + indexBySlider = {s: i for i, s in enumerate(simplex.sliders)} + indexByShape = {s: i for i, s in enumerate(simplex.shapes)} + floaters = set(simplex.getFloatingShapes()) + floatIdxs = {indexByShape[s] for s in floaters} + + shapeDict = {} + for item in itertools.chain(simplex.sliders, simplex.combos): + for pair in item.prog.pairs: + if not pair.shape.isRest: + shapeDict[pair.shape] = (item, pair.value) + + ###################### + # Actually do the work + vecByShape = {} # store this for later use + ptsByShape = {} + + if pBar is not None: + pBar.setMaximum(len(shapeObjs)) + pBar.setValue(0) + QApplication.processEvents() + + flatShapes = shapes.reshape((len(shapes), -1)) + for i, shape in enumerate(shapeObjs): + if pBar is not None: + pBar.setValue(i) + QApplication.processEvents() + else: + print(f"Building {i + 1} of {len(shapeObjs)}\r", end=" ") + + item, value = shapeDict[shape] + inVec = _buildSolverInputs(simplex, item, value, indexBySlider) + outVec = solver.solve(inVec) + if shape not in floaters: + for fi in floatIdxs: + outVec[fi] = 0.0 + outVec = np.array(outVec) + outVec[np.where(np.isclose(outVec, 0))] = 0 + outVec[np.where(np.isclose(outVec, 1))] = 1 + vecByShape[shape] = outVec + pts = np.dot(outVec, flatShapes) + pts = pts.reshape((-1, 3)) + ptsByShape[shape] = pts + if pBar is None: + print() + + return ptsByShape, vecByShape + + +def collapseFullShapes(simplex: Simplex, allPts, ptsByShape, vecByShape, pBar=None): + """Given a set of shapes that are full-on shapes (not just deltas) + Collapse them back into deltas in the simplex shape list + + Parameters + ---------- + simplex : Simplex + A simplex system + allPts : np.array + All the point positions + ptsByShape : {Shape: np.array, ...} + A dictionary of point positions indexed by the shape + vecByShape : {Shape: [float, ...], ...} + A dictionary of solver inputs indexed by the shape + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + : np.array + The collapsed shapes + + """ + # Figure out what order to build the deltas + # so that the deltas exist when I try to combine them + ctrlOrder = simplex.controllersByDepth() + shapeOrder = [pp.shape for ctrl in ctrlOrder for pp in ctrl.prog.pairs] + shapeOrder = [i for i in shapeOrder if not i.isRest] + + # Incrementally Build the numpy array of delta shapes + # build deltaShapeArray as a 2d array because numpy is like 10x faster on 2d arrays + indexByShape = {v: k for k, v in enumerate(simplex.shapes)} + deltaShapeArray = np.zeros((len(simplex.shapes), allPts.shape[1] * 3)) + + if pBar is not None: + pBar.setValue(0) + pBar.setMaximum(len(shapeOrder)) + pBar.setLabelText("Building Corrected Deltas") + QApplication.processEvents() + + for shpOrderIdx, shape in enumerate(shapeOrder): + if pBar is not None: + pBar.setValue(shpOrderIdx) + pBar.setLabelText(f"Building Corrected Deltas\n{shape.name}") + QApplication.processEvents() + else: + print( + f"Collapsing {shpOrderIdx + 1} of {len(shapeOrder)}\r", + end=" ", + ) + + shpIdx = indexByShape[shape] + if shape in ptsByShape: + base = np.dot(vecByShape[shape], deltaShapeArray) + deltaShapeArray[shpIdx] = ( + ptsByShape[shape] - base.reshape((-1, 3)) + ).flatten() + else: + deltaShapeArray[shpIdx] = allPts[shpIdx].flatten() + + return deltaShapeArray.reshape((len(deltaShapeArray), -1, 3)) + + +def applyCorrectives( + simplex: Simplex, + allShapePts, + restPts, + solver, + shapes: list[Shape], + refIdxs: list[int], + references, + pBar=None, +): + """Loop over the shapes and references, apply them, and return a new np.array + of shape points + + Parameters + ---------- + simplex : Simplex + Simplex system + allShapePts : np.array + deltas per shape + restPts : np.array + The rest point positions + solver : PySimplex + The Python Simplex solver object + shapes : [Shape, ...] + The simplex shape objects we care about + refIdxs : [int, ...] + The reference index per shape + references : np.array + A list of matrix-per-points + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + : np.array + The new shape points with correctives applied + + """ + # The rule of thumb is "THE SHAPE IS ALWAYS A DELTA" + + if pBar is not None: + pBar.setLabelText("Inverting References") + pBar.setValue(0) + pBar.setMaximum(len(references)) + QApplication.processEvents() + else: + print("Inverting References") + + # The initial reference is the rig rest shape + # This way we can handle a difference between + # The .smpx rest shape, and the rig rest shape + + # shape 0, all points, the tranform row of the matrix, the first 3 values in that row + rigRest = references[0, :, 3, :3] + restDelta = rigRest - restPts + + inverses = [] + for i, r in enumerate(references): + if pBar is not None: + pBar.setValue(i) + QApplication.processEvents() + inverses.append(invertAll(r)) + + if pBar is not None: + pBar.setLabelText("Building Full Shapes") + QApplication.processEvents() + else: + print("Building Full Shapes") + ptsByShape, vecByShape = buildFullShapes(simplex, shapes, allShapePts, solver, pBar) + + if pBar is not None: + pBar.setLabelText("Correcting Shapes") + pBar.setValue(0) + pBar.setMaximum(len(shapes)) + newPtsByShape = {} + for i, (shape, refIdx) in enumerate(zip(shapes, refIdxs)): + if pBar is not None: + pBar.setValue(i) + QApplication.processEvents() + else: + print(f"Correcting {i + 1} of {len(shapes)}: {shape.name}") + + inv = inverses[refIdx] + pts = ptsByShape[shape] + newPts = applyReference(pts, restPts, restDelta, inv) + newPtsByShape[shape] = newPts + + newShapePts = collapseFullShapes( + simplex, allShapePts, newPtsByShape, vecByShape, pBar + ) + newShapePts = newShapePts + restPts[None, ...] + + return newShapePts + + +def readAndApplyCorrectives(inPath, namePath, refPath, outPath, pBar=None) -> None: + """Read the provided files, apply the correctives, then output a new file + + Parameters + ---------- + inPath : str + The input path + namePath : str + A file correlating the shape names and indices + refPath : str + The reference matrices per point of deformation + outPath : str + The output path + pBar : QProgressDialog, optional + An optional progress dialog + """ + + if pBar is not None: + pBar.setLabelText("Reading reference data") + QApplication.processEvents() + + jsString, simplex, solver, allShapePts, restPts = loadSimplex(inPath) + with open(namePath, "r") as f: + nr = f.read() + nr = [i.split(";") for i in nr.split("\n") if i] + nr = nr[1:] # ignore the rest shape for this stuff + names, refIdxs = list(zip(*nr)) + refIdxs = list(map(int, refIdxs)) + refs = np.load(refPath, allow_pickle=True) + shapeByName = {i.name: i for i in simplex.shapes} + shapes = [shapeByName[n] for n in names] + newPts = applyCorrectives( + simplex, allShapePts, restPts, solver, shapes, refIdxs, refs, pBar + ) + writeSimplex(inPath, outPath, newPts, pBar=pBar) + print("DONE") diff --git a/src/python/simplexui/commands/correctiveInterface.py b/src/python/simplexui/commands/correctiveInterface.py index 2470ee2e..ffc4473b 100644 --- a/src/python/simplexui/commands/correctiveInterface.py +++ b/src/python/simplexui/commands/correctiveInterface.py @@ -1,261 +1,259 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -from Qt.QtWidgets import QApplication - -try: - import numpy as np -except ImportError: - pass - -from .mayaCorrectiveInterface import getShiftValues, resetPose, setPose - -dcc = "maya" - - -def getRefForPoses(mesh, poses, multipliers): - """Given a set of poses and a multiplier, get the reference - - Parameters - ---------- - mesh : object - The DCC object - poses : [[(str, float), ...], ...] - Property/value pairs for different rig poses - multiplier : float - The percent of the pose to apply - - Returns - ------- - : np.array - The point reference matrices in pose - - """ - - for pose, mul in zip(poses, multipliers): - setPose(pose, mul) - - ref = getDeformReference(mesh) - - for pose in poses: - resetPose(pose) - return ref - - -def getDeformReference(mesh): - """Build the 4x4 deformation reference matrices given a mesh - - Parameters - ---------- - mesh : object - The DCC mesh object - - Returns - ------- - : np.array: - The point reference matrices in pose - - """ - zero, oneX, oneY, oneZ = getShiftValues(mesh) - - zero = np.array(zero) - dx = np.array(oneX) - zero - dy = np.array(oneY) - zero - dz = np.array(oneZ) - zero - - # Maya has numpy 1.09, but np.stack comes from 1.10 - # mats = np.stack((dx, dy, dz, zero), axis=1) - - # Make the new axis to concatenate on - zero = zero[:, None] - dx = dx[:, None] - dy = dy[:, None] - dz = dz[:, None] - mats = np.concatenate((dx, dy, dz, zero), axis=1) - - # Turn the Nx4x3 matrix into a Nx4x4 - zzz = np.zeros((len(mats), 4, 1)) - zzz[:, 3] = 1.0 - mats = np.concatenate((mats, zzz), axis=2) - - return mats - - -def buildCorrectiveReferences(mesh, simplex, poses, sliders, pBar=None): - """Take correlated poses and sliders, and expand down the - simplex combo tree, building references for each required shape - - Parameters - ---------- - mesh : object - The DCC mesh object - simplex : Simplex - The Simplex system - poses : [[(str, float), ...], ...] - Property/value pairs for different rig poses - sliders : [Slider, ...] - Simplex slider objects that are controlled by the poses - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - : np.array - The output matrix-per-point arrays - : [Shape, ...] - The shapes active - : [int, ...] - The Reference pose per shape - """ - # cache the pose search - - # Pre-cache the combo search - allCombosBySliderValue = {} - for c in simplex.combos: - for p in c.pairs: - allCombosBySliderValue.setdefault((p.slider, p.value), []).append(c) - - # This is only my subset set of downstreams - # Get the downstreams by slider and value - sliderValuesByCombo = {} - for slider in sliders: - for p in slider.prog.pairs: - combos = allCombosBySliderValue.get((slider, p.value), []) - for combo in combos: - sliderValuesByCombo.setdefault(combo, []).append((slider, p.value)) - - # out = [] - refCache = {} - refs, shapes, refIdxs = [], [], [] - - # get the slider outputs - if pBar is not None: - pBar.setLabelText("Building Shape References") - pBar.setValue(0) - mv = 0 - for slider in sliders: - for p in slider.prog.pairs: - if not p.shape.isRest: - mv += 1 - pBar.setMaximum(mv) - QApplication.processEvents() - - # Make sure to export the rest reference first - ref = getRefForPoses(mesh, [], []) - refIdxs.append(len(refs)) - cacheKey = frozenset([("", 0.0)]) - refCache[cacheKey] = len(refs) - refs.append(ref) - shapes.append(simplex.restShape) - - # Now export everything else - poseBySlider = {} - for slider, pose in zip(sliders, poses): - poseBySlider[slider] = pose - for p in slider.prog.pairs: - if not p.shape.isRest: - if pBar is not None: - pBar.setValue(pBar.value()) - QApplication.processEvents() - cacheKey = frozenset([(slider, p.value)]) - if cacheKey in refCache: - idx = refCache[cacheKey] - refIdxs.append(idx) - else: - ref = getRefForPoses(mesh, [pose], [p.value]) - refIdxs.append(len(refs)) - refCache[cacheKey] = len(refs) - refs.append(ref) - shapes.append(p.shape) - - # Get the combo outputs - if pBar is not None: - pBar.setLabelText("Building Combo References") - pBar.setValue(0) - mv = 0 - for combo in sliderValuesByCombo: - for p in combo.prog.pairs: - if not p.shape.isRest: - mv += 1 - pBar.setMaximum(mv) - QApplication.processEvents() - - for combo, sliderVals in sliderValuesByCombo.items(): - # components = frozenset(sliderVals) - poses = [poseBySlider[s] for s, _ in sliderVals] - for p in combo.prog.pairs: - if not p.shape.isRest: - if pBar is not None: - pBar.setValue(pBar.value()) - QApplication.processEvents() - - cacheKey = frozenset(sliderVals) - if cacheKey in refCache: - idx = refCache[cacheKey] - refIdxs.append(idx) - else: - vals = [p.value * v[1] for v in sliderVals] - ref = getRefForPoses(mesh, poses, vals) - refIdxs.append(len(refs)) - refCache[cacheKey] = len(refs) - refs.append(ref) - shapes.append(p.shape) - - return np.array(refs), shapes, refIdxs - - -def outputCorrectiveReferences( - outNames, outRefs, simplex, mesh, poses, sliders, pBar=None -): - """Output the proper files for an external corrective application - - Parameters - ---------- - outNames : str - The filepath for the output shape and reference indices - outRefs : str - The filepath for the deformation references - simplex : Simplex - A simplex system - mesh : object - The mesh object to deform - poses : [[(str, float), ...], ...] - Lists of parameter - sliders : [Slider, ...] - The simplex sliders that correspond to the poses - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - - """ - refs, shapes, refIdxs = buildCorrectiveReferences( - mesh, simplex, poses, sliders, pBar - ) - - if pBar is not None: - pBar.setLabelText("Writing Names") - QApplication.processEvents() - nameWrite = ["{};{}".format(s.name, r) for s, r in zip(shapes, refIdxs)] - with open(outNames, "w") as f: - f.write("\n".join(nameWrite)) - - if pBar is not None: - pBar.setLabelText("Writing References") - QApplication.processEvents() - refs.dump(outRefs) +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +from __future__ import annotations + +import numpy as np +from Qt.QtWidgets import QApplication + +from .mayaCorrectiveInterface import getShiftValues, resetPose, setPose + +dcc = "maya" + + +def getRefForPoses(mesh, poses, multipliers): + """Given a set of poses and a multiplier, get the reference + + Parameters + ---------- + mesh : object + The DCC object + poses : [[(str, float), ...], ...] + Property/value pairs for different rig poses + multiplier : float + The percent of the pose to apply + + Returns + ------- + : np.array + The point reference matrices in pose + + """ + + for pose, mul in zip(poses, multipliers): + setPose(pose, mul) + + ref = getDeformReference(mesh) + + for pose in poses: + resetPose(pose) + return ref + + +def getDeformReference(mesh): + """Build the 4x4 deformation reference matrices given a mesh + + Parameters + ---------- + mesh : object + The DCC mesh object + + Returns + ------- + : np.array: + The point reference matrices in pose + + """ + zero, oneX, oneY, oneZ = getShiftValues(mesh) + + zero = np.array(zero) + dx = np.array(oneX) - zero + dy = np.array(oneY) - zero + dz = np.array(oneZ) - zero + + # Maya has numpy 1.09, but np.stack comes from 1.10 + # mats = np.stack((dx, dy, dz, zero), axis=1) + + # Make the new axis to concatenate on + zero = zero[:, None] + dx = dx[:, None] + dy = dy[:, None] + dz = dz[:, None] + mats = np.concatenate((dx, dy, dz, zero), axis=1) + + # Turn the Nx4x3 matrix into a Nx4x4 + zzz = np.zeros((len(mats), 4, 1)) + zzz[:, 3] = 1.0 + mats = np.concatenate((mats, zzz), axis=2) + + return mats + + +def buildCorrectiveReferences(mesh, simplex, poses, sliders, pBar=None): + """Take correlated poses and sliders, and expand down the + simplex combo tree, building references for each required shape + + Parameters + ---------- + mesh : object + The DCC mesh object + simplex : Simplex + The Simplex system + poses : [[(str, float), ...], ...] + Property/value pairs for different rig poses + sliders : [Slider, ...] + Simplex slider objects that are controlled by the poses + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + : np.array + The output matrix-per-point arrays + : [Shape, ...] + The shapes active + : [int, ...] + The Reference pose per shape + """ + # cache the pose search + + # Pre-cache the combo search + allCombosBySliderValue = {} + for c in simplex.combos: + for p in c.pairs: + allCombosBySliderValue.setdefault((p.slider, p.value), []).append(c) + + # This is only my subset set of downstreams + # Get the downstreams by slider and value + sliderValuesByCombo = {} + for slider in sliders: + for p in slider.prog.pairs: + combos = allCombosBySliderValue.get((slider, p.value), []) + for combo in combos: + sliderValuesByCombo.setdefault(combo, []).append((slider, p.value)) + + # out = [] + refCache = {} + refs, shapes, refIdxs = [], [], [] + + # get the slider outputs + if pBar is not None: + pBar.setLabelText("Building Shape References") + pBar.setValue(0) + mv = 0 + for slider in sliders: + for p in slider.prog.pairs: + if not p.shape.isRest: + mv += 1 + pBar.setMaximum(mv) + QApplication.processEvents() + + # Make sure to export the rest reference first + ref = getRefForPoses(mesh, [], []) + refIdxs.append(len(refs)) + cacheKey = frozenset([("", 0.0)]) + refCache[cacheKey] = len(refs) + refs.append(ref) + shapes.append(simplex.restShape) + + # Now export everything else + poseBySlider = {} + for slider, pose in zip(sliders, poses): + poseBySlider[slider] = pose + for p in slider.prog.pairs: + if not p.shape.isRest: + if pBar is not None: + pBar.setValue(pBar.value()) + QApplication.processEvents() + cacheKey = frozenset([(slider, p.value)]) + if cacheKey in refCache: + idx = refCache[cacheKey] + refIdxs.append(idx) + else: + ref = getRefForPoses(mesh, [pose], [p.value]) + refIdxs.append(len(refs)) + refCache[cacheKey] = len(refs) + refs.append(ref) + shapes.append(p.shape) + + # Get the combo outputs + if pBar is not None: + pBar.setLabelText("Building Combo References") + pBar.setValue(0) + mv = 0 + for combo in sliderValuesByCombo: + for p in combo.prog.pairs: + if not p.shape.isRest: + mv += 1 + pBar.setMaximum(mv) + QApplication.processEvents() + + for combo, sliderVals in sliderValuesByCombo.items(): + # components = frozenset(sliderVals) + poses = [poseBySlider[s] for s, _ in sliderVals] + for p in combo.prog.pairs: + if not p.shape.isRest: + if pBar is not None: + pBar.setValue(pBar.value()) + QApplication.processEvents() + + cacheKey = frozenset(sliderVals) + if cacheKey in refCache: + idx = refCache[cacheKey] + refIdxs.append(idx) + else: + vals = [p.value * v[1] for v in sliderVals] + ref = getRefForPoses(mesh, poses, vals) + refIdxs.append(len(refs)) + refCache[cacheKey] = len(refs) + refs.append(ref) + shapes.append(p.shape) + + return np.array(refs), shapes, refIdxs + + +def outputCorrectiveReferences( + outNames, outRefs, simplex, mesh, poses, sliders, pBar=None +) -> None: + """Output the proper files for an external corrective application + + Parameters + ---------- + outNames : str + The filepath for the output shape and reference indices + outRefs : str + The filepath for the deformation references + simplex : Simplex + A simplex system + mesh : object + The mesh object to deform + poses : [[(str, float), ...], ...] + Lists of parameter + sliders : [Slider, ...] + The simplex sliders that correspond to the poses + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + + """ + refs, shapes, refIdxs = buildCorrectiveReferences( + mesh, simplex, poses, sliders, pBar + ) + + if pBar is not None: + pBar.setLabelText("Writing Names") + QApplication.processEvents() + nameWrite = [f"{s.name};{r}" for s, r in zip(shapes, refIdxs)] + with open(outNames, "w") as f: + f.write("\n".join(nameWrite)) + + if pBar is not None: + pBar.setLabelText("Writing References") + QApplication.processEvents() + refs.dump(outRefs) diff --git a/src/python/simplexui/commands/expandedExport.py b/src/python/simplexui/commands/expandedExport.py index 1f7411e8..690dbd5f 100644 --- a/src/python/simplexui/commands/expandedExport.py +++ b/src/python/simplexui/commands/expandedExport.py @@ -1,436 +1,434 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -from pysimplex import PySimplex - -from ..interface.mayaInterface import DCC, disconnected -from ..items import Combo, Slider, Traversal, Simplex -from .alembicCommon import buildSmpx - -try: - import numpy as np -except ImportError: - np = None - - -def _setSliders(ctrl, val, svs): - slis, vals = svs.setdefault(ctrl.simplex, ([], [])) - - if isinstance(ctrl, Slider): - slis.append(ctrl) - vals.append(val) - elif isinstance(ctrl, Combo): - for cp in ctrl.pairs: - slis.append(cp.slider) - vals.append(cp.value * abs(val)) - elif isinstance(ctrl, Traversal): - # set the ctrl.multiplierCtrl.controller to 1 - multCtrl = ctrl.multiplierCtrl.controller - multVal = ctrl.multiplierCtrl.value if val != 0.0 else 0.0 - progCtrl = ctrl.progressCtrl.controller - _setSliders(multCtrl, multVal, svs) - _setSliders(progCtrl, val, svs) - - -def setSliderGroup(ctrls, val): - """Set a group of controls to a given value - - Parameters - ---------- - ctrls : [object, ...] - A list of simplex objects - val : float - The value to set - - Returns - ------- - - """ - svs = {} - for ctrl in ctrls: - _setSliders(ctrl, val, svs) - - for smpx, (slis, vals) in svs.items(): - smpx.setSlidersWeights(slis, vals) - - -def clientPartition(master, clients): - """ - - Parameters - ---------- - master : Simplex - The master simplex system - clients : [Simplex, ...] - The client simplex systems - - Returns - ------- - : {str: [Slider, ...]} - Dictionary of name to slider - : {str: [Combo, ...]} - Dictionary of name to combo - : {str: [Traversal, ...]} - Dictionary of name to traversal - - """ - sliders, combos, traversals = {}, {}, {} - for cli in [master] + clients: - for sli in cli.sliders: - sliders.setdefault(sli.name, []).append(sli) - - for com in cli.combos: - combos.setdefault(com.name, []).append(com) - - for trav in cli.traversals: - traversals.setdefault(trav.name, []).append(trav) - return sliders, combos, traversals - - -def zeroAll(smpxs): - """Set all sliders on the given simplex systems to 0 - - Parameters - ---------- - smpxs : [Simplex, ...] - A list of Simplex systems - - Returns - ------- - - """ - for smpx in smpxs: - smpx.setSlidersWeights(smpx.sliders, [0.0] * len(smpx.sliders)) - - -def getExpandedData(master, clients, mesh): - """Get the fully expanded shape data for each slider, combo, and traversal - at each of its underlying shapes - - Parameters - ---------- - master : Simplex - The master simplex system - clients : [Simplex, ...] - The client simplex systems - mesh : str - The name of the maya shape node - - Returns - ------- - : np.array - The rest shape point positions - : np.array - The slider shape point positions - : np.array - The combo shape point positions - : np.array - The traversal shape point positions - - """ - # zero everything - zeroAll([master] + clients) - sliPart, cmbPart, travPart = clientPartition(master, clients) - sliderShapes = {} - for slider in master.sliders: - ss = {} - sliderShapes[slider] = ss - for pp in slider.prog.pairs: - if pp.shape.isRest: - continue - setSliderGroup(sliPart[slider.name], pp.value) - ss[pp] = DCC.getNumpyShape(mesh) - setSliderGroup(sliPart[slider.name], 0.0) - - # Disable traversals - zeroAll([master] + clients) - travShapeThings = [] - comboShapes = {} - for trav in master.traversals: - for pp in trav.prog.pairs: - if pp.shape.isRest: - continue - travShapeThings.append(pp.shape) - travShapeThings = [i.thing for i in travShapeThings] - - # Get the combos with traversals disconnected - with disconnected(travShapeThings): - for combo in master.combos: - ss = {} - comboShapes[combo] = ss - for pp in combo.prog.pairs: - if pp.shape.isRest: - continue - setSliderGroup(cmbPart[combo.name], pp.value) - ss[pp] = DCC.getNumpyShape(mesh) - setSliderGroup(cmbPart[combo.name], 0.0) - - zeroAll([master] + clients) - travShapes = {} - for trav in master.traversals: - ss = {} - travShapes[trav] = ss - progCtrl = trav.progressCtrl.controller - progVal = trav.progressCtrl.value - for pp in progCtrl.prog.pairs: - if pp.shape.isRest: - continue - if pp.value * progVal <= 0.0: - # Traversals only activate if the progression is in the same - # pos/neg direction as the value. Otherwise we just skip - continue - - setSliderGroup(travPart[trav.name], pp.value) - ss[pp] = DCC.getNumpyShape(mesh) - setSliderGroup(travPart[trav.name], 0.0) - - zeroAll([master] + clients) - restShape = DCC.getNumpyShape(mesh) - - return restShape, sliderShapes, comboShapes, travShapes - - -def _setInputs(inVec, item, indexBySlider, value): - """Being clever - Sliders or Combos just set the value and return - Traversals recursively call this function with the controllers (that only either sliders or combos) - """ - if isinstance(item, Slider): - inVec[indexBySlider[item]] = value - return inVec - elif isinstance(item, Combo): - for pair in item.pairs: - inVec[indexBySlider[pair.slider]] = pair.value * abs(value) - return inVec - elif isinstance(item, Traversal): - inVec = _setInputs( - inVec, - item.multiplierCtrl.controller, - indexBySlider, - item.multiplierCtrl.value, - ) - inVec = _setInputs( - inVec, - item.progressCtrl.controller, - indexBySlider, - item.progressCtrl.value * value, - ) - return inVec - raise ValueError( - "Not a Slider, Combo, or Traversal. Got type {0}: {1}".format(type(item), item) - ) - - -def _buildSolverInputs(simplex, item, value, indexBySlider): - """Build an input vector for the solver that will - produce a required progression value on an item - """ - inVec = [0.0] * len(simplex.sliders) - return _setInputs(inVec, item, indexBySlider, value) - - -def getTravDepth(trav): - """Get the depth of a traversal object - - Parameters - ---------- - trav : Traversal - The traversal object - - Returns - ------- - : int - The depth of the given Traversal - - """ - inputs = [] - mult = trav.multiplierCtrl.controller - prog = trav.progressCtrl.controller - for item in (mult, prog): - if isinstance(item, Slider): - inputs.append(item) - elif isinstance(item, Combo): - for cp in item.pairs: - inputs.append(cp.slider) - return len(set(inputs)) - - -def parseExpandedData(smpx, restShape, sliderShapes, comboShapes, travShapes): - """Turn the expanded data into shapeDeltas connected to the actual Shape objects - - Parameters - ---------- - smpx : Simplex - A simplex system - restShape : np.array - The rest point positions - sliderShapes : np.array - The slider shape point positions - comboShapes : np.array - The combo shape point positions - travShapes : np.array - The traversal shape point positions - - Returns - ------- - : np.array - The point positions for a new set of shapes - - """ - if np is None: - raise RuntimeError("Numpy is not available") - - solver = PySimplex(smpx.dump()) - shapeArray = np.zeros((len(smpx.shapes), len(restShape), 3)) - - indexBySlider = {s: i for i, s in enumerate(smpx.sliders)} - indexByShape = {s: i for i, s in enumerate(smpx.shapes)} - - floatShapeSet = set(smpx.getFloatingShapes()) - floatIdxs = sorted({indexByShape[s] for s in floatShapeSet}) - travShapeSet = {pp.shape for t in smpx.traversals for pp in t.prog.pairs} - travIdxs = sorted({indexByShape[s] for s in travShapeSet}) - - # Sliders are simple, just set their shapes directly - for ppDict in sliderShapes.values(): - for pp, shp in ppDict.items(): - shapeArray[indexByShape[pp.shape]] = shp - restShape - - # First sort the combos by depth - comboByDepth = {} - for combo in smpx.combos: - comboByDepth.setdefault(len(combo.pairs), []).append(combo) - - for depth in sorted(comboByDepth.keys()): - for combo in comboByDepth[depth]: - for pp, shp in comboShapes[combo].items(): - inVec = _buildSolverInputs(smpx, combo, pp.value, indexBySlider) - outVec = np.array(solver.solve(inVec)) - outVec[np.where(np.isclose(outVec, 0.0))] = 0.0 - outVec[np.where(np.isclose(outVec, 1.0))] = 1.0 - outVec[indexByShape[pp.shape]] = 0.0 - - # ignore any traversals - outVec[travIdxs] = 0.0 - - # ignore floaters if we're not currently checking floaters - if pp.shape not in floatShapeSet: - outVec[floatIdxs] = 0.0 - - # set the shape delta to the output - baseShape = np.dot(outVec, shapeArray.swapaxes(0, 1)) - shapeArray[indexByShape[pp.shape]] = shp - restShape - baseShape - - # First the traversals by depth - travByDepth = {} - for trav in smpx.traversals: - travByDepth.setdefault(getTravDepth(trav), []).append(trav) - - for depth in sorted(travByDepth.keys()): - for trav in travByDepth[depth]: - for pp, shp in travShapes[trav].items(): - inVec = _buildSolverInputs(smpx, trav, pp.value, indexBySlider) - outVec = np.array(solver.solve(inVec)) - outVec[np.where(np.isclose(outVec, 0.0))] = 0.0 - outVec[np.where(np.isclose(outVec, 1.0))] = 1.0 - outVec[indexByShape[pp.shape]] = 0.0 - - # set the shape delta to the output - baseShape = np.dot(outVec, shapeArray.swapaxes(0, 1)) - shapeArray[indexByShape[pp.shape]] = shp - restShape - baseShape - return shapeArray - - -def buildShapeArray(mesh, master, clients): - """Build the outpu shape array - - Parameters - ---------- - mesh : str - The maya shape node name - master : Simplex - The master simplex system - clients : - The client simplex systems - - Returns - ------- - : np.array - The full output shape array - - """ - restShape, sliderShapes, comboShapes, travShapes = getExpandedData( - master, clients, mesh - ) - shapeArray = parseExpandedData( - master, restShape, sliderShapes, comboShapes, travShapes - ) - shapeArray += restShape[None, ...] - return shapeArray - - -def expandedExportAbc(path, mesh, master, clients=()): - """Export the alembic by re-building the deltas from all of the full shapes - This is required for delta-mushing a system, because the sum of mushed shapes - is not the same as the mushed sum-of-shapes - - Parameters - ---------- - path : str - Output path for the .smpx - mesh : str - The maya shape node name - master : Simplex - The master simplex system - clients : [Simplex, ...] - The client simplex systems - - Returns - ------- - - """ - # Convert clients to a list if need be - if not clients: - clients = [] - elif not isinstance(clients, list): - if isinstance(clients, tuple): - clients = list(clients) - else: - clients = [clients] - - faces, counts, uvs = DCC.getAbcFaces(mesh) - shapeArray = buildShapeArray(mesh, master, clients) - jsString = str(master.dump()) - - buildSmpx( - path, - shapeArray, - faces, - jsString, - master.name, - faceCounts=counts, - uvs=uvs, - ) - - -if __name__ == "__main__": - # get the smpx from the UI - - master = Simplex.buildSystemFromMesh("Face_SIMPLEX", "Face") - client = Simplex.buildSystemFromMesh("Face_SIMPLEX2", "Face2") - outPath = r"D:\Users\tyler\Desktop\TEST\expanded.smpx" - expandedExportAbc(outPath, "Face_SIMPLEX", master, client) +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +from __future__ import annotations + +import numpy as np +from pysimplex import PySimplex + +from ..interface.mayaInterface import DCC, disconnected +from ..items import Combo, Simplex, Slider, Traversal +from .alembicCommon import buildSmpx + + +def _setSliders(ctrl, val, svs) -> None: + slis, vals = svs.setdefault(ctrl.simplex, ([], [])) + + if isinstance(ctrl, Slider): + slis.append(ctrl) + vals.append(val) + elif isinstance(ctrl, Combo): + for cp in ctrl.pairs: + slis.append(cp.slider) + vals.append(cp.value * abs(val)) + elif isinstance(ctrl, Traversal): + # set the ctrl.multiplierCtrl.controller to 1 + multCtrl = ctrl.multiplierCtrl.controller + multVal = ctrl.multiplierCtrl.value if val != 0.0 else 0.0 + progCtrl = ctrl.progressCtrl.controller + _setSliders(multCtrl, multVal, svs) + _setSliders(progCtrl, val, svs) + + +def setSliderGroup(ctrls, val: float) -> None: + """Set a group of controls to a given value + + Parameters + ---------- + ctrls : [object, ...] + A list of simplex objects + val : float + The value to set + + Returns + ------- + + """ + svs = {} + for ctrl in ctrls: + _setSliders(ctrl, val, svs) + + for smpx, (slis, vals) in svs.items(): + smpx.setSlidersWeights(slis, vals) + + +def clientPartition(master, clients): + """ + + Parameters + ---------- + master : Simplex + The master simplex system + clients : [Simplex, ...] + The client simplex systems + + Returns + ------- + : {str: [Slider, ...]} + Dictionary of name to slider + : {str: [Combo, ...]} + Dictionary of name to combo + : {str: [Traversal, ...]} + Dictionary of name to traversal + + """ + sliders, combos, traversals = {}, {}, {} + for cli in [master] + clients: + for sli in cli.sliders: + sliders.setdefault(sli.name, []).append(sli) + + for com in cli.combos: + combos.setdefault(com.name, []).append(com) + + for trav in cli.traversals: + traversals.setdefault(trav.name, []).append(trav) + return sliders, combos, traversals + + +def zeroAll(smpxs) -> None: + """Set all sliders on the given simplex systems to 0 + + Parameters + ---------- + smpxs : [Simplex, ...] + A list of Simplex systems + + Returns + ------- + + """ + for smpx in smpxs: + smpx.setSlidersWeights(smpx.sliders, [0.0] * len(smpx.sliders)) + + +def getExpandedData(master, clients, mesh: str): + """Get the fully expanded shape data for each slider, combo, and traversal + at each of its underlying shapes + + Parameters + ---------- + master : Simplex + The master simplex system + clients : [Simplex, ...] + The client simplex systems + mesh : str + The name of the maya shape node + + Returns + ------- + : np.array + The rest shape point positions + : np.array + The slider shape point positions + : np.array + The combo shape point positions + : np.array + The traversal shape point positions + + """ + # zero everything + zeroAll([master] + clients) + sliPart, cmbPart, travPart = clientPartition(master, clients) + sliderShapes = {} + for slider in master.sliders: + ss = {} + sliderShapes[slider] = ss + for pp in slider.prog.pairs: + if pp.shape.isRest: + continue + setSliderGroup(sliPart[slider.name], pp.value) + ss[pp] = DCC.getNumpyShape(mesh) + setSliderGroup(sliPart[slider.name], 0.0) + + # Disable traversals + zeroAll([master] + clients) + travShapeThings = [] + comboShapes = {} + for trav in master.traversals: + for pp in trav.prog.pairs: + if pp.shape.isRest: + continue + travShapeThings.append(pp.shape) + travShapeThings = [i.thing for i in travShapeThings] + + # Get the combos with traversals disconnected + with disconnected(travShapeThings): + for combo in master.combos: + ss = {} + comboShapes[combo] = ss + for pp in combo.prog.pairs: + if pp.shape.isRest: + continue + setSliderGroup(cmbPart[combo.name], pp.value) + ss[pp] = DCC.getNumpyShape(mesh) + setSliderGroup(cmbPart[combo.name], 0.0) + + zeroAll([master] + clients) + travShapes = {} + for trav in master.traversals: + ss = {} + travShapes[trav] = ss + progCtrl = trav.progressCtrl.controller + progVal = trav.progressCtrl.value + for pp in progCtrl.prog.pairs: + if pp.shape.isRest: + continue + if pp.value * progVal <= 0.0: + # Traversals only activate if the progression is in the same + # pos/neg direction as the value. Otherwise we just skip + continue + + setSliderGroup(travPart[trav.name], pp.value) + ss[pp] = DCC.getNumpyShape(mesh) + setSliderGroup(travPart[trav.name], 0.0) + + zeroAll([master] + clients) + restShape = DCC.getNumpyShape(mesh) + + return restShape, sliderShapes, comboShapes, travShapes + + +def _setInputs(inVec: list[float], item, indexBySlider, value) -> list[float]: + """Being clever + Sliders or Combos just set the value and return + Traversals recursively call this function with the controllers (that only either sliders or combos) + """ + if isinstance(item, Slider): + inVec[indexBySlider[item]] = value + return inVec + elif isinstance(item, Combo): + for pair in item.pairs: + inVec[indexBySlider[pair.slider]] = pair.value * abs(value) + return inVec + elif isinstance(item, Traversal): + inVec = _setInputs( + inVec, + item.multiplierCtrl.controller, + indexBySlider, + item.multiplierCtrl.value, + ) + inVec = _setInputs( + inVec, + item.progressCtrl.controller, + indexBySlider, + item.progressCtrl.value * value, + ) + return inVec + raise ValueError( + f"Not a Slider, Combo, or Traversal. Got type {type(item)}: {item}" + ) + + +def _buildSolverInputs(simplex, item, value, indexBySlider) -> list[float]: + """Build an input vector for the solver that will + produce a required progression value on an item + """ + inVec = [0.0] * len(simplex.sliders) + return _setInputs(inVec, item, indexBySlider, value) + + +def getTravDepth(trav) -> int: + """Get the depth of a traversal object + + Parameters + ---------- + trav : Traversal + The traversal object + + Returns + ------- + : int + The depth of the given Traversal + + """ + inputs = [] + mult = trav.multiplierCtrl.controller + prog = trav.progressCtrl.controller + for item in (mult, prog): + if isinstance(item, Slider): + inputs.append(item) + elif isinstance(item, Combo): + for cp in item.pairs: + inputs.append(cp.slider) + return len(set(inputs)) + + +def parseExpandedData(smpx, restShape, sliderShapes, comboShapes, travShapes): + """Turn the expanded data into shapeDeltas connected to the actual Shape objects + + Parameters + ---------- + smpx : Simplex + A simplex system + restShape : np.array + The rest point positions + sliderShapes : np.array + The slider shape point positions + comboShapes : np.array + The combo shape point positions + travShapes : np.array + The traversal shape point positions + + Returns + ------- + : np.array + The point positions for a new set of shapes + + """ + if np is None: + raise RuntimeError("Numpy is not available") + + solver = PySimplex(smpx.dump()) + shapeArray = np.zeros((len(smpx.shapes), len(restShape), 3)) + + indexBySlider = {s: i for i, s in enumerate(smpx.sliders)} + indexByShape = {s: i for i, s in enumerate(smpx.shapes)} + + floatShapeSet = set(smpx.getFloatingShapes()) + floatIdxs = sorted({indexByShape[s] for s in floatShapeSet}) + travShapeSet = {pp.shape for t in smpx.traversals for pp in t.prog.pairs} + travIdxs = sorted({indexByShape[s] for s in travShapeSet}) + + # Sliders are simple, just set their shapes directly + for ppDict in sliderShapes.values(): + for pp, shp in ppDict.items(): + shapeArray[indexByShape[pp.shape]] = shp - restShape + + # First sort the combos by depth + comboByDepth = {} + for combo in smpx.combos: + comboByDepth.setdefault(len(combo.pairs), []).append(combo) + + for depth in sorted(comboByDepth.keys()): + for combo in comboByDepth[depth]: + for pp, shp in comboShapes[combo].items(): + inVec = _buildSolverInputs(smpx, combo, pp.value, indexBySlider) + outVec = np.array(solver.solve(inVec)) + outVec[np.where(np.isclose(outVec, 0.0))] = 0.0 + outVec[np.where(np.isclose(outVec, 1.0))] = 1.0 + outVec[indexByShape[pp.shape]] = 0.0 + + # ignore any traversals + outVec[travIdxs] = 0.0 + + # ignore floaters if we're not currently checking floaters + if pp.shape not in floatShapeSet: + outVec[floatIdxs] = 0.0 + + # set the shape delta to the output + baseShape = np.dot(outVec, shapeArray.swapaxes(0, 1)) + shapeArray[indexByShape[pp.shape]] = shp - restShape - baseShape + + # First the traversals by depth + travByDepth = {} + for trav in smpx.traversals: + travByDepth.setdefault(getTravDepth(trav), []).append(trav) + + for depth in sorted(travByDepth.keys()): + for trav in travByDepth[depth]: + for pp, shp in travShapes[trav].items(): + inVec = _buildSolverInputs(smpx, trav, pp.value, indexBySlider) + outVec = np.array(solver.solve(inVec)) + outVec[np.where(np.isclose(outVec, 0.0))] = 0.0 + outVec[np.where(np.isclose(outVec, 1.0))] = 1.0 + outVec[indexByShape[pp.shape]] = 0.0 + + # set the shape delta to the output + baseShape = np.dot(outVec, shapeArray.swapaxes(0, 1)) + shapeArray[indexByShape[pp.shape]] = shp - restShape - baseShape + return shapeArray + + +def buildShapeArray(mesh: str, master: Simplex, clients): + """Build the outpu shape array + + Parameters + ---------- + mesh : str + The maya shape node name + master : Simplex + The master simplex system + clients : + The client simplex systems + + Returns + ------- + : np.array + The full output shape array + + """ + restShape, sliderShapes, comboShapes, travShapes = getExpandedData( + master, clients, mesh + ) + shapeArray = parseExpandedData( + master, restShape, sliderShapes, comboShapes, travShapes + ) + shapeArray += restShape[None, ...] + return shapeArray + + +def expandedExportAbc(path: str, mesh: str, master: Simplex, clients: tuple = ()) -> None: + """Export the alembic by re-building the deltas from all of the full shapes + This is required for delta-mushing a system, because the sum of mushed shapes + is not the same as the mushed sum-of-shapes + + Parameters + ---------- + path : str + Output path for the .smpx + mesh : str + The maya shape node name + master : Simplex + The master simplex system + clients : [Simplex, ...] + The client simplex systems + + Returns + ------- + + """ + # Convert clients to a list if need be + if not clients: + clients = [] + elif not isinstance(clients, list): + if isinstance(clients, tuple): + clients = list(clients) + else: + clients = [clients] + + faces, counts, uvs = DCC.getAbcFaces(mesh) + shapeArray = buildShapeArray(mesh, master, clients) + jsString = str(master.dump()) + + buildSmpx( + path, + shapeArray, + faces, + jsString, + master.name, + faceCounts=counts, + uvs=uvs, + ) + + +if __name__ == "__main__": + # get the smpx from the UI + + master = Simplex.buildSystemFromMesh("Face_SIMPLEX", "Face") + client = Simplex.buildSystemFromMesh("Face_SIMPLEX2", "Face2") + outPath = r"D:\Users\tyler\Desktop\TEST\expanded.smpx" + expandedExportAbc(outPath, "Face_SIMPLEX", master, client) diff --git a/src/python/simplexui/commands/hdf5Convert.py b/src/python/simplexui/commands/hdf5Convert.py index 17194d0f..a28d3699 100644 --- a/src/python/simplexui/commands/hdf5Convert.py +++ b/src/python/simplexui/commands/hdf5Convert.py @@ -1,60 +1,62 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -import json - -from .alembicCommon import buildSmpx, readSmpx - - -def hdf5Convert(inPath, outPath, ogawa=False): - """Load and parse all the data from a simplex file - - Parameters - ---------- - inPath : str - The input .smpx file path - outPath : str - The output .smpx file path - ogawa : bool - Whether to write out in Ogawa format. Defaults False - - Returns - ------- - - """ - jsString, counts, verts, faces, uvs, uvFaces = readSmpx(inPath) - - js = json.loads(jsString) - name = js["systemName"] - - buildSmpx( - outPath, - verts, - faces, - jsString, - name, - faceCounts=counts, - uvs=uvs, - uvFaces=uvFaces, - ogawa=ogawa, - ) - - -if __name__ == "__main__": - inPath = r"D:\Users\tyler\Desktop\Head_Morphs_Main_Head-Face_v0010.smpx" - outPath = r"D:\Users\tyler\Desktop\Head_ogawa.smpx" - hdf5Convert(inPath, outPath, ogawa=True) +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +from __future__ import annotations + +import json + +from .alembicCommon import buildSmpx, readSmpx + + +def hdf5Convert(inPath: str, outPath: str, ogawa=False) -> None: + """Load and parse all the data from a simplex file + + Parameters + ---------- + inPath : str + The input .smpx file path + outPath : str + The output .smpx file path + ogawa : bool + Whether to write out in Ogawa format. Defaults False + + Returns + ------- + + """ + jsString, counts, verts, faces, uvs, uvFaces = readSmpx(inPath) + + js = json.loads(jsString) + name = js["systemName"] + + buildSmpx( + outPath, + verts, + faces, + jsString, + name, + faceCounts=counts, + uvs=uvs, + uvFaces=uvFaces, + ogawa=ogawa, + ) + + +if __name__ == "__main__": + inPath = r"D:\Users\tyler\Desktop\Head_Morphs_Main_Head-Face_v0010.smpx" + outPath = r"D:\Users\tyler\Desktop\Head_ogawa.smpx" + hdf5Convert(inPath, outPath, ogawa=True) diff --git a/src/python/simplexui/commands/mayaCorrectiveInterface.py b/src/python/simplexui/commands/mayaCorrectiveInterface.py index f4258139..d95d9fdf 100644 --- a/src/python/simplexui/commands/mayaCorrectiveInterface.py +++ b/src/python/simplexui/commands/mayaCorrectiveInterface.py @@ -1,161 +1,159 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -"""Get the corrective deltas from a rig in Maya""" - -from ctypes import c_float - -from maya import OpenMaya as om -from maya import cmds - -try: - import numpy as np -except ImportError: - pass - - -def setPose(pvp, multiplier): - """Set a percentage of a pose - - Parameters - ---------- - pvp : [(str, float), ...] - A list of property/value pairs - multiplier : float - The percentage multiplier of the pose - """ - for prop, val in pvp: - cmds.setAttr(prop, val * multiplier) - - -def resetPose(pvp): - """Reset everything back to rest - - Parameters - ---------- - pvp : [(str, float), ...] - A list of property/value pairs - """ - for prop, _val in pvp: - cmds.setAttr(prop, 0) - - -def _getDagPath(mesh): - sl = om.MSelectionList() - sl.add(mesh) - dagPath = om.MDagPath() - sl.getDagPath(0, dagPath) - return dagPath - - -def _getMayaPoints(meshFn): - rawPts = meshFn.getRawPoints() - ptCount = meshFn.numVertices() - cta = (c_float * 3 * ptCount).from_address(int(rawPts)) - out = np.ctypeslib.as_array(cta) - out = np.copy(out) - out = out.reshape((-1, 3)) - return out - - -def getDeformerChain(chkObj): - # Follow the deformer chain - memo = [] - while chkObj and chkObj not in memo: - memo.append(chkObj) - - typ = cmds.nodeType(chkObj) - if typ == "mesh": - cnx = cmds.listConnections( - chkObj + ".inMesh", destination=False, shapes=True - ) or [None] - chkObj = cnx[0] - elif typ == "groupParts": - cnx = cmds.listConnections( - chkObj + ".inputGeometry", destination=False, shapes=True - ) or [None] - chkObj = cnx[0] - elif typ == "polySoftEdge": - cnx = cmds.listConnections( - chkObj + ".inputPolymesh", destination=False, shapes=True - ) or [None] - chkObj = cnx[0] - elif typ == "AlembicNode": - # Alembic nodes aren't part of the deformer chain - # Cut it off, and return - return memo[:-1] - else: - cnx = cmds.ls(chkObj, type="geometryFilter") or [None] - chkObj = cnx[0] - if chkObj: # we have a deformer - # Get the mesh index of this deformer - cnx = cmds.listConnections( - chkObj, connections=True, plugs=True, source=False - ) - prev = cmds.ls(memo[-2])[0] # Get the minimal unique name for testing - defIdx = 0 - for i in range(0, len(cnx), 2): - if cnx[i + 1].startswith(prev): - defIdx = int(cnx[i].split("[")[-1][:-1]) - break - # Use that mesh index to get the output - cnx = cmds.listConnections( - chkObj + ".input[{0}].inputGeometry".format(defIdx), - destination=False, - shapes=True, - ) or [None] - chkObj = cnx[0] - - return memo - - -def getShiftValues(thing): - """Shift the vertices along each axis *before* the skinning - op in the deformer history - - Parameters - ---------- - mesh : str - The name of a mesh - - Returns - ------- - : [vert, ...] - A list of un-shifted vertices - : [vert, ...] - A list of vertices pre-shifted by 1 along the X axis - : [vert, ...] - A list of vertices pre-shifted by 1 along the Y axis - : [vert, ...] - A list of vertices pre-shifted by 1 along the Z axis - """ - orig = getDeformerChain(thing)[-1] - - dp = _getDagPath(thing) - meshFn = om.MFnMesh(dp) - allVerts = "{0}.vtx[*]".format(orig) - - zero = _getMayaPoints(meshFn) - cmds.move(1, 0, 0, allVerts, relative=1, objectSpace=1) - oneX = _getMayaPoints(meshFn) - cmds.move(-1, 1, 0, allVerts, relative=1, objectSpace=1) - oneY = _getMayaPoints(meshFn) - cmds.move(0, -1, 1, allVerts, relative=1, objectSpace=1) - oneZ = _getMayaPoints(meshFn) - cmds.move(0, 0, -1, allVerts, relative=1, objectSpace=1) - - return zero, oneX, oneY, oneZ +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +"""Get the corrective deltas from a rig in Maya""" + +from __future__ import annotations + +from ctypes import c_float + +import numpy as np +from maya import OpenMaya as om +from maya import cmds + + +def setPose(pvp, multiplier) -> None: + """Set a percentage of a pose + + Parameters + ---------- + pvp : [(str, float), ...] + A list of property/value pairs + multiplier : float + The percentage multiplier of the pose + """ + for prop, val in pvp: + cmds.setAttr(prop, val * multiplier) + + +def resetPose(pvp) -> None: + """Reset everything back to rest + + Parameters + ---------- + pvp : [(str, float), ...] + A list of property/value pairs + """ + for prop, _val in pvp: + cmds.setAttr(prop, 0) + + +def _getDagPath(mesh): + sl = om.MSelectionList() + sl.add(mesh) + dagPath = om.MDagPath() + sl.getDagPath(0, dagPath) + return dagPath + + +def _getMayaPoints(meshFn): + rawPts = meshFn.getRawPoints() + ptCount = meshFn.numVertices() + cta = (c_float * 3 * ptCount).from_address(int(rawPts)) + out = np.ctypeslib.as_array(cta) + out = np.copy(out) + out = out.reshape((-1, 3)) + return out + + +def getDeformerChain(chkObj): + # Follow the deformer chain + memo = [] + while chkObj and chkObj not in memo: + memo.append(chkObj) + + typ = cmds.nodeType(chkObj) + if typ == "mesh": + cnx = cmds.listConnections( + chkObj + ".inMesh", destination=False, shapes=True + ) or [None] + chkObj = cnx[0] + elif typ == "groupParts": + cnx = cmds.listConnections( + chkObj + ".inputGeometry", destination=False, shapes=True + ) or [None] + chkObj = cnx[0] + elif typ == "polySoftEdge": + cnx = cmds.listConnections( + chkObj + ".inputPolymesh", destination=False, shapes=True + ) or [None] + chkObj = cnx[0] + elif typ == "AlembicNode": + # Alembic nodes aren't part of the deformer chain + # Cut it off, and return + return memo[:-1] + else: + cnx = cmds.ls(chkObj, type="geometryFilter") or [None] + chkObj = cnx[0] + if chkObj: # we have a deformer + # Get the mesh index of this deformer + cnx = cmds.listConnections( + chkObj, connections=True, plugs=True, source=False + ) + prev = cmds.ls(memo[-2])[0] # Get the minimal unique name for testing + defIdx = 0 + for i in range(0, len(cnx), 2): + if cnx[i + 1].startswith(prev): + defIdx = int(cnx[i].split("[")[-1][:-1]) + break + # Use that mesh index to get the output + cnx = cmds.listConnections( + chkObj + f".input[{defIdx}].inputGeometry", + destination=False, + shapes=True, + ) or [None] + chkObj = cnx[0] + + return memo + + +def getShiftValues(thing): + """Shift the vertices along each axis *before* the skinning + op in the deformer history + + Parameters + ---------- + mesh : str + The name of a mesh + + Returns + ------- + : [vert, ...] + A list of un-shifted vertices + : [vert, ...] + A list of vertices pre-shifted by 1 along the X axis + : [vert, ...] + A list of vertices pre-shifted by 1 along the Y axis + : [vert, ...] + A list of vertices pre-shifted by 1 along the Z axis + """ + orig = getDeformerChain(thing)[-1] + + dp = _getDagPath(thing) + meshFn = om.MFnMesh(dp) + allVerts = f"{orig}.vtx[*]" + + zero = _getMayaPoints(meshFn) + cmds.move(1, 0, 0, allVerts, relative=1, objectSpace=1) + oneX = _getMayaPoints(meshFn) + cmds.move(-1, 1, 0, allVerts, relative=1, objectSpace=1) + oneY = _getMayaPoints(meshFn) + cmds.move(0, -1, 1, allVerts, relative=1, objectSpace=1) + oneZ = _getMayaPoints(meshFn) + cmds.move(0, 0, -1, allVerts, relative=1, objectSpace=1) + + return zero, oneX, oneY, oneZ diff --git a/src/python/simplexui/commands/mayatonumpy.py b/src/python/simplexui/commands/mayatonumpy.py index 95f3f4ed..bab0bf0c 100644 --- a/src/python/simplexui/commands/mayatonumpy.py +++ b/src/python/simplexui/commands/mayatonumpy.py @@ -1,6 +1,10 @@ -from maya import OpenMaya as om +from __future__ import annotations + +from ctypes import c_double, c_float, c_int, c_uint +from typing import TYPE_CHECKING, TypeVar, Union + import numpy as np -from ctypes import c_float, c_double, c_int, c_uint +from maya import OpenMaya as om # fmt: off _CONVERT_DICT = { @@ -15,8 +19,36 @@ } # fmt: on - -def _swigConnect(mArray, count, util): +OMArrays = Union[ + om.MPointArray, + om.MFloatPointArray, + om.MVectorArray, + om.MFloatVectorArray, + om.MDoubleArray, + om.MFloatArray, + om.MIntArray, + om.MUintArray, +] +OMPtrs = Union[ + om.MScriptUtil.asDouble4Ptr, + om.MScriptUtil.asFloat4Ptr, + om.MScriptUtil.asDouble3Ptr, + om.MScriptUtil.asFloat3Ptr, + om.MScriptUtil.asDoublePtr, + om.MScriptUtil.asFloatPtr, + om.MScriptUtil.asIntPtr, + om.MScriptUtil.asUintPtr, +] + +if TYPE_CHECKING: + + class SwigPyObject: + def __int__(self) -> int: ... + + +def _swigConnect( + mArray: OMArrays, count: int, util: om.MScriptUtil +) -> tuple[np.ndarray, OMPtrs]: """ Use an MScriptUtil to build SWIG array that we can read from and write to. Make sure to get the MScriptUtil from outside this function, otherwise @@ -54,9 +86,11 @@ def _swigConnect(mArray, count, util): return npArray, ptr -def _swigConnectMatrix(mat, ctp): +def _swigConnectMatrix( + mat: om.MMatrix, ctp: type[c_double] | type[c_float] +) -> tuple[np.ndarray, SwigPyObject]: # With a matrix, you can just get the double[4][4] without an MScriptUtil - ptr = mat.matrix + ptr: SwigPyObject = mat.matrix cdata = ctp * 4 * 4 # int(ptr) gives the memory address @@ -68,7 +102,7 @@ def _swigConnectMatrix(mat, ctp): return npArray, ptr -def mayaToNumpy(mArray): +def mayaToNumpy(mArray: OMArrays) -> np.ndarray: """Convert a maya array to a numpy array Parameters @@ -80,7 +114,6 @@ def mayaToNumpy(mArray): ------- : np.array : A numpy array that contains the data from mArray - """ if isinstance(mArray, om.MMatrix): npArray, _ = _swigConnectMatrix(mArray, c_double) @@ -93,7 +126,10 @@ def mayaToNumpy(mArray): return np.copy(npArray) -def numpyToMaya(ary, mType): +T = TypeVar('T', bound=OMArrays) + + +def numpyToMaya(ary: np.ndarray, mType: type[T]) -> T: """Convert a numpy array to a specific maya type array Parameters @@ -177,7 +213,9 @@ def numpyToMaya(ary, mType): # fmt: on -def getNumpyAttr(attrName): +def getNumpyAttr( + attrName: om.MPlug | str, +) -> np.ndarray | float | int | tuple[int, ...] | tuple[float, ...]: """Read attribute data directly from the plugs into numpy This function will read most numeric data types directly into numpy arrays @@ -256,13 +294,14 @@ def getNumpyAttr(attrName): return mayaToNumpy(mat) else: apiTypeStr = pmo.apiTypeStr() - raise NotImplementedError( - "I don't know how to handle {0} yet".format(apiTypeStr) - ) + raise NotImplementedError(f"I don't know how to handle {apiTypeStr} yet") raise NotImplementedError("Fell all the way through") -def setNumpyAttr(attrName, value): +def setNumpyAttr( + attrName: str | om.MPlug, + value: np.ndarray | float | int | tuple[int, ...] | tuple[float, ...], +) -> None: """Write a numpy array directly into a maya plug This function will handle most numeric plug types. @@ -286,7 +325,7 @@ def setNumpyAttr(attrName, value): elif isinstance(attrName, om.MPlug): plug = attrName else: - raise ValueError("Data must be string or MPlug. Got {0}".format(type(attrName))) + raise ValueError(f"Data must be string or MPlug. Got {type(attrName)}") # First just check if the data is numeric mdh = plug.asMDataHandle() @@ -294,6 +333,7 @@ def setNumpyAttr(attrName, value): # So, at this point, you should really just use setattr ntype = mdh.numericType() if ntype in _NTYPE_DICT: + assert isinstance(value, tuple) _NTYPE_DICT[ntype][1](mdh, *value) plug.setMObject(mdh.data()) elif ntype == om.MFnNumericData.k4Double: @@ -315,6 +355,7 @@ def setNumpyAttr(attrName, value): # build the pointArrayData fnType, mType = _DTYPE_DICT[apiType] fn = fnType() + assert isinstance(value, np.ndarray) mPts = numpyToMaya(value, mType) dataObj = fn.create(mPts) plug.setMObject(dataObj) @@ -325,6 +366,7 @@ def setNumpyAttr(attrName, value): compList = fnCompList.create() fnIdx = om.MFnSingleIndexedComponent() idxObj = fnIdx.create(om.MFn.kMeshVertComponent) + assert isinstance(value, np.ndarray) mIdxs = numpyToMaya(value, om.MIntArray) fnIdx.addElements(mIdxs) fnCompList.add(idxObj) @@ -332,9 +374,7 @@ def setNumpyAttr(attrName, value): return else: apiTypeStr = pmo.apiTypeStr() - raise NotImplementedError( - "I don't know how to handle {0} yet".format(apiTypeStr) - ) + raise NotImplementedError(f"I don't know how to handle {apiTypeStr} yet") raise NotImplementedError("WTF? How did you get here??") @@ -342,8 +382,9 @@ def setNumpyAttr(attrName, value): ################################################################################ -def test(): +def test() -> None: import time + from maya import cmds meshName = "pSphere1" @@ -354,7 +395,7 @@ def test(): # A quick test showing how to build a numpy array # containing the deltas for a shape on a blendshape node numVerts = cmds.polyEvaluate(meshName, vertex=True) - baseAttr = "{0}.it[{1}].itg[{2}].iti[6000]".format(bsName, meshIdx, bsIdx) + baseAttr = f"{bsName}.it[{meshIdx}].itg[{bsIdx}].iti[6000]" inPtAttr = baseAttr + ".inputPointsTarget" inCompAttr = baseAttr + ".inputComponentsTarget" diff --git a/src/python/simplexui/commands/mesh.py b/src/python/simplexui/commands/mesh.py index b6a8e23a..33f7d265 100644 --- a/src/python/simplexui/commands/mesh.py +++ b/src/python/simplexui/commands/mesh.py @@ -28,8 +28,12 @@ VertSet and FaceSet classes are just sets that also contain references back to the mesh """ +from __future__ import annotations -class Mesh(object): +from types import NotImplementedType + + +class Mesh: """ The inputs to this mesh object are inspired by the .obj file format @@ -99,7 +103,7 @@ def __init__( uvMap=None, uvFaceMap=None, ensureWinding=False, - ): + ) -> None: self._verts = None self._faces = None self._uvs = {} @@ -167,7 +171,7 @@ def __init__( else: self.vertToFaces = [vertToFaces[i] for i in range(vertCount)] - def ensureWinding(self): + def ensureWinding(self) -> None: """Ensure the winding of the mesh after-the-fact""" if self._wound: return @@ -183,7 +187,7 @@ def ensureWinding(self): self.vertToFaces.append(self._linkPairs(wings)) @classmethod - def loadObj(cls, path, ensureWinding=True): + def loadObj(cls, path, ensureWinding: bool = True) -> Mesh: """Read a .obj file and produce a Mesh object Parameters @@ -243,7 +247,7 @@ def loadObj(cls, path, ensureWinding=True): ) @classmethod - def loadAbc(cls, path, meshName=None, ensureWinding=True): + def loadAbc(cls, path, meshName=None, ensureWinding: bool = True) -> Mesh: """Read a .abc file and produce a Mesh object Parameters @@ -300,7 +304,7 @@ def loadAbc(cls, path, meshName=None, ensureWinding=True): return cls(verts, faces, uvs=uvs, uvFaces=uvFaces, ensureWinding=ensureWinding) @classmethod - def loadPrimitive(cls, prim, channelName=None, ensureWinding=True): + def loadPrimitive(cls, prim, channelName=None, ensureWinding: bool = True) -> Mesh: """Read the vertex and face data from a cross3d primitive Parameters @@ -473,11 +477,11 @@ def adjacentVertsByEdge(self, vertIdx): """ return self.vertNeighbors[vertIdx] - def vertCount(self): + def vertCount(self) -> int: """Get the number of vertices in this mesh""" return len(self.vertArray) - def faceCount(self): + def faceCount(self) -> int: """Get the number of faces in this mesh""" return len(self.faceVertArray) @@ -505,7 +509,7 @@ def faces(self): self._faces = [Face(self, i) for i in range(len(self.faceVertArray))] return self._faces - def vertSet(self): + def vertSet(self) -> VertSet: """Get a vertex set containing the whole mesh Returns @@ -517,7 +521,7 @@ def vertSet(self): ret.update(list(range(len(self.vertArray)))) return ret - def faceSet(self): + def faceSet(self) -> FaceSet: """Get a face set containing the whole mesh Returns @@ -529,7 +533,7 @@ def faceSet(self): ret.update(list(range(len(self.faceVertArray)))) return ret - def uvs(self, channelName="default"): + def uvs(self, channelName: str = "default"): """Get all UV convenience objects Returns @@ -543,7 +547,7 @@ def uvs(self, channelName="default"): self._uvs[channelName] = [UV(self, channelName, i) for i in uvm] return self._uvs.get(channelName) - def uvFaces(self, channelName="default"): + def uvFaces(self, channelName: str = "default"): """Get all UV convenience objects Returns @@ -559,7 +563,7 @@ def uvFaces(self, channelName="default"): ] return self._uvFaces.get(channelName) - def isBorderVert(self, vertIdx): + def isBorderVert(self, vertIdx) -> bool: """Check if the given vertex index is along a border Returns @@ -574,7 +578,7 @@ def isBorderVert(self, vertIdx): return True return False - def getBorderVerts(self): + def getBorderVerts(self) -> VertSet: """Get a vertex set of the border vertices Returns @@ -588,7 +592,7 @@ def getBorderVerts(self): out.update(edge) return out - def clearCache(self): + def clearCache(self) -> None: """Clear all cached convenience classes""" self._verts = None self._faces = None @@ -600,7 +604,7 @@ def clearCache(self): ####################################################################################### -class MeshComponent(object): +class MeshComponent: """Base class for all mesh components Handles keeping track of the mesh and index @@ -614,7 +618,7 @@ class MeshComponent(object): __slot__ = "mesh", "index" - def __init__(self, mesh, index): + def __init__(self, mesh: Mesh, index) -> None: self.mesh = mesh self.index = index self.mesh.children.append(self) @@ -622,7 +626,7 @@ def __init__(self, mesh, index): def __int__(self): return self.index - def clear(self): + def clear(self) -> None: """Remove all reference data from this object""" self.mesh = None self.mesh.children.remove(self) @@ -692,7 +696,7 @@ def value(self): """ return self.mesh.vertArray[self.index] - def setValue(self, pos): + def setValue(self, pos) -> None: """Set the vertex position Parameters @@ -732,12 +736,12 @@ def adjacentFacesByVert(self): faces = self.mesh.faces() return [faces[i] for i in idxs] - def __eq__(self, other): + def __eq__(self, other) -> NotImplementedType | bool: if isinstance(other, Face): return set(self.verts()) == set(other.verts()) return NotImplemented - def __hash__(self): + def __hash__(self) -> int: return hash(self.verts()) def verts(self): @@ -752,7 +756,7 @@ def verts(self): verts = self.mesh.verts() return [verts[i] for i in idxs] - def uvs(self, name="default"): + def uvs(self, name: str = "default"): """Get all uvs that make up this face Returns @@ -780,9 +784,9 @@ class UV(MeshComponent): __slot__ = "mesh", "index", "name" - def __init__(self, mesh, name, index): + def __init__(self, mesh: Mesh, name: str, index) -> None: self.name = name - super(UV, self).__init__(mesh, index) + super().__init__(mesh, index) def value(self): """Get the uv's position @@ -794,7 +798,7 @@ def value(self): """ return self.mesh.uvMap[self.name][self.index] - def setValue(self, pos): + def setValue(self, pos) -> None: """Set the uv's position Parameters @@ -806,7 +810,7 @@ def setValue(self, pos): assert len(t) == 2 self.mesh.uvMap[self.name][self.index] = t - def __hash__(self): + def __hash__(self) -> int: return hash(self.name, self.index) @@ -815,16 +819,16 @@ class UVFace(MeshComponent): __slot__ = "mesh", "index", "name" - def __init__(self, mesh, name, index): + def __init__(self, mesh: Mesh, name: str, index) -> None: self.name = name - super(UVFace, self).__init__(mesh, index) + super().__init__(mesh, index) - def __eq__(self, other): + def __eq__(self, other) -> NotImplementedType | bool: if isinstance(other, UVFace): return set(self.uvs()) == set(other.uvs()) return NotImplemented - def __hash__(self): + def __hash__(self) -> int: return hash(self.verts()) def verts(self): @@ -839,7 +843,7 @@ def verts(self): verts = self.mesh.verts() return [verts[i] for i in idxs] - def uvs(self, name="default"): + def uvs(self, name: str = "default"): """Get all uvs that make up this UVFace Returns @@ -883,7 +887,7 @@ def __new__(mcs, clsName, bases, dct): rnames = ["__rand__", "__ror__", "__rsub__", "__rxor__"] - def wrap_closure(name, right): + def wrap_closure(name: str, right: bool): def inner(self, *args): result = getattr(set, name)(self, *args) if not hasattr(result, "mesh"): @@ -905,15 +909,15 @@ def inner(self, *args): for attr in rnames: dct[attr] = wrap_closure(attr, True) - return super(MeshSetMeta, mcs).__new__(mcs, clsName, bases, dct) + return super().__new__(mcs, clsName, bases, dct) class MeshSet(set, metaclass=MeshSetMeta): """An set-like object that deals with geometry""" - def __init__(self, mesh, indices=None): + def __init__(self, mesh: Mesh, indices=None) -> None: idxs = [] if indices is None else [int(i) for i in indices] - super(MeshSet, self).__init__(idxs) + super().__init__(idxs) self.mesh = mesh self.mesh.children.append(self) @@ -992,7 +996,7 @@ def _partitionIslands(self, growMethod): class VertSet(MeshSet): """A set-like object that deals with vertices""" - def growByEdge(self, exclude=None, track=False): + def growByEdge(self, exclude=None, track: bool = False): """Add verts that share edges with the current set Parameters ---------- @@ -1008,7 +1012,7 @@ def growByEdge(self, exclude=None, track=False): """ return self.grow(self.mesh.adjacentVertsByEdge, exclude=exclude, track=track) - def growByFace(self, exclude=None, track=False): + def growByFace(self, exclude=None, track: bool = False): """Add verts that share faces with the current set Parameters ---------- @@ -1032,13 +1036,13 @@ def partitionIslands(self): : [VertSet, ...] A list of interconnected object sets """ - return super(VertSet, self)._partitionIslands(self.mesh.adjacentVertsByFace) + return super()._partitionIslands(self.mesh.adjacentVertsByFace) class FaceSet(MeshSet): """A set-like object that deals with faces""" - def growByEdge(self, exclude=None, track=False): + def growByEdge(self, exclude=None, track: bool = False): """Add faces that share edges with the current set Parameters ---------- @@ -1054,7 +1058,7 @@ def growByEdge(self, exclude=None, track=False): """ return self.grow(self.mesh.adjacentFacesByEdge, exclude=exclude, track=track) - def growByVert(self, exclude=None, track=False): + def growByVert(self, exclude=None, track: bool = False): """Add faces that share verts with the current set Parameters ---------- @@ -1078,4 +1082,4 @@ def partitionIslands(self): : [FaceSet, ...] A list of interconnected object sets """ - return super(FaceSet, self)._partitionIslands(self.mesh.adjacentFacesByVert) + return super()._partitionIslands(self.mesh.adjacentFacesByVert) diff --git a/src/python/simplexui/commands/numpytoimath.py b/src/python/simplexui/commands/numpytoimath.py deleted file mode 100644 index 4a16b230..00000000 --- a/src/python/simplexui/commands/numpytoimath.py +++ /dev/null @@ -1,319 +0,0 @@ -import imath -import ctypes -import numpy as np -from typing import TypeVar, Type - - -NTYPEDICT: dict[type, type] = { - ctypes.c_bool: bool, - ctypes.c_byte: np.int8, - ctypes.c_double: np.float64, - ctypes.c_float: np.float32, - ctypes.c_long: np.int32, - ctypes.c_short: np.int16, - ctypes.c_ubyte: np.uint8, - ctypes.c_ulong: np.uint32, - ctypes.c_ushort: np.uint16, -} - -# fmt: off -TYPEDICT: dict[type, tuple[list[int], type, str]] = { - imath.BoolArray: ([], ctypes.c_bool, 'array'), - imath.DoubleArray: ([], ctypes.c_double, 'array'), - imath.FloatArray: ([], ctypes.c_float, 'array'), - imath.IntArray: ([], ctypes.c_long, 'array'), - imath.ShortArray: ([], ctypes.c_short, 'array'), - imath.SignedCharArray: ([], ctypes.c_byte, 'array'), - imath.UnsignedCharArray: ([], ctypes.c_ubyte, 'array'), - imath.UnsignedIntArray: ([], ctypes.c_ulong, 'array'), - imath.UnsignedShortArray: ([], ctypes.c_ushort, 'array'), - - imath.Box2dArray: ([2, 2], ctypes.c_double, 'array'), - imath.Box2fArray: ([2, 2], ctypes.c_float, 'array'), - imath.Box2iArray: ([2, 2], ctypes.c_long, 'array'), - imath.Box2sArray: ([2, 2], ctypes.c_short, 'array'), - imath.Box3dArray: ([2, 3], ctypes.c_double, 'array'), - imath.Box3fArray: ([2, 3], ctypes.c_float, 'array'), - imath.Box3iArray: ([2, 3], ctypes.c_long, 'array'), - imath.Box3sArray: ([2, 3], ctypes.c_short, 'array'), - imath.C3cArray: ([3], ctypes.c_byte, 'array'), - imath.C3fArray: ([3], ctypes.c_float, 'array'), - imath.C4cArray: ([4], ctypes.c_byte, 'array'), - imath.C4fArray: ([4], ctypes.c_float, 'array'), - imath.M22dArray: ([2, 2], ctypes.c_double, 'array'), - imath.M22fArray: ([2, 2], ctypes.c_float, 'array'), - imath.M33dArray: ([3, 3], ctypes.c_double, 'array'), - imath.M33fArray: ([3, 3], ctypes.c_float, 'array'), - imath.M44dArray: ([4, 4], ctypes.c_double, 'array'), - imath.M44fArray: ([4, 4], ctypes.c_float, 'array'), - imath.QuatdArray: ([4], ctypes.c_double, 'array'), - imath.QuatfArray: ([4], ctypes.c_float, 'array'), - imath.V2dArray: ([2], ctypes.c_double, 'array'), - imath.V2fArray: ([2], ctypes.c_float, 'array'), - imath.V2iArray: ([2], ctypes.c_long, 'array'), - imath.V2sArray: ([2], ctypes.c_short, 'array'), - imath.V3dArray: ([3], ctypes.c_double, 'array'), - imath.V3fArray: ([3], ctypes.c_float, 'array'), - imath.V3iArray: ([3], ctypes.c_long, 'array'), - imath.V3sArray: ([3], ctypes.c_short, 'array'), - imath.V4dArray: ([4], ctypes.c_double, 'array'), - imath.V4fArray: ([4], ctypes.c_float, 'array'), - imath.V4iArray: ([4], ctypes.c_long, 'array'), - imath.V4sArray: ([4], ctypes.c_short, 'array'), - - imath.Color4cArray2D: ([4], ctypes.c_byte, 'array2d'), - imath.Color4fArray2D: ([4], ctypes.c_float, 'array2d'), - imath.DoubleArray2D: ([], ctypes.c_double, 'array2d'), - imath.FloatArray2D: ([], ctypes.c_float, 'array2d'), - imath.IntArray2D: ([], ctypes.c_long, 'array2d'), - - imath.DoubleMatrix: ([], ctypes.c_double, 'matrix'), - imath.FloatMatrix: ([], ctypes.c_float, 'matrix'), - imath.IntMatrix: ([], ctypes.c_long, 'matrix'), - - imath.Box2d: ([2, 2], ctypes.c_double, 'box'), - imath.Box2f: ([2, 2], ctypes.c_float, 'box'), - imath.Box2i: ([2, 2], ctypes.c_long, 'box'), - imath.Box2s: ([2, 2], ctypes.c_short, 'box'), - imath.Box3d: ([2, 3], ctypes.c_double, 'box'), - imath.Box3f: ([2, 3], ctypes.c_float, 'box'), - imath.Box3i: ([2, 3], ctypes.c_long, 'box'), - imath.Box3s: ([2, 3], ctypes.c_short, 'box'), - - imath.Line3d: ([2, 3], ctypes.c_double, 'line'), - imath.Line3f: ([2, 3], ctypes.c_float, 'line'), - - imath.Color3c: ([3], ctypes.c_byte, ''), - imath.Color3f: ([3], ctypes.c_float, ''), - imath.Color4c: ([4], ctypes.c_byte, ''), - imath.Color4f: ([4], ctypes.c_float, ''), - imath.M22d: ([2, 2], ctypes.c_double, ''), - imath.M22dRow: ([2], ctypes.c_double, 'row'), - imath.M22f: ([2, 2], ctypes.c_float, ''), - imath.M22fRow: ([2], ctypes.c_float, 'row'), - imath.M33d: ([3, 3], ctypes.c_double, ''), - imath.M33dRow: ([3], ctypes.c_double, 'row'), - imath.M33f: ([3, 3], ctypes.c_float, ''), - imath.M33fRow: ([3], ctypes.c_float, 'row'), - imath.M44d: ([4, 4], ctypes.c_double, ''), - imath.M44dRow: ([4], ctypes.c_double, 'row'), - imath.M44f: ([4, 4], ctypes.c_float, ''), - imath.M44fRow: ([4], ctypes.c_float, 'row'), - imath.Quatd: ([4], ctypes.c_double, ''), - imath.Quatf: ([4], ctypes.c_float, ''), - imath.Shear6d: ([6], ctypes.c_double, ''), - imath.Shear6f: ([6], ctypes.c_float, ''), - imath.V2d: ([2], ctypes.c_double, ''), - imath.V2f: ([2], ctypes.c_float, ''), - imath.V2i: ([2], ctypes.c_long, ''), - imath.V2s: ([2], ctypes.c_short, ''), - imath.V3c: ([3], ctypes.c_byte, ''), - imath.V3d: ([3], ctypes.c_double, ''), - imath.V3f: ([3], ctypes.c_float, ''), - imath.V3i: ([3], ctypes.c_long, ''), - imath.V3s: ([3], ctypes.c_short, ''), - imath.V4c: ([4], ctypes.c_byte, ''), - imath.V4d: ([4], ctypes.c_double, ''), - imath.V4f: ([4], ctypes.c_float, ''), - imath.V4i: ([4], ctypes.c_long, ''), - imath.V4s: ([4], ctypes.c_short, ''), -} -# fmt: on - -# Define the in-memory structures of the python objects -# I'm *GUESSING* on most of the types here, so they could be refined -# in the future if required - -# Here's hoping these structs don't change with different versions -# of python or imath - - -class PyImoObj(ctypes.Structure): - _fields_ = [ - ("refcount", ctypes.c_ssize_t), # from the PyObject c struct - ("typeptr", ctypes.c_void_p), # from the PyObject c struct - ("unknown1", ctypes.c_ssize_t), # Seems always -48 for some reason - ("unknown2", ctypes.c_ssize_t), # Seems always 0 - ("unknown3", ctypes.c_ssize_t), # Seems always 0 - ("dataptr", ctypes.c_void_p), # pointer to the PyImoDataObj - ] - - -class PyImoDataObj(ctypes.Structure): - _fields_ = [ - ("magic", ctypes.c_ssize_t), # Some kind of type ID? - ("unknown1", ctypes.c_ssize_t), # Seems always 0 - ("dataptr", ctypes.c_void_p), # Pointer to the allocated memory - # There's MORE data after this, like the row/column count - # and some other pointers. But I don't need them - # Plus, there are some edge cases with the different types - # like rows or bounding boxes - ] - - -PyImoObjPtr = ctypes.POINTER(PyImoObj) -PyImoDataObjPtr = ctypes.POINTER(PyImoDataObj) - - -def _getImoPointer(imo, extra: str) -> int: - """Get the memory address to the actual imath data - - Args: - imo (imath object): The imath object to inspect - extra (str): The metadata of this current type - - Returns: - int: The memory address to the actual data - """ - # This is a scary function - # I found this stuff out by trial and error - pyStruct = ctypes.cast(id(imo), PyImoObjPtr).contents - if extra == "box": - # I'm guessing that since the bounding box data is a known - # size, they just put it directly into the structure - # And the data is stored where the dataptr would be. - # So I can just add the pyStruct.dataptr and the memory offset - # of the PyImoDataObj.dataptr to get memory address of the box - return pyStruct.dataptr + PyImoDataObj.dataptr.offset - - pyImoStruct = ctypes.cast(pyStruct.dataptr, PyImoDataObjPtr).contents - return pyImoStruct.dataptr - - -def _link(imo) -> tuple[np.ndarray, int]: - """Build a numpy object that's referencing the same memory - as the given imath object - - Args: - imo (imath object): The imath object to build a link to - - Returns: - np.ndarray: The numpy array - int: The pointer to the memory address where the data lives - """ - size, cdata, extra = TYPEDICT[type(imo)] - if extra == "array": - shape = [len(imo)] + size - elif extra == "array2d": - shape = list(imo.size()) + size - elif extra == "matrix": - shape = [imo.rows(), imo.columns()] + size - elif extra in ("box", "line"): - shape = size - else: - shape = [len(imo)] - - for s in shape[::-1]: - cdata = cdata * s # type: ignore - - ptr = _getImoPointer(imo, extra) - ctypearray = cdata.from_address(ptr) - nparray = np.ctypeslib.as_array(ctypearray) - return nparray, ptr - - -def imathToNumpy(imo) -> np.ndarray: - """Copy an imath object into a numpy array - - Args: - imo (imath object): The imath object to convert - - Returns: - np.ndarray: A copy of the imath object as a numpy array - """ - gcarray, _ptr = _link(imo) - return np.copy(gcarray) - - -T = TypeVar("T") - - -def numpyToImath(npo: np.ndarray, imtype: Type[T]) -> T: - """Convert a numpy array to the given imath type - - Args: - npo (array like): An object that can be cast to a numpy array - imtype (type): The imath type to convert to - - Returns: - imathObj: An instantiated imtype object with the numpy data loaded into it - """ - size, cdata, extra = TYPEDICT[imtype] - npo = np.asarray(npo, dtype=NTYPEDICT[cdata]) - if extra == "array": - assert npo.ndim == len(size) + 1 - imo = imtype(len(npo)) # type: ignore - elif extra in ("matrix", "array2d"): - assert npo.ndim == 2 + len(size) - imo = imtype(*npo.shape[:2]) - else: - imo = imtype() - - tret, _ptr = _link(imo) - np.copyto(tret, npo) - return imo - - -def _test(): - """A quick test for all this crazy stuff""" - - # By default the bounding box objects are set to - # The min/max for their types - box3dnp = np.empty((2, 3), dtype=np.float64) - box3dnp[0] = imath.DBL_MAX - box3dnp[1] = imath.DBL_MIN - - box3fnp = np.empty((2, 3), dtype=np.float32) - box3fnp[0] = imath.FLT_MAX - box3fnp[1] = imath.FLT_MIN - - box3fanp = np.empty((5, 2, 3), dtype=np.float32) - box3fanp[:] = box3fnp - - # M44fArray - eyes = np.zeros((13, 4, 4), dtype=np.float32) - eyes[:] = np.eye(4, dtype=np.float32) - - # Line3f - line = np.zeros((2, 3), dtype=np.float32) - line[1, 0] = 1.0 - - # M33fRow - im = imath.M33f() - nm = np.eye(3, dtype=np.float32) - - # equivalent to an np.empty(3, 5) - # so I have to set the values manually - dubm = imath.DoubleMatrix(3, 5) - tt = 0.0 - for i in range(3): - row = dubm[i] - for j in range(5): - row[j] = tt - tt += 1.0 - - # fmt: off - eqpairs = [ - (imath.V3dArray(11), np.zeros((11, 3))), - (imath.M44fArray(13), eyes), - (imath.V3d(), np.zeros(3)), - (imath.Color4cArray2D(5, 7), np.zeros((5, 7, 4), dtype=np.int8)), - (imath.Box3fArray(5), box3fanp), - (imath.Box3d(), box3dnp), - (imath.Box3f(), box3fnp), - (imath.FloatArray(13), np.zeros((13), dtype=np.float32)), - (imath.Line3f(), line), - (im[1], nm[1]), - (dubm, np.arange(15, dtype=float).reshape((3, 5))), - ] - # fmt: on - - for imo, chk in eqpairs: - nv = imathToNumpy(imo) - assert nv.dtype == chk.dtype - assert np.all(nv == chk) - - imtype = type(imo) - _size, _cdata, extra = TYPEDICT[imtype] - if extra != "row": # Can't directly build row objects - _iv = numpyToImath(chk, imtype) diff --git a/src/python/simplexui/commands/poseblendlib.py b/src/python/simplexui/commands/poseblendlib.py index c23b9263..107e8b70 100644 --- a/src/python/simplexui/commands/poseblendlib.py +++ b/src/python/simplexui/commands/poseblendlib.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import numpy as np # I could have used the Scipy rotations library, but it doeosn't deal diff --git a/src/python/simplexui/commands/reorderSimplexPoints.py b/src/python/simplexui/commands/reorderSimplexPoints.py index 82ca7eef..ce4aa0cb 100644 --- a/src/python/simplexui/commands/reorderSimplexPoints.py +++ b/src/python/simplexui/commands/reorderSimplexPoints.py @@ -1,102 +1,102 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -"""Transfer shapes between mismatched models - -Given a 1:1 point correspondence, transfer the shapes from one -geometry to another. Figuring out the correspondence is currently -outside the scope of this tool, though I may release one later. - -The point correspondence should look like an unordered range, and -will be used as a numpy index to get the output values. It's also -possible to invert the range if you think you've got it backwards -""" - -# pylint:disable=wrong-import-position -import json - -from .alembicCommon import buildSmpx, readSmpx - -try: - import numpy as np -except ImportError: - pass - - -def reorderSimplexPoints(sourcePath, matchPath, outPath, invertMatch=False): - """Transfer shape data from the sourcePath using the numpy int array - at matchPath to make the final output at outPath - - Parameters - ---------- - sourcePath : str - The source .smpx file path - matchPath : str - The new vert order in numpy, or json format. The data should be an Nx2 array - of integers - outPath : str - The new output .smpx path - invertMatch : bool - Whether to directly apply the match from matchPath, or whether to invert it - - Returns - ------- - - """ - jsString, counts, verts, faces, uvs, uvFaces = readSmpx(sourcePath) - - js = json.loads(jsString) - name = js["systemName"] - - print("Loading Correspondence") - if matchPath.endswith(".json"): - with open(matchPath, "r") as f: - c = json.load(f) - c = np.array(c) - else: - c = np.load(matchPath) - - c = c[c[:, 0].argsort()].T[1] - ci = c.argsort() - if invertMatch: - ci, c = c, ci - - print("Reordering") - verts = verts[:, c, :] - faces = ci[faces] - - buildSmpx( - outPath, - verts, - faces, - jsString, - name, - faceCounts=counts, - uvs=uvs, - uvFaces=uvFaces, - ) - - -if __name__ == "__main__": - import os - - base = r"K:\Departments\CharacterModeling\Library\Head\MaleHead_Standard\005" - _sourcePath = os.path.join(base, "HeadMaleStandard_High_Split_BadOrder.smpx") - _matchPath = os.path.join(base, "Reorder.np") - _outPath = os.path.join(base, "HeadMaleStandard_High_Split2.smpx") - - reorderSimplexPoints(_sourcePath, _matchPath, _outPath) +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +"""Transfer shapes between mismatched models + +Given a 1:1 point correspondence, transfer the shapes from one +geometry to another. Figuring out the correspondence is currently +outside the scope of this tool, though I may release one later. + +The point correspondence should look like an unordered range, and +will be used as a numpy index to get the output values. It's also +possible to invert the range if you think you've got it backwards +""" + +from __future__ import annotations + +import json + +import numpy as np + +from .alembicCommon import buildSmpx, readSmpx + + +def reorderSimplexPoints( + sourcePath: str, matchPath: str, outPath: str, invertMatch=False +) -> None: + """Transfer shape data from the sourcePath using the numpy int array + at matchPath to make the final output at outPath + + Parameters + ---------- + sourcePath : str + The source .smpx file path + matchPath : str + The new vert order in numpy, or json format. The data should be an Nx2 array + of integers + outPath : str + The new output .smpx path + invertMatch : bool + Whether to directly apply the match from matchPath, or whether to invert it + + Returns + ------- + + """ + jsString, counts, verts, faces, uvs, uvFaces = readSmpx(sourcePath) + + js = json.loads(jsString) + name = js["systemName"] + + print("Loading Correspondence") + if matchPath.endswith(".json"): + with open(matchPath, "r") as f: + c = json.load(f) + c = np.array(c) + else: + c = np.load(matchPath) + + c = c[c[:, 0].argsort()].T[1] + ci = c.argsort() + if invertMatch: + ci, c = c, ci + + print("Reordering") + verts = verts[:, c, :] + faces = ci[faces] + + buildSmpx( + outPath, + verts, + faces, + jsString, + name, + faceCounts=counts, + uvs=uvs, + uvFaces=uvFaces, + ) + + +if __name__ == "__main__": + import os + + base = r"K:\Departments\CharacterModeling\Library\Head\MaleHead_Standard\005" + _sourcePath = os.path.join(base, "HeadMaleStandard_High_Split_BadOrder.smpx") + _matchPath = os.path.join(base, "Reorder.np") + _outPath = os.path.join(base, "HeadMaleStandard_High_Split2.smpx") + + reorderSimplexPoints(_sourcePath, _matchPath, _outPath) diff --git a/src/python/simplexui/commands/replaceDefinitionJson.py b/src/python/simplexui/commands/replaceDefinitionJson.py new file mode 100644 index 00000000..11063933 --- /dev/null +++ b/src/python/simplexui/commands/replaceDefinitionJson.py @@ -0,0 +1,74 @@ +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + + +from .alembic_walker import AlembicWalker + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from alembic.Abc import IProperty, OProperty + + +class ReplaceSmpxAttr(AlembicWalker): + @classmethod + def copy_property_data( + cls, + inProp: IProperty, + outProp: OProperty, + name: list[str], + objDepth: int, + propDepth: int, + *args, + **kwargs, + ) -> None: + """Copy the data from an input property to an output property + + Override this method if you need to change the data that gets stored + on a specific property + + Arguments: + inProp (iProperty): The input property to copy data from + outProp (oProperty): The output property to copy data to + name (list): The full path-name of the property being copied + objDepth (int): The depth in the *object* hierarchy that the parent + object of this property is + propDepth (int): The depth of the *property* hierarchy that this + property is + """ + + if name[-1] == 'simplex' and 'newjs' in kwargs: + if not inProp.isCompound(): + AlembicWalker.copy_time_sampling(inProp, outProp, *args, **kwargs) + for _ in inProp.samples: + outProp.setValue(kwargs['newjs']) + else: + AlembicWalker.copy_property_data( + inProp, outProp, name, objDepth, propDepth, *args, **kwargs + ) + + +def replaceDefinitionJson(inSmpxPath: str, outSmpxPath: str, newJsonString: str): + """Given a simplex file, make a copy of that file with the new json definition string + This is good for doing manual renames and edits of the json + + Args: + inSmpxPath (str): The filepath to the existing simplex + outSmpxPath (str): The filepath save the new simplex + newJsonString (str): The json string that will replace the old one + """ + ReplaceSmpxAttr.copy_alembic(inSmpxPath, outSmpxPath, newjs=newJsonString) diff --git a/src/python/simplexui/commands/rigidAlign.py b/src/python/simplexui/commands/rigidAlign.py index e51acb6a..e9a10a4a 100644 --- a/src/python/simplexui/commands/rigidAlign.py +++ b/src/python/simplexui/commands/rigidAlign.py @@ -1,99 +1,97 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -try: - import numpy as np -except ImportError: - pass - - -def rigidAlign(P, Q, iters=10): - """Rigidly align meshes with matching vert order by a least-squares error. - Uses a variation of an algorithm by Umeyama - Relevant links: - * https://gist.github.com/nh2/bc4e2981b0e213fefd4aaa33edfb3893 (this code) - * http://stackoverflow.com/a/32244818/263061 (solution with scale) - - Parameters - ---------- - P : np.array - Static set of points - Q : np.array - Points to align with non-uniform scale - iters : int - The number of iterations (Defaults to 10) - - Returns - ------- - : np.array - The 4x4 transformation matrix that most closely aligns Q to P - """ - # pylint:disable=invalid-name - assert P.shape == Q.shape - - n, dim = P.shape - assert dim == 3 - - if iters <= 1: - raise ValueError("Must run at least 1 iteration") - - # Get the centroid of each object - Qm = Q.mean(axis=0) - Pm = P.mean(axis=0) - - # Subtract out the centroid to get the basic aligned mesh - cP = P - Pm # centeredP - cQRaw = Q - Qm # centeredQ - - cQ = cQRaw.copy() - cumulation = np.eye(3) # build an accumulator for the rotation - - # Here, we find an approximate rotation and scaling, but only - # keep track of the accumulated rotations. - # Then we apply the non-uniform scale by comparing bounding boxes - # This way we don't get any shear in our matrix, and we relatively - # quickly walk our way towards a minimum - for _ in range(iters): - # Magic? - C = np.dot(cP.T, cQ) / n - V, S, W = np.linalg.svd(C) - - # Handle negative scaling - d = (np.linalg.det(V) * np.linalg.det(W)) < 0.0 - if d: - S[-1] = -S[-1] - V[:, -1] = -V[:, -1] - - # build the rotation matrix for this iteration - # and add it to the accumulation - R = np.dot(V, W) - cumulation = np.dot(cumulation, R.T) - - # Now apply the accumulated rotation to the raw point positions - # Then grab the non-uniform scaling from the bounding box - # And set up cQ for the next iteration - cQ = np.dot(cQRaw, cumulation) - sf = (cP.max(axis=0) - cP.min(axis=0)) / (cQ.max(axis=0) - cQ.min(axis=0)) - cQ = cQ * sf - - # Build the final transformation - csf = cumulation * sf - tran = Pm - Qm.dot(csf) - outMat = np.eye(4) - outMat[:3, :3] = csf - outMat[3, :3] = tran - return outMat +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +from __future__ import annotations + +import numpy as np + + +def rigidAlign(P, Q, iters: int = 10): + """Rigidly align meshes with matching vert order by a least-squares error. + Uses a variation of an algorithm by Umeyama + Relevant links: + * https://gist.github.com/nh2/bc4e2981b0e213fefd4aaa33edfb3893 (this code) + * http://stackoverflow.com/a/32244818/263061 (solution with scale) + + Parameters + ---------- + P : np.array + Static set of points + Q : np.array + Points to align with non-uniform scale + iters : int + The number of iterations (Defaults to 10) + + Returns + ------- + : np.array + The 4x4 transformation matrix that most closely aligns Q to P + """ + assert P.shape == Q.shape + + n, dim = P.shape + assert dim == 3 + + if iters <= 1: + raise ValueError("Must run at least 1 iteration") + + # Get the centroid of each object + Qm = Q.mean(axis=0) + Pm = P.mean(axis=0) + + # Subtract out the centroid to get the basic aligned mesh + cP = P - Pm # centeredP + cQRaw = Q - Qm # centeredQ + + cQ = cQRaw.copy() + cumulation = np.eye(3) # build an accumulator for the rotation + + # Here, we find an approximate rotation and scaling, but only + # keep track of the accumulated rotations. + # Then we apply the non-uniform scale by comparing bounding boxes + # This way we don't get any shear in our matrix, and we relatively + # quickly walk our way towards a minimum + for _ in range(iters): + # Magic? + C = np.dot(cP.T, cQ) / n + V, S, W = np.linalg.svd(C) + + # Handle negative scaling + d = (np.linalg.det(V) * np.linalg.det(W)) < 0.0 + if d: + S[-1] = -S[-1] + V[:, -1] = -V[:, -1] + + # build the rotation matrix for this iteration + # and add it to the accumulation + R = np.dot(V, W) + cumulation = np.dot(cumulation, R.T) + + # Now apply the accumulated rotation to the raw point positions + # Then grab the non-uniform scaling from the bounding box + # And set up cQ for the next iteration + cQ = np.dot(cQRaw, cumulation) + sf = (cP.max(axis=0) - cP.min(axis=0)) / (cQ.max(axis=0) - cQ.min(axis=0)) + cQ = cQ * sf + + # Build the final transformation + csf = cumulation * sf + tran = Pm - Qm.dot(csf) + outMat = np.eye(4) + outMat[:3, :3] = csf + outMat[3, :3] = tran + return outMat diff --git a/src/python/simplexui/commands/smpxBlend.py b/src/python/simplexui/commands/smpxBlend.py deleted file mode 100644 index d8c1b772..00000000 --- a/src/python/simplexui/commands/smpxBlend.py +++ /dev/null @@ -1,629 +0,0 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -from ..items import ( - Combo, - ComboPair, - Falloff, - Group, - ProgPair, - Progression, - Shape, - Simplex, - Slider, - Traversal, - TravPair, -) - - -class Skip(object): - """A pseudo-singleton object to signify completely skipping the un-matched shape - This will be used by running ``smpxMismatchCheck``, taking the dict, and manually - telling it to ``Skip`` certain shapes - """ - - pass - - -def smpxMismatchCheck(simpA, simpB): - """Search for mismatches between two simplex systems - - Parameters - ---------- - simpA : Simplex - A simplex system - simpB : Simplex - A simplex system - - Returns - ------- - : {str: {str: (object, object)}} - A structure of dict[objectType][objectName] - That returns an ordered pair of objects. The object is None if it doesn't - exist in (simplexA, simplexB) - """ - mmDict = {} - - # saShapeNames = {sa.name: sa for sa in simpA.shapes} - # sbShapeNames = {sb.name: sb for sb in simpB.shapes} - # saShapeNameUnique = saShapeNames.viewkeys() - sbShapeNames.viewkeys() - # sbShapeNameUnique = sbShapeNames.viewkeys() - saShapeNames.viewkeys() - # slnMatch = [(san, None) for san in saShapeNameUnique] + [(None, sbn) for sbn in sbShapeNameUnique] - # mmDict['shape'] = slnMatch - - saSliderNames = {sa.name: sa for sa in simpA.sliders} - sbSliderNames = {sb.name: sb for sb in simpB.sliders} - saSliderNameUnique = saSliderNames.keys() - sbSliderNames.keys() - sbSliderNameUnique = sbSliderNames.keys() - saSliderNames.keys() - slnMatch = {} - for san in saSliderNameUnique: - slnMatch[san] = (san, None) - for sbn in sbSliderNameUnique: - slnMatch[sbn] = (None, sbn) - mmDict["slider"] = slnMatch - - saComboNames = {sa.name: sa for sa in simpA.combos} - sbComboNames = {sb.name: sb for sb in simpB.combos} - saComboNameUnique = saComboNames.keys() - sbComboNames.keys() - sbComboNameUnique = sbComboNames.keys() - saComboNames.keys() - # TODO search for combos with mis-ordered inputs - cnMatch = {} - for can in saComboNameUnique: - cnMatch[can] = (can, None) - for cbn in sbComboNameUnique: - cnMatch[cbn] = (None, cbn) - mmDict["combo"] = cnMatch - - saTravNames = {sa.name: sa for sa in simpA.traversals} - sbTravNames = {sb.name: sb for sb in simpB.traversals} - saTravNameUnique = saTravNames.keys() - sbTravNames.keys() - sbTravNameUnique = sbTravNames.keys() - saTravNames.keys() - # TODO search for travs with mis-ordered inputs - tnMatch = {} - for tan in saTravNameUnique: - tnMatch[tan] = (tan, None) - for tbn in sbTravNameUnique: - tnMatch[tbn] = (None, tbn) - mmDict["traversal"] = tnMatch - - return mmDict - - -def orderedMerge(va, vb): - """Merge two sets, keeping some semblance of order - - Parameters - ---------- - va : set - A set - vb : set - A set - - Returns - ------- - : list - An ordered list - """ - # come up with a better way that keeps some of the input structure - return sorted(set(va) | set(vb)) - - -# Falloffs -def mergeFalloffs(simpA, simpB, outSimp, translation, nameOnly=True): - """Merge the falloffs between two simplex systems - - Parameters - ---------- - simpA : Simplex - The master Simplex - simpB : Simplex - A simplex system to merge - outSimp : Simplex - The output simplex that is being built - translation : {object: object} - A dictionary of input objects to output objects - nameOnly : bool - Whether to match the falloffs by name only. - Defaults to True. False is not implemented yet - """ - if not nameOnly: - raise ValueError("Not implemented yet") - aNames = [f.name for f in simpA.falloffs] - aDict = dict(zip(aNames, simpA.falloffs)) - bNames = [f.name for f in simpB.falloffs] - bDict = dict(zip(bNames, simpB.falloffs)) - oNames = orderedMerge(aNames, bNames) - for oName in oNames: - baseFo = aDict.get(oName, bDict[oName]) - if baseFo.splitType == "planar": - data = ( - baseFo.splitType, - baseFo.axis, - baseFo.maxVal, - baseFo.maxHandle, - baseFo.minHandle, - baseFo.minVal, - ) - else: - data = (baseFo.splitType, baseFo.mapName) - fo = Falloff(oName, outSimp, *data) - translation[aDict.get(oName)] = fo - translation[bDict.get(oName)] = fo - - -# Groups -def _mergeGroupSubset(aTypedGroups, bTypedGroups, outSimp, gType, translation): - asG = [g.name for g in aTypedGroups] - bsG = [g.name for g in bTypedGroups] - asGDict = dict(zip(asG, aTypedGroups)) - bsGDict = dict(zip(bsG, bTypedGroups)) - osG = orderedMerge(asG, bsG) - for groupName in osG: - newGroup = Group(groupName, outSimp, gType) - translation[asGDict.get(groupName)] = newGroup - translation[bsGDict.get(groupName)] = newGroup - - -def mergeGroups(simpA, simpB, outSimp, translation): - """Merge the groups from simpA and simpB - - Parameters - ---------- - simpA : Simplex - The master Simplex - simpB : Simplex - A simplex system to merge - outSimp : Simplex - The output simplex that is being built - translation : {object: object} - A dictionary of input objects to output objects - """ - _mergeGroupSubset( - simpA.sliderGroups, simpB.sliderGroups, outSimp, Slider, translation - ) - _mergeGroupSubset(simpA.comboGroups, simpB.comboGroups, outSimp, Combo, translation) - _mergeGroupSubset( - simpA.traversalGroups, simpB.traversalGroups, outSimp, Traversal, translation - ) - - -# Shapes -def _blendShape(aShape, bShape, outSimp, blendVal, translation): - if aShape in translation or bShape in translation: - return translation.get(aShape, translation[bShape]) - - newShape = Shape(aShape.name, outSimp, create=True) - - aDeltas = aShape.verts - aShape.simplex.restShape.verts - bDeltas = bShape.verts - bShape.simplex.restShape.verts - deltas = aDeltas * (1.0 - blendVal) + bDeltas * blendVal - newShape.verts = outSimp.restShape.verts + deltas - - translation[aShape] = newShape - translation[bShape] = newShape - return newShape - - -def _copyShape(shape, outSimp, translation, deltaOverride=None): - if shape in translation: - return translation[shape] - newShape = Shape(shape.name, outSimp, create=True) - - if deltaOverride is None: - deltas = shape.verts - shape.simplex.restShape.verts - newShape.verts = outSimp.restShape.verts + deltas - else: - newShape.verts = deltaOverride - - translation[shape] = newShape - return newShape - - -# Progs -def _deltaProg(shape, srcMin, srcMax, tarMin, tarMax, tVal): - srcExtDelta = tVal * (srcMax.verts - srcMin.verts) - srcShpDelta = shape.verts - srcMin.verts - srcOffset = srcShpDelta - srcExtDelta - ret = tVal * (tarMax.verts - tarMin.verts) + srcOffset - return ret - - -def _blendProg(aProg, bProg, outSimp, blendVal, translation): - aVals = [float(pp.value) for pp in aProg.pairs] - bVals = [float(pp.value) for pp in bProg.pairs] - commonVals = sorted(set(aVals) & set(bVals)) - aUniq = set(aVals) - set(commonVals) - bUniq = set(bVals) - set(commonVals) - aValDict = dict(zip(aVals, aProg.pairs)) - bValDict = dict(zip(bVals, bProg.pairs)) - - # First handle all the common values - outValDict = {} - for c in commonVals: - newShape = _blendShape( - aValDict[c].shape, bValDict[c].shape, outSimp, blendVal, translation - ) - outValDict[c] = newShape - - # Then handle any unique extremes - if 1.0 in aUniq: - outValDict[1.0] = _copyShape(aValDict[1.0].shape, outSimp, translation) - if -1.0 in aUniq: - outValDict[-1.0] = _copyShape(aValDict[-1.0].shape, outSimp, translation) - if 1.0 in bUniq: - outValDict[1.0] = _copyShape(bValDict[1.0].shape, outSimp, translation) - if -1.0 in bUniq: - outValDict[-1.0] = _copyShape(bValDict[-1.0].shape, outSimp, translation) - - # Finally handle the unique progressions as deltas off the extremes - outRest = outValDict[0.0] - aRest = aValDict[0.0].shape - for c in aUniq: - aShp = aValDict[c].shape - mm = 1.0 if c > 0.0 else -1.0 - dd = _deltaProg( - aShp, aRest, aValDict[mm].shape, outRest, outValDict[mm], mm * c - ) - outValDict[c] = _copyShape(aShp, outSimp, translation, deltaOverride=dd) - - bRest = bValDict[0.0].shape - for c in bUniq: - bShp = bValDict[c].shape - mm = 1.0 if c > 0.0 else -1.0 - dd = _deltaProg( - bShp, bRest, bValDict[mm].shape, outRest, outValDict[mm], mm * c - ) - outValDict[c] = _copyShape(bShp, outSimp, translation, deltaOverride=dd) - - outPairs = [ - ProgPair(outSimp, outValDict[val], val) for val in sorted(outValDict.keys()) - ] - prog = Progression(aProg.name, outSimp, pairs=outPairs, interp=aProg.interp) - translation[aProg] = prog - translation[bProg] = prog - return prog - - -def _copyProg(prog, outSimp, translation): - pairs = [] - for pp in prog.pairs: - newShape = _copyShape(pp.shape, outSimp, translation) - pair = ProgPair(outSimp, newShape, pp.value) - pairs.append(pair) - outProg = Progression(prog.name, outSimp, pairs=pairs, interp=prog.interp) - translation[prog] = outProg - return outProg - - -# Sliders -def _blendSliders(aSlider, bSlider, outSimp, blendVal, translation): - if aSlider in translation or bSlider in translation: - return translation.get(aSlider, translation[bSlider]) - - group = translation.get(aSlider.group, translation[bSlider.group]) - prog = _blendProg(aSlider.prog, bSlider.prog, outSimp, blendVal, translation) - outSlider = Slider(aSlider.name, outSimp, prog, group) - translation[aSlider] = outSlider - translation[bSlider] = outSlider - return outSlider - - -def _copySlider(slider, outSimp, translation): - if slider in translation: - return translation[slider] - - group = translation[slider.group] - prog = _copyProg(slider.prog, outSimp, translation) - outSlider = Slider(slider.name, outSimp, prog, group) - translation[slider] = outSlider - return outSlider - - -def sliderBlend(simpA, simpB, outSimp, blendVal, translation, mismatch): - """Blend between the sliders of simpA and simpB - - Parameters - ---------- - simpA : Simplex - The master Simplex - simpB : Simplex - A simplex system to merge - outSimp : Simplex - The output simplex that is being built - blendVal : float - The 0 to 1 blend value between the simplices - translation : {object: object} - A dictionary of input objects to output objects - mismatch : dict - The crazy mismatch dict from ``smpxMismatchCheck`` - """ - aNames = [s.name for s in simpA.sliders] - aDict = dict(zip(aNames, simpA.sliders)) - bNames = [s.name for s in simpB.sliders] - bDict = dict(zip(bNames, simpB.sliders)) - oNames = orderedMerge(aNames, bNames) - - for oIdx, oName in enumerate(oNames): - print("Copying Slider {0} of {1}: {2}".format(oIdx, len(oNames), oName)) - aName, bName = mismatch["slider"].get(oName, (oName, oName)) - if aName is Skip or bName is Skip: - continue - elif aName is None: - _copySlider(bDict[bName], outSimp, translation) - elif bName is None: - _copySlider(aDict[aName], outSimp, translation) - else: - _blendSliders(aDict[aName], bDict[bName], outSimp, blendVal, translation) - - -# Combos -def _blendCombos(aCombo, bCombo, outSimp, blendVal, translation): - if aCombo in translation or bCombo in translation: - return translation.get(aCombo, translation[bCombo]) - - group = translation.get(aCombo.group, translation[bCombo.group]) - - cPairs = [] - for aPair, bPair in zip(aCombo.pairs, bCombo.pairs): - slider = _blendSliders( - aPair.slider, bPair.slider, outSimp, blendVal, translation - ) - cPairs.append(ComboPair(slider, aPair.value)) - - prog = _blendProg(aCombo.prog, bCombo.prog, outSimp, blendVal, translation) - outCombo = Combo(aCombo.name, outSimp, cPairs, prog, group, aCombo.solveType) - translation[aCombo] = outCombo - translation[bCombo] = outCombo - return outCombo - - -def _copyCombo(combo, outSimp, translation): - if combo in translation: - return translation[combo] - group = translation[combo.group] - - cPairs = [] - for pair in combo.pairs: - slider = _copySlider(pair.slider, outSimp, translation) - cPairs.append(ComboPair(slider, pair.value)) - prog = _copyProg(combo.prog, outSimp, translation) - - outCombo = Combo(combo.name, outSimp, cPairs, prog, group, combo.solveType) - translation[combo] = outCombo - return outCombo - - -def comboBlend(simpA, simpB, outSimp, blendVal, translation, mismatch): - """Blend between the combos of simpA and simpB - - Parameters - ---------- - simpA : Simplex - The master Simplex - simpB : Simplex - A simplex system to merge - outSimp : Simplex - The output simplex that is being built - blendVal : float - The 0 to 1 blend value between the simplices - translation : {object: object} - A dictionary of input objects to output objects - mismatch : dict - The crazy mismatch dict from ``smpxMismatchCheck`` - """ - aNames = [s.name for s in simpA.combos] - aDict = dict(zip(aNames, simpA.combos)) - bNames = [s.name for s in simpB.combos] - bDict = dict(zip(bNames, simpB.combos)) - oNames = orderedMerge(aNames, bNames) - - for oIdx, oName in enumerate(oNames): - print("Copying Combo {0} of {1}: {2}".format(oIdx, len(oNames), oName)) - aName, bName = mismatch["combo"].get(oName, (oName, oName)) - - if oName in mismatch: - print("Getting", oName) - print("Mismatch Get", oName in mismatch, aName, bName) - - if aName is Skip or bName is Skip: - continue - elif aName is None: - _copyCombo(bDict[bName], outSimp, translation) - elif bName is None: - _copyCombo(aDict[aName], outSimp, translation) - else: - _blendCombos(aDict[aName], bDict[bName], outSimp, blendVal, translation) - - -# Traversals -def _blendController(aItem, bItem, outSimp, blendVal, translation): - aCtrl = aItem.controller - bCtrl = bItem.controller - - if isinstance(aCtrl, Slider): - ctrl = _blendSliders(aCtrl, bCtrl, outSimp, blendVal, translation) - elif isinstance(aCtrl, Combo): - ctrl = _blendCombos(aCtrl, bCtrl, outSimp, blendVal, translation) - else: - raise ValueError("Bad object type: {0} {1}".format(aCtrl, type(aCtrl))) - return TravPair(ctrl, aItem.value, aItem.usage) - - -def _copyController(item, outSimp, translation): - iCtrl = item.controller - if isinstance(iCtrl, Slider): - ctrl = _copySlider(iCtrl, outSimp, translation) - elif isinstance(iCtrl, Combo): - ctrl = _copyCombo(iCtrl, outSimp, translation) - else: - raise ValueError("Bad object type: {0} {1}".format(iCtrl, type(iCtrl))) - return TravPair(ctrl, item.value, item.usage) - - -def _blendTraversals(aTrav, bTrav, outSimp, blendVal, translation): - group = translation.get(aTrav.group, translation[bTrav.group]) - multCtrl = _blendController( - aTrav.multiplierCtrl, bTrav.multiplierCtrl, outSimp, blendVal, translation - ) - progCtrl = _blendController( - aTrav.progressCtrl, bTrav.progressCtrl, outSimp, blendVal, translation - ) - prog = _blendProg(aTrav.prog, bTrav.prog, outSimp, blendVal, translation) - outTrav = Traversal(aTrav.name, outSimp, multCtrl, progCtrl, prog, group) - translation[aTrav] = outTrav - translation[bTrav] = outTrav - return outTrav - - -def _copyTraversal(traversal, outSimp, translation): - group = translation[traversal.group] - multCtrl = _copyController(traversal.multiplierCtrl, outSimp, translation) - progCtrl = _copyController(traversal.progressCtrl, outSimp, translation) - prog = _copyProg(traversal.prog, outSimp, translation) - outTrav = Traversal(traversal.name, outSimp, multCtrl, progCtrl, prog, group) - translation[traversal] = outTrav - return outTrav - - -def traversalBlend(simpA, simpB, outSimp, blendVal, translation, mismatch): - """Blend between the traversals of simpA and simpB - - Parameters - ---------- - simpA : Simplex - The master Simplex - simpB : Simplex - A simplex system to merge - outSimp : Simplex - The output simplex that is being built - blendVal : float - The 0 to 1 blend value between the simplices - translation : {object: object} - A dictionary of input objects to output objects - mismatch : dict - The crazy mismatch dict from ``smpxMismatchCheck`` - """ - aNames = [s.name for s in simpA.traversals] - aDict = dict(zip(aNames, simpA.traversals)) - bNames = [s.name for s in simpB.traversals] - bDict = dict(zip(bNames, simpB.traversals)) - oNames = orderedMerge(aNames, bNames) - - for oIdx, oName in enumerate(oNames): - print("Copying Traversal {0} of {1}: {2}".format(oIdx, len(oNames), oName)) - aName, bName = mismatch["traversal"].get(oName, (oName, oName)) - if aName is Skip or bName is Skip: - continue - elif aName is None: - _copyTraversal(bDict[bName], outSimp, translation) - elif bName is None: - _copyTraversal(aDict[aName], outSimp, translation) - else: - _blendTraversals(aDict[aName], bDict[bName], outSimp, blendVal, translation) - - -# Simplex -def smpxBlend(simpA, simpB, blendVal=0.50, mismatchDict=None, name="Face"): - """Blend the deltas from simplexA and simplexB. Apply the output to simplexA - Equal falloffs will be combined. Others will be given suffixes - - Parameters - ---------- - simpA : Simplex - The master Simplex - simpB : Simplex - The Simplex to be merged into simpA - blendVal : float - A value between 0 and 1, where 0 is fully simpA, and 1 is fully simpB. Defaults to 0.5 - mismatchDict : dict or None - A dictionary like the one returned from ``spxMismatchCheck``. Defaults to None - name : str - The new name of the output system - - Returns - ------- - : Simplex - A new simplex system blended between the two inputs - - """ - mismatchDict = mismatchDict or {} - simpA.stack.enabled = False - simpB.stack.enabled = False - - outSimp = Simplex(name=name, forceDummy=True) - outSimp.stack.enabled = False - - dcc = outSimp.DCC - # TODO: Maybe add accessors to the simplex or DCC - dcc._faces = simpA.DCC._faces - dcc._counts = simpA.DCC._counts - dcc._uvs = simpA.DCC._uvs - dcc._falloffs = simpA.DCC._falloffs - dcc._numVerts = simpA.DCC._numVerts - - rest = Shape.buildRest(outSimp) - rest.verts = ( - simpA.restShape.verts * (1 - blendVal) + simpB.restShape.verts * blendVal - ) - - outSimp.restShape = rest - - # When walking through this smpx file, keep a "translation" dictionary - # where translation[InputObjectID] = OutputObject - translation = {} - translation[simpA.restShape] = rest - translation[simpB.restShape] = rest - - # Merge the groups. This one is easy. Merge by name - mergeGroups(simpA, simpB, outSimp, translation) - - # Merge the falloffs. We can either require them to have equal values to "merge" - # Or we can just merge by name. The nameOnly kwarg handles this. True for now - mergeFalloffs(simpA, simpB, outSimp, translation, nameOnly=True) - - # Copying one of the "big" object types should create and blend its child prog and shapes - print("Copying Sliders") - sliderBlend(simpA, simpB, outSimp, blendVal, translation, mismatchDict) - print("Copying Combos") - comboBlend(simpA, simpB, outSimp, blendVal, translation, mismatchDict) - print("Copying Traversals") - traversalBlend(simpA, simpB, outSimp, blendVal, translation, mismatchDict) - - # push the verts to the dummy dcc for later export - dcc.pushAllShapeVertices(outSimp.shapes) - return outSimp - - -if __name__ == "__main__": - pathA = r"D:\Users\tyler\Desktop\TEST\GumboA.smpx" - pathB = r"D:\Users\tyler\Desktop\TEST\GumboB.smpx" - outPath = r"D:\Users\tyler\Desktop\TEST\hoping2.smpx" - - print("Loading SimpA") - simpA = Simplex.buildSystemFromSmpx(pathA, forceDummy=True) - print("Loading SimpB") - simpB = Simplex.buildSystemFromSmpx(pathB, forceDummy=True) - - print("Matching A:B") - mismatch = smpxMismatchCheck(simpA, simpB) - outSmpx = smpxBlend(simpA, simpB, blendVal=0.5, mismatchDict=mismatch) - - print("Exporting") - print("OUT SMPX", outSmpx) - outSmpx.exportAbc(outPath) - - print("DONE") diff --git a/src/python/simplexui/commands/unsubdivide.py b/src/python/simplexui/commands/unsubdivide.py index 26178ae7..9a64d631 100644 --- a/src/python/simplexui/commands/unsubdivide.py +++ b/src/python/simplexui/commands/unsubdivide.py @@ -1,1227 +1,1222 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# pylint: disable=unused-argument, too-many-locals -# pylint:disable=E0611,E0401 - -import json -from itertools import chain, zip_longest - -from Qt.QtWidgets import QApplication -from .alembicCommon import buildSmpx, pbPrint, readSmpx - -try: - import numpy as np -except ImportError: - np = None - - -def mergeCycles(groups): - """Take a list of ordered items, and sort them so the last item of each list matches the first of the next list - - Then return the groups of lists mashed together - For instance, with two cycles: - +---------+------------------------------------------------------------+ - | input | [(1, 2), (11, 12), (3, 1), (10, 11), (2, 3), (12, 10)] | - +---------+------------------------------------------------------------+ - | reorder | [[(1, 2), (2, 3), (3, 1)], [(10, 11), (11, 12), (12, 13)]] | - +---------+------------------------------------------------------------+ - | output | [[1, 2, 3], [10, 11, 12, 13]] | - +---------+------------------------------------------------------------+ - - Also, return whether the cycles merged form a single closed group - - Parameters - ---------- - groups : [(int, int), ...] - A list of pairs of integers - - Returns - ------- - : [[int, ...], ...] - The ordered cycles - """ - groups = [list(g) for g in groups] - heads = {g[0]: g for g in groups} - tails = {g[-1]: g for g in groups} - - headGetter = lambda x: heads.get(x[-1]) # noqa: E731 - headSetter = lambda x, y: x + y[1:] # noqa: E731 - - tailGetter = lambda x: tails.get(x[0]) # noqa: E731 - tailSetter = lambda x, y: y + x[1:] # noqa: E731 - - searches = ((headGetter, headSetter), (tailGetter, tailSetter)) - - out = [] - cycles = [] - while groups: - g = groups.pop() - del heads[g[0]] - del tails[g[-1]] - - for getter, setter in searches: - while True: - adder = getter(g) - if adder is None: - break - g = setter(g, adder) - del heads[adder[0]] - del tails[adder[-1]] - adder[:] = [] - groups = [x for x in groups if x] - - cycle = False - if g[0] == g[-1]: - g.pop() - cycle = True - cycles.append(cycle) - out.append(g) - return out, cycles - - -def grow(neigh, verts, exclude): - """Grow the vertex set, also keeping track of which vertices to ignore for the next iteration - - Parameters - ---------- - neigh : {int: [int, ...]} - A dict mapping a vert index to a list of neighbor vert indices - verts : set(int) - A set of vert indices to grow - exclude : set(int) - A set of vert indices to ignore - - Returns - ------- - : set(int) - The newly grown vertices - : set(int) - ``exclude`` combined with ``verts`` - - """ - grown = set() - growSet = verts - exclude - for v in growSet: - grown.update(neigh[v]) - newGrown = grown - exclude - newExclude = exclude | growSet - return newGrown, newExclude - - -def buildHint(island, neigh, borders): - """Find star points that are an even number of grows from an edge - - Parameters - ---------- - island : [int, ...] - A list of vertices as part of a mesh island - neigh : {int: [int, ...]} - The dictionary of vertex neighbors - borders : set(int) - A set of border vertices - - Returns - ------- - : int - The first star point encountered at an even grow from the given borders - """ - borders = borders & island - if not borders: - # Well ... we don't have any good way of dealing with this - # Best thing I can do is search for a point with the least - # number of similar valences, and return that - d = {} - for v in island: - d.setdefault(len(neigh[v]), []).append(v) - - dd = {} - for k, v in d.items(): - dd.setdefault(len(v), []).append(k) - - mkey = min(dd.keys()) - return d[dd[mkey][0]][0] - - exclude = set() - while borders: - borders, exclude = grow(neigh, borders, exclude) - borders, exclude = grow(neigh, borders, exclude) - for b in borders: - if len(neigh[b]) != 4: - return b - return None - - -def partitionIslands(faces, neigh, pBar=None): - """Find all groups of connected verts - - Parameters - ---------- - faces : [[int, ...], ...] - The list of lists of vertex indices making up faces - neigh : {int: [int, ...]} - The dict of vertex neighbors - pBar : QProgressDialog or None - An optional progress bar - - Returns - ------- - : [set(int), ...] - A list of sets of non-connected vertex islands - """ - allVerts = set(chain.from_iterable(faces)) - islands = [] - count = float(len(allVerts)) - - if pBar is not None: - pBar.setValue(0) - pBar.setMaximum(count) - QApplication.processEvents() - - while allVerts: - verts = {allVerts.pop()} - exclude = set() - while verts: - verts, exclude = grow(neigh, verts, exclude) - islands.append(exclude) - allVerts.difference_update(exclude) - - if pBar is not None: - pBar.setValue(count - len(allVerts)) - QApplication.processEvents() - - return islands - - -def buildUnsubdivideHints(faces, neigh, borders, pBar=None): - """Get one vertex per island that was part of the original mesh - - Parameters - ---------- - faces : [[int, ...], ...] - The list of lists of vertex indices making up faces - neigh : {int: [int, ...]} - The dict of vertex neighbors - borders : set(int) - A set of border vertices - pBar : QProgressDialog or None - An optional progress bar - - Returns - ------- - : [int, ...] - A list of star-points (one per island) to un-subdivide from - """ - islands = partitionIslands(faces, neigh, pBar=pBar) - hints = [] - - if pBar is not None: - pBar.setValue(0) - pBar.setMaximum(len(islands)) - QApplication.processEvents() - - for i, isle in enumerate(islands): - if pBar is not None: - pBar.setValue(i) - QApplication.processEvents() - hints.append(buildHint(isle, neigh, borders)) - - hints = [h for h in hints if h is not None] - return hints - - -def getFaceCenterDel(faces, eNeigh, hints, pBar=None): - """Given a list of hint "keeper" points - - Parameters - ---------- - faces : [[int, ...], ...] - The list of lists of vertex indices making up faces - eNeigh : {int: [int, ...]} - The dict of vertex neighbors - hints : [int, ...] - A list of star-points (one per island) to un-subdivide from - pBar : QProgressDialog or None - An optional progress bar - - Returns - ------- - : set(int) - Centers of the original faces during a subdivision - : bool - Whether the operation failed(True) or not(False) - """ - vertToFaces = {} - vc = set() - for i, face in enumerate(faces): - for f in face: - vertToFaces.setdefault(f, []).append(i) - vc.add(f) - - count = len(vc) - centers = set() - midpoints = set() - originals = set(hints) - queue = set(hints) - - if pBar is not None: - pBar.setValue(0) - pBar.setMaximum(count) - QApplication.processEvents() - - i = 0 - fail = False - while queue: - cur = queue.pop() - if cur in midpoints: - continue - - if pBar is not None: - pBar.setValue(i) - QApplication.processEvents() - i += 2 # Add 2 because I *shouldn't* get any midpoints - - midpoints.update(eNeigh[cur]) - t = centers if cur in originals else originals - - for f in vertToFaces[cur]: - nVerts = faces[f] - - if len(nVerts) != 4: - fail = True - continue - - curFaceIndex = nVerts.index(cur) - - half = int(len(nVerts) / 2) - diag = nVerts[curFaceIndex - half] - - isOrig = diag in originals - isCtr = diag in centers - if not isOrig and not isCtr: - t.add(diag) - queue.add(diag) - elif ( - (isCtr and t is originals) - or (isOrig and t is centers) - or (diag in midpoints) - ): - fail = True - - return centers, fail - - -def getBorders(faces): - """Get the indices of verts along the borders of a mesh - - Parameters - ---------- - faces : [[int, ...], ...] - The list of lists of vertex indices making up faces - - Returns - ------- - : set(int) - A set of border vertices - """ - edgePairs = set() - for face in faces: - for f in range(len(face)): - edgePairs.add((face[f], face[f - 1])) - borders = set() - for ep in edgePairs: - if (ep[1], ep[0]) not in edgePairs: - borders.update(ep) - return borders - - -def buildEdgeDict(faces): - """Build a dictionary of un-ordered neighboring vertices along edges - - Parameters - ---------- - faces : [[int, ...], ...] - The list of lists of vertex indices making up faces - - Returns - ------- - : {int: set(int)} - A dictionary of neighboring vertices along edges - """ - edgeDict = {} - for face in faces: - for f in range(len(face)): - ff = edgeDict.setdefault(face[f - 1], set()) - ff.add(face[f]) - ff.add(face[f - 2]) - return edgeDict - - -def buildNeighborDict(faces): - """Build a structure to ask for edge and face neighboring vertices - The returned neighbor list starts with an edge neighbor, and - proceeds counter clockwise, alternating between edge and face neighbors - Also, while I'm here, grab the border verts - - Parameters - ---------- - faces : [[int, ...], ...] - The list of lists of vertex indices making up faces - - Returns - ------- - : {int: [[int, ...], ...]} - A dictionary keyed from a vert index whose - values are ordered cycles or fans - : set(int) - A set of vertices that are on the border - """ - fanDict = {} - edgeDict = {} - for face in faces: - for i in range(len(face)): - fanDict.setdefault(face[i], []).append(face[i + 1 :] + face[:i]) - ff = edgeDict.setdefault(face[i - 1], set()) - ff.add(face[i]) - ff.add(face[i - 2]) - - borders = set() - out = {} - for k, v in fanDict.items(): - fans, cycles = mergeCycles(v) - for f, c in zip(fans, cycles): - if not c: - borders.update((f[0], f[-1], k)) - out[k] = fans - return out, edgeDict, borders - - -def _fanMatch(fan, uFan, dWings): - """Twist a single fan so it matches the uFan if it can""" - uIdx = uFan[0] - for f, fIdx in enumerate(fan): - dw = dWings.get(fIdx, []) - if uIdx in dw: - return fan[f:] + fan[:f] - return None - - -def _align(neigh, uNeigh, dWings): - """Twist all the neighs so they match the uNeigh""" - out = [] - for uFan in uNeigh: - for fan in neigh: - fm = _fanMatch(fan, uFan, dWings) - if fm is not None: - out.append(fm) - break - return out - - -def buildLayeredNeighborDicts(faces, uFaces, dWings): - """Build and align two neighbor dicts for both faces and uFaces - This guarantees that the neighbors at the same index are analogous - (ie. they go in the same direction) - - Parameters - ---------- - faces : [[int, ...], ...] - The list of lists of vertex indices making up faces - uFaces : [[int, ...], ...] - The list of lists of vertex indices making up un-subdivided faces - dWings : ??? - ??? - - Returns - ------- - - """ - neighDict, edgeDict, borders = buildNeighborDict(faces) - uNeighDict, uEdgeDict, uBorders = buildNeighborDict(uFaces) - - assert ( - borders >= uBorders - ), "Somehow the unsubdivided borders contain different vIdxs" - - for k, uNeigh in uNeighDict.items(): - neighDict[k] = _align(neighDict[k], uNeigh, dWings) - - return neighDict, uNeighDict, edgeDict, uEdgeDict, borders - - -def _findOldPositionBorder( - faces, - uFaces, - verts, - uVerts, - neighDict, - uNeighDict, - edgeDict, - uEdgeDict, - borders, - vIdx, - computed, -): - """Find the position of the un-subdivided mesh if the vertex was on the border""" - nei = neighDict[vIdx][0] - nei = [i for i in nei if i in borders] - assert len(nei) == 2, "Found multi border, {}".format(nei) - uVerts[vIdx] = 2 * verts[vIdx] - ((verts[nei[0]] + verts[nei[1]]) / 2) - computed.add(vIdx) - - -def _findOldPositionSimple( - faces, - uFaces, - verts, - uVerts, - neighDict, - uNeighDict, - edgeDict, - uEdgeDict, - vIdx, - computed, -): - """Find the position of the un-subdivided mesh if the vertex has at least 4 neighbors. - Updates uVerts in-place - """ - neigh = neighDict[vIdx][0] - - eTest = edgeDict[vIdx] - e = [p for p in neigh if p in eTest] - f = [p for p in neigh if p not in eTest] - - es = verts[e].sum(axis=0) - fs = verts[f].sum(axis=0) - - n = len(e) - term1 = verts[vIdx] * (n / (n - 3.0)) - term2 = es * (4 / (n * (n - 3.0))) - term3 = fs * (1 / (n * (n - 3.0))) - vk = term1 - term2 + term3 - - uVerts[vIdx] = vk - computed.add(vIdx) - - -def _findOldPosition3Valence( - faces, - uFaces, - verts, - uVerts, - neighDict, - uNeighDict, - edgeDict, - uEdgeDict, - vIdx, - computed, -): - """Find the position of the un-subdivided mesh if the vertex has exactly 3 neighbors - Updates uVerts in-place. It is possible for this to fail. - - Returns - ------- - : bool - Whether an update happened - """ - neigh = neighDict[vIdx][0] - uNeigh = uNeighDict[vIdx][0] - - eTest = edgeDict[vIdx] - eNeigh = [n for n in neigh if n in eTest] - fNeigh = [n for n in neigh if n not in eTest] - - ueTest = uEdgeDict[vIdx] - ueNeigh = [n for n in uNeigh if n in ueTest] - # ufNeigh = [n for n in uNeigh if n not in ueTest] - - intr = computed.intersection(ueNeigh) - if intr: - # Easy valence 3 case. I only need - # The computed new neighbor - # The midpoint on the edge to that neighbor - # The "face" verts neighboring the midpoint - - # Get the matching subbed an unsubbed neighbor indexes - uNIdx = intr.pop() - nIdx = eNeigh[ueNeigh.index(uNIdx)] - - # Get the "face" verts next to the subbed neighbor - xx = neigh.index(nIdx) - fnIdxs = (neigh[xx - 1], neigh[(xx + 1) % len(neigh)]) - - # Then compute - # vk = 4*k1e - ke - k1fNs[0] - k1fNs[1] - vka = uVerts[uNIdx] + verts[fnIdxs[0]] + verts[fnIdxs[1]] - vkb = verts[nIdx] * 4 - uVerts[vIdx] = vkb - vka - computed.add(vIdx) - return True - - else: - # The Hard valence 3 case. Made even harder - # because the paper has a mistake in it - - # vk = 4*ejk1 + 4*ejpk1 - fjnk1 - fjpk1 - 6*fjk1 + sum(fik) - # where k1 means subdivided mesh - # where j means an index, jn and jp are next/prev adjacents - # sum(fik) is the sum of all the points of the face that - # *aren't* the original, or edge-adjacent - # There could be more than 1 if an n-gon was subdivided - # - # I wonder: If it was a triangle that was subdivided, what - # would sum(fik) because there are no verts that fit that - # description. I think this is a degenerate case - - # First, find an adjacent face on the unsub mesh that - # is only missing the neighbors of the vIdx - - fnIdx = None - fik = None - fCtrIdx = None - for x, v in enumerate(fNeigh): - # working with neigh, but should only ever contain uNeigh indexes - eTest = edgeDict[vIdx] - origFace = {n for n in neighDict[v][0] if n not in eTest} - - check = (origFace - set(ueNeigh)) - {vIdx} - if computed >= check: - fCtrIdx = v - fnIdx = x - fik = sorted(check) - break - - if fnIdx is None: - # No possiblity found - return False - - # Then apply the equation from above - neighIdx = neigh.index(fCtrIdx) - ejnk1 = neigh[(neighIdx + 1) % len(neigh)] - ejpk1 = neigh[neighIdx - 1] - fjnk1 = fNeigh[(fnIdx + 1) % len(fNeigh)] - fjpk1 = fNeigh[fnIdx - 1] - fjk1 = verts[fCtrIdx] - sumFik = uVerts[fik].sum(axis=0) - vk = 4 * ejnk1 + 4 * ejpk1 - fjnk1 - fjpk1 - 6 * fjk1 + sumFik - uVerts[vIdx] = vk - computed.add(vIdx) - return True - return False - - -def deleteCenters(meshFaces, uvFaces, centerDel, pBar=None): - """Delete the given vertices and connected edges from a face representation - to give a new representation. - - Parameters - ---------- - meshFaces : [[int, ...], ...] - The list of lists of vertex indices making up faces - centerDel : set(int) - A set of vertices to delete. These were the vertices added - to the centers of the faces when subdividing. - uvFaces : [[int, ...], ...] - The list of lists of uv indices making up uvFaces - pBar : QProgressDialog or None - An optional progress bar - - Returns - ------- - : [[int, ...], ...] - The new list of faces of the mesh - : [[int, ...], ...] - The new list of uvfaces of the mesh - : {int: (int, int)} - A dict of a deleted edge-midpoint to its two existing neighbor verts - : {int: (int, int)} - A dict of a deleted uv edge-midpoint to its two existing neighbor uvs - """ - # For each deleted index, grab the neighboring faces, - # and twist the faces so the deleted index is first - cds = set(centerDel) - faceDelDict = {} - uvDelDict = {} - uvFaces = uvFaces or [] - for face, uvFace in zip_longest(meshFaces, uvFaces): - fi = cds.intersection(face) - # If we are a subdivided mesh, Then each face will have exactly one - # vertex that is part of the deletion set - if len(fi) != 1: - raise ValueError("Found a face with an unrecognized connectivity") - # Get that one vert - idx = fi.pop() - # Each face is a cycle. Rotate the cycle - # so that idx is first in the list - rv = face.index(idx) - rFace = face[rv:] + face[:rv] - faceDelDict.setdefault(idx, []).append(rFace) - - if uvFace is not None: - rUVFace = uvFace[rv:] + uvFace[:rv] - uvDelDict.setdefault(idx, []).append(rUVFace) - - newFaces = [] - nUVFaces = [] - wings = {} - uvWings = {} - - if pBar is not None: - pBar.setValue(0) - pBar.setMaximum(len(faceDelDict)) - - chk = -1 - for idx, rFaces in faceDelDict.items(): - chk += 1 - if pBar is not None: - pBar.setValue(chk) - QApplication.processEvents() - - ruvFaces = uvDelDict.get(idx, []) - # The faces are guaranteed to be in a single loop cycle - # so I don't have to handle any annoying edge cases! Yay! - faceEnds = { - f[1]: (f[2], f[3], uvf) for f, uvf in zip_longest(rFaces, ruvFaces) - } # face ends - - end = rFaces[-1][-1] # get an arbitrary face to start with - newFace = [] - nUVFace = [] - while faceEnds: - try: - diag, nxt, uvf = faceEnds.pop(end) - except KeyError: - print("rFaces", rFaces) - print("fe", faceEnds) - raise - if uvf is not None: - try: - nUVFace.append(uvf[2]) - uvWings.setdefault(uvf[1], []).append(uvf[2]) - uvWings.setdefault(uvf[3], []).append(uvf[2]) - except IndexError: - print("UVF", uvf, chk) - raise - - newFace.append(diag) - wings.setdefault(end, []).append(diag) - wings.setdefault(nxt, []).append(diag) - - end = nxt - newFaces.append(newFace) - if nUVFace: - nUVFaces.append(nUVFace) - nUVFaces = nUVFaces or None - - return newFaces, nUVFaces, wings, uvWings - - -def fixVerts( - faces, - uFaces, - verts, - neighDict, - uNeighDict, - edgeDict, - uEdgeDict, - borders, - pinned, - pBar=None, -): - """Given the faces, vertex positions, and the point indices that - were created at the face centers for a subdivision step - Return the faces and verts of the mesh from before the - subdivision step. This algorithm doesn't handle UV's yet - - Parameters - ---------- - faces : [[int, ...], ...] - The list of lists of vertex indices making up faces - verts : np.array - The Nx3 array of vertices - centerDel : set(int) - A set of vertices to delete - uFaces : [[int, ...], ...] - The list of lists of vertex indices making up the - un-subdivided faces. Not all numbers in the vert range are in this structure - neighDict : {int: [int, ...]} - The dictionary of vertex neighbors based on ``faces`` - uNeighDict : {int: [int, ...]} - The dictionary of vertex neighbors based on ``uFaces`` - edgeDict : TODO - Some kind of dictionary representing individual edges - uEdgeDict : TODO - Some kind of dictionary representing individual edges in the unsub mesh - borders : set(int) - The set of vertices along the borders - pinned : set(int) - The set of vertices that will not be moved by this process - pBar : QProgressDialog or None - An optional progress bar - - Returns - ------- - : np.array - An array of vertex positions - """ - uVerts = verts.copy() - uIdxs = sorted(set(chain.from_iterable(uFaces))) - - v3Idxs = [] - # bowtie verts are pinned - bowTieIdxs = [] - computed = set() - i = 0 - if pBar is not None: - pBar.setValue(0) - pBar.setMaximum(len(uIdxs)) - - for idx in uIdxs: - if pBar is not None: - pBar.setValue(i) - QApplication.processEvents() - if len(uNeighDict[idx]) > 1: - bowTieIdxs.append(idx) - i += 1 - elif idx in pinned: - pass - elif idx in borders: - _findOldPositionBorder( - faces, - uFaces, - verts, - uVerts, - neighDict, - uNeighDict, - edgeDict, - uEdgeDict, - borders, - idx, - computed, - ) - i += 1 - elif sum(map(len, neighDict[idx])) > 6: # if valence > 3 - _findOldPositionSimple( - faces, - uFaces, - verts, - uVerts, - neighDict, - uNeighDict, - edgeDict, - uEdgeDict, - idx, - computed, - ) - i += 1 - else: - v3Idxs.append(idx) - - updated = True - while updated: - updated = False - rem = set() - for idx in v3Idxs: - up = _findOldPosition3Valence( - faces, - uFaces, - verts, - uVerts, - neighDict, - uNeighDict, - edgeDict, - uEdgeDict, - idx, - computed, - ) - if not up: - continue - if pBar is not None: - pBar.setValue(i) - QApplication.processEvents() - i += 1 - updated = True - rem.add(idx) - v3Idxs = list(set(v3Idxs) - rem) - - return uVerts - - -def getUVPins(faces, borders, uvFaces, uvBorders, pinBorders): - """Find which uvBorders are also mesh borders - - Parameters - ---------- - faces : [[int, ...], ...] - The list of lists of vertex indices making up faces - borders : set(int) - The set of vertices along the borders - uvFaces : [[int, ...], ...] - The list of lists of uv indices making up uvFaces - uvBorders : set(int) - The set of vertices along the borders of uvs - pinBorders : bool - Whether to just pin all uv borders - - Returns - ------- - : set(int) - The vertex indices to pin - """ - if uvFaces is None: - return set() - if pinBorders: - return set(uvBorders) - - pinnit = set() - for face, uvFace in zip(faces, uvFaces): - for i in range(len(face)): - f = face[i] - pf = face[i - 1] - - uv = uvFace[i] - puv = uvFace[i - 1] - - if not (f in borders and pf in borders): - if uv in uvBorders and puv in uvBorders: - pinnit.add(puv) - pinnit.add(uv) - - return uvBorders & pinnit - - -def collapse(faces, verts, uvFaces, uvs): - """Take a mesh representation with unused vertex indices, and remove those vertices - - Parameters - ---------- - faces : [[int, ...], ...] - The list of lists of vertex indices making up faces - The set of integers used here will be non-contiguous - verts : np.array - The N*3 array of vertices - uvFaces : [[int, ...], ...] - The list of lists of uv indices making up uvFaces - The set of integers used here will be non-contiguous - uvs : np.array - The N*2 array of uvs - - Returns - ------- - : [[int, ...], ...] - The list of lists of vertex indices making up faces - The set of integers used here will be contiguous - : np.array - The new N*3 array of vertices - : [[int, ...], ...] - The list of lists of uv indices making up uvFaces - The set of integers used here will be non-contiguous - : np.array - The new N*2 array of uvs - """ - vset = sorted(set(chain.from_iterable(faces))) - nVerts = verts[vset] - vDict = {v: i for i, v in enumerate(vset)} - nFaces = [[vDict[f] for f in face] for face in faces] - - if uvFaces is not None: - uvset = sorted(set(chain.from_iterable(uvFaces))) - nUVs = uvs[uvset] - uvDict = {v: i for i, v in enumerate(uvset)} - nUVFaces = [[uvDict[f] for f in face] for face in uvFaces] - else: - nUVs = None - nUVFaces = None - - return nFaces, nVerts, nUVFaces, nUVs - - -def getCenters(faces, hints=None, pBar=None): - """Given a set of faces, find the face-center vertices from the subdivision - - Parameters - ---------- - faces : [[int, ...], ...] - The list of lists of vertex indices making up faces - hints : set(int) or None - An optional list of star points that were part of the original un-subdivided mesh - pBar : QProgressDialog or None - An optional progress bar - - Returns - ------- - : set(int) - The vertices that were added to the centers of the faces as part of the subdivision process - """ - if pBar is not None: - pBar.setLabelText("Crawling Edges") - pBar.show() - QApplication.processEvents() - - eNeigh = buildEdgeDict(faces) - if hints is None: - borders = getBorders(faces) - hints = buildUnsubdivideHints( - faces, eNeigh, borders, pBar=None - ) # purposely no PBar - centerDel, fail = getFaceCenterDel(faces, eNeigh, hints, pBar=pBar) - assert not fail, "Could not detect subdivided topology with the provided hints" - - if pBar is not None: - pBar.close() - - return centerDel - - -def unSubdivide( - faces, - verts, - uvFaces, - uvs, - hints=None, - repositionVerts=True, - pinBorders=False, - pBar=None, -): - """Given a mesh representation (faces and vertices) remove the edges added - by a subdivision, and optionally reposition the verts - - Parameters - ---------- - faces : [[int, ...], ...] - The list of lists of vertex indices making up faces - verts : np.array - The N*3 array of vertices - uvFaces : [[int, ...], ...] - The list of lists of uv indices making up uvFaces - uvs : np.array - The N*2 array of uvs - hints : set(int) or None - An optional list of star points that were part of the original un-subdivided mesh - repositionVerts : bool - Whether to also estimate the positions of the un-subdivided verts. Defaults to True - pinBorders : bool - Whether or not to pin the border vertices. Defaults to False - pBar : QProgressDialog or None - An optional progress bar - - Returns - ------- - : [[vIdx ...], ...] - The un-subdivided face structure - : np.array - The un-subdivided vertex positions - : [[vIdx ...], ...] or None - The un-subdivided uv-face structure if it exists - : np.array or None - The un-subdivided uvs if they exist - """ - if pBar is not None: - pBar.show() - pBar.setLabelText("Finding Neighbors") - QApplication.processEvents() - - eNeigh = buildEdgeDict(faces) - - if hints is None: - if pBar is not None: - pBar.show() - pBar.setLabelText("Getting Hints") - QApplication.processEvents() - borders = getBorders(faces) - hints = buildUnsubdivideHints( - faces, eNeigh, borders, pBar=None - ) # Purposely no PBar - - if pBar is not None: - pBar.setLabelText("Crawling Edges") - QApplication.processEvents() - centerDel, fail = getFaceCenterDel(faces, eNeigh, hints, pBar=pBar) - assert not fail, "Could not detect subdivided topology with the provided hints" - - if pBar is not None: - pBar.setLabelText("Deleting Edges") - QApplication.processEvents() - uFaces, uUVFaces, dWings, uvDWings = deleteCenters( - faces, uvFaces, centerDel, pBar=pBar - ) - - uVerts = verts - uUVs = uvs - if repositionVerts: - # Handle the verts - if pBar is not None: - pBar.setLabelText("Building Correspondences") - QApplication.processEvents() - neighDict, uNeighDict, edgeDict, uEdgeDict, borders = buildLayeredNeighborDicts( - faces, uFaces, dWings - ) - pinned = set(borders) if pinBorders else [] - if pBar is not None: - pBar.setLabelText("Fixing Vert Positions") - QApplication.processEvents() - uVerts = fixVerts( - faces, - uFaces, - verts, - neighDict, - uNeighDict, - edgeDict, - uEdgeDict, - borders, - pinned, - pBar=pBar, - ) - - # Handle the UVs - if uvFaces is not None: - ( - uvNeighDict, - uUVNeighDict, - uvEdgeDict, - uvUEdgeDict, - uvBorders, - ) = buildLayeredNeighborDicts(uvFaces, uUVFaces, uvDWings) - uvPinned = getUVPins(faces, borders, uvFaces, uvBorders, pinBorders) - if pBar is not None: - pBar.setLabelText("Fixing UV Positions") - QApplication.processEvents() - uUVs = fixVerts( - uvFaces, - uUVFaces, - uvs, - uvNeighDict, - uUVNeighDict, - uvEdgeDict, - uvUEdgeDict, - uvBorders, - uvPinned, - pBar=pBar, - ) - - rFaces, rVerts, rUVFaces, rUVs = collapse(uFaces, uVerts, uUVFaces, uUVs) - - if pBar is not None: - pBar.close() - - return rFaces, rVerts, rUVFaces, rUVs - - -#################################################################### -# Handle .smpx files here # -#################################################################### - - -def _ussmpx(faces, verts, uvFaces, uvs, pBar=None): - """Unsubdivide a simplex""" - pbPrint(pBar, message="Finding Neighbors") - eNeigh = buildEdgeDict(faces) - - pbPrint(pBar, message="Getting Hints") - borders = getBorders(faces) - hints = buildUnsubdivideHints( - faces, eNeigh, borders, pBar=None - ) # Purposely no PBar - - pbPrint(pBar, message="Crawling Edges") - centerDel, fail = getFaceCenterDel(faces, eNeigh, hints, pBar=pBar) - assert not fail, "Could not detect subdivided topology with the provided hints" - - pbPrint(pBar, message="Deleting Edges") - uFaces, uUVFaces, _, _ = deleteCenters(faces, uvFaces, centerDel, pBar=pBar) - - pbPrint(pBar, message="Collapsing Indexes") - uVerts = verts - uUVs = uvs - - rFaces, rVerts, rUVFaces, rUVs = collapse(uFaces, uVerts, uUVFaces, uUVs) - - if pBar is not None: - pBar.close() - else: - print("Done") - - return rFaces, rVerts, rUVFaces, rUVs - - -def _applyShapePrefix(shapePrefix, jsString): - """Apply a prefix to the shape names. - For XSI where shape names must be unique - """ - if shapePrefix is not None: - d = json.loads(jsString) - if d["encodingVersion"] > 1: - for shape in d["shapes"]: - shape["name"] = shapePrefix + shape["name"] - else: - d["shapes"] = [shapePrefix + i for i in d["shapes"]] - jsString = json.dumps(d) - return jsString - - -def _unflattenFaces(flatFaces, counts): - faces = [] - ptr = 0 - for c in counts: - faces.append(flatFaces[ptr : ptr + c].tolist()) - ptr += c - return faces - - -def unsubdivideSimplex(inPath, outPath, shapePrefix=None, pBar=None): - """Unsubdivde a .smpx file on disk - - Parameters - ---------- - inPath : str - The input .smpx file path - outPath : str - The output .smpx file path - shapePrefix : str or None - An optional string to prefix the shape names with - pBar : QProgressDialog or None - An optional progress bar - """ - if np is None: - raise RuntimeError("Un-Subdivide requires numpy, and it is not available here") - - pbPrint(pBar, message="Loading smpx") - jsString, counts, verts, flatFaces, uvs, flatUVFaces = readSmpx(inPath) - jsString = _applyShapePrefix(shapePrefix, jsString) - - js = json.loads(jsString) - name = js["systemName"] - - faces = _unflattenFaces(flatFaces, counts) - uvFaces = None - if flatUVFaces is not None: - uvFaces = _unflattenFaces(flatUVFaces, counts) - - pbPrint(pBar, message="Unsubdividing") - verts = verts.swapaxes(0, 1) - uFaces, uVerts, uUVFaces, uUVs = _ussmpx(faces, verts, uvFaces, uvs, pBar=pBar) - - pbPrint(pBar, message="Exporting") - buildSmpx( - outPath, - uVerts.swapaxes(0, 1), - uFaces, - jsString, - name, - uvs=uUVs, - uvFaces=uUVFaces, - ) +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +import json +from itertools import chain, zip_longest + +import numpy as np +from Qt.QtWidgets import QApplication + +from .alembicCommon import buildSmpx, pbPrint, readSmpx + + +def mergeCycles(groups): + """Take a list of ordered items, and sort them so the last item of each list matches the first of the next list + + Then return the groups of lists mashed together + For instance, with two cycles: + +---------+------------------------------------------------------------+ + | input | [(1, 2), (11, 12), (3, 1), (10, 11), (2, 3), (12, 10)] | + +---------+------------------------------------------------------------+ + | reorder | [[(1, 2), (2, 3), (3, 1)], [(10, 11), (11, 12), (12, 13)]] | + +---------+------------------------------------------------------------+ + | output | [[1, 2, 3], [10, 11, 12, 13]] | + +---------+------------------------------------------------------------+ + + Also, return whether the cycles merged form a single closed group + + Parameters + ---------- + groups : [(int, int), ...] + A list of pairs of integers + + Returns + ------- + : [[int, ...], ...] + The ordered cycles + """ + groups = [list(g) for g in groups] + heads = {g[0]: g for g in groups} + tails = {g[-1]: g for g in groups} + + headGetter = lambda x: heads.get(x[-1]) # noqa: E731 + headSetter = lambda x, y: x + y[1:] # noqa: E731 + + tailGetter = lambda x: tails.get(x[0]) # noqa: E731 + tailSetter = lambda x, y: y + x[1:] # noqa: E731 + + searches = ((headGetter, headSetter), (tailGetter, tailSetter)) + + out = [] + cycles = [] + while groups: + g = groups.pop() + del heads[g[0]] + del tails[g[-1]] + + for getter, setter in searches: + while True: + adder = getter(g) + if adder is None: + break + g = setter(g, adder) + del heads[adder[0]] + del tails[adder[-1]] + adder[:] = [] + groups = [x for x in groups if x] + + cycle = False + if g[0] == g[-1]: + g.pop() + cycle = True + cycles.append(cycle) + out.append(g) + return out, cycles + + +def grow(neigh, verts, exclude): + """Grow the vertex set, also keeping track of which vertices to ignore for the next iteration + + Parameters + ---------- + neigh : {int: [int, ...]} + A dict mapping a vert index to a list of neighbor vert indices + verts : set(int) + A set of vert indices to grow + exclude : set(int) + A set of vert indices to ignore + + Returns + ------- + : set(int) + The newly grown vertices + : set(int) + ``exclude`` combined with ``verts`` + + """ + grown = set() + growSet = verts - exclude + for v in growSet: + grown.update(neigh[v]) + newGrown = grown - exclude + newExclude = exclude | growSet + return newGrown, newExclude + + +def buildHint(island, neigh, borders): + """Find star points that are an even number of grows from an edge + + Parameters + ---------- + island : [int, ...] + A list of vertices as part of a mesh island + neigh : {int: [int, ...]} + The dictionary of vertex neighbors + borders : set(int) + A set of border vertices + + Returns + ------- + : int + The first star point encountered at an even grow from the given borders + """ + borders = borders & island + if not borders: + # Well ... we don't have any good way of dealing with this + # Best thing I can do is search for a point with the least + # number of similar valences, and return that + d = {} + for v in island: + d.setdefault(len(neigh[v]), []).append(v) + + dd = {} + for k, v in d.items(): + dd.setdefault(len(v), []).append(k) + + mkey = min(dd.keys()) + return d[dd[mkey][0]][0] + + exclude = set() + while borders: + borders, exclude = grow(neigh, borders, exclude) + borders, exclude = grow(neigh, borders, exclude) + for b in borders: + if len(neigh[b]) != 4: + return b + return None + + +def partitionIslands(faces, neigh, pBar=None): + """Find all groups of connected verts + + Parameters + ---------- + faces : [[int, ...], ...] + The list of lists of vertex indices making up faces + neigh : {int: [int, ...]} + The dict of vertex neighbors + pBar : QProgressDialog or None + An optional progress bar + + Returns + ------- + : [set(int), ...] + A list of sets of non-connected vertex islands + """ + allVerts = set(chain.from_iterable(faces)) + islands = [] + count = float(len(allVerts)) + + if pBar is not None: + pBar.setValue(0) + pBar.setMaximum(count) + QApplication.processEvents() + + while allVerts: + verts = {allVerts.pop()} + exclude = set() + while verts: + verts, exclude = grow(neigh, verts, exclude) + islands.append(exclude) + allVerts.difference_update(exclude) + + if pBar is not None: + pBar.setValue(count - len(allVerts)) + QApplication.processEvents() + + return islands + + +def buildUnsubdivideHints(faces, neigh, borders, pBar=None): + """Get one vertex per island that was part of the original mesh + + Parameters + ---------- + faces : [[int, ...], ...] + The list of lists of vertex indices making up faces + neigh : {int: [int, ...]} + The dict of vertex neighbors + borders : set(int) + A set of border vertices + pBar : QProgressDialog or None + An optional progress bar + + Returns + ------- + : [int, ...] + A list of star-points (one per island) to un-subdivide from + """ + islands = partitionIslands(faces, neigh, pBar=pBar) + hints = [] + + if pBar is not None: + pBar.setValue(0) + pBar.setMaximum(len(islands)) + QApplication.processEvents() + + for i, isle in enumerate(islands): + if pBar is not None: + pBar.setValue(i) + QApplication.processEvents() + hints.append(buildHint(isle, neigh, borders)) + + hints = [h for h in hints if h is not None] + return hints + + +def getFaceCenterDel(faces, eNeigh, hints, pBar=None): + """Given a list of hint "keeper" points + + Parameters + ---------- + faces : [[int, ...], ...] + The list of lists of vertex indices making up faces + eNeigh : {int: [int, ...]} + The dict of vertex neighbors + hints : [int, ...] + A list of star-points (one per island) to un-subdivide from + pBar : QProgressDialog or None + An optional progress bar + + Returns + ------- + : set(int) + Centers of the original faces during a subdivision + : bool + Whether the operation failed(True) or not(False) + """ + vertToFaces = {} + vc = set() + for i, face in enumerate(faces): + for f in face: + vertToFaces.setdefault(f, []).append(i) + vc.add(f) + + count = len(vc) + centers = set() + midpoints = set() + originals = set(hints) + queue = set(hints) + + if pBar is not None: + pBar.setValue(0) + pBar.setMaximum(count) + QApplication.processEvents() + + i = 0 + fail = False + while queue: + cur = queue.pop() + if cur in midpoints: + continue + + if pBar is not None: + pBar.setValue(i) + QApplication.processEvents() + i += 2 # Add 2 because I *shouldn't* get any midpoints + + midpoints.update(eNeigh[cur]) + t = centers if cur in originals else originals + + for f in vertToFaces[cur]: + nVerts = faces[f] + + if len(nVerts) != 4: + fail = True + continue + + curFaceIndex = nVerts.index(cur) + + half = int(len(nVerts) / 2) + diag = nVerts[curFaceIndex - half] + + isOrig = diag in originals + isCtr = diag in centers + if not isOrig and not isCtr: + t.add(diag) + queue.add(diag) + elif ( + (isCtr and t is originals) + or (isOrig and t is centers) + or (diag in midpoints) + ): + fail = True + + return centers, fail + + +def getBorders(faces): + """Get the indices of verts along the borders of a mesh + + Parameters + ---------- + faces : [[int, ...], ...] + The list of lists of vertex indices making up faces + + Returns + ------- + : set(int) + A set of border vertices + """ + edgePairs = set() + for face in faces: + for f in range(len(face)): + edgePairs.add((face[f], face[f - 1])) + borders = set() + for ep in edgePairs: + if (ep[1], ep[0]) not in edgePairs: + borders.update(ep) + return borders + + +def buildEdgeDict(faces): + """Build a dictionary of un-ordered neighboring vertices along edges + + Parameters + ---------- + faces : [[int, ...], ...] + The list of lists of vertex indices making up faces + + Returns + ------- + : {int: set(int)} + A dictionary of neighboring vertices along edges + """ + edgeDict = {} + for face in faces: + for f in range(len(face)): + ff = edgeDict.setdefault(face[f - 1], set()) + ff.add(face[f]) + ff.add(face[f - 2]) + return edgeDict + + +def buildNeighborDict(faces): + """Build a structure to ask for edge and face neighboring vertices + The returned neighbor list starts with an edge neighbor, and + proceeds counter clockwise, alternating between edge and face neighbors + Also, while I'm here, grab the border verts + + Parameters + ---------- + faces : [[int, ...], ...] + The list of lists of vertex indices making up faces + + Returns + ------- + : {int: [[int, ...], ...]} + A dictionary keyed from a vert index whose + values are ordered cycles or fans + : set(int) + A set of vertices that are on the border + """ + fanDict = {} + edgeDict = {} + for face in faces: + for i in range(len(face)): + fanDict.setdefault(face[i], []).append(face[i + 1 :] + face[:i]) + ff = edgeDict.setdefault(face[i - 1], set()) + ff.add(face[i]) + ff.add(face[i - 2]) + + borders = set() + out = {} + for k, v in fanDict.items(): + fans, cycles = mergeCycles(v) + for f, c in zip(fans, cycles): + if not c: + borders.update((f[0], f[-1], k)) + out[k] = fans + return out, edgeDict, borders + + +def _fanMatch(fan, uFan, dWings): + """Twist a single fan so it matches the uFan if it can""" + uIdx = uFan[0] + for f, fIdx in enumerate(fan): + dw = dWings.get(fIdx, []) + if uIdx in dw: + return fan[f:] + fan[:f] + return None + + +def _align(neigh, uNeigh, dWings): + """Twist all the neighs so they match the uNeigh""" + out = [] + for uFan in uNeigh: + for fan in neigh: + fm = _fanMatch(fan, uFan, dWings) + if fm is not None: + out.append(fm) + break + return out + + +def buildLayeredNeighborDicts(faces, uFaces, dWings): + """Build and align two neighbor dicts for both faces and uFaces + This guarantees that the neighbors at the same index are analogous + (ie. they go in the same direction) + + Parameters + ---------- + faces : [[int, ...], ...] + The list of lists of vertex indices making up faces + uFaces : [[int, ...], ...] + The list of lists of vertex indices making up un-subdivided faces + dWings : ??? + ??? + + Returns + ------- + + """ + neighDict, edgeDict, borders = buildNeighborDict(faces) + uNeighDict, uEdgeDict, uBorders = buildNeighborDict(uFaces) + + assert ( + borders >= uBorders + ), "Somehow the unsubdivided borders contain different vIdxs" + + for k, uNeigh in uNeighDict.items(): + neighDict[k] = _align(neighDict[k], uNeigh, dWings) + + return neighDict, uNeighDict, edgeDict, uEdgeDict, borders + + +def _findOldPositionBorder( + faces, + uFaces, + verts, + uVerts, + neighDict, + uNeighDict, + edgeDict, + uEdgeDict, + borders, + vIdx, + computed, +) -> None: + """Find the position of the un-subdivided mesh if the vertex was on the border""" + nei = neighDict[vIdx][0] + nei = [i for i in nei if i in borders] + assert len(nei) == 2, f"Found multi border, {nei}" + uVerts[vIdx] = 2 * verts[vIdx] - ((verts[nei[0]] + verts[nei[1]]) / 2) + computed.add(vIdx) + + +def _findOldPositionSimple( + faces, + uFaces, + verts, + uVerts, + neighDict, + uNeighDict, + edgeDict, + uEdgeDict, + vIdx, + computed, +) -> None: + """Find the position of the un-subdivided mesh if the vertex has at least 4 neighbors. + Updates uVerts in-place + """ + neigh = neighDict[vIdx][0] + + eTest = edgeDict[vIdx] + e = [p for p in neigh if p in eTest] + f = [p for p in neigh if p not in eTest] + + es = verts[e].sum(axis=0) + fs = verts[f].sum(axis=0) + + n = len(e) + term1 = verts[vIdx] * (n / (n - 3.0)) + term2 = es * (4 / (n * (n - 3.0))) + term3 = fs * (1 / (n * (n - 3.0))) + vk = term1 - term2 + term3 + + uVerts[vIdx] = vk + computed.add(vIdx) + + +def _findOldPosition3Valence( + faces, + uFaces, + verts, + uVerts, + neighDict, + uNeighDict, + edgeDict, + uEdgeDict, + vIdx, + computed, +): + """Find the position of the un-subdivided mesh if the vertex has exactly 3 neighbors + Updates uVerts in-place. It is possible for this to fail. + + Returns + ------- + : bool + Whether an update happened + """ + neigh = neighDict[vIdx][0] + uNeigh = uNeighDict[vIdx][0] + + eTest = edgeDict[vIdx] + eNeigh = [n for n in neigh if n in eTest] + fNeigh = [n for n in neigh if n not in eTest] + + ueTest = uEdgeDict[vIdx] + ueNeigh = [n for n in uNeigh if n in ueTest] + # ufNeigh = [n for n in uNeigh if n not in ueTest] + + intr = computed.intersection(ueNeigh) + if intr: + # Easy valence 3 case. I only need + # The computed new neighbor + # The midpoint on the edge to that neighbor + # The "face" verts neighboring the midpoint + + # Get the matching subbed an unsubbed neighbor indexes + uNIdx = intr.pop() + nIdx = eNeigh[ueNeigh.index(uNIdx)] + + # Get the "face" verts next to the subbed neighbor + xx = neigh.index(nIdx) + fnIdxs = (neigh[xx - 1], neigh[(xx + 1) % len(neigh)]) + + # Then compute + # vk = 4*k1e - ke - k1fNs[0] - k1fNs[1] + vka = uVerts[uNIdx] + verts[fnIdxs[0]] + verts[fnIdxs[1]] + vkb = verts[nIdx] * 4 + uVerts[vIdx] = vkb - vka + computed.add(vIdx) + return True + + else: + # The Hard valence 3 case. Made even harder + # because the paper has a mistake in it + + # vk = 4*ejk1 + 4*ejpk1 - fjnk1 - fjpk1 - 6*fjk1 + sum(fik) + # where k1 means subdivided mesh + # where j means an index, jn and jp are next/prev adjacents + # sum(fik) is the sum of all the points of the face that + # *aren't* the original, or edge-adjacent + # There could be more than 1 if an n-gon was subdivided + # + # I wonder: If it was a triangle that was subdivided, what + # would sum(fik) because there are no verts that fit that + # description. I think this is a degenerate case + + # First, find an adjacent face on the unsub mesh that + # is only missing the neighbors of the vIdx + + fnIdx = None + fik = None + fCtrIdx = None + for x, v in enumerate(fNeigh): + # working with neigh, but should only ever contain uNeigh indexes + eTest = edgeDict[vIdx] + origFace = {n for n in neighDict[v][0] if n not in eTest} + + check = (origFace - set(ueNeigh)) - {vIdx} + if computed >= check: + fCtrIdx = v + fnIdx = x + fik = sorted(check) + break + + if fnIdx is None: + # No possiblity found + return False + + # Then apply the equation from above + neighIdx = neigh.index(fCtrIdx) + ejnk1 = neigh[(neighIdx + 1) % len(neigh)] + ejpk1 = neigh[neighIdx - 1] + fjnk1 = fNeigh[(fnIdx + 1) % len(fNeigh)] + fjpk1 = fNeigh[fnIdx - 1] + fjk1 = verts[fCtrIdx] + sumFik = uVerts[fik].sum(axis=0) + vk = 4 * ejnk1 + 4 * ejpk1 - fjnk1 - fjpk1 - 6 * fjk1 + sumFik + uVerts[vIdx] = vk + computed.add(vIdx) + return True + return False + + +def deleteCenters(meshFaces, uvFaces, centerDel, pBar=None): + """Delete the given vertices and connected edges from a face representation + to give a new representation. + + Parameters + ---------- + meshFaces : [[int, ...], ...] + The list of lists of vertex indices making up faces + centerDel : set(int) + A set of vertices to delete. These were the vertices added + to the centers of the faces when subdividing. + uvFaces : [[int, ...], ...] + The list of lists of uv indices making up uvFaces + pBar : QProgressDialog or None + An optional progress bar + + Returns + ------- + : [[int, ...], ...] + The new list of faces of the mesh + : [[int, ...], ...] + The new list of uvfaces of the mesh + : {int: (int, int)} + A dict of a deleted edge-midpoint to its two existing neighbor verts + : {int: (int, int)} + A dict of a deleted uv edge-midpoint to its two existing neighbor uvs + """ + # For each deleted index, grab the neighboring faces, + # and twist the faces so the deleted index is first + cds = set(centerDel) + faceDelDict = {} + uvDelDict = {} + uvFaces = uvFaces or [] + for face, uvFace in zip_longest(meshFaces, uvFaces): + fi = cds.intersection(face) + # If we are a subdivided mesh, Then each face will have exactly one + # vertex that is part of the deletion set + if len(fi) != 1: + raise ValueError("Found a face with an unrecognized connectivity") + # Get that one vert + idx = fi.pop() + # Each face is a cycle. Rotate the cycle + # so that idx is first in the list + rv = face.index(idx) + rFace = face[rv:] + face[:rv] + faceDelDict.setdefault(idx, []).append(rFace) + + if uvFace is not None: + rUVFace = uvFace[rv:] + uvFace[:rv] + uvDelDict.setdefault(idx, []).append(rUVFace) + + newFaces = [] + nUVFaces = [] + wings = {} + uvWings = {} + + if pBar is not None: + pBar.setValue(0) + pBar.setMaximum(len(faceDelDict)) + + chk = -1 + for idx, rFaces in faceDelDict.items(): + chk += 1 + if pBar is not None: + pBar.setValue(chk) + QApplication.processEvents() + + ruvFaces = uvDelDict.get(idx, []) + # The faces are guaranteed to be in a single loop cycle + # so I don't have to handle any annoying edge cases! Yay! + faceEnds = { + f[1]: (f[2], f[3], uvf) for f, uvf in zip_longest(rFaces, ruvFaces) + } # face ends + + end = rFaces[-1][-1] # get an arbitrary face to start with + newFace = [] + nUVFace = [] + while faceEnds: + try: + diag, nxt, uvf = faceEnds.pop(end) + except KeyError: + print("rFaces", rFaces) + print("fe", faceEnds) + raise + if uvf is not None: + try: + nUVFace.append(uvf[2]) + uvWings.setdefault(uvf[1], []).append(uvf[2]) + uvWings.setdefault(uvf[3], []).append(uvf[2]) + except IndexError: + print("UVF", uvf, chk) + raise + + newFace.append(diag) + wings.setdefault(end, []).append(diag) + wings.setdefault(nxt, []).append(diag) + + end = nxt + newFaces.append(newFace) + if nUVFace: + nUVFaces.append(nUVFace) + nUVFaces = nUVFaces or None + + return newFaces, nUVFaces, wings, uvWings + + +def fixVerts( + faces, + uFaces, + verts, + neighDict, + uNeighDict, + edgeDict, + uEdgeDict, + borders, + pinned, + pBar=None, +): + """Given the faces, vertex positions, and the point indices that + were created at the face centers for a subdivision step + Return the faces and verts of the mesh from before the + subdivision step. This algorithm doesn't handle UV's yet + + Parameters + ---------- + faces : [[int, ...], ...] + The list of lists of vertex indices making up faces + verts : np.array + The Nx3 array of vertices + centerDel : set(int) + A set of vertices to delete + uFaces : [[int, ...], ...] + The list of lists of vertex indices making up the + un-subdivided faces. Not all numbers in the vert range are in this structure + neighDict : {int: [int, ...]} + The dictionary of vertex neighbors based on ``faces`` + uNeighDict : {int: [int, ...]} + The dictionary of vertex neighbors based on ``uFaces`` + edgeDict : TODO + Some kind of dictionary representing individual edges + uEdgeDict : TODO + Some kind of dictionary representing individual edges in the unsub mesh + borders : set(int) + The set of vertices along the borders + pinned : set(int) + The set of vertices that will not be moved by this process + pBar : QProgressDialog or None + An optional progress bar + + Returns + ------- + : np.array + An array of vertex positions + """ + uVerts = verts.copy() + uIdxs = sorted(set(chain.from_iterable(uFaces))) + + v3Idxs = [] + # bowtie verts are pinned + bowTieIdxs = [] + computed = set() + i = 0 + if pBar is not None: + pBar.setValue(0) + pBar.setMaximum(len(uIdxs)) + + for idx in uIdxs: + if pBar is not None: + pBar.setValue(i) + QApplication.processEvents() + if len(uNeighDict[idx]) > 1: + bowTieIdxs.append(idx) + i += 1 + elif idx in pinned: + pass + elif idx in borders: + _findOldPositionBorder( + faces, + uFaces, + verts, + uVerts, + neighDict, + uNeighDict, + edgeDict, + uEdgeDict, + borders, + idx, + computed, + ) + i += 1 + elif sum(map(len, neighDict[idx])) > 6: # if valence > 3 + _findOldPositionSimple( + faces, + uFaces, + verts, + uVerts, + neighDict, + uNeighDict, + edgeDict, + uEdgeDict, + idx, + computed, + ) + i += 1 + else: + v3Idxs.append(idx) + + updated = True + while updated: + updated = False + rem = set() + for idx in v3Idxs: + up = _findOldPosition3Valence( + faces, + uFaces, + verts, + uVerts, + neighDict, + uNeighDict, + edgeDict, + uEdgeDict, + idx, + computed, + ) + if not up: + continue + if pBar is not None: + pBar.setValue(i) + QApplication.processEvents() + i += 1 + updated = True + rem.add(idx) + v3Idxs = list(set(v3Idxs) - rem) + + return uVerts + + +def getUVPins(faces, borders, uvFaces, uvBorders, pinBorders: bool): + """Find which uvBorders are also mesh borders + + Parameters + ---------- + faces : [[int, ...], ...] + The list of lists of vertex indices making up faces + borders : set(int) + The set of vertices along the borders + uvFaces : [[int, ...], ...] + The list of lists of uv indices making up uvFaces + uvBorders : set(int) + The set of vertices along the borders of uvs + pinBorders : bool + Whether to just pin all uv borders + + Returns + ------- + : set(int) + The vertex indices to pin + """ + if uvFaces is None: + return set() + if pinBorders: + return set(uvBorders) + + pinnit = set() + for face, uvFace in zip(faces, uvFaces): + for i in range(len(face)): + f = face[i] + pf = face[i - 1] + + uv = uvFace[i] + puv = uvFace[i - 1] + + if not (f in borders and pf in borders): + if uv in uvBorders and puv in uvBorders: + pinnit.add(puv) + pinnit.add(uv) + + return uvBorders & pinnit + + +def collapse(faces, verts, uvFaces, uvs): + """Take a mesh representation with unused vertex indices, and remove those vertices + + Parameters + ---------- + faces : [[int, ...], ...] + The list of lists of vertex indices making up faces + The set of integers used here will be non-contiguous + verts : np.array + The N*3 array of vertices + uvFaces : [[int, ...], ...] + The list of lists of uv indices making up uvFaces + The set of integers used here will be non-contiguous + uvs : np.array + The N*2 array of uvs + + Returns + ------- + : [[int, ...], ...] + The list of lists of vertex indices making up faces + The set of integers used here will be contiguous + : np.array + The new N*3 array of vertices + : [[int, ...], ...] + The list of lists of uv indices making up uvFaces + The set of integers used here will be non-contiguous + : np.array + The new N*2 array of uvs + """ + vset = sorted(set(chain.from_iterable(faces))) + nVerts = verts[vset] + vDict = {v: i for i, v in enumerate(vset)} + nFaces = [[vDict[f] for f in face] for face in faces] + + if uvFaces is not None: + uvset = sorted(set(chain.from_iterable(uvFaces))) + nUVs = uvs[uvset] + uvDict = {v: i for i, v in enumerate(uvset)} + nUVFaces = [[uvDict[f] for f in face] for face in uvFaces] + else: + nUVs = None + nUVFaces = None + + return nFaces, nVerts, nUVFaces, nUVs + + +def getCenters(faces, hints=None, pBar=None): + """Given a set of faces, find the face-center vertices from the subdivision + + Parameters + ---------- + faces : [[int, ...], ...] + The list of lists of vertex indices making up faces + hints : set(int) or None + An optional list of star points that were part of the original un-subdivided mesh + pBar : QProgressDialog or None + An optional progress bar + + Returns + ------- + : set(int) + The vertices that were added to the centers of the faces as part of the subdivision process + """ + if pBar is not None: + pBar.setLabelText("Crawling Edges") + pBar.show() + QApplication.processEvents() + + eNeigh = buildEdgeDict(faces) + if hints is None: + borders = getBorders(faces) + hints = buildUnsubdivideHints( + faces, eNeigh, borders, pBar=None + ) # purposely no PBar + centerDel, fail = getFaceCenterDel(faces, eNeigh, hints, pBar=pBar) + assert not fail, "Could not detect subdivided topology with the provided hints" + + if pBar is not None: + pBar.close() + + return centerDel + + +def unSubdivide( + faces, + verts, + uvFaces, + uvs, + hints=None, + repositionVerts: bool = True, + pinBorders: bool = False, + pBar=None, +): + """Given a mesh representation (faces and vertices) remove the edges added + by a subdivision, and optionally reposition the verts + + Parameters + ---------- + faces : [[int, ...], ...] + The list of lists of vertex indices making up faces + verts : np.array + The N*3 array of vertices + uvFaces : [[int, ...], ...] + The list of lists of uv indices making up uvFaces + uvs : np.array + The N*2 array of uvs + hints : set(int) or None + An optional list of star points that were part of the original un-subdivided mesh + repositionVerts : bool + Whether to also estimate the positions of the un-subdivided verts. Defaults to True + pinBorders : bool + Whether or not to pin the border vertices. Defaults to False + pBar : QProgressDialog or None + An optional progress bar + + Returns + ------- + : [[vIdx ...], ...] + The un-subdivided face structure + : np.array + The un-subdivided vertex positions + : [[vIdx ...], ...] or None + The un-subdivided uv-face structure if it exists + : np.array or None + The un-subdivided uvs if they exist + """ + if pBar is not None: + pBar.show() + pBar.setLabelText("Finding Neighbors") + QApplication.processEvents() + + eNeigh = buildEdgeDict(faces) + + if hints is None: + if pBar is not None: + pBar.show() + pBar.setLabelText("Getting Hints") + QApplication.processEvents() + borders = getBorders(faces) + hints = buildUnsubdivideHints( + faces, eNeigh, borders, pBar=None + ) # Purposely no PBar + + if pBar is not None: + pBar.setLabelText("Crawling Edges") + QApplication.processEvents() + centerDel, fail = getFaceCenterDel(faces, eNeigh, hints, pBar=pBar) + assert not fail, "Could not detect subdivided topology with the provided hints" + + if pBar is not None: + pBar.setLabelText("Deleting Edges") + QApplication.processEvents() + uFaces, uUVFaces, dWings, uvDWings = deleteCenters( + faces, uvFaces, centerDel, pBar=pBar + ) + + uVerts = verts + uUVs = uvs + if repositionVerts: + # Handle the verts + if pBar is not None: + pBar.setLabelText("Building Correspondences") + QApplication.processEvents() + neighDict, uNeighDict, edgeDict, uEdgeDict, borders = buildLayeredNeighborDicts( + faces, uFaces, dWings + ) + pinned = set(borders) if pinBorders else [] + if pBar is not None: + pBar.setLabelText("Fixing Vert Positions") + QApplication.processEvents() + uVerts = fixVerts( + faces, + uFaces, + verts, + neighDict, + uNeighDict, + edgeDict, + uEdgeDict, + borders, + pinned, + pBar=pBar, + ) + + # Handle the UVs + if uvFaces is not None: + ( + uvNeighDict, + uUVNeighDict, + uvEdgeDict, + uvUEdgeDict, + uvBorders, + ) = buildLayeredNeighborDicts(uvFaces, uUVFaces, uvDWings) + uvPinned = getUVPins(faces, borders, uvFaces, uvBorders, pinBorders) + if pBar is not None: + pBar.setLabelText("Fixing UV Positions") + QApplication.processEvents() + uUVs = fixVerts( + uvFaces, + uUVFaces, + uvs, + uvNeighDict, + uUVNeighDict, + uvEdgeDict, + uvUEdgeDict, + uvBorders, + uvPinned, + pBar=pBar, + ) + + rFaces, rVerts, rUVFaces, rUVs = collapse(uFaces, uVerts, uUVFaces, uUVs) + + if pBar is not None: + pBar.close() + + return rFaces, rVerts, rUVFaces, rUVs + + +#################################################################### +# Handle .smpx files here # +#################################################################### + + +def _ussmpx(faces, verts, uvFaces, uvs, pBar=None): + """Unsubdivide a simplex""" + pbPrint(pBar, message="Finding Neighbors") + eNeigh = buildEdgeDict(faces) + + pbPrint(pBar, message="Getting Hints") + borders = getBorders(faces) + hints = buildUnsubdivideHints( + faces, eNeigh, borders, pBar=None + ) # Purposely no PBar + + pbPrint(pBar, message="Crawling Edges") + centerDel, fail = getFaceCenterDel(faces, eNeigh, hints, pBar=pBar) + assert not fail, "Could not detect subdivided topology with the provided hints" + + pbPrint(pBar, message="Deleting Edges") + uFaces, uUVFaces, _, _ = deleteCenters(faces, uvFaces, centerDel, pBar=pBar) + + pbPrint(pBar, message="Collapsing Indexes") + uVerts = verts + uUVs = uvs + + rFaces, rVerts, rUVFaces, rUVs = collapse(uFaces, uVerts, uUVFaces, uUVs) + + if pBar is not None: + pBar.close() + else: + print("Done") + + return rFaces, rVerts, rUVFaces, rUVs + + +def _applyShapePrefix(shapePrefix, jsString: str) -> str: + """Apply a prefix to the shape names. + For XSI where shape names must be unique + """ + if shapePrefix is not None: + d = json.loads(jsString) + if d["encodingVersion"] > 1: + for shape in d["shapes"]: + shape["name"] = shapePrefix + shape["name"] + else: + d["shapes"] = [shapePrefix + i for i in d["shapes"]] + jsString = json.dumps(d) + return jsString + + +def _unflattenFaces(flatFaces, counts): + faces = [] + ptr = 0 + for c in counts: + faces.append(flatFaces[ptr : ptr + c].tolist()) + ptr += c + return faces + + +def unsubdivideSimplex(inPath, outPath, shapePrefix=None, pBar=None) -> None: + """Unsubdivde a .smpx file on disk + + Parameters + ---------- + inPath : str + The input .smpx file path + outPath : str + The output .smpx file path + shapePrefix : str or None + An optional string to prefix the shape names with + pBar : QProgressDialog or None + An optional progress bar + """ + if np is None: + raise RuntimeError("Un-Subdivide requires numpy, and it is not available here") + + pbPrint(pBar, message="Loading smpx") + jsString, counts, verts, flatFaces, uvs, flatUVFaces = readSmpx(inPath) + jsString = _applyShapePrefix(shapePrefix, jsString) + + js = json.loads(jsString) + name = js["systemName"] + + faces = _unflattenFaces(flatFaces, counts) + uvFaces = None + if flatUVFaces is not None: + uvFaces = _unflattenFaces(flatUVFaces, counts) + + pbPrint(pBar, message="Unsubdividing") + verts = verts.swapaxes(0, 1) + uFaces, uVerts, uUVFaces, uUVs = _ussmpx(faces, verts, uvFaces, uvs, pBar=pBar) + + pbPrint(pBar, message="Exporting") + buildSmpx( + outPath, + uVerts.swapaxes(0, 1), + uFaces, + jsString, + name, + uvs=uUVs, + uvFaces=uUVFaces, + ) diff --git a/src/python/simplexui/commands/uvTransfer.py b/src/python/simplexui/commands/uvTransfer.py index ff69dfbd..45f69d2e 100644 --- a/src/python/simplexui/commands/uvTransfer.py +++ b/src/python/simplexui/commands/uvTransfer.py @@ -1,977 +1,976 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# pylint: disable=invalid-name -import numpy as np - -INF = float("inf") -EPS = 1e-7 - - -######################## -# Mean Value Coords -######################## - - -def _lerp(idx, corners, p): - # If I'm here, I know that p lies on the line - # between the corners at idx and idx+1. - # Return the lerp value - ip = (idx + 1) % len(corners) - c1 = corners[idx] - c2 = corners[ip] - base = c1 - c2 - proj = np.dot(p - c2, base) - b = proj / (base**2).sum() - ret = np.zeros(len(corners)) - ret[idx] = b - ret[ip] = 1.0 - b - return ret - - -def mvc(corners, p, tol=EPS): - """Get the Mean Value Coordinates of a point p in the polygon defined by corners - - Parameters - ---------- - corners : [int, ...] - A list of corner indices - p : np.array - The array of points - tol : float - A small tolerance value, defaulting to the global EPS - - Returns - ------- - : list - The normalized list of barycentric weights for this point in the polygon - - """ - spokes = corners - p - spokeLens = (spokes * spokes).sum(axis=1) - - # Check if p is on top of a vertex - for i, v in enumerate(spokeLens): - if v < tol: - bary = np.zeros(len(corners)) - bary[i] = 1.0 - return bary - - rspokes = np.roll(spokes, -1, axis=0) - areas = np.cross(spokes, rspokes) * 0.5 - dots = (spokes * rspokes).sum(axis=1) - - for i, v in enumerate(areas): - if v < tol and dots[i] < 0.0: - return _lerp(i, corners, p) - - spokeLens = spokeLens**0.5 - rspokeLens = np.roll(spokeLens, -1) - t = areas / (spokeLens * rspokeLens + dots) - rawWeights = (np.roll(t, 1) + t) / spokeLens - return rawWeights / sum(rawWeights) - - -def mmvc(rawFaces, points, samples, uvToFace, tol=EPS): - """Multi-MeanValueCoords - Get the MVC's of many points over many faces using numpy - - Parameters - ---------- - rawFaces : [[int, ...], ...] - A list of face index lists - points : np.array - A numpy array of uv points - samples : np.array - A numpy array of points to get the barycentric coords for - uvToFace : {int: [int, ...], ...} - A dictionary of UV indices to a list of Face Indices - tol : float - A small tolerance value, defaulting to the global EPS - - Returns - ------- - : {fc: (wh, barys)} - A dictionary of face counts to a tuple containing the indices of the face indices - checked, and the barycentric coords - - """ - uvIdxs, faceIdxs = sorted(zip(*list(uvToFace.items()))) - uvIdxs = np.array(uvIdxs) - faces = [rawFaces[fi] for fi in faceIdxs] - faceLens = np.array([len(i) for i in faces]) - fcs = np.unique(faceLens) - - out = {} - for fc in fcs: - wh = np.where(faceLens == fc)[0] - cIdxs = np.array([faces[i] for i in wh]) - barys = np.zeros(cIdxs.shape) - qIdxs = uvIdxs[wh] - out[fc] = (qIdxs, barys) # edit barys in-place - - # cornerses is a [f, fc, 2] array - # where f is num of faces, fc is verts per face, and 2 because uv is 2d - # pts is [f, 2] array where f is num faces - cornerses = points[cIdxs] - pts = samples[qIdxs] - - # Get the "spokes" from the query point to the face corners - # and get their squared lengths - spokes = cornerses - pts[:, None, :] - spokeLens2 = (spokes * spokes).sum(axis=-1) - - # Handle any samples that are directly on top of a corner - # (where the spoke length is zero) - zeros = np.any(spokeLens2 < tol, axis=1) - onPoint = np.where(zeros) - offPoint = np.where(~zeros) - if onPoint[0].size: - pIdxs = spokeLens2[onPoint].argmin(axis=1) - barys[onPoint, pIdxs] = 1.0 - - if not offPoint[0].size: - continue - - # Ignore those points that have already been computed - idxs = offPoint[0] - spokes = spokes[offPoint] - spokeLens2 = spokeLens2[offPoint] - - # Get the signed area of each triangle created by the spokes - # and the dot product for the angle between each - rspokes = np.roll(spokes, -1, axis=1) - areas = np.cross(spokes, rspokes) * 0.5 - dots = (spokes * rspokes).sum(axis=-1) - - # Handle any samples that are directly on an edge between - # two corners. (Where the triangle area is 0, and the - # dot product is negative) - aareas = abs(areas) - zeros = aareas < tol - ndots = dots < 0.0 - zn = zeros & ndots - toLerp = np.any(zn, axis=-1) - onEdge = np.where(toLerp) - offEdge = np.where(~toLerp) - if onEdge[0].size: - subIdx = idxs[onEdge] - pIdxs = zn[onEdge].argmax(axis=-1) - xIdxs = (pIdxs + 1) % fc - - ppIdxs = cIdxs[subIdx, pIdxs] - xpIdxs = cIdxs[subIdx, xIdxs] - - cp = pts[subIdx] - pp = points[ppIdxs] - xp = points[xpIdxs] - - bases = pp - xp - diff = cp - xp - proj = (bases * diff).sum(axis=-1) - b = proj / (bases**2).sum(axis=1) - mb = 1.0 - b - barys[subIdx, pIdxs] = b - barys[subIdx, xIdxs] = mb - - if not offEdge[0].size: - continue - - idxs = idxs[offEdge] - dots = dots[offEdge] - areas = areas[offEdge] - spokeLens2 = spokeLens2[offEdge] - spokeLens = spokeLens2**0.5 - rspokeLens = np.roll(spokeLens, -1, axis=1) - - t = areas / (spokeLens * rspokeLens + dots) - rawWeights = (np.roll(t, -1, axis=1) + t) / spokeLens - b = rawWeights / rawWeights.sum(axis=1)[..., None] - barys[idxs] = b - - ret = {} - for qIdxs, barys in out.values(): - for qi, b in zip(qIdxs, barys): - ret[qi] = (uvToFace[qi], b) - - return ret - - -######################## -# Sweep algorithm -######################## - - -def triArea(a, b, c): - """Use the cross-ish-product to find the area of the triangle - Depending on the winding, the area could be positive or negative - - Parameters - ---------- - a : [float, float, float] - The first point - b : [float, float, float] - The second point - c : [float, float, float] - The third point - - Returns - ------- - : float - The area of the triangle - - """ - return (a[0] * (c[1] - b[1]) + b[0] * (a[1] - c[1]) + c[0] * (b[1] - a[1])) / 2.0 - - -def pointInTri(p, a, b, c, tol=EPS): - """Check that the point is inside the triangle - - Parameters - ---------- - p : [float, float, float] - The point to check is inside the triangle - a : [float, float, float] - The first point of the triangle - b : [float, float, float] - The second point of the triangle - c : [float, float, float] - The third point of the triangle - tol : float - A small tolerance value, defaulting to the global EPS - - Returns - ------- - : bool - Whether the point is inside the given triangle - - """ - area = abs(triArea(a, b, c)) - chis = abs(triArea(p, b, c)) + abs(triArea(a, p, c)) + abs(triArea(a, b, p)) - return abs(area - chis) < tol - - -def sweep(qPoints, uvs, tris, pBar=None): - """Get what triangle each query point is inside - - Runs a sweep-line algorithm to check for points in triangles. - Imagine a uv layout, and a bunch of points on the layout. - Now imagine a vertical line sweeping across the uv plane from the left - to the right. The sorted u-values of certain properties then handled - one-by-one. - - The algorithm keeps track of what triangles the vertical line is currently intersecting. - - * This is done by storing the min and max u-values for each triangle. - * If the u-value encountered is the min for a tri, that tri is added to the list - * If the u-value encountered is the max for a tri, that tri is removed from the list - - Then, each time a query point is encountered, it only has to check the intersection list - - Parameters - ---------- - qPoints : np.array - The points to get barycentric coordinates of - uvs : np.array - The UVs we're searching - tris : np.array - The Nx3 array of triangle indices - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - : {int: int, ...} - A dictionary of out[pointIndex] = triIndex - : set() - A set containing the pointIndexes that weren't found in a triangle - - """ - tpts = uvs[tris] # [tIdx, abc, xy] - - allmxs, allmns = tpts.max(axis=1), tpts.min(axis=1) - ymxs, ymns = allmxs[:, 1], allmns[:, 1] - mxs, mns = allmxs[:, 0], allmns[:, 0] - qpx = qPoints[:, 0] - qpSIdxs, mxSIdxs, mnSIdxs = np.argsort(qpx), np.argsort(mxs), np.argsort(mns) - qpSIdx, mxSIdx, mnSIdx = 0, 0, 0 - qpIdx, mxIdx, mnIdx = qpSIdxs[qpSIdx], mxSIdxs[mxSIdx], mnSIdxs[mnSIdx] - qp, mx, mn = qpx[qpIdx], mxs[mxIdx], mns[mnIdx] - - out = {} - missing = set() - - if pBar is not None: - pBar.setValue(0) - allVals = len(qpSIdxs) + len(mxSIdxs) + len(mnSIdxs) - pBar.setRange(0, allVals) - pBar.setLabelText("Sweeping ...") - from Qt.QtWidgets import QApplication - - QApplication.processEvents() - - # skip any triangles to the left of the first query point - # The algorithm will stop once we hit the last query point - skip = [qp > m for m in mxs] - activeTris = [False for _ in mxs] # For fast membership testing - atSet = set() # For fast iteration - - while True: - if pBar is not None: - cVal = qpSIdx + mnSIdx + mxSIdx - if cVal % 1283 == 0: # Just a random prime - pBar.setValue(cVal) - pBar.setLabelText("Sweeping ...\n{0}/{1}".format(cVal, allVals)) - QApplication.processEvents() - if pBar.wasCanceled(): - raise RuntimeError("Cancelled!") - - if mn <= mx and mn <= qp: - # Always add triangles first if possible - aTriIdx = mnSIdxs[mnSIdx] - if not activeTris[aTriIdx] and not skip[aTriIdx]: - activeTris[aTriIdx] = True - atSet.add(aTriIdx) - - mnSIdx += 1 - if mnSIdx != len(mnSIdxs): - mnIdx = mnSIdxs[mnSIdx] - mn = mns[mnIdx] - else: - mn = mxs[mxSIdxs[-1]] + 1 - - elif qp <= mx: - # Check query points between adding and removing - qPoint = qPoints[qpIdx] - yv = qPoint[1] - # A linear search is faster than more complex collections here - for t in atSet: - if ymns[t] <= yv <= ymxs[t]: - a, b, c = uvs[tris[t]] - if pointInTri(qPoint, a, b, c): - out[qpIdx] = t - break - else: - missing.add(qpIdx) - - qpSIdx += 1 - if qpSIdx == len(qpSIdxs): - break - qpIdx = qpSIdxs[qpSIdx] - qp = qpx[qpIdx] - - else: - # always remove triangles last - aTriIdx = mxSIdxs[mxSIdx] - if activeTris[aTriIdx] and not skip[aTriIdx]: - activeTris[aTriIdx] = False - atSet.remove(aTriIdx) - - mxSIdx += 1 - if mxSIdx != len(mxSIdxs): - mxIdx = mxSIdxs[mxSIdx] - mx = mxs[mxIdx] - - return out, missing - - -######################## -# Triangulation -######################## - - -def inBox(point, mxs, mns): - """Check if a point is inside the bounding box - - Parameters - ---------- - point : np.array - A 3d point - mxs : np.array - The max values of all the coordinates per axis - mns : np.array - The min values of all the coordinates per axis - - Returns - ------- - : bool - Whether the point is in the given bounding box - - """ - return np.all(point <= mxs) and np.all(point >= mns) - - -def _isEar(a, b, c, polygon, tol=EPS): - """Check if the points a,b,c of the polygon could be their own triangle""" - signedArea = triArea(a, b, c) - - # Check that the triange is wound the correct way. - if signedArea > 0: - return False - - # Check that the triangle has non-zero area - # we already know the area is negative from above - if -signedArea < tol: - return False - - # Check that none of the other points in the polygon are contained in triangle - for p in polygon: - if p not in (a, b, c): - if pointInTri(p, a, b, c, tol=tol): - return False - return True - - -def earclip(idxs, verts): - """Simple earclipping algorithm - For a polygon with n points it will return n-2 triangles. - - Parameters - ---------- - idxs : [int, ...] - The indices that make up a polygon - verts : np.array - All the UV positions - - Returns - ------- - : [[int, int, int], ...] - A list of triangle indices - - """ - earVerts = [] - tris = [] - idxs = list(idxs) - polygon = [tuple(verts[i]) for i in idxs] - - numPts = len(polygon) - for i in range(numPts): - prev = polygon[i - 2] - cur = polygon[i - 1] - nxt = polygon[i] - if _isEar(prev, cur, nxt, polygon): - earVerts.append(cur) - - while earVerts and numPts >= 3: - ear = earVerts.pop() - i = polygon.index(ear) - pi, ni = i - 1, (i + 1) % numPts - - prevPt = polygon[pi] - nxtPt = polygon[ni] - tris.append((idxs[pi], idxs[i], idxs[ni])) - - polygon.pop(i) - idxs.pop(i) - numPts -= 1 - if numPts > 3: - prePrePt = polygon[i - 2] - nxtNxtPt = polygon[(i + 1) % numPts] - - groups = [ - (prePrePt, prevPt, nxtPt, polygon), - (prevPt, nxtPt, nxtNxtPt, polygon), - ] - - for group in groups: - p = group[1] - if _isEar(*group): - if p not in earVerts: - earVerts.append(p) - elif p in earVerts: - earVerts.remove(p) - return tris - - -def triangulateUVs(faces, uvs): - """Take a set of uvFaces and uv points, and triangulate it - - Parameters - ---------- - faces : [[int, ...], ...] - A uv face structure - uvs : np.array - The uv positions - - Returns - ------- - : np.array - A Nx3 array of uv indexes making triangles - : [int, ...] - A list where the index is the triangle index, and the value is the face index - : {(int, int): int, ...} - A dictionary of border edge pairs to border faces - - """ - uvs = np.array(uvs) - triMap = [] - tris = [] - borderFaceMap = {} - # Build the naiive triangulation - # And get the borders while I'm looping - for f, face in enumerate(faces): - for i in range(2, len(face)): - triMap.append(f) - tris.append((face[0], face[i - 1], face[i])) - - for i in range(len(face)): - ep = (face[i - 1], face[i]) - if ep[0] > ep[1]: - ep = (ep[1], ep[0]) - if ep in borderFaceMap: - borderFaceMap.pop(ep) - else: - borderFaceMap[ep] = f - - tris = np.array(tris) - tuvs = uvs[tris] - - # Get the area using a 2d-pseudo-cross-product - a = tuvs[:, 0] - tuvs[:, 1] - b = tuvs[:, 2] - tuvs[:, 1] - signedAreas = a[:, 0] * b[:, 1] - a[:, 1] * b[:, 0] - - # Things with negative area are wound backwards in this case - negArea = np.where(signedAreas < 0) - - # Do we need to retriangulate any of the polys - retri = sorted({triMap[i] for i in negArea[0]}) - if retri: - tris = tris.tolist() - tmpFaceMap = {} - # tmpFaceMap is only good while we re-triangulate - # it has to be re-built after - for t, f in enumerate(triMap): - tmpFaceMap.setdefault(f, []).append(t) - - for f in retri[::-1]: - good = earclip(faces[f], uvs) - tidxs = tmpFaceMap[f] - tris[tidxs[0] : tidxs[-1] + 1] = good - triMap[tidxs[0] : tidxs[-1] + 1] = [f] * len(good) - tris = np.array(tris) - - return tris, triMap, borderFaceMap - - -######################## -# UV Transferring -######################## - -MISSING = {} - - -def cooSparseMul(M, v): - """Multply the sparse matrix by the vector - - Parameters - ---------- - M : SparseMatrix - My own sparse matrix representation - v : np.array - A vector - - Returns - ------- - : np.array - The result of the multiplication - """ - # Using scipy is at least 2x faster than the - # pure numpy implementation. But even that - # is about 5x faster than using a dense matrix - try: - from scipy import sparse - except ImportError: - # No scipy, use numpy - # The numpy universal function (ufunc) .add.at() - # makes this possible without any python looping - row, col, val, shape = M - v = v.swapaxes(0, -2) - vshape = val.shape + tuple([1] * (len(v) - 1)) - val = val.reshape(vshape) - out = np.zeros(shape[:1] + v.shape[1:]) - np.add.at(out, row, val * v[col]) - out = out.swapaxes(0, -2) - else: - row, col, val, shape = M - spM = sparse.coo_matrix((val, (row, col)), shape=shape) - spM = sparse.csr_matrix(spM) - if len(v.shape) == 3: - out = np.array([spM.dot(frm) for frm in v]) - else: - out = spM.dot(v) - return out - - -def getUvCorrelation(samples, points, faces, tol=0.0001, handleMissing=True, pBar=None): - """Get the per-face correlation of the sample points in uv space. - - Parameters - ---------- - samples : np.array - The sample points - points : np.array - The background points - faces : [[int, ...], ...] - The Face list - tol : float - A small tolerance value, defaulting to the global EPS - handleMissing : - (Default value = True) - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - : {idx: (idx, [float, ...]), ...} - A dictionary from the sample index to a faceIdx/cornerWeights pair - - """ - tris, triMap, borderMap = triangulateUVs(faces, points) - swept, missing = sweep(samples, points, tris, pBar=pBar) - uvToFace = {uvI: triMap[tI] for uvI, tI in swept.items()} - # import __main__ - # __main__.__dict__.update(locals()) - # raise RuntimeError("STOPPIT") - if pBar is not None: - pBar.setValue(0) - pBar.setRange(0, len(uvToFace)) - pBar.setLabelText("Calculate Mean Value Coords") - from Qt.QtWidgets import QApplication - - QApplication.processEvents() - - # mvcDict = {} - # for i, (uvIdx, faceIdx) in enumerate(uvToFace.iteritems()): - # if pBar is not None: - # pBar.setValue(i) - # pBar.setLabelText("Calculate Mean Value Coords\n{0}/{1}".format(i, len(uvToFace))) - # QApplication.processEvents() - # uv = samples[uvIdx] - # corners = points[faces[faceIdx]] - # bary = mvc(corners, uv) - # mvcDict[uvIdx] = (faceIdx, bary) - - mvcDict = mmvc(faces, points, samples, uvToFace) - - # find the closest border - # Just use a brute-force search - # ... for now - - if missing and handleMissing: - if pBar is not None: - pBar.setValue(0) - pBar.setLabelText("Handle Missing") - QApplication.processEvents() - tol = tol**2 - bk = list(borderMap.keys()) - borders = np.array(bk) - bStarts = points[borders[:, 0]] - bEnds = points[borders[:, 1]] - - bDiff = bEnds - bStarts - bLens2 = (bDiff * bDiff).sum(axis=1) - for mIdx in missing: - mp = samples[mIdx] - dSquared = _mpCheck(bStarts, bDiff, bLens2, mp) - minIdx = np.argmin(dSquared) - if dSquared[mIdx] < tol: - faceIdx = borderMap[bk[minIdx]] - corners = points[faces[faceIdx]] - bary = mvc(corners, mp) - mvcDict[mIdx] = (faceIdx, bary) - - return mvcDict - - -def _mpCheck(a, d, dr2, pt): - """Point to Multi-segment squared distance. Uses pre-computed values""" - lerp = ((pt - a) * d).sum(axis=1) / dr2 - lerp = np.clip(lerp, 0, 1) - xy = (lerp[:, None] * d) + a - _dxy = xy - pt - return (_dxy * _dxy).sum(axis=1) - - -def getVertCorrelation( - sUvFaces, sUvs, tVertFaces, tUvFaces, tUvs, tol=0.0001, pBar=None -): - """Build the vertex position correlation between two meshes - by looking through UV-space. Handle combining multiple uvs - per vertex, and having samples that live outside the coverage - - Parameters - ---------- - sUvFaces : [[int, ...], ...] - The source UVFace list - sUvs : np.array - The source UV positions - tVertFaces : [[int, ...], ...] - The target vertex face list - tUvFaces : [[int, ...], ...] - The target UV face list - tUvs : np.array - The target UV positions - tol : float - A small tolerance value, defaulting to the global EPS - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - : {idx: (idx, [float, ...]), ...} - A dictionary from the sample index to a faceIdx/cornerWeights pair - """ - childUvToVert = {} - cNumVerts = -1 - for vF, uvF in zip(tVertFaces, tUvFaces): - for vIdx, uvIdx in zip(vF, uvF): - cNumVerts = max(cNumVerts, vIdx) - childUvToVert[uvIdx] = vIdx - - mvcUvDict = getUvCorrelation(tUvs, sUvs, sUvFaces, tol=tol, pBar=pBar) - - mvcVertDict = {} - for uvIdx in range(len(tUvs)): - if uvIdx not in mvcUvDict: - continue - mvcVertDict.setdefault(childUvToVert[uvIdx], []).append(mvcUvDict[uvIdx]) - - missing = set(range(cNumVerts)) - mvcVertDict.keys() - if missing: - import time - - v = time.time() - print( - "Missing correspondences found. Stored in uvTransfer.MISSING[{0}]".format(v) - ) - MISSING[v] = missing - - return mvcVertDict - - -def applyTransfer(parVerts, parFaces, correlation, outputSize): - """Given a vertex corelation, a driver, and driven points, - Apply the driver deformation to the driven. This could be - for one frame, or many frames - - Parameters - ---------- - parVerts : np.array - The "parent" vertex positions - parFaces : [[int, ...], ...] - The "parent" face list - correlation : {int: [int, ...], ...} - A dict correlatng the vertIdx to its possible correlations - outputSize : (int, ...) - A numpy output size tuple - - Returns - ------- - : np.array - The new vertex positions - - """ - if len(parVerts.shape) == 2: - parVerts = parVerts[None, ...] - - rows, cols, vals = [], [], [] - for cVertIdx, corrPoss in correlation.items(): - if len(corrPoss) == 1: - pFaceIdx, bary = corrPoss[0] - else: - # pick the one with the highest sum-of-squares - x = [sum(bary * bary) for _, bary in corrPoss] - idx = x.index(max(x)) - pFaceIdx, bary = corrPoss[idx] - - rows.extend([cVertIdx] * len(bary)) - vals.extend(bary) - cols.extend(parFaces[pFaceIdx]) - M = np.array(rows), np.array(cols), np.array(vals), (outputSize, parVerts.shape[-2]) - out = cooSparseMul(M, parVerts) - return out - - -def uvTransfer( - srcFaces, - srcUvFaces, - srcVerts, - srcUvs, - tarFaces, - tarUvFaces, - tarVerts, - tarUvs, - tol=0.0001, - pBar=None, -): - """A helper function that transfers pre-loaded data. - The source data will be transferred onto the tar data - - Parameters - ---------- - srcFaces : [[int, ...], ...] - The source vertex face list - srcUvFaces : [[int, ...], ...] - The source uv face list - srcUvs : np.array - The source UV positions - tarFaces : [[int, ...], ...] - The target vertex face list - tarUvFaces : [[int, ...], ...] - The target uv face list - tarUvs : np.array - The Target UV Positions - srcVerts : np.array - The source Vertex positions - tarVerts : np.array - The target Vertex positions - tol : float - A small tolerance value, defaulting to the global EPS - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - : np.array - The new target vert positions - - """ - corr = getVertCorrelation( - srcUvFaces, srcUvs, tarFaces, tarUvFaces, tarUvs, tol=tol, pBar=pBar - ) - if pBar is not None: - pBar.setValue(0) - pBar.setLabelText("Apply Transfer") - from Qt.QtWidgets import QApplication - - QApplication.processEvents() - - return applyTransfer(srcVerts, srcFaces, corr, len(tarVerts)) - - -def uvTransferLoad( - srcPath, tarPath, srcUvSet="default", tarUvSet="default", tol=0.0001, pBar=None -): - """Transfer the shape from the source to the target through uv space - Return the data needed to write out the result - - Parameters - ---------- - srcPath : str - The source mesh path (obj, abc, or smpx) - tarPath : str - The target mesh path (obj, abc, or smpx) - srcUvSet : str - The name of the uv set to use on the source - tarUvSet : str - The name of the uv set to use on the target - tol : float - A small tolerance value, defaulting to the global EPS - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - : np.array - The target vertex positions - : list - The target vertex faces - : np.array - The target uvs - : list - The target uv faces - - """ - from . import alembicCommon as abc - from .mesh import Mesh - - if srcPath.endswith(".abc") or srcPath.endswith(".smpx"): - src = Mesh.loadAbc(srcPath, ensureWinding=False) - srcVerts = abc.getSampleArray(abc.getMesh(srcPath)) - elif srcPath.endswith(".obj"): - src = Mesh.loadObj(srcPath, ensureWinding=False) - srcVerts = np.array(src.vertArray) - - if tarPath.endswith(".abc"): - tar = Mesh.loadAbc(tarPath, ensureWinding=False) - elif tarPath.endswith(".obj"): - tar = Mesh.loadObj(tarPath, ensureWinding=False) - - srcFaces = src.faceVertArray - srcUvFaces = src.uvFaceMap[srcUvSet] - srcUvs = np.array(src.uvMap[srcUvSet]) - - tarFaces = tar.faceVertArray - tarUvFaces = tar.uvFaceMap[tarUvSet] - tarUvs = np.array(tar.uvMap[tarUvSet]) - oldTarVerts = np.array(tar.vertArray) - tarVerts = uvTransfer( - srcFaces, - srcUvFaces, - srcVerts, - srcUvs, - tarFaces, - tarUvFaces, - oldTarVerts, - tarUvs, - tol=tol, - pBar=pBar, - ) - - return tarVerts, tarFaces, tarUvs, tarUvFaces - - -def uvTransferFiles( - srcPath, - tarPath, - outAbcPath, - srcUvSet="default", - tarUvSet="default", - tol=0.0001, - pBar=None, -): - """Transfer the shape from the source to the target through uv space - and write out the result - - Parameters - ---------- - srcPath : str - The source mesh path (obj, abc, or smpx) - tarPath : str - The target mesh path (obj, abc, or smpx) - outAbcPath : str - The path to the output .abc file - srcUvSet : str - The name of the uv set to use on the source - tarUvSet : str - The name of the uv set to use on the target - tol : float - A small tolerance value, defaulting to the global EPS - pBar : QProgressDialog, optional - An optional progress dialog - - Returns - ------- - - """ - from . import alembicCommon as abc - - tarVerts, tarFaces, tarUvs, tarUvFaces = uvTransferLoad( - srcPath, tarPath, srcUvSet=srcUvSet, tarUvSet=tarUvSet, tol=tol, pBar=pBar - ) - abc.buildAbc(outAbcPath, tarVerts, tarFaces, uvs=tarUvs, uvFaces=tarUvFaces) +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +from __future__ import annotations + +import numpy as np + +INF = float("inf") +EPS = 1e-7 + + +######################## +# Mean Value Coords +######################## + + +def _lerp(idx: int, corners, p): + # If I'm here, I know that p lies on the line + # between the corners at idx and idx+1. + # Return the lerp value + ip = (idx + 1) % len(corners) + c1 = corners[idx] + c2 = corners[ip] + base = c1 - c2 + proj = np.dot(p - c2, base) + b = proj / (base**2).sum() + ret = np.zeros(len(corners)) + ret[idx] = b + ret[ip] = 1.0 - b + return ret + + +def mvc(corners, p, tol=EPS): + """Get the Mean Value Coordinates of a point p in the polygon defined by corners + + Parameters + ---------- + corners : [int, ...] + A list of corner indices + p : np.array + The array of points + tol : float + A small tolerance value, defaulting to the global EPS + + Returns + ------- + : list + The normalized list of barycentric weights for this point in the polygon + + """ + spokes = corners - p + spokeLens = (spokes * spokes).sum(axis=1) + + # Check if p is on top of a vertex + for i, v in enumerate(spokeLens): + if v < tol: + bary = np.zeros(len(corners)) + bary[i] = 1.0 + return bary + + rspokes = np.roll(spokes, -1, axis=0) + areas = np.cross(spokes, rspokes) * 0.5 + dots = (spokes * rspokes).sum(axis=1) + + for i, v in enumerate(areas): + if v < tol and dots[i] < 0.0: + return _lerp(i, corners, p) + + spokeLens = spokeLens**0.5 + rspokeLens = np.roll(spokeLens, -1) + t = areas / (spokeLens * rspokeLens + dots) + rawWeights = (np.roll(t, 1) + t) / spokeLens + return rawWeights / sum(rawWeights) + + +def mmvc(rawFaces, points, samples, uvToFace, tol=EPS): + """Multi-MeanValueCoords + Get the MVC's of many points over many faces using numpy + + Parameters + ---------- + rawFaces : [[int, ...], ...] + A list of face index lists + points : np.array + A numpy array of uv points + samples : np.array + A numpy array of points to get the barycentric coords for + uvToFace : {int: [int, ...], ...} + A dictionary of UV indices to a list of Face Indices + tol : float + A small tolerance value, defaulting to the global EPS + + Returns + ------- + : {fc: (wh, barys)} + A dictionary of face counts to a tuple containing the indices of the face indices + checked, and the barycentric coords + + """ + uvIdxs, faceIdxs = sorted(zip(*list(uvToFace.items()))) + uvIdxs = np.array(uvIdxs) + faces = [rawFaces[fi] for fi in faceIdxs] + faceLens = np.array([len(i) for i in faces]) + fcs = np.unique(faceLens) + + out = {} + for fc in fcs: + wh = np.where(faceLens == fc)[0] + cIdxs = np.array([faces[i] for i in wh]) + barys = np.zeros(cIdxs.shape) + qIdxs = uvIdxs[wh] + out[fc] = (qIdxs, barys) # edit barys in-place + + # cornerses is a [f, fc, 2] array + # where f is num of faces, fc is verts per face, and 2 because uv is 2d + # pts is [f, 2] array where f is num faces + cornerses = points[cIdxs] + pts = samples[qIdxs] + + # Get the "spokes" from the query point to the face corners + # and get their squared lengths + spokes = cornerses - pts[:, None, :] + spokeLens2 = (spokes * spokes).sum(axis=-1) + + # Handle any samples that are directly on top of a corner + # (where the spoke length is zero) + zeros = np.any(spokeLens2 < tol, axis=1) + onPoint = np.where(zeros) + offPoint = np.where(~zeros) + if onPoint[0].size: + pIdxs = spokeLens2[onPoint].argmin(axis=1) + barys[onPoint, pIdxs] = 1.0 + + if not offPoint[0].size: + continue + + # Ignore those points that have already been computed + idxs = offPoint[0] + spokes = spokes[offPoint] + spokeLens2 = spokeLens2[offPoint] + + # Get the signed area of each triangle created by the spokes + # and the dot product for the angle between each + rspokes = np.roll(spokes, -1, axis=1) + areas = np.cross(spokes, rspokes) * 0.5 + dots = (spokes * rspokes).sum(axis=-1) + + # Handle any samples that are directly on an edge between + # two corners. (Where the triangle area is 0, and the + # dot product is negative) + aareas = abs(areas) + zeros = aareas < tol + ndots = dots < 0.0 + zn = zeros & ndots + toLerp = np.any(zn, axis=-1) + onEdge = np.where(toLerp) + offEdge = np.where(~toLerp) + if onEdge[0].size: + subIdx = idxs[onEdge] + pIdxs = zn[onEdge].argmax(axis=-1) + xIdxs = (pIdxs + 1) % fc + + ppIdxs = cIdxs[subIdx, pIdxs] + xpIdxs = cIdxs[subIdx, xIdxs] + + cp = pts[subIdx] + pp = points[ppIdxs] + xp = points[xpIdxs] + + bases = pp - xp + diff = cp - xp + proj = (bases * diff).sum(axis=-1) + b = proj / (bases**2).sum(axis=1) + mb = 1.0 - b + barys[subIdx, pIdxs] = b + barys[subIdx, xIdxs] = mb + + if not offEdge[0].size: + continue + + idxs = idxs[offEdge] + dots = dots[offEdge] + areas = areas[offEdge] + spokeLens2 = spokeLens2[offEdge] + spokeLens = spokeLens2**0.5 + rspokeLens = np.roll(spokeLens, -1, axis=1) + + t = areas / (spokeLens * rspokeLens + dots) + rawWeights = (np.roll(t, -1, axis=1) + t) / spokeLens + b = rawWeights / rawWeights.sum(axis=1)[..., None] + barys[idxs] = b + + ret = {} + for qIdxs, barys in out.values(): + for qi, b in zip(qIdxs, barys): + ret[qi] = (uvToFace[qi], b) + + return ret + + +######################## +# Sweep algorithm +######################## + + +def triArea(a, b, c): + """Use the cross-ish-product to find the area of the triangle + Depending on the winding, the area could be positive or negative + + Parameters + ---------- + a : [float, float, float] + The first point + b : [float, float, float] + The second point + c : [float, float, float] + The third point + + Returns + ------- + : float + The area of the triangle + + """ + return (a[0] * (c[1] - b[1]) + b[0] * (a[1] - c[1]) + c[0] * (b[1] - a[1])) / 2.0 + + +def pointInTri(p, a, b, c, tol=EPS): + """Check that the point is inside the triangle + + Parameters + ---------- + p : [float, float, float] + The point to check is inside the triangle + a : [float, float, float] + The first point of the triangle + b : [float, float, float] + The second point of the triangle + c : [float, float, float] + The third point of the triangle + tol : float + A small tolerance value, defaulting to the global EPS + + Returns + ------- + : bool + Whether the point is inside the given triangle + + """ + area = abs(triArea(a, b, c)) + chis = abs(triArea(p, b, c)) + abs(triArea(a, p, c)) + abs(triArea(a, b, p)) + return abs(area - chis) < tol + + +def sweep(qPoints, uvs, tris, pBar=None): + """Get what triangle each query point is inside + + Runs a sweep-line algorithm to check for points in triangles. + Imagine a uv layout, and a bunch of points on the layout. + Now imagine a vertical line sweeping across the uv plane from the left + to the right. The sorted u-values of certain properties then handled + one-by-one. + + The algorithm keeps track of what triangles the vertical line is currently intersecting. + + * This is done by storing the min and max u-values for each triangle. + * If the u-value encountered is the min for a tri, that tri is added to the list + * If the u-value encountered is the max for a tri, that tri is removed from the list + + Then, each time a query point is encountered, it only has to check the intersection list + + Parameters + ---------- + qPoints : np.array + The points to get barycentric coordinates of + uvs : np.array + The UVs we're searching + tris : np.array + The Nx3 array of triangle indices + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + : {int: int, ...} + A dictionary of out[pointIndex] = triIndex + : set() + A set containing the pointIndexes that weren't found in a triangle + + """ + tpts = uvs[tris] # [tIdx, abc, xy] + + allmxs, allmns = tpts.max(axis=1), tpts.min(axis=1) + ymxs, ymns = allmxs[:, 1], allmns[:, 1] + mxs, mns = allmxs[:, 0], allmns[:, 0] + qpx = qPoints[:, 0] + qpSIdxs, mxSIdxs, mnSIdxs = np.argsort(qpx), np.argsort(mxs), np.argsort(mns) + qpSIdx, mxSIdx, mnSIdx = 0, 0, 0 + qpIdx, mxIdx, mnIdx = qpSIdxs[qpSIdx], mxSIdxs[mxSIdx], mnSIdxs[mnSIdx] + qp, mx, mn = qpx[qpIdx], mxs[mxIdx], mns[mnIdx] + + out = {} + missing = set() + + if pBar is not None: + pBar.setValue(0) + allVals = len(qpSIdxs) + len(mxSIdxs) + len(mnSIdxs) + pBar.setRange(0, allVals) + pBar.setLabelText("Sweeping ...") + from Qt.QtWidgets import QApplication + + QApplication.processEvents() + + # skip any triangles to the left of the first query point + # The algorithm will stop once we hit the last query point + skip = [qp > m for m in mxs] + activeTris = [False for _ in mxs] # For fast membership testing + atSet = set() # For fast iteration + + while True: + if pBar is not None: + cVal = qpSIdx + mnSIdx + mxSIdx + if cVal % 1283 == 0: # Just a random prime + pBar.setValue(cVal) + pBar.setLabelText(f"Sweeping ...\n{cVal}/{allVals}") + QApplication.processEvents() + if pBar.wasCanceled(): + raise RuntimeError("Cancelled!") + + if mn <= mx and mn <= qp: + # Always add triangles first if possible + aTriIdx = mnSIdxs[mnSIdx] + if not activeTris[aTriIdx] and not skip[aTriIdx]: + activeTris[aTriIdx] = True + atSet.add(aTriIdx) + + mnSIdx += 1 + if mnSIdx != len(mnSIdxs): + mnIdx = mnSIdxs[mnSIdx] + mn = mns[mnIdx] + else: + mn = mxs[mxSIdxs[-1]] + 1 + + elif qp <= mx: + # Check query points between adding and removing + qPoint = qPoints[qpIdx] + yv = qPoint[1] + # A linear search is faster than more complex collections here + for t in atSet: + if ymns[t] <= yv <= ymxs[t]: + a, b, c = uvs[tris[t]] + if pointInTri(qPoint, a, b, c): + out[qpIdx] = t + break + else: + missing.add(qpIdx) + + qpSIdx += 1 + if qpSIdx == len(qpSIdxs): + break + qpIdx = qpSIdxs[qpSIdx] + qp = qpx[qpIdx] + + else: + # always remove triangles last + aTriIdx = mxSIdxs[mxSIdx] + if activeTris[aTriIdx] and not skip[aTriIdx]: + activeTris[aTriIdx] = False + atSet.remove(aTriIdx) + + mxSIdx += 1 + if mxSIdx != len(mxSIdxs): + mxIdx = mxSIdxs[mxSIdx] + mx = mxs[mxIdx] + + return out, missing + + +######################## +# Triangulation +######################## + + +def inBox(point, mxs, mns) -> bool: + """Check if a point is inside the bounding box + + Parameters + ---------- + point : np.array + A 3d point + mxs : np.array + The max values of all the coordinates per axis + mns : np.array + The min values of all the coordinates per axis + + Returns + ------- + : bool + Whether the point is in the given bounding box + + """ + return bool(np.all(point <= mxs) and np.all(point >= mns)) + + +def _isEar(a, b, c, polygon, tol=EPS) -> bool: + """Check if the points a,b,c of the polygon could be their own triangle""" + signedArea = triArea(a, b, c) + + # Check that the triange is wound the correct way. + if signedArea > 0: + return False + + # Check that the triangle has non-zero area + # we already know the area is negative from above + if -signedArea < tol: + return False + + # Check that none of the other points in the polygon are contained in triangle + for p in polygon: + if p not in (a, b, c): + if pointInTri(p, a, b, c, tol=tol): + return False + return True + + +def earclip(idxs, verts): + """Simple earclipping algorithm + For a polygon with n points it will return n-2 triangles. + + Parameters + ---------- + idxs : [int, ...] + The indices that make up a polygon + verts : np.array + All the UV positions + + Returns + ------- + : [[int, int, int], ...] + A list of triangle indices + + """ + earVerts = [] + tris = [] + idxs = list(idxs) + polygon = [tuple(verts[i]) for i in idxs] + + numPts = len(polygon) + for i in range(numPts): + prev = polygon[i - 2] + cur = polygon[i - 1] + nxt = polygon[i] + if _isEar(prev, cur, nxt, polygon): + earVerts.append(cur) + + while earVerts and numPts >= 3: + ear = earVerts.pop() + i = polygon.index(ear) + pi, ni = i - 1, (i + 1) % numPts + + prevPt = polygon[pi] + nxtPt = polygon[ni] + tris.append((idxs[pi], idxs[i], idxs[ni])) + + polygon.pop(i) + idxs.pop(i) + numPts -= 1 + if numPts > 3: + prePrePt = polygon[i - 2] + nxtNxtPt = polygon[(i + 1) % numPts] + + groups = [ + (prePrePt, prevPt, nxtPt, polygon), + (prevPt, nxtPt, nxtNxtPt, polygon), + ] + + for group in groups: + p = group[1] + if _isEar(*group): + if p not in earVerts: + earVerts.append(p) + elif p in earVerts: + earVerts.remove(p) + return tris + + +def triangulateUVs(faces, uvs): + """Take a set of uvFaces and uv points, and triangulate it + + Parameters + ---------- + faces : [[int, ...], ...] + A uv face structure + uvs : np.array + The uv positions + + Returns + ------- + : np.array + A Nx3 array of uv indexes making triangles + : [int, ...] + A list where the index is the triangle index, and the value is the face index + : {(int, int): int, ...} + A dictionary of border edge pairs to border faces + + """ + uvs = np.array(uvs) + triMap = [] + tris = [] + borderFaceMap = {} + # Build the naiive triangulation + # And get the borders while I'm looping + for f, face in enumerate(faces): + for i in range(2, len(face)): + triMap.append(f) + tris.append((face[0], face[i - 1], face[i])) + + for i in range(len(face)): + ep = (face[i - 1], face[i]) + if ep[0] > ep[1]: + ep = (ep[1], ep[0]) + if ep in borderFaceMap: + borderFaceMap.pop(ep) + else: + borderFaceMap[ep] = f + + tris = np.array(tris) + tuvs = uvs[tris] + + # Get the area using a 2d-pseudo-cross-product + a = tuvs[:, 0] - tuvs[:, 1] + b = tuvs[:, 2] - tuvs[:, 1] + signedAreas = a[:, 0] * b[:, 1] - a[:, 1] * b[:, 0] + + # Things with negative area are wound backwards in this case + negArea = np.where(signedAreas < 0) + + # Do we need to retriangulate any of the polys + retri = sorted({triMap[i] for i in negArea[0]}) + if retri: + tris = tris.tolist() + tmpFaceMap = {} + # tmpFaceMap is only good while we re-triangulate + # it has to be re-built after + for t, f in enumerate(triMap): + tmpFaceMap.setdefault(f, []).append(t) + + for f in retri[::-1]: + good = earclip(faces[f], uvs) + tidxs = tmpFaceMap[f] + tris[tidxs[0] : tidxs[-1] + 1] = good + triMap[tidxs[0] : tidxs[-1] + 1] = [f] * len(good) + tris = np.array(tris) + + return tris, triMap, borderFaceMap + + +######################## +# UV Transferring +######################## + +MISSING = {} + + +def cooSparseMul(M, v): + """Multply the sparse matrix by the vector + + Parameters + ---------- + M : SparseMatrix + My own sparse matrix representation + v : np.array + A vector + + Returns + ------- + : np.array + The result of the multiplication + """ + # Using scipy is at least 2x faster than the + # pure numpy implementation. But even that + # is about 5x faster than using a dense matrix + try: + from scipy import sparse + except ImportError: + # No scipy, use numpy + # The numpy universal function (ufunc) .add.at() + # makes this possible without any python looping + row, col, val, shape = M + v = v.swapaxes(0, -2) + vshape = val.shape + tuple([1] * (len(v) - 1)) + val = val.reshape(vshape) + out = np.zeros(shape[:1] + v.shape[1:]) + np.add.at(out, row, val * v[col]) + out = out.swapaxes(0, -2) + else: + row, col, val, shape = M + spM = sparse.coo_matrix((val, (row, col)), shape=shape) + spM = sparse.csr_matrix(spM) + if len(v.shape) == 3: + out = np.array([spM.dot(frm) for frm in v]) + else: + out = spM.dot(v) + return out + + +def getUvCorrelation(samples, points, faces, tol=0.0001, handleMissing=True, pBar=None): + """Get the per-face correlation of the sample points in uv space. + + Parameters + ---------- + samples : np.array + The sample points + points : np.array + The background points + faces : [[int, ...], ...] + The Face list + tol : float + A small tolerance value, defaulting to the global EPS + handleMissing : + (Default value = True) + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + : {idx: (idx, [float, ...]), ...} + A dictionary from the sample index to a faceIdx/cornerWeights pair + + """ + tris, triMap, borderMap = triangulateUVs(faces, points) + swept, missing = sweep(samples, points, tris, pBar=pBar) + uvToFace = {uvI: triMap[tI] for uvI, tI in swept.items()} + # import __main__ + # __main__.__dict__.update(locals()) + # raise RuntimeError("STOPPIT") + if pBar is not None: + pBar.setValue(0) + pBar.setRange(0, len(uvToFace)) + pBar.setLabelText("Calculate Mean Value Coords") + from Qt.QtWidgets import QApplication + + QApplication.processEvents() + + # mvcDict = {} + # for i, (uvIdx, faceIdx) in enumerate(uvToFace.iteritems()): + # if pBar is not None: + # pBar.setValue(i) + # pBar.setLabelText("Calculate Mean Value Coords\n{0}/{1}".format(i, len(uvToFace))) + # QApplication.processEvents() + # uv = samples[uvIdx] + # corners = points[faces[faceIdx]] + # bary = mvc(corners, uv) + # mvcDict[uvIdx] = (faceIdx, bary) + + mvcDict = mmvc(faces, points, samples, uvToFace) + + # find the closest border + # Just use a brute-force search + # ... for now + + if missing and handleMissing: + if pBar is not None: + pBar.setValue(0) + pBar.setLabelText("Handle Missing") + QApplication.processEvents() + tol = tol**2 + bk = list(borderMap.keys()) + borders = np.array(bk) + bStarts = points[borders[:, 0]] + bEnds = points[borders[:, 1]] + + bDiff = bEnds - bStarts + bLens2 = (bDiff * bDiff).sum(axis=1) + for mIdx in missing: + mp = samples[mIdx] + dSquared = _mpCheck(bStarts, bDiff, bLens2, mp) + minIdx = np.argmin(dSquared) + if dSquared[mIdx] < tol: + faceIdx = borderMap[bk[minIdx]] + corners = points[faces[faceIdx]] + bary = mvc(corners, mp) + mvcDict[mIdx] = (faceIdx, bary) + + return mvcDict + + +def _mpCheck(a, d, dr2, pt): + """Point to Multi-segment squared distance. Uses pre-computed values""" + lerp = ((pt - a) * d).sum(axis=1) / dr2 + lerp = np.clip(lerp, 0, 1) + xy = (lerp[:, None] * d) + a + _dxy = xy - pt + return (_dxy * _dxy).sum(axis=1) + + +def getVertCorrelation( + sUvFaces, sUvs, tVertFaces, tUvFaces, tUvs, tol=0.0001, pBar=None +): + """Build the vertex position correlation between two meshes + by looking through UV-space. Handle combining multiple uvs + per vertex, and having samples that live outside the coverage + + Parameters + ---------- + sUvFaces : [[int, ...], ...] + The source UVFace list + sUvs : np.array + The source UV positions + tVertFaces : [[int, ...], ...] + The target vertex face list + tUvFaces : [[int, ...], ...] + The target UV face list + tUvs : np.array + The target UV positions + tol : float + A small tolerance value, defaulting to the global EPS + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + : {idx: (idx, [float, ...]), ...} + A dictionary from the sample index to a faceIdx/cornerWeights pair + """ + childUvToVert = {} + cNumVerts = -1 + for vF, uvF in zip(tVertFaces, tUvFaces): + for vIdx, uvIdx in zip(vF, uvF): + cNumVerts = max(cNumVerts, vIdx) + childUvToVert[uvIdx] = vIdx + + mvcUvDict = getUvCorrelation(tUvs, sUvs, sUvFaces, tol=tol, pBar=pBar) + + mvcVertDict = {} + for uvIdx in range(len(tUvs)): + if uvIdx not in mvcUvDict: + continue + mvcVertDict.setdefault(childUvToVert[uvIdx], []).append(mvcUvDict[uvIdx]) + + missing = set(range(cNumVerts)) - mvcVertDict.keys() + if missing: + import time + + v = time.time() + print(f"Missing correspondences found. Stored in uvTransfer.MISSING[{v}]") + MISSING[v] = missing + + return mvcVertDict + + +def applyTransfer(parVerts, parFaces, correlation, outputSize: int): + """Given a vertex corelation, a driver, and driven points, + Apply the driver deformation to the driven. This could be + for one frame, or many frames + + Parameters + ---------- + parVerts : np.array + The "parent" vertex positions + parFaces : [[int, ...], ...] + The "parent" face list + correlation : {int: [int, ...], ...} + A dict correlatng the vertIdx to its possible correlations + outputSize : (int, ...) + A numpy output size tuple + + Returns + ------- + : np.array + The new vertex positions + + """ + if len(parVerts.shape) == 2: + parVerts = parVerts[None, ...] + + rows, cols, vals = [], [], [] + for cVertIdx, corrPoss in correlation.items(): + if len(corrPoss) == 1: + pFaceIdx, bary = corrPoss[0] + else: + # pick the one with the highest sum-of-squares + x = [sum(bary * bary) for _, bary in corrPoss] + idx = x.index(max(x)) + pFaceIdx, bary = corrPoss[idx] + + rows.extend([cVertIdx] * len(bary)) + vals.extend(bary) + cols.extend(parFaces[pFaceIdx]) + M = np.array(rows), np.array(cols), np.array(vals), (outputSize, parVerts.shape[-2]) + out = cooSparseMul(M, parVerts) + return out + + +def uvTransfer( + srcFaces, + srcUvFaces, + srcVerts, + srcUvs, + tarFaces, + tarUvFaces, + tarVerts, + tarUvs, + tol=0.0001, + pBar=None, +): + """A helper function that transfers pre-loaded data. + The source data will be transferred onto the tar data + + Parameters + ---------- + srcFaces : [[int, ...], ...] + The source vertex face list + srcUvFaces : [[int, ...], ...] + The source uv face list + srcUvs : np.array + The source UV positions + tarFaces : [[int, ...], ...] + The target vertex face list + tarUvFaces : [[int, ...], ...] + The target uv face list + tarUvs : np.array + The Target UV Positions + srcVerts : np.array + The source Vertex positions + tarVerts : np.array + The target Vertex positions + tol : float + A small tolerance value, defaulting to the global EPS + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + : np.array + The new target vert positions + + """ + corr = getVertCorrelation( + srcUvFaces, srcUvs, tarFaces, tarUvFaces, tarUvs, tol=tol, pBar=pBar + ) + if pBar is not None: + pBar.setValue(0) + pBar.setLabelText("Apply Transfer") + from Qt.QtWidgets import QApplication + + QApplication.processEvents() + + return applyTransfer(srcVerts, srcFaces, corr, len(tarVerts)) + + +def uvTransferLoad( + srcPath, tarPath, srcUvSet="default", tarUvSet="default", tol=0.0001, pBar=None +): + """Transfer the shape from the source to the target through uv space + Return the data needed to write out the result + + Parameters + ---------- + srcPath : str + The source mesh path (obj, abc, or smpx) + tarPath : str + The target mesh path (obj, abc, or smpx) + srcUvSet : str + The name of the uv set to use on the source + tarUvSet : str + The name of the uv set to use on the target + tol : float + A small tolerance value, defaulting to the global EPS + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + : np.array + The target vertex positions + : list + The target vertex faces + : np.array + The target uvs + : list + The target uv faces + + """ + from . import alembicCommon as abc + from .mesh import Mesh + + if srcPath.endswith(".abc") or srcPath.endswith(".smpx"): + src = Mesh.loadAbc(srcPath, ensureWinding=False) + srcVerts = abc.getSampleArray(abc.getMesh(srcPath)) + elif srcPath.endswith(".obj"): + src = Mesh.loadObj(srcPath, ensureWinding=False) + srcVerts = np.array(src.vertArray) + + if tarPath.endswith(".abc"): + tar = Mesh.loadAbc(tarPath, ensureWinding=False) + elif tarPath.endswith(".obj"): + tar = Mesh.loadObj(tarPath, ensureWinding=False) + + srcFaces = src.faceVertArray + srcUvFaces = src.uvFaceMap[srcUvSet] + srcUvs = np.array(src.uvMap[srcUvSet]) + + tarFaces = tar.faceVertArray + tarUvFaces = tar.uvFaceMap[tarUvSet] + tarUvs = np.array(tar.uvMap[tarUvSet]) + oldTarVerts = np.array(tar.vertArray) + tarVerts = uvTransfer( + srcFaces, + srcUvFaces, + srcVerts, + srcUvs, + tarFaces, + tarUvFaces, + oldTarVerts, + tarUvs, + tol=tol, + pBar=pBar, + ) + + return tarVerts, tarFaces, tarUvs, tarUvFaces + + +def uvTransferFiles( + srcPath, + tarPath, + outAbcPath, + srcUvSet: str = "default", + tarUvSet: str = "default", + tol: float = 0.0001, + pBar=None, +) -> None: + """Transfer the shape from the source to the target through uv space + and write out the result + + Parameters + ---------- + srcPath : str + The source mesh path (obj, abc, or smpx) + tarPath : str + The target mesh path (obj, abc, or smpx) + outAbcPath : str + The path to the output .abc file + srcUvSet : str + The name of the uv set to use on the source + tarUvSet : str + The name of the uv set to use on the target + tol : float + A small tolerance value, defaulting to the global EPS + pBar : QProgressDialog, optional + An optional progress dialog + + Returns + ------- + + """ + from . import alembicCommon as abc + + tarVerts, tarFaces, tarUvs, tarUvFaces = uvTransferLoad( + srcPath, tarPath, srcUvSet=srcUvSet, tarUvSet=tarUvSet, tol=tol, pBar=pBar + ) + abc.buildAbc(outAbcPath, tarVerts, tarFaces, uvs=tarUvs, uvFaces=tarUvFaces) diff --git a/src/python/simplexui/dragFilter.py b/src/python/simplexui/dragFilter.py index f53c4fdb..e15b11b7 100644 --- a/src/python/simplexui/dragFilter.py +++ b/src/python/simplexui/dragFilter.py @@ -1,299 +1,334 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - - -from Qt.QtCore import QEvent, QObject, QPoint, Qt, Signal -from Qt.QtGui import QCursor, QMouseEvent -from Qt.QtWidgets import QApplication - - -class DragFilter(QObject): - """Drag Event Filter - - This class provides an event filter that can be installed - on a Qt Widget to take click/drag events and give them slider - type interaction. - - Signals: - dragPressed = Signal() - Emitted when the drag passes the startSensitivity - dragTick (int, float): - NumberOfTicks, TickMultiplier - Emitted after dragPressed every time the drag passes dragSensitivity - dragReleased = Signal() - Emitted when the mouse is released after dragPressed - - Instance Options: - dragSensitivity(int): default=5 - The number of pixels the cursor has to travel to emit a tick - startSensitivity(int): default=10 - The number of pixels the cursor has to travel to enter into drag mode - cursorLock(bool): default=False - Whether the cursor returns to its start position every tick - wrapBoundary(int): default=10 - When wrapping around the current screen, this is the number of pixels - in from the edge of the screen that the cursor will appear - dragCursor(int): default=self.CURSOR_ARROWS - The cursor that will be displayed while dragging - CURSOR_ARROWS will show horizontal/vertical drag - CURSOR_BLANK will hid the cursor - dragButton(Qt.MouseButton): default=Qt.MouseButton.MiddleButton - The button that will kick off the drag behavior - fastModifier(Qt.KeyboardModifier): default=Qt.KeyboardModifier.ControlModifier - The modifier key that will cause the multiplier to emit with the signal - fastMultiplier(float): default=5.0 - The size of the multiplier - slowModifier(Qt.KeyboardModifier): default=Qt.KeyboardModifier.ShiftModifier - The modifier key that will cause the divisor to emit with the signal - slowDivisor(float): default=5.0 - The size of the divisor - isSpinbox(bool): default=False - This must be set to True if you are setting this as the handler - for a QSpinBox. The QSpinBox will tick every 0.25s as long as your mouse is - held down. This option turns that off - """ - - DRAG_ENABLED = 0 - DRAG_NONE = 0 - DRAG_HORIZONTAL = 1 - DRAG_VERTICAL = 2 - - CURSOR_NONE = 0 - CURSOR_BLANK = 1 - CURSOR_ARROWS = 2 - - dragTick = Signal(int, float) # NumberOfTicks, TickMultiplier - dragPressed = Signal() - dragReleased = Signal() - - def __init__(self, parent): - super(DragFilter, self).__init__(parent) - - self.dragSensitivity = 5 # pixels for one step - self.startSensitivity = 10 # pixel move to start the dragging - self.cursorLock = False - self.wrapBoundary = 10 # wrap when within boundary of screen edge - self.dragCursor = self.CURSOR_ARROWS - self.dragButton = Qt.MouseButton.MiddleButton - - # The QSpinbox has an option where, if you hold down the mouse button - # it will continually increment. This flag enables a workaround - # for that problem - self.isSpinbox = False - - self.fastModifier = Qt.KeyboardModifier.ControlModifier - self.slowModifier = Qt.KeyboardModifier.ShiftModifier - - self.fastMultiplier = 5.0 - self.slowDivisor = 5.0 - - # private vars - self._lastPos = QPoint() - self._leftover = 0 - self._dragStart = None - self._firstDrag = False - self._dragType = self.DRAG_NONE - self._overridden = False - self._screen = None - self._isDragging = False - - def doOverrideCursor(self): - """Change the cursor based on the current drag type""" - if self._overridden: - return - if self.dragCursor == self.CURSOR_BLANK: - QApplication.setOverrideCursor(Qt.CursorShape.BlankCursor) - elif self.dragCursor == self.CURSOR_ARROWS: - if self._dragType == self.DRAG_VERTICAL: - QApplication.setOverrideCursor(Qt.CursorShape.SizeVerCursor) - elif self._dragType == self.DRAG_HORIZONTAL: - QApplication.setOverrideCursor(Qt.CursorShape.SizeHorCursor) - - self._overridden = True - - def restoreOverrideCursor(self): - """Restore the cursor to the normal pointer""" - if not self._overridden: - return - QApplication.restoreOverrideCursor() - self._overridden = False - - def doDrag(self, o, e): - """Handle a mouse drag event - - Parameters - ---------- - o : QObject - The object that is being dragged - e : QEvent - The QEvent of the mouse drag - """ - if self._dragType == self.DRAG_HORIZONTAL: - delta = e.pos().x() - self._lastPos.x() - else: - delta = self._lastPos.y() - e.pos().y() - - self._leftover += delta - self._lastPos = e.pos() - - count = int(self._leftover / self.dragSensitivity) - if count: - mul = 1.0 - if e.modifiers() & self.fastModifier: - mul = self.fastMultiplier - elif e.modifiers() & self.slowModifier: - mul = 1.0 / self.slowDivisor - self.dragTick.emit(count, mul) - - # If we start off with negative leftover, then this modulo - # needs to return negative leftover steps - neg = self._leftover < 0 - self._leftover %= self.dragSensitivity - if neg and self._leftover > 0: - self._leftover -= self.dragSensitivity - - if self.cursorLock: - QCursor.setPos(self.mapToGlobal(self._dragStart)) - self._lastPos = self._dragStart - else: - r = self._screen - b = self.wrapBoundary - p = o.mapToGlobal(e.pos()) - - # when wrapping move to the other side in by 2*boundary - # so we don't loop the wrapping - if p.x() > r.right() - b: - p.setX(r.left() + 2 * b) - - if p.x() < r.left() + b: - p.setX(r.right() - 2 * b) - - if p.y() > r.bottom() - b: - p.setY(r.top() + 2 * b) - - if p.y() < r.top() + b: - p.setY(r.bottom() - 2 * b) - - if p != e.globalPos(): - QCursor.setPos(p) - self._lastPos = self.parent().mapFromGlobal(p) - self._leftover = 0 - - def startDrag(self, o, e): - """Start the drag event handling - - Parameters - ---------- - o : QObject - The object that is being dragged - e : QEvent - The QEvent of the mouse drag - """ - if self._dragStart is None: - self._dragStart = e.pos() - dtop = QApplication.desktop() - sn = dtop.screenNumber(o.mapToGlobal(e.pos())) - self._screen = dtop.availableGeometry(sn) - - if abs(e.x() - self._dragStart.x()) > self.startSensitivity: - self._dragType = self.DRAG_HORIZONTAL - elif abs(e.y() - self._dragStart.y()) > self.startSensitivity: - self._dragType = self.DRAG_VERTICAL - - if self._dragType: - self._leftover = 0 - self._lastPos = e.pos() - self._firstDrag = True - - self.dragPressed.emit() - self.doOverrideCursor() - - if self.isSpinbox: - if e.buttons() & self.dragButton: - # Send mouseRelease to spin buttons when dragging - # otherwise the spinbox will keep ticking. @longClickFix - # There's gotta be a better way to do this :-/ - mouseup = QMouseEvent( - QEvent.Type.MouseButtonRelease, - e.pos(), - self.dragButton, - e.buttons(), - e.modifiers(), - ) - QApplication.sendEvent(o, mouseup) - - def myendDrag(self, o, e): - """End the drag event handling. Can't call it endDrag because that's taken - - Parameters - ---------- - o : QObject - The object that is being dragged - e : QEvent - The QEvent of the mouse drag - """ - - # Only end dragging if it's *not* the first mouse release. See @longClickFix - if self._firstDrag and self.isSpinbox: - self._firstDrag = False - - elif self._dragType: - self.restoreOverrideCursor() - self._dragType = self.DRAG_NONE - self._lastPos = QPoint() - self._dragStart = None - self._screen = None - self.dragReleased.emit() - - def eventFilter(self, o, e): - """Overridden Qt eventFilter - - Parameters - ---------- - o : QObject - The object that is being dragged - e : QEvent - The QEvent of the mouse drag - """ - if hasattr(self, "DRAG_ENABLED"): - if e.type() == QEvent.Type.MouseMove: - if self._isDragging: - try: - if self._dragType != self.DRAG_NONE: - self.doDrag(o, e) - elif e.buttons() & self.dragButton: - self.startDrag(o, e) - except Exception: - # fix the cursor if there's an error during dragging - self.restoreOverrideCursor() - raise # re-raise the exception - return True - - elif e.type() == QEvent.Type.MouseButtonRelease: - self.myendDrag(o, e) - if e.button() & self.dragButton: - # Catch any dragbutton releases and handle them - self._isDragging = False - return True - - elif e.type() == QEvent.Type.MouseButtonPress: - if e.button() & self.dragButton: - # Catch any dragbutton presses and handle them - self._isDragging = True - return True - - return super(DragFilter, self).eventFilter(o, e) +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +from enum import Enum + +from Qt.QtCore import QEvent, QObject, QPoint, QRect, Qt, Signal +from Qt.QtGui import QCursor, QGuiApplication, QMouseEvent +from Qt.QtWidgets import QApplication, QWidget + + +class DragType(Enum): + DRAG_NONE = 0 + DRAG_HORIZONTAL = 1 + DRAG_VERTICAL = 2 + + +class DragCursor(Enum): + CURSOR_NONE = 0 + CURSOR_BLANK = 1 + CURSOR_ARROWS = 2 + + +class DragFilter(QObject): + """Drag Event Filter + + This class provides an event filter that can be installed + on a Qt Widget to take click/drag events and give them slider + type interaction. + + Signals: + dragPressed = Signal() + Emitted when the drag passes the startSensitivity + dragTick (int, float): + NumberOfTicks, TickMultiplier + Emitted after dragPressed every time the drag passes dragSensitivity + dragReleased = Signal() + Emitted when the mouse is released after dragPressed + + Instance Options: + dragSensitivity(int): default=5 + The number of pixels the cursor has to travel to emit a tick + startSensitivity(int): default=10 + The number of pixels the cursor has to travel to enter into drag mode + cursorLock(bool): default=False + Whether the cursor returns to its start position every tick + wrapBoundary(int): default=10 + When wrapping around the current screen, this is the number of pixels + in from the edge of the screen that the cursor will appear + dragCursor(int): default=self.CURSOR_ARROWS + The cursor that will be displayed while dragging + CURSOR_ARROWS will show horizontal/vertical drag + CURSOR_BLANK will hid the cursor + dragButton(Qt.MouseButton): default=Qt.MouseButton.MiddleButton + The button that will kick off the drag behavior + fastModifier(Qt.KeyboardModifier): default=Qt.KeyboardModifier.ControlModifier + The modifier key that will cause the multiplier to emit with the signal + fastMultiplier(float): default=5.0 + The size of the multiplier + slowModifier(Qt.KeyboardModifier): default=Qt.KeyboardModifier.ShiftModifier + The modifier key that will cause the divisor to emit with the signal + slowDivisor(float): default=5.0 + The size of the divisor + isSpinbox(bool): default=False + This must be set to True if you are setting this as the handler + for a QSpinBox. The QSpinBox will tick every 0.25s as long as your mouse is + held down. This option turns that off + """ + + DRAG_ENABLED: int = 0 + + dragTick: Signal = Signal(int, float) # NumberOfTicks, TickMultiplier + dragPressed: Signal = Signal() + dragReleased: Signal = Signal() + + def __init__(self, parent: QWidget | None) -> None: + self._parent: QWidget | None = parent # Hold onto this to keep the qwidget type + super().__init__(parent) + + self.dragSensitivity: int = 5 # pixels for one step + self.startSensitivity: int = 10 # pixel move to start the dragging + self.cursorLock: bool = False + self.wrapBoundary: int = 10 # wrap when within boundary of screen edge + self.dragCursor: DragCursor = DragCursor.CURSOR_ARROWS + self.dragButton: Qt.MouseButton = Qt.MouseButton.MiddleButton + + # The QSpinbox has an option where, if you hold down the mouse button + # it will continually increment. This flag enables a workaround + # for that problem + self.isSpinbox: bool = False + + self.fastModifier: Qt.KeyboardModifier = Qt.KeyboardModifier.ControlModifier + self.slowModifier: Qt.KeyboardModifier = Qt.KeyboardModifier.ShiftModifier + + self.fastMultiplier: float = 5.0 + self.slowDivisor: float = 5.0 + + # private vars + self._lastPos: QPoint | None = None + self._leftover: float = 0.0 + self._dragStart: QPoint | None = None + self._firstDrag: bool = False + self._dragType: DragType = DragType.DRAG_NONE + self._overridden: bool = False + self._screen: QRect | None = None + self._isDragging: bool = False + + def doOverrideCursor(self) -> None: + """Change the cursor based on the current drag type""" + if self._overridden: + return + if self.dragCursor == DragCursor.CURSOR_BLANK: + QApplication.setOverrideCursor(Qt.CursorShape.BlankCursor) + elif self.dragCursor == DragCursor.CURSOR_ARROWS: + if self._dragType == DragType.DRAG_VERTICAL: + QApplication.setOverrideCursor(Qt.CursorShape.SizeVerCursor) + elif self._dragType == DragType.DRAG_HORIZONTAL: + QApplication.setOverrideCursor(Qt.CursorShape.SizeHorCursor) + + self._overridden = True + + def restoreOverrideCursor(self) -> None: + """Restore the cursor to the normal pointer""" + if not self._overridden: + return + QApplication.restoreOverrideCursor() + self._overridden = False + + def doDrag(self, o: QObject, e: QMouseEvent) -> None: + """Handle a mouse drag event + + Parameters + ---------- + o : QObject + The object that is being dragged + e : QEvent + The QEvent of the mouse drag + """ + delta = 0.0 + epos = e.position().toPoint() + + if self._lastPos is not None: + if self._dragType == DragType.DRAG_HORIZONTAL: + delta = epos.x() - self._lastPos.x() + else: + delta = self._lastPos.y() - epos.y() + + self._leftover += delta + self._lastPos = epos + + count = int(self._leftover / self.dragSensitivity) + if count: + mul = 1.0 + if e.modifiers() & self.fastModifier: + mul = self.fastMultiplier + elif e.modifiers() & self.slowModifier: + mul = 1.0 / self.slowDivisor + self.dragTick.emit(count, mul) + + # If we start off with negative leftover, then this modulo + # needs to return negative leftover steps + neg = self._leftover < 0 + self._leftover %= self.dragSensitivity + if neg and self._leftover > 0: + self._leftover -= self.dragSensitivity + + if self.cursorLock: + if self._dragStart is not None and self._parent is not None: + QCursor.setPos(self._parent.mapToGlobal(self._dragStart)) + self._lastPos = self._dragStart + else: + if self._screen is None: + raise RuntimeError("Could not determine screen") + r = self._screen + b = self.wrapBoundary + p = e.globalPosition().toPoint() + + # when wrapping move to the other side in by 2*boundary + # so we don't loop the wrapping + if p.x() > r.right() - b: + p.setX(r.left() + 2 * b) + + if p.x() < r.left() + b: + p.setX(r.right() - 2 * b) + + if p.y() > r.bottom() - b: + p.setY(r.top() + 2 * b) + + if p.y() < r.top() + b: + p.setY(r.bottom() - 2 * b) + + if p != e.globalPosition().toPoint(): + QCursor.setPos(p) + self._leftover = 0 + par = self._parent + if par is not None: + self._lastPos = par.mapFromGlobal(p) + else: + self._lastPos = None + + def getCurrentScreen(self, o: QObject, global_pos: QPoint) -> QRect: + screen = QGuiApplication.screenAt(global_pos) + if screen is None: + if self._parent is not None: + screen = self._parent.windowHandle().screen() + else: + screen = QGuiApplication.primaryScreen() + self._screen = screen.availableGeometry() + return self._screen + + def startDrag(self, o: QObject, e: QMouseEvent) -> None: + """Start the drag event handling + + Parameters + ---------- + o : QObject + The object that is being dragged + e : QEvent + The QEvent of the mouse drag + """ + + epos = e.position().toPoint() + if self._dragStart is None: + self._dragStart = epos + global_pos = e.globalPosition().toPoint() + self.getCurrentScreen(o, global_pos) + + if abs(epos.x() - self._dragStart.x()) > self.startSensitivity: + self._dragType = DragType.DRAG_HORIZONTAL + elif abs(epos.y() - self._dragStart.y()) > self.startSensitivity: + self._dragType = DragType.DRAG_VERTICAL + + if self._dragType: + self._leftover = 0 + self._firstDrag = True + + self.dragPressed.emit() + self.doOverrideCursor() + + if self.isSpinbox and (e.buttons() & self.dragButton): + # Send mouseRelease to spin buttons when dragging + # otherwise the spinbox will keep ticking. @longClickFix + # There's gotta be a better way to do this :-/ + mouseup = QMouseEvent( + QEvent.Type.MouseButtonRelease, + epos, + self.dragButton, + e.buttons(), + e.modifiers(), + ) + QApplication.sendEvent(o, mouseup) + + def myendDrag(self, o: QObject, e: QMouseEvent) -> None: + """End the drag event handling. Can't call it endDrag because that's taken + + Parameters + ---------- + o : QObject + The object that is being dragged + e : QEvent + The QEvent of the mouse drag + """ + + # Only end dragging if it's *not* the first mouse release. See @longClickFix + if self._firstDrag and self.isSpinbox: + self._firstDrag = False + + elif self._dragType: + self.restoreOverrideCursor() + self._dragType = DragType.DRAG_NONE + self._lastPos = None + self._dragStart = None + self._screen = None + self.dragReleased.emit() + + def eventFilter(self, o: QObject, e: QEvent) -> bool: + """Overridden Qt eventFilter + + Parameters + ---------- + o : QObject + The object that is being dragged + e : QEvent + The QEvent of the mouse drag + """ + + # Qt keeps references around and I don't know how to properly remove them + # so If I don't do this check for DRAG_ENABLED then + # Qt starts throwing errors when the UI gets deleted + if hasattr(self, "DRAG_ENABLED"): + if e.type() == QEvent.Type.MouseMove: + assert isinstance(e, QMouseEvent) + if self._isDragging: + try: + if self._dragType != DragType.DRAG_NONE: + self.doDrag(o, e) + elif e.buttons() & self.dragButton: + self.startDrag(o, e) + except Exception: + # fix the cursor if there's an error during dragging + self.restoreOverrideCursor() + raise # re-raise the exception + return True + + elif e.type() == QEvent.Type.MouseButtonRelease: + assert isinstance(e, QMouseEvent) + self.myendDrag(o, e) + if e.button() & self.dragButton: + # Catch any dragbutton releases and handle them + self._isDragging = False + return True + + elif e.type() == QEvent.Type.MouseButtonPress: + assert isinstance(e, QMouseEvent) + if e.button() & self.dragButton: + # Catch any dragbutton presses and handle them + self._isDragging = True + return True + + return super().eventFilter(o, e) diff --git a/src/python/simplexui/falloffDialog.py b/src/python/simplexui/falloffDialog.py index 5a5a53da..469fd2f9 100644 --- a/src/python/simplexui/falloffDialog.py +++ b/src/python/simplexui/falloffDialog.py @@ -1,471 +1,504 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# This module imports QT from PyQt4, PySide or PySide2 -# Depending on what's available -import os -import re - -import Qt as QtLib -from .interfaceModel import FalloffDataModel -from .items import Falloff -from Qt import QtCompat -from Qt.QtCore import ( - QByteArray, - QLineF, - QPoint, - QPointF, - QRectF, - Qt, - Signal, -) -from Qt.QtGui import ( - QBrush, - QColor, - QPainter, - QPainterPath, - QPalette, - QPen, - QStandardItemModel, -) -from Qt.QtWidgets import ( - QDataWidgetMapper, - QDialog, - QInputDialog, - QMessageBox, - QSizePolicy, - QWidget, -) -from .utils import getNextName, getUiFile, Prefs - -AT_BLUR = os.environ.get("SIMPLEX_AT_BLUR") == "true" -NAME_CHECK = re.compile(r"[A-Za-z][\w.]*") - - -class CurveEditWidget(QWidget): - tangentUpdated = Signal(float, float) - - def __init__(self, parent): - super(CurveEditWidget, self).__init__(parent) - self.leftTan = None - self.rightTan = None - self._controlPoints = [ - QPointF(0, 1), - QPointF(0, 1), - QPointF(1, 0), - QPointF(1, 0), - ] - self.setTangent(leftTan=1 / 3.0, rightTan=2 / 3.0) - - self._activeControlPoint = None - self.mouseDrag = False - self.mousePress = QPoint() - self.startDragDistance = 20 - - self.canvasMargin = 16 - self.setMinimumHeight(2 * self.canvasMargin) - - self.bgColor = Qt.GlobalColor.white - self.lineColor = Qt.GlobalColor.black - self.limitColor = Qt.GlobalColor.gray - - def setTangent(self, leftTan=None, rightTan=None): - """Set the falloff tangents, clamped 0 to 1 - - Parameters - ---------- - leftTan : float - The x-value of the left tangent point - rightTan : float - The x-value of the right tangent point - """ - if leftTan is not None: - self.leftTan = max(min(leftTan, 1.0), 0.0) - self._controlPoints[1] = QPointF(self.leftTan, 1) - if rightTan is not None: - self.rightTan = max(min(rightTan, 1.0), 0.0) - self._controlPoints[2] = QPointF(self.rightTan, 0) - self.update() - - def mapToCanvas(self, point): - """Map a point from widget space to canvas space - The "canvas" is a 0-1 parameterized space, centered in the widget - The size of the canvas relative to the widget is dictated by the canvasMargin - - Parameters - ---------- - point : QPointF - The point to map - - Returns - ------- - : QPointF - The mapped point - """ - canvasWidth = self.width() - 2 * self.canvasMargin - canvasHeight = self.height() - 2 * self.canvasMargin - - x = point.x() * canvasWidth + self.canvasMargin - y = canvasHeight - point.y() * canvasHeight + self.canvasMargin - return QPointF(x, y) - - def mapFromCanvas(self, point): - """Map a point from canvas space to widget space - The "canvas" is a 0-1 parameterized space, centered in the widget - The size of the canvas relative to the widget is dictated by the canvasMargin - - Parameters - ---------- - point : QPointF - The point to map - - Returns - ------- - : QPointF - The mapped point - """ - canvasWidth = self.width() - 2 * self.canvasMargin - canvasHeight = self.height() - 2 * self.canvasMargin - - x = (point.x() - self.canvasMargin) / float(canvasWidth) - y = 1.0 - (point.y() - self.canvasMargin) / float(canvasHeight) - return QPointF(x, y) - - def _drawCleanLine(self, painter, p1, p2): - painter.drawLine(p1 + QPointF(0.5, 0.5), p2 + QPointF(0.5, 0.5)) - - def _paintBG(self, painter): - painter.save() - painter.setBrush(self.palette().color(QPalette.ColorRole.Background)) - painter.drawRect(0, 0, self.width(), self.height()) - painter.restore() - - def _paintLimits(self, painter): - painter.save() - # pen = QPen(self.limitColor) - baseColor = self.palette().color(QPalette.ColorRole.Base) - pen = QPen(baseColor) - pen.setWidth(1) - pen.setStyle(Qt.PenStyle.DashLine) - painter.setPen(pen) - self._drawCleanLine( - painter, self.mapToCanvas(QPoint(0, 0)), self.mapToCanvas(QPoint(1, 0)) - ) - self._drawCleanLine( - painter, self.mapToCanvas(QPoint(0, 1)), self.mapToCanvas(QPoint(1, 1)) - ) - painter.restore() - - def _paintPath(self, painter, p0, p1, p2, p3): - painter.save() - path = QPainterPath() - path.moveTo(p0) - path.cubicTo(p1, p2, p3) - # painter.strokePath(path, QPen(QBrush(self.lineColor), 2)) - foregroundColor = self.palette().color(QPalette.ColorRole.Foreground) - painter.strokePath(path, QPen(QBrush(foregroundColor), 2)) - painter.restore() - - def _paintTangents(self, painter, p0, p1, p2, p3): - # draw the tangent lines - foregroundColor = self.palette().color(QPalette.ColorRole.Foreground) - pen = QPen(foregroundColor) - pen.setWidth(1) - pen.setStyle(Qt.PenStyle.DashLine) - painter.setPen(pen) - painter.drawLine(p0, p1) - painter.drawLine(p3, p2) - - for i in range(len(self._controlPoints)): - online = self.indexIsRealPoint(i) - active = i == self._activeControlPoint - self.paintControlPoint(self._controlPoints[i], painter, online, active) - - def paintEvent(self, e): - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - self._paintBG(painter) - self._paintLimits(painter) - - # load the points - p0 = self.mapToCanvas(self._controlPoints[0]) - p1 = self.mapToCanvas(self._controlPoints[1]) - p2 = self.mapToCanvas(self._controlPoints[2]) - p3 = self.mapToCanvas(self._controlPoints[3]) - - self._paintPath(painter, p0, p1, p2, p3) - self._paintTangents(painter, p0, p1, p2, p3) - - def indexIsRealPoint(self, i): - return (i % 3) == 0 - - def paintControlPoint(self, point, painter, real, active): - pointSize = 4 - - if real: - pointSize = 6 - painter.setBrush(QColor(80, 80, 210, 150)) - elif active: - painter.setBrush(QColor(140, 140, 240, 255)) - else: - painter.setBrush(QColor(120, 120, 220, 255)) - - painter.setPen(QColor(50, 50, 50, 140)) - - painter.drawRect( - QRectF( - self.mapToCanvas(point).x() - pointSize + 0.5, - self.mapToCanvas(point).y() - pointSize + 0.5, - pointSize * 2, - pointSize * 2, - ) - ) - - def findControlPoint(self, point, tolerance=10): - d = QLineF(self.mapToCanvas(self._controlPoints[1]), point).length() - if d < tolerance: - return 1 - - d = QLineF(self.mapToCanvas(self._controlPoints[2]), point).length() - if d < tolerance: - return 2 - return None - - def mousePressEvent(self, e): - if e.button() == Qt.MouseButton.LeftButton: - self._activeControlPoint = self.findControlPoint(e.pos()) - if self._activeControlPoint is not None: - self.mouseMoveEvent(e) - self.mousePress = e.pos() - e.accept() - - def mouseReleaseEvent(self, e): - if e.button() == Qt.MouseButton.LeftButton: - self._activeControlPoint = None - self.mouseDrag = False - e.accept() - - def mouseMoveEvent(self, e): - if ( - not self.mouseDrag - and QPoint(self.mousePress - e.pos()).manhattanLength() - > self.startDragDistance - ): - self.mouseDrag = True - - p = self.mapFromCanvas(e.pos()) - if self.mouseDrag and self._activeControlPoint is not None: - if self._activeControlPoint == 1: - self.setTangent(leftTan=min(max(p.x(), 0.0), 1.0)) - else: - self.setTangent(rightTan=min(max(p.x(), 0.0), 1.0)) - self.tangentUpdated.emit(self.leftTan, self.rightTan) - self.update() - - -class FalloffDialog(QDialog): - """The ui for interacting with Falloffs""" - - def __init__(self, parent): - super(FalloffDialog, self).__init__(parent) - uiPath = getUiFile(__file__) - QtCompat.loadUi(uiPath, self) - self.parUI = parent - - self.simplex = None - self.parUI.simplexLoaded.connect(self.loadSimplex) - self.foModel = QStandardItemModel() - - self.uiFalloffWID = CurveEditWidget(self) - policy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) - # policy.setVerticalStretch(1) - self.uiFalloffWID.setSizePolicy(policy) - self.uiFalloffWID.tangentUpdated.connect(self.updateTangents) - - self.uiFalloffLAY.addWidget(self.uiFalloffWID) - - self._falloffMapper = QDataWidgetMapper(self) - self.uiShapeFalloffCBOX.currentIndexChanged.connect( - self._falloffMapper.setCurrentIndex - ) - - # Falloff connections - self.uiShapeFalloffNewBTN.clicked.connect(self.newFalloff) - self.uiShapeFalloffDuplicateBTN.clicked.connect(self.duplicateFalloff) - self.uiShapeFalloffDeleteBTN.clicked.connect(self.deleteFalloff) - self.uiShapeFalloffRenameBTN.clicked.connect(self.renameFalloff) - - self.uiFalloffMaxHandleSPN.valueChanged.connect(self.setLeftTangent) - self.uiFalloffMinHandleSPN.valueChanged.connect(self.setRightTangent) - self.loadSimplex() - - def updateTangents(self, leftTangent, rightTangent): - self.uiFalloffMaxHandleSPN.setValue(leftTangent) - self.uiFalloffMinHandleSPN.setValue(rightTangent) - - cbIdx = self.uiShapeFalloffCBOX.currentIndex() - - leftTanIdx = self.foModel.index(cbIdx, 5) - rightTanIdx = self.foModel.index(cbIdx, 4) - - self.foModel.setData(leftTanIdx, leftTangent, role=Qt.ItemDataRole.EditRole) - self.foModel.setData(rightTanIdx, rightTangent, role=Qt.ItemDataRole.EditRole) - - def setLeftTangent(self, val): - self.uiFalloffWID.setTangent(leftTan=val) - - def setRightTangent(self, val): - self.uiFalloffWID.setTangent(rightTan=val) - - def loadSimplex(self): - """Load the Simplex system from the parent UI""" - system = self.parUI.simplex - if system == self.simplex: - return - - if system is None: - self.foModel = QStandardItemModel() - self.uiShapeFalloffCBOX.setModel(self.foModel) - if self._falloffMapper is not None: - self._falloffMapper.clearMapping() - self._falloffMapper.setModel(self.foModel) - self.uiFalloffSettingsGRP.setEnabled(False) - return - else: - self.uiFalloffSettingsGRP.setEnabled(True) - - print("Setting System") - self.simplex = system - - # Populate Settings widgets - print("Populating") - self.foModel = FalloffDataModel(self.simplex, self) - self.simplex.falloffModels.append(self.foModel) - self.uiShapeFalloffCBOX.setModel(self.foModel) - self._falloffMapper.setModel(self.foModel) - - print("Adding Mappings") - currentIndex = "currentIndex" - if QtLib.IsPySide2 or QtLib.IsPyQt5: - currentIndex = QByteArray(bytes("Test", encoding="utf-8")) - - self._falloffMapper.addMapping(self.uiFalloffTypeCBOX, 1, currentIndex) - self._falloffMapper.addMapping(self.uiFalloffAxisCBOX, 2, currentIndex) - self._falloffMapper.addMapping(self.uiFalloffMinSPN, 3) - self._falloffMapper.addMapping(self.uiFalloffMinHandleSPN, 4) - self._falloffMapper.addMapping(self.uiFalloffMaxHandleSPN, 5) - self._falloffMapper.addMapping(self.uiFalloffMaxSPN, 6) - - print("Setting Index 0") - self.uiShapeFalloffCBOX.setCurrentIndex(0) - self._falloffMapper.setCurrentIndex(0) - - # Falloff Settings - def newFalloff(self): - """Create a new Falloff object""" - foNames = [f.name for f in self.simplex.falloffs] - tempName = getNextName("NewFalloff", foNames) - - newName, good = QInputDialog.getText( - self, "Rename Falloff", "Enter a new name for the Falloff", text=tempName - ) - if not good: - return - - if not NAME_CHECK.match(newName): - message = "Falloff name can only contain letters and numbers, and cannot start with a number" - QMessageBox.warning(self, "Warning", message) - return - - nn = getNextName(newName, foNames) - Falloff.createPlanar(nn, self.simplex, "X", 1.0, 0.66, 0.33, -1.0) - - def duplicateFalloff(self): - """Duplicate the selected falloff""" - if not self.simplex.falloffs: - self.newFalloff() - return - - idx = self.uiShapeFalloffCBOX.currentIndex() - if idx < 0: - return - - fo = self.simplex.falloffs[idx] - - foNames = [f.name for f in self.simplex.falloffs] - nn = getNextName(fo.name, foNames) - fo.duplicate(nn) - - def deleteFalloff(self): - """Delete the selected falloff""" - if not self.simplex.falloffs: - return - idx = self.uiShapeFalloffCBOX.currentIndex() - if idx < 0: - return - - fo = self.simplex.falloffs[idx] - fo.delete() - - def renameFalloff(self): - """Rename the selected falloff""" - if not self.simplex.falloffs: - return - idx = self.uiShapeFalloffCBOX.currentIndex() - if idx < 0: - return - fo = self.simplex.falloffs[idx] - foNames = [f.name for f in self.simplex.falloffs] - foNames.pop(idx) - - newName, good = QInputDialog.getText( - self, "Rename Falloff", "Enter a new name for the Falloff", text=fo.name - ) - if not good: - return - - if not NAME_CHECK.match(newName): - message = "Falloff name can only contain letters and numbers, and cannot start with a number" - QMessageBox.warning(self, "Warning", message) - return - - nn = getNextName(newName, foNames) - fo.name = nn - - def storeSettings(self): - """Store the UI settings for this dialog""" - pref = Prefs() - pref.recordProperty("fogeometry", self.saveGeometry()) - pref.save() - - def loadSettings(self): - """Load the UI settings for this dialog""" - pref = Prefs() - geo = pref.restoreProperty("fogeometry", None) - if geo is not None: - self.restoreGeometry(geo) - - def hideEvent(self, event): - """Override the hide event to store settings""" - self.storeSettings() - super(FalloffDialog, self).hideEvent(event) - - def showEvent(self, event): - """Override the show event to restore settings""" - super(FalloffDialog, self).showEvent(event) - self.loadSettings() +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +# This module imports QT from PyQt4, PySide or PySide2 +# Depending on what's available +from __future__ import annotations + +from typing import TYPE_CHECKING + +from Qt import QtCompat +from Qt.QtCore import ( + QByteArray, + QLineF, + QPoint, + QPointF, + QRectF, + Qt, + Signal, +) +from Qt.QtGui import ( + QBrush, + QColor, + QHideEvent, + QMouseEvent, + QPainter, + QPainterPath, + QPaintEvent, + QPalette, + QPen, + QShowEvent, +) +from Qt.QtWidgets import ( + QComboBox, + QDataWidgetMapper, + QDialog, + QDoubleSpinBox, + QGroupBox, + QInputDialog, + QLabel, + QMessageBox, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from .interfaceModel import FalloffDataModel +from .items.falloff import PlanarFalloff +from .utils import Prefs, getNextName, getUiFile + +if TYPE_CHECKING: + from .items import Simplex + from .simplexDialog import SimplexDialog + + +class CurveEditWidget(QWidget): + tangentUpdated = Signal(float, float) + + def __init__(self, parent: QWidget | None) -> None: + super().__init__(parent) + self.leftTan: float | None = None + self.rightTan: float | None = None + self._controlPoints: list[QPointF] = [ + QPointF(0, 1), + QPointF(0, 1), + QPointF(1, 0), + QPointF(1, 0), + ] + self.setTangent(leftTan=1 / 3.0, rightTan=2 / 3.0) + + self._activeControlPoint: int | None = None + self.mouseDrag: bool = False + self.mousePress: QPoint = QPoint() + self.startDragDistance: int = 20 + + self.canvasMargin: int = 16 + self.setMinimumHeight(2 * self.canvasMargin) + + self.bgColor: Qt.GlobalColor = Qt.GlobalColor.white + self.lineColor: Qt.GlobalColor = Qt.GlobalColor.black + self.limitColor: Qt.GlobalColor = Qt.GlobalColor.gray + + def setTangent( + self, leftTan: float | None = None, rightTan: float | None = None + ) -> None: + """Set the falloff tangents, clamped 0 to 1 + + Parameters + ---------- + leftTan : float + The x-value of the left tangent point + rightTan : float + The x-value of the right tangent point + """ + if leftTan is not None: + self.leftTan = max(min(leftTan, 1.0), 0.0) + self._controlPoints[1] = QPointF(self.leftTan, 1) + if rightTan is not None: + self.rightTan = max(min(rightTan, 1.0), 0.0) + self._controlPoints[2] = QPointF(self.rightTan, 0) + self.update() + + def mapToCanvas(self, point: QPointF) -> QPointF: + """Map a point from widget space to canvas space + The "canvas" is a 0-1 parameterized space, centered in the widget + The size of the canvas relative to the widget is dictated by the canvasMargin + + Parameters + ---------- + point : QPointF + The point to map + + Returns + ------- + : QPointF + The mapped point + """ + canvasWidth = self.width() - 2 * self.canvasMargin + canvasHeight = self.height() - 2 * self.canvasMargin + + x = point.x() * canvasWidth + self.canvasMargin + y = canvasHeight - point.y() * canvasHeight + self.canvasMargin + return QPointF(x, y) + + def mapFromCanvas(self, point: QPointF) -> QPointF: + """Map a point from canvas space to widget space + The "canvas" is a 0-1 parameterized space, centered in the widget + The size of the canvas relative to the widget is dictated by the canvasMargin + + Parameters + ---------- + point : QPointF + The point to map + + Returns + ------- + : QPointF + The mapped point + """ + canvasWidth = self.width() - 2 * self.canvasMargin + canvasHeight = self.height() - 2 * self.canvasMargin + + x = (point.x() - self.canvasMargin) / float(canvasWidth) + y = 1.0 - (point.y() - self.canvasMargin) / float(canvasHeight) + return QPointF(x, y) + + def _drawCleanLine(self, painter: QPainter, p1: QPointF, p2: QPointF) -> None: + painter.drawLine(p1 + QPointF(0.5, 0.5), p2 + QPointF(0.5, 0.5)) + + def _paintBG(self, painter: QPainter) -> None: + painter.save() + painter.setBrush(self.palette().color(QPalette.ColorRole.Window)) + painter.drawRect(0, 0, self.width(), self.height()) + painter.restore() + + def _paintLimits(self, painter: QPainter) -> None: + painter.save() + # pen = QPen(self.limitColor) + baseColor = self.palette().color(QPalette.ColorRole.Base) + pen = QPen(baseColor) + pen.setWidth(1) + pen.setStyle(Qt.PenStyle.DashLine) + painter.setPen(pen) + self._drawCleanLine( + painter, self.mapToCanvas(QPointF(0, 0)), self.mapToCanvas(QPointF(1, 0)) + ) + self._drawCleanLine( + painter, self.mapToCanvas(QPointF(0, 1)), self.mapToCanvas(QPointF(1, 1)) + ) + painter.restore() + + def _paintPath( + self, painter: QPainter, p0: QPointF, p1: QPointF, p2: QPointF, p3: QPointF + ) -> None: + painter.save() + path = QPainterPath() + path.moveTo(p0) + path.cubicTo(p1, p2, p3) + # painter.strokePath(path, QPen(QBrush(self.lineColor), 2)) + foregroundColor = self.palette().color(QPalette.ColorRole.WindowText) + painter.strokePath(path, QPen(QBrush(foregroundColor), 2)) + painter.restore() + + def _paintTangents( + self, painter: QPainter, p0: QPointF, p1: QPointF, p2: QPointF, p3: QPointF + ) -> None: + # draw the tangent lines + foregroundColor = self.palette().color(QPalette.ColorRole.WindowText) + pen = QPen(foregroundColor) + pen.setWidth(1) + pen.setStyle(Qt.PenStyle.DashLine) + painter.setPen(pen) + painter.drawLine(p0, p1) + painter.drawLine(p3, p2) + + for i in range(len(self._controlPoints)): + online = self.indexIsRealPoint(i) + active = i == self._activeControlPoint + self.paintControlPoint(self._controlPoints[i], painter, online, active) + + def paintEvent(self, e: QPaintEvent) -> None: + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + self._paintBG(painter) + self._paintLimits(painter) + + # load the points + p0 = self.mapToCanvas(self._controlPoints[0]) + p1 = self.mapToCanvas(self._controlPoints[1]) + p2 = self.mapToCanvas(self._controlPoints[2]) + p3 = self.mapToCanvas(self._controlPoints[3]) + + self._paintPath(painter, p0, p1, p2, p3) + self._paintTangents(painter, p0, p1, p2, p3) + + def indexIsRealPoint(self, i: int) -> bool: + return (i % 3) == 0 + + def paintControlPoint( + self, point: QPointF, painter: QPainter, real: bool, active: bool + ) -> None: + pointSize = 4 + + if real: + pointSize = 6 + painter.setBrush(QColor(80, 80, 210, 150)) + elif active: + painter.setBrush(QColor(140, 140, 240, 255)) + else: + painter.setBrush(QColor(120, 120, 220, 255)) + + painter.setPen(QColor(50, 50, 50, 140)) + + painter.drawRect( + QRectF( + self.mapToCanvas(point).x() - pointSize + 0.5, + self.mapToCanvas(point).y() - pointSize + 0.5, + pointSize * 2, + pointSize * 2, + ) + ) + + def findControlPoint(self, point: QPointF, tolerance: int = 10) -> int | None: + d = QLineF(self.mapToCanvas(self._controlPoints[1]), point).length() + if d < tolerance: + return 1 + + d = QLineF(self.mapToCanvas(self._controlPoints[2]), point).length() + if d < tolerance: + return 2 + return None + + def mousePressEvent(self, e: QMouseEvent) -> None: + if e.button() == Qt.MouseButton.LeftButton: + self._activeControlPoint = self.findControlPoint(e.position()) + if self._activeControlPoint is not None: + self.mouseMoveEvent(e) + self.mousePress = e.pos() + e.accept() + + def mouseReleaseEvent(self, e: QMouseEvent) -> None: + if e.button() == Qt.MouseButton.LeftButton: + self._activeControlPoint = None + self.mouseDrag = False + e.accept() + + def mouseMoveEvent(self, e: QMouseEvent) -> None: + if ( + not self.mouseDrag + and QPoint(self.mousePress - e.pos()).manhattanLength() + > self.startDragDistance + ): + self.mouseDrag = True + + p = self.mapFromCanvas(e.position()) + if self.mouseDrag and self._activeControlPoint is not None: + if self._activeControlPoint == 1: + self.setTangent(leftTan=min(max(p.x(), 0.0), 1.0)) + else: + self.setTangent(rightTan=min(max(p.x(), 0.0), 1.0)) + self.tangentUpdated.emit(self.leftTan, self.rightTan) + self.update() + + +class FalloffDialog(QDialog): + """The ui for interacting with Falloffs""" + + uiFalloffSettingsGRP: QGroupBox + uiShapeFalloffLBL: QLabel + uiShapeFalloffCBOX: QComboBox + uiShapeFalloffRenameBTN: QPushButton + uiShapeFalloffNewBTN: QPushButton + uiShapeFalloffDuplicateBTN: QPushButton + uiShapeFalloffDeleteBTN: QPushButton + uiFalloffLAY: QVBoxLayout + uiFalloffTypeCBOX: QComboBox + uiFalloffAxisCBOX: QComboBox + uiFalloffMaxSPN: QDoubleSpinBox + uiFalloffMaxHandleSPN: QDoubleSpinBox + uiFalloffMinHandleSPN: QDoubleSpinBox + uiFalloffMinSPN: QDoubleSpinBox + + def __init__(self, parent: SimplexDialog) -> None: + super().__init__(parent) + uiPath = getUiFile(__file__) + QtCompat.loadUi(uiPath, self) + self.parUI: SimplexDialog = parent + + self.simplex: Simplex | None = None + self.parUI.simplexLoaded.connect(self.loadSimplex) + self.foModel = FalloffDataModel(None, self) + + self.uiFalloffWID = CurveEditWidget(self) + policy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) + # policy.setVerticalStretch(1) + self.uiFalloffWID.setSizePolicy(policy) + self.uiFalloffWID.tangentUpdated.connect(self.updateTangents) + + self.uiFalloffLAY.addWidget(self.uiFalloffWID) + + self._falloffMapper = QDataWidgetMapper(self) + self.uiShapeFalloffCBOX.currentIndexChanged.connect( + self._falloffMapper.setCurrentIndex + ) + + # Falloff connections + self.uiShapeFalloffNewBTN.clicked.connect(self.newFalloff) + self.uiShapeFalloffDuplicateBTN.clicked.connect(self.duplicateFalloff) + self.uiShapeFalloffDeleteBTN.clicked.connect(self.deleteFalloff) + self.uiShapeFalloffRenameBTN.clicked.connect(self.renameFalloff) + + self.uiFalloffMaxHandleSPN.valueChanged.connect(self.setLeftTangent) + self.uiFalloffMinHandleSPN.valueChanged.connect(self.setRightTangent) + self.loadSimplex() + + def updateTangents(self, leftTangent: float, rightTangent: float) -> None: + self.uiFalloffMaxHandleSPN.setValue(leftTangent) + self.uiFalloffMinHandleSPN.setValue(rightTangent) + + cbIdx = self.uiShapeFalloffCBOX.currentIndex() + + leftTanIdx = self.foModel.index(cbIdx, 5) + rightTanIdx = self.foModel.index(cbIdx, 4) + + self.foModel.setData(leftTanIdx, leftTangent, role=Qt.ItemDataRole.EditRole) + self.foModel.setData(rightTanIdx, rightTangent, role=Qt.ItemDataRole.EditRole) + + def setLeftTangent(self, val: float) -> None: + self.uiFalloffWID.setTangent(leftTan=val) + + def setRightTangent(self, val: float) -> None: + self.uiFalloffWID.setTangent(rightTan=val) + + def loadSimplex(self) -> None: + """Load the Simplex system from the parent UI""" + system = self.parUI.simplex + if system == self.simplex: + return + + if system is None: + self.foModel = FalloffDataModel(None, self) + self.uiShapeFalloffCBOX.setModel(self.foModel) + if self._falloffMapper is not None: + self._falloffMapper.clearMapping() + self._falloffMapper.setModel(self.foModel) + self.uiFalloffSettingsGRP.setEnabled(False) + return + else: + self.uiFalloffSettingsGRP.setEnabled(True) + + self.simplex = system + + # Populate Settings widgets + self.foModel = FalloffDataModel(self.simplex, self) + self.uiShapeFalloffCBOX.setModel(self.foModel) + self._falloffMapper.setModel(self.foModel) + + currentIndex = QByteArray(b"currentIndex") + + self._falloffMapper.addMapping(self.uiFalloffTypeCBOX, 1, currentIndex) + self._falloffMapper.addMapping(self.uiFalloffAxisCBOX, 2, currentIndex) + self._falloffMapper.addMapping(self.uiFalloffMinSPN, 3) + self._falloffMapper.addMapping(self.uiFalloffMinHandleSPN, 4) + self._falloffMapper.addMapping(self.uiFalloffMaxHandleSPN, 5) + self._falloffMapper.addMapping(self.uiFalloffMaxSPN, 6) + + self.uiShapeFalloffCBOX.setCurrentIndex(0) + self._falloffMapper.setCurrentIndex(0) + + # Falloff Settings + def newFalloff(self) -> None: + """Create a new Falloff object""" + if self.simplex is None: + return + foNames = [f.name for f in self.simplex.falloffs] + tempName = getNextName("NewFalloff", foNames) + + newName, good = QInputDialog.getText( + self, "Rename Falloff", "Enter a new name for the Falloff", text=tempName + ) + if not good: + return + + if not newName.isidentifier(): + message = "Falloff name can only contain letters and numbers, and cannot start with a number" + QMessageBox.warning(self, "Warning", message) + return + + nn = getNextName(newName, foNames) + PlanarFalloff.createPlanar(nn, self.simplex, "X", 1.0, 0.66, 0.33, -1.0) + + def duplicateFalloff(self) -> None: + """Duplicate the selected falloff""" + if self.simplex is None: + return + if not self.simplex.falloffs: + self.newFalloff() + return + + idx = self.uiShapeFalloffCBOX.currentIndex() + if idx < 0: + return + + fo = self.simplex.falloffs[idx] + + foNames = [f.name for f in self.simplex.falloffs] + nn = getNextName(fo.name, foNames) + fo.duplicate(nn) + + def deleteFalloff(self) -> None: + """Delete the selected falloff""" + if not self.simplex or not self.simplex.falloffs: + return + idx = self.uiShapeFalloffCBOX.currentIndex() + if idx < 0: + return + + fo = self.simplex.falloffs[idx] + fo.delete() + + def renameFalloff(self) -> None: + """Rename the selected falloff""" + if not self.simplex or not self.simplex.falloffs: + return + idx = self.uiShapeFalloffCBOX.currentIndex() + if idx < 0: + return + fo = self.simplex.falloffs[idx] + foNames = [f.name for f in self.simplex.falloffs] + foNames.pop(idx) + + newName, good = QInputDialog.getText( + self, "Rename Falloff", "Enter a new name for the Falloff", text=fo.name + ) + if not good: + return + + if not newName.isidentifier(): + message = "Falloff name can only contain letters and numbers, and cannot start with a number" + QMessageBox.warning(self, "Warning", message) + return + + nn = getNextName(newName, foNames) + fo.name = nn + + def storeSettings(self) -> None: + """Store the UI settings for this dialog""" + pref = Prefs() + pref.recordProperty("fogeometry", self.saveGeometry().data()) + pref.save() + + def loadSettings(self) -> None: + """Load the UI settings for this dialog""" + pref = Prefs() + geo = pref.restoreProperty("fogeometry", None) + if geo is not None: + if isinstance(geo, bytes): + geo = QByteArray(geo) + self.restoreGeometry(geo) + + def hideEvent(self, event: QHideEvent) -> None: + """Override the hide event to store settings""" + self.storeSettings() + super().hideEvent(event) + + def showEvent(self, event: QShowEvent) -> None: + """Override the show event to restore settings""" + super().showEvent(event) + self.loadSettings() diff --git a/src/python/simplexui/interface/__init__.py b/src/python/simplexui/interface/__init__.py index d577438f..85696eab 100644 --- a/src/python/simplexui/interface/__init__.py +++ b/src/python/simplexui/interface/__init__.py @@ -16,6 +16,8 @@ # along with Simplex. If not, see . # This file will serve as the only place where the choice of DCC will be chosen +from __future__ import annotations + import os import sys diff --git a/src/python/simplexui/interface/dummyInterface.py b/src/python/simplexui/interface/dummyInterface.py index 3546163c..6342e65f 100644 --- a/src/python/simplexui/interface/dummyInterface.py +++ b/src/python/simplexui/interface/dummyInterface.py @@ -1,1585 +1,1313 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# pylint: disable=invalid-name, unused-argument -"""A placeholder interface that takes arguments and does nothing with them""" - -import copy -from contextlib import contextmanager -from functools import wraps - -from Qt import QtCore -from Qt.QtCore import Signal - -try: - import numpy as np -except ImportError: - np = None - -from alembic.AbcGeom import OPolyMeshSchemaSample - -from ..commands.alembicCommon import ( - getSampleArray, - getStaticMeshData, - getUvSample, - mkSampleIntArray, - mkSampleVertexPoints, - mkUvSample, -) -from Qt.QtWidgets import QApplication - - -# UNDO STACK INTEGRATION -@contextmanager -def undoContext(inst=None): - """A context that wraps undo chunks - - Parameters - ---------- - inst : DCC - An instantiated DCC if available. (Default value = None) - - """ - if inst is None: - DCC.staticUndoOpen() - else: - inst.undoOpen() - try: - yield - finally: - if inst is None: - DCC.staticUndoClose() - else: - inst.undoClose() - - -def undoable(f): - """A decorator that wraps a function in an undoContext""" - - @wraps(f) - def stacker(self, *args, **kwargs): - """The wrapper closure""" - with undoContext(): - return f(self, *args, **kwargs) - - return stacker - - -class DummyScene(object): - """A dcc scene containing existing dummy objects""" - - def __init__(self): - self.items = {} - - def get(self, tpe, name): - return self.items.get(tpe, {}).get(name) - - def add(self, item): - typeDict = self.items.setdefault(type(item), {}) - typeDict[item.name] = item - - def remove(self, item): - typeDict = self.items.setdefault(type(item), {}) - typeDict.pop(item.name, None) - - -DB = DummyScene() # Default module level dummy scene - - -class DummyAttr(object): - """A generic named object attribute""" - - def __init__(self, name, value, parent): - self.name = name - self.value = value - self.parent = parent - parent.attrs[name] = self - - -class DummyNode(object): - """A generic DCC node""" - - def __init__(self, name, db=DB): - self.name = name - self.attrs = {} - self.ops = [] - if db is not None: - db.add(self) - - -class DummySimplex(DummyNode): - """A generic simplex node""" - - def __init__(self, name, parent, db=DB): - super(DummySimplex, self).__init__(name, db) - self.definition = "" - parent.ops.append(self) - - -class DummyFalloff(DummyNode): - """A generic simplex node""" - - def __init__(self, name, parent, db=DB): - super(DummyFalloff, self).__init__(name, db) - self.weightmap = None - - -class DummyShape(object): - """A generic named blendshape shape""" - - def __init__(self, name, shapeNode): - self.name = name - self.value = 0.0 - self.points = None - self.shapeNode = shapeNode - shapeNode.shapes[name] = self - - -class DummyBlendshape(DummyNode): - """A generic blendshape node""" - - def __init__(self, name, parent, db=DB): - # TODO: Just reuse the DummyAttr instead of making an equivalent - super(DummyBlendshape, self).__init__(name, db) - self.shapes = {} - parent.ops.append(self) - - -class DummyMesh(DummyNode): - """A generic mesh""" - - def __init__(self, name, db=DB): - super(DummyMesh, self).__init__(name, db) - self.importPath = "" - self.faces = None - self.counts = None - self.uvs = None - self.uvFaces = None - self.verts = None - - -class DCC(object): - """ """ - - program = "dummy" - - def __init__(self, simplex, stack=None): - self.simplex = simplex # the abstract representation of the setup - self.name = simplex.name - - self.scene = DummyScene() - self.mesh = None - self.ctrl = None - self.shapeNode = None - self.op = None - - self._live = True - self._revision = 0 - # self._shapes = {} # hold the shapes from the .smpx file as a dict - # self._faces = None # Faces for the mesh (Alembic-style) - # self._counts = None # Face counts for the mesh (Alembic-style) - # self._uvs = None # UV data for the mesh - # self._numVerts = None - self._falloffs = {} # weightPerVert values - self.sliderMul = self.simplex.sliderMul - - def dummyLoad(self, other, pBar=None): - """Method to copy the information in a DCC to a DummyDCC - - Parameters - ---------- - other : DCC - The DCC to load into the dummy - pBar : QProgressDialog, optional - An optional progress dialog (Default value = None) - - Returns - ------- - : DummyDCC : - The new DummyDCC - """ - other.getAllShapeVertices(other.simplex.shapes, pBar=pBar) - - # Load all the data onto the new dummy mesh - points, faces, counts, uvs, uvFaces = other.getMeshTopology(other.mesh) - dummyMesh = self.buildRawTopology( - other.name, points, faces, counts, uvs, uvFaces - ) - self.loadNodes(self.simplex, dummyMesh) - - # Re-create the dummy shapes and sliders - for shape in self.simplex.shapes: - shape.thing = DummyShape(shape.name, self.shapeNode) - - for oShape, nShape in zip(other.simplex.shapes, self.simplex.shapes): - nShape.verts = copy.copy(oShape.verts) - - self.pushAllShapeVertices(self.simplex.shapes) - restVerts = self.simplex.restShape.verts - - for slider in self.simplex.sliders: - slider.thing = DummyAttr(slider.name, 0.0, self.ctrl) - - for fo in self.simplex.falloffs: - fo.thing = DummyFalloff(fo.name, self.scene) - fo.verts = restVerts - - def preLoad(self, simp, simpDict, create=True, pBar=None): - """Code to execute before loading a simplex system into a dcc - - Parameters - ---------- - simp : Simplex - The simplex system that will be created - simpDict : dict - The dictionary definition of the simplex to be created - create : bool - Whether to create the missing objects in the DCC (Default value = True) - pBar : QProgressDialog, optional - An optional progress dialog (Default value = None) - - Returns - ------- - : object : - A generic python object to be used in the post-load - """ - return None - - def postLoad(self, simp, preRet): - """Code to execute after loading a simplex system into a dcc - - Parameters - ---------- - simp : Simplex - The simplex system that was created - preRet : object - The object returnd from the preload method - - """ - pass - - def checkForErrors(self, window): - """Check for any DCC specific errors - - Parameters - ---------- - window : QMainWindow - The simplex window - """ - pass - - # System IO - @undoable - def loadNodes(self, simp, thing, create=True, pBar=None): - """Load the nodes from a simplex system onto a thing - - Parameters - ---------- - simp : Simplex - The system we're loading for - thing : object - The DCC thing we're reading - create : bool - Whether to create the missing nodes (Default value = True) - pBar : QProgressDialog, optional - An optional progress dialog (Default value = None) - - """ - if thing is None: - thing = DummyMesh(simp.name) - - self.name = simp.name - self.mesh = thing - self.scene.add(self.mesh) - - self.shapeNode = self.scene.get(DummyBlendshape, self.name) - - if self.shapeNode is None: - if not create: - raise RuntimeError( - "Blendshape operator not found with creation turned off: {0}".format( - self.name - ) - ) - self.shapeNode = DummyBlendshape(self.name, self.mesh, self.scene) - - self.op = self.scene.get(DummySimplex, self.name) - if self.op is None: - if not create: - raise RuntimeError( - "Simplex operator not found with creation turned off" - ) - self.op = DummySimplex(self.name, self.mesh, self.scene) - - self.ctrl = self.scene.get(DummyNode, self.name) - if self.ctrl is None: - if not create: - raise RuntimeError("Control object not found with creation turned off") - self.ctrl = DummyNode(self.name, self.scene) - - def loadConnections(self, simp, pBar=None): - """Load the connections that exist in a simplex system - - Parameters - ---------- - simp : Simplex - The simplex system to load connections for - pBar : QProgressDialog, optional - An optional progress dialog (Default value = None) - - """ - pass - - def getShapeThing(self, shapeName): - """Get the DCC Thing for a given shape name - - Parameters - ---------- - shapeName : str - The name of the shape to get - - Returns - ------- - : object : - The DCC Thing - - """ - return self.shapeNode.shapes.get(shapeName) - - def getSliderThing(self, sliderName): - """Get the DCC Thing for a given slider name - - Parameters - ---------- - sliderName : str - The name of the slider to get - - Returns - ------- - : object : - The DCC Thing - """ - return self.ctrl.attrs.get(sliderName) - - @staticmethod - @undoable - def buildRestAbc(abcMesh, name): - """Build the rest Alembic node in the dcc - - Parameters - ---------- - abcMesh : IPolyMesh - The Alembic mesh object - name : str - The name of the object to create - - """ - mesh = DummyMesh(name) # don't add it to a scene - - sa = getSampleArray(abcMesh) - if len(sa.shape) == 3: - sa = sa[0] - mesh.verts = sa - faces, counts = getStaticMeshData(abcMesh) - uvs = getUvSample(abcMesh) - - aryType = list if np is None else np.array - if uvs is not None: - mesh.uvs = aryType(uvs.getVals()) - mesh.uvFaces = aryType(uvs.getIndices()) - mesh.faces = aryType(faces) - mesh.counts = aryType(counts) - - return mesh - - @staticmethod - @undoable - def buildRawTopology(name, points, faces, counts, uvs=None, uvFaces=None): - """Build a mesh directly from raw numerical data""" - # TODO: Move this guy out to the rest of the DCC's - mesh = DummyMesh(name) # don't add it to a scene - - aryType = list if np is None else np.array - mesh.points = aryType(points) - mesh.faces = aryType(faces) - mesh.counts = aryType(counts) - if uvs is not None and uvFaces is not None: - mesh.uvs = aryType(uvs) - mesh.uvFaces = aryType(uvFaces) - return mesh - - @staticmethod - def vertCount(mesh): - """Get the vert count of the given DCC Object - - Parameters - ---------- - mesh : object - The mesh to check - - Returns - ------- - : int : - The Number of verts - """ - return len(mesh.verts) - - @undoable - def loadAbcPoses(self, abcMesh, js, pBar=None): - """Load the joints/skin from an alembic file onto an already-created system - - Parameters - ---------- - abcMesh : IPolyMesh - The Alembic mesh to load shapes from - js : dict - The simplex definition dictionary - pBar : QProgressDialog, optional - An optional progress dialog (Default value = None) - - """ - pass - - @undoable - def loadAbc(self, abcMesh, js, pBar=None): - """Load the shapes from an alembic file onto an already-created system - - Parameters - ---------- - abcMesh : IPolyMesh - The Alembic mesh to load shapes from - js : dict - The simplex definition dictionary - pBar : QProgressDialog, optional - An optional progress dialog (Default value = None) - - """ - shapes = js["shapes"] - if js["encodingVersion"] > 1: - shapes = [i["name"] for i in shapes] - pointPositions = getSampleArray(abcMesh) - for name, ppos in zip(shapes, pointPositions): - dummyShape = self.shapeNode.shapes[name] - dummyShape.points = ppos - - def getAllShapeVertices(self, shapes, pBar=None): - """Load all shape vertices into the simplex system for processing - - Parameters - ---------- - shapes : [Shape, ...] - A list of simplex Shape objects to get positions for - - pBar : QProgressDialog, optional - An optional progress dialog (Default value = None) - - """ - for shape in shapes: - verts = self.getShapeVertices(shape) - shape.verts = verts - - def getShapeVertices(self, shape): - """Get the point positions of a shape - - Parameters - ---------- - shape : Shape - A simplex Shape object to get the vertices for - - Returns - ------- - : np.array : - A numpy array of the point positions - - """ - return shape.thing.points - - def pushAllShapeVertices(self, shapes, pBar=None): - """Push the computed vertex positions for the given shapes back to the DCC - - Parameters - ---------- - shapes : [Shape, ...] - A list of simplex Shape objects - - pBar : QProgressDialog, optional - An optional progress dialog (Default value = None) - - """ - for shape in shapes: - self.pushShapeVertices(shape) - - def pushShapeVertices(self, shape): - """Push the computed vertex positions for the given shape back to the DCC - Parameters - ---------- - shape : Shape - The Simplex Shape object to update - - """ - shape.thing.points = shape.verts - - def loadMeshTopology(self): - """Load the mesh topology from the DCC into the simplex interface""" - # Here in Dummy I either have the data already or I don't, So nothing to do - pass - - @staticmethod - def getMeshTopology(mesh, uvName=None): - """Get the topology of a mesh - - Parameters - ---------- - mesh : object - The DCC Mesh to read - uvName : str, optional - The name of the uv set to read - - Returns - ------- - : np.array : - The vertex array - : np.array : - The "faces" array - : np.array : - The "counts" array - : np.array : - The uv positions - : np.array : - The "uvFaces" array - """ - return mesh.verts, mesh.faces, mesh.counts, mesh.uvs, mesh.uvFaces - - def exportAbc( - self, dccMesh, abcMesh, js, world=False, ensureCorrect=False, pBar=None - ): - """Export a .smpx file - - Parameters - ---------- - dccMesh : object - The DCC Mesh to export - abcMesh : OPolyMesh - The Alembic output mesh - js : dict - The definition dictionary - world : bool - Do the export in worldspace (Default value = False) - pBar : QProgressDialog, optional - An optional progress dialog (Default value = None) - - """ - # export the data to alembic - if dccMesh is None: - dccMesh = self.mesh - - shapeDict = {i.name: i for i in self.simplex.shapes} - - shapeNames = js["shapes"] - if js["encodingVersion"] > 1: - shapeNames = [i["name"] for i in shapeNames] - shapes = [shapeDict[i] for i in shapeNames] - schema = abcMesh.getSchema() - - if pBar is not None: - pBar.show() - pBar.setMaximum(len(shapes)) - spacerName = "_" * max(list(map(len, shapeNames))) - pBar.setLabelText("Exporting:\n{0}".format(spacerName)) - QApplication.processEvents() - - faces = mkSampleIntArray(self.mesh.faces) - counts = mkSampleIntArray(self.mesh.counts) - uvs = None - if self.mesh.uvs is not None and self.mesh.uvFaces is not None: - uvs = mkUvSample(self.mesh.uvs, self.mesh.uvFaces) - - for i, shape in enumerate(shapes): - if pBar is not None: - pBar.setLabelText("Exporting:\n{0}".format(shape.name)) - pBar.setValue(i) - QApplication.processEvents() - if pBar.wasCanceled(): - return - verts = mkSampleVertexPoints(shape.thing.points) - if uvs is not None: - # Alembic doesn't allow for uvs=None for some reason - abcSample = OPolyMeshSchemaSample(verts, faces, counts, uvs) - else: - abcSample = OPolyMeshSchemaSample(verts, faces, counts) - schema.set(abcSample) - - def exportOtherAbc(self, dccMesh, abcMesh, js, world=False, pBar=None): - """Export a .smpx file of a mesh other than self.mesh - - Parameters - ---------- - dccMesh : object - The DCC Mesh to export - abcMesh : OPolyMesh - The Alembic output mesh - js : dict - The definition dictionary - world : bool - Do the export in worldspace (Default value = False) - pBar : QProgressDialog, optional - An optional progress dialog (Default value = None) - - """ - if dccMesh is None: - raise ValueError( - "Export Other requires an explicitly defined mesh to export" - ) - self.exportAbc( - dccMesh, abcMesh, js, world=world, ensureCorrect=False, pBar=pBar - ) - - def deleteObj(self, dccMesh, path): - """Export a mesh to the given path""" - pass - - def exportMesh(self, dccMesh, path): - """Export a mesh to the given path""" - pass - - # Revision tracking - def getRevision(self): - """Get the simplex revision number""" - return self._revision - - def incrementRevision(self): - """Increment the revision number""" - self._revision += 1 - return self._revision - - def setRevision(self, val): - """Manually set the revision numer - - Parameters - ---------- - val : int - The value to set - - """ - self._revision = val - - # System level - @undoable - def renameSystem(self, name): - """Rename a simplex system - - Parameters - ---------- - name : str - The new name - - """ - # TODO - # oldName = self.name - self.name = name - # for dd in (DB.nodes, DB.ops, DB.bss, DB.meshes): - # oo = dd.get(oldName) - # if oo is not None: - # oo.name = self.name - # dd[self.name] = oo - # dd.pop(oldName, None) - - @undoable - def deleteSystem(self): - """Delete the current system""" - # for dd in (DB.nodes, DB.ops, DB.bss, DB.meshes): - # if self.name in dd: - # dd.pop(self.name, None) - # TODO - self.name = None - self.simplex = None - - # Shapes - @undoable - def createShape(self, shape, live=False, offset=10): - """Create a dcc shape - - Parameters - ---------- - shape : Shape - A simplex shape object - live : bool - Whether this shape is live-connected (Default value = False) - offset : float - The offset of the created shape (Default value = 10) - - """ - newShape = DummyShape(shape.name, self.shapeNode) - newShape.points = copy.copy(self.mesh.verts) - return newShape - - @undoable - def extractWithDeltaShape(self, shape, live=True, offset=10.0): - """Make a mesh representing a shape. Can be live or not. - Also, make a shapenode that is the delta of the change being made - - Parameters - ---------- - shape : - - live : - (Default value = True) - offset : - (Default value = 10.0) - - """ - pass - - @undoable - def extractWithDeltaConnection(self, shape, delta, value, live=True, offset=10.0): - """Extract a shape with a live partial delta added in. - Useful for updating progressive shapes - - Parameters - ---------- - shape : - - delta : - - value : - - live : - (Default value = True) - offset : - (Default value = 10.0) - - """ - pass - - @undoable - def extractShape(self, shape, live=True, offset=10.0): - """Make a mesh representing a shape. Can be live or not. - Can also store its starting shape and delta data - - Parameters - ---------- - shape : - - live : - (Default value = True) - offset : - (Default value = 10.0) - - """ - pass - - @undoable - def connectShape(self, shape, mesh=None, live=False, delete=False): - """Force a shape to match a mesh - The "connect shape" button is: - mesh=None, delete=True - The "match shape" button is: - mesh=someMesh, delete=False - There is a possibility of a "make live" button: - live=True, delete=False - - Parameters - ---------- - shape : - - mesh : - (Default value = None) - live : - (Default value = False) - delete : - (Default value = False) - - """ - pass - - @undoable - def extractPosedShape(self, shape): - """??? - - Parameters - ---------- - shape : - - """ - pass - - @undoable - def zeroShape(self, shape): - """Set a shape back to rest - - Parameters - ---------- - shape : Shape - The simplex shpae to zero out - - """ - shape.thing.points = self.getShapeVertices(self.simplex.restShape) - - @undoable - def deleteShape(self, toDelShape): - """Delete a shape from the system - - Parameters - ---------- - toDelShape : - - """ - self.shapeNode.shapes.pop(toDelShape.name, None) - - @undoable - def renameShape(self, shape, name): - """Rename a shape - - Parameters - ---------- - shape : Shape - The simplex Shape object to rename - name : str - The new name - - """ - self.shapeNode.shapes.pop(shape.thing.name, None) - shape.thing.name = name - self.shapeNode.shapes[name] = shape.thing - - @undoable - def convertShapeToCorrective(self, shape): - """??? - - Parameters - ---------- - shape : - - """ - pass - - # Falloffs - def createFalloff(self, falloff): - """Create a per-vert falloff weightmap - - Parameters - ---------- - falloff : Falloff - The simplex Falloff object to create - - """ - fo = DummyFalloff(falloff.name, self.scene) - fo.weights = np.zeros(len(self.mesh.verts)) - - def duplicateFalloff(self, falloff, newFalloff): - """Create a new falloff from an already existing one - - Parameters - ---------- - falloff : Falloff - The already existing falloff - newFalloff : Falloff - The newly created falloff to store the newly duplicated data - - """ - fo = DummyFalloff(newFalloff.name, self.scene) - fo.weights = copy.copy(falloff.thing.weights) - - def deleteFalloff(self, falloff): - """Delete a falloff object - - Parameters - ---------- - falloff : Falloff - The Falloff object to delete - - """ - self.scene.remove(falloff.thing) - - def setFalloffData( - self, falloff, splitType, axis, minVal, minHandle, maxHandle, maxVal, mapName - ): - """Set the data of a falloff object - - Parameters - ---------- - falloff : - - splitType : - - axis : - - minVal : - - minHandle : - - maxHandle : - - maxVal : - - mapName : - - """ - # TODO: set the per-vert falloffs - pass # for eventual live splits - - def getFalloffThing(self, falloff): - """Get the thing for a given falloff - - Parameters - ---------- - falloff : Falloff - The simplex falloff object to get - - """ - return self.scene.get(DummyFalloff, falloff.name) - - # Sliders - @undoable - def createSlider(self, slider): - """Create a slider object - - Parameters - ---------- - slider : Slider - The simplex slider object to create - - """ - return DummyAttr(slider.name, 0.0, self.ctrl) - - @undoable - def renameSlider(self, slider, name): - """Rename a slider - - Parameters - ---------- - slider : Slider - The slider to rename - name : str - The new name - - """ - self.ctrl.attrs.pop(slider.thing.name, None) - self.ctrl.attrs[name] = slider.thing - slider.thing.name = name - - @undoable - def setSliderRange(self, slider): - """Set the min and max of a slider - - Parameters - ---------- - slider : Slider - The slider to set - - """ - pass - - @undoable - def deleteSlider(self, toDelSlider): - """Delete a slider - - Parameters - ---------- - toDelSlider : Slider - The slider to delete - - """ - self.ctrl.attrs.pop(toDelSlider.name, None) - - @undoable - def addProgFalloff(self, prog, falloff): - """ - - Parameters - ---------- - prog : - - falloff : - - """ - pass # for eventual live splits - - @undoable - def removeProgFalloff(self, prog, falloff): - """ - - Parameters - ---------- - prog : - - falloff : - - """ - pass # for eventual live splits - - @undoable - def setSlidersWeights(self, sliders, weights): - """Set the values for the given sliders - - Parameters - ---------- - sliders : [Slider, ...] - The sliders to set values for - weights : [float, ...] - The values to set - - """ - for slider, val in zip(sliders, weights): - slider.thing.value = val - - @undoable - def setSliderWeight(self, slider, weight): - """Set the value for a given slider - - Parameters - ---------- - slider : Slider - The slider - weight : float - The value - - """ - slider.thing.value = weight - - @undoable - def updateSlidersRange(self, sliders): - """Update the range of the given sliders - - Parameters - ---------- - sliders : - - """ - pass - - @undoable - def extractTraversalShape(self, trav, shape, live=True, offset=10.0): - """Extract a shape from a traversal progression - - Parameters - ---------- - trav : - - shape : - - live : - (Default value = True) - offset : - (Default value = 10.0) - - """ - pass - - @undoable - def connectTraversalShape(self, trav, shape, mesh=None, live=True, delete=False): - """Connect a shape to a traversal progression - - Parameters - ---------- - trav : - - shape : - - mesh : - (Default value = None) - live : - (Default value = True) - delete : - (Default value = False) - - """ - pass - - # Combos - @undoable - def extractComboShape(self, combo, shape, live=True, offset=10.0): - """Extract a shape from a combo progression - - Parameters - ---------- - combo : - - shape : - - live : - (Default value = True) - offset : - (Default value = 10.0) - - """ - pass - - @undoable - def connectComboShape(self, combo, shape, mesh=None, live=True, delete=False): - """Connect a shape to a combo progression - - Parameters - ---------- - combo : - - shape : - - mesh : - (Default value = None) - live : - (Default value = True) - delete : - (Default value = False) - - """ - pass - - @staticmethod - def setDisabled(op): - """Disable the output of any simplex systems - - Parameters - ---------- - op : The operator to disable - - - Returns - ------- - : object : - Some object that will allow us to re-enable the system - - """ - return None - - @staticmethod - def reEnable(helpers): - """Re-enable a simplex system - - Parameters - ---------- - helpers : object - The helper object returned from setDisabled - - """ - pass - - @undoable - def renameCombo(self, combo, name): - """Set the name of a Combo - - Parameters - ---------- - combo : Combo - The combo to rename - name : str - The new name - - """ - pass - - # Data Access - @staticmethod - def getSimplexOperators(): - """Get all simplex operators in the DCC""" - return list(DB.ops.values()) - - @staticmethod - def getSimplexOperatorsByName(name): - """Get a simplex operator - - Parameters - ---------- - name : str - The name to search for - - - Returns - ------- - : object : - The simplex operator for the DCC - - """ - return DB.ops.get(name) - - @staticmethod - def getSimplexOperatorsOnObject(thing): - """Get all simplex operators controlling an object - - Parameters - ---------- - thing : object - A dcc object to check for simplex operators - - Returns - ------- - : [object, ...] : - A list of simplex operators - - """ - return [o for o in thing.ops if isinstance(o, DummySimplex)] - - @staticmethod - def getSimplexString(op): - """Get the simplex string from the given operator - - Parameters - ---------- - op : object - The Simplex operator to get the definition from - - Returns - ------- - : str : - The simplex definition - - """ - return op.definition - - @staticmethod - def getSimplexStringOnThing(thing, systemName): - """Get the definition on an object by name - - Parameters - ---------- - thing : object - The DCC object to check for a simplex operator - systemName : str - The system name to check for - - Returns - ------- - : str : - The simplex definition - - """ - for op in thing.ops: - if op.name == systemName: - return op.definition - return None - - @staticmethod - def setSimplexString(op, val): - """Set the definition string on an object - - Parameters - ---------- - op : object - The operator to set the definition on - val : str - The definition to set - - Returns - ------- - - """ - op.definition = val - - @staticmethod - def selectObject(thing): - """Select an object in the DCC - - Parameters - ---------- - thing : - - """ - pass - - def selectCtrl(self): - """Select the system's control object""" - pass - - @staticmethod - def getObjectByName(name): - """Get an object by name - - Parameters - ---------- - name : str - The name to search for - - Returns - ------- - : object : - The found object - - """ - # TODO: maybe also filter by type?? - # return DB.meshes.get(name) - return DB.get(DummyMesh, name) - - @staticmethod - def getObjectName(thing): - """Get the name of an object - - Parameters - ---------- - thing : object - The dcc object to get the name for - - - Returns - ------- - : str : - The Object Name - - """ - return thing.name - - @staticmethod - def staticUndoOpen(): - """Open an undo chunk without knowledge of Simplex""" - pass - - @staticmethod - def staticUndoClose(): - """Close an undo chunk without knowledge of Simplex""" - pass - - def undoOpen(self): - """Open an undo chunk with knowledge of Simplex""" - pass - - def undoClose(self): - """Close an undo chunk with knowledge of Simplex""" - pass - - @classmethod - def getPersistentFalloff(cls, thing): - """Get a representation of the given object that won't get deleted or garbage collected - - Parameters - ---------- - thing : object - The thing to get a persistent representation of - - Returns - ------- - : object : - The requested persistent object - - """ - return cls.getObjectName(thing) - - @classmethod - def loadPersistentFalloff(cls, thing): - """Get the usable representation of the given persistent thing - - Parameters - ---------- - thing : object - A persistent representation - - Returns - ------- - : object : - The requested volatile object - - """ - return cls.getObjectByName(thing) - - @classmethod - def getPersistentShape(cls, thing): - """Get a representation of the given object that won't get deleted or garbage collected - - Parameters - ---------- - thing : object - The thing to get a persistent representation of - - Returns - ------- - : object : - The requested persistent object - - """ - return cls.getObjectName(thing) - - @classmethod - def loadPersistentShape(cls, thing): - """Get the usable representation of the given persistent thing - - Parameters - ---------- - thing : object - A persistent representation - - Returns - ------- - : object : - The requested volatile object - - """ - return cls.getObjectByName(thing) - - @classmethod - def getPersistentSlider(cls, thing): - """Get a representation of the given object that won't get deleted or garbage collected - - Parameters - ---------- - thing : object - The thing to get a persistent representation of - - Returns - ------- - : object : - The requested persistent object - - """ - return cls.getObjectName(thing) - - @classmethod - def loadPersistentSlider(cls, thing): - """Get the usable representation of the given persistent thing - - Parameters - ---------- - thing : object - A persistent representation - - Returns - ------- - : object : - The requested volatile object - - """ - return cls.getObjectByName(thing) - - @staticmethod - def getSelectedObjects(): - """Get the selected objects""" - # Here in the dummy interface, we short-circuit this - # And return a default selected object called "thing" - return [DummyNode("thing")] - - def getFreezeThing(self, combo): - return [] - - -class SliderDispatch(QtCore.QObject): - """ """ - - valueChanged = Signal() - - def __init__(self, node, parent=None): - super(SliderDispatch, self).__init__(parent) - - def emitValueChanged(self, *args, **kwargs): - """ - - Parameters - ---------- - *args : - - **kwargs : - - """ - self.valueChanged.emit() - - -class Dispatch(QtCore.QObject): - """ """ - - beforeNew = Signal() - afterNew = Signal() - beforeOpen = Signal() - afterOpen = Signal() - undo = Signal() - redo = Signal() - - def __init__(self, parent=None): - super(Dispatch, self).__init__(parent) - - def connectCallbacks(self): - """ """ - pass - - def disconnectCallbacks(self): - """ """ - pass - - def emitBeforeNew(self, *args, **kwargs): - """ - - Parameters - ---------- - *args : - - **kwargs : - - """ - self.beforeNew.emit() - - def emitAfterNew(self, *args, **kwargs): - """ - - Parameters - ---------- - *args : - - **kwargs : - - """ - self.afterNew.emit() - - def emitBeforeOpen(self, *args, **kwargs): - """ - - Parameters - ---------- - *args : - - **kwargs : - - """ - self.beforeOpen.emit() - - def emitAfterOpen(self, *args, **kwargs): - """ - - Parameters - ---------- - *args : - - **kwargs : - - """ - self.afterOpen.emit() - - def emitUndo(self, *args, **kwargs): - """ - - Parameters - ---------- - *args : - - **kwargs : - - """ - self.undo.emit() - - def emitRedo(self, *args, **kwargs): - """ - - Parameters - ---------- - *args : - - **kwargs : - - """ - self.redo.emit() - - -DISPATCH = Dispatch() - - -def rootWindow(): - """ """ - return None +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +"""A placeholder interface that takes arguments and does nothing with them""" + +from __future__ import annotations + +import copy +from contextlib import contextmanager +from functools import wraps + +import numpy as np +from alembic.AbcGeom import OPolyMeshSchemaSample +from Qt import QtCore +from Qt.QtCore import Signal +from Qt.QtWidgets import QApplication + +from ..commands.alembicCommon import ( + getSampleArray, + getSampleArrayIndex, + getStaticMeshData, + getUvSample, + mkSampleIntArray, + mkSampleVertexPoints, + mkUvSample, +) + + +# UNDO STACK INTEGRATION +@contextmanager +def undoContext(inst=None): + """A context that wraps undo chunks + + Parameters + ---------- + inst : DCC + An instantiated DCC if available. (Default value = None) + """ + if inst is None: + DCC.staticUndoOpen() + else: + inst.undoOpen() + try: + yield + finally: + if inst is None: + DCC.staticUndoClose() + else: + inst.undoClose() + + +def undoable(f): + """A decorator that wraps a function in an undoContext""" + + @wraps(f) + def stacker(self, *args, **kwargs): + """The wrapper closure""" + with undoContext(): + return f(self, *args, **kwargs) + + return stacker + + +class DummyScene: + """A dcc scene containing existing dummy objects""" + + def __init__(self) -> None: + self.items = {} + + def get( + self, + tpe: type[DummyBlendshape] + | type[DummyFalloff] + | type[DummyMesh] + | type[DummyNode] + | type[DummySimplex], + name, + ): + return self.items.get(tpe, {}).get(name) + + def add(self, item) -> None: + typeDict = self.items.setdefault(type(item), {}) + typeDict[item.name] = item + + def remove(self, item) -> None: + typeDict = self.items.setdefault(type(item), {}) + typeDict.pop(item.name, None) + + +DB = DummyScene() # Default module level dummy scene + + +class DummyAttr: + """A generic named object attribute""" + + def __init__(self, name, value: float, parent) -> None: + self.name = name + self.value = value + self.parent = parent + parent.attrs[name] = self + + +class DummyNode: + """A generic DCC node""" + + def __init__(self, name: str, db=DB) -> None: + self.name = name + self.attrs = {} + self.ops = [] + if db is not None: + db.add(self) + + +class DummySimplex(DummyNode): + """A generic simplex node""" + + def __init__(self, name, parent, db: DummyScene = DB) -> None: + super().__init__(name, db) + self.definition = "" + parent.ops.append(self) + + +class DummyFalloff(DummyNode): + """A generic simplex node""" + + def __init__(self, name, parent: DummyScene, db=DB) -> None: + super().__init__(name, db) + self.weightmap = None + + +class DummyShape: + """A generic named blendshape shape""" + + def __init__(self, name, shapeNode) -> None: + self.name = name + self.value = 0.0 + self.points = None + self.shapeNode = shapeNode + shapeNode.shapes[name] = self + + +class DummyBlendshape(DummyNode): + """A generic blendshape node""" + + def __init__(self, name, parent, db: DummyScene = DB) -> None: + # TODO: Just reuse the DummyAttr instead of making an equivalent + super().__init__(name, db) + self.shapes = {} + parent.ops.append(self) + + +class DummyMesh(DummyNode): + """A generic mesh""" + + def __init__(self, name, db=DB) -> None: + super().__init__(name, db) + self.importPath = "" + self.faces = None + self.counts = None + self.uvs = None + self.uvFaces = None + self.verts = None + + +class DCC: + program = "dummy" + + def __init__(self, simplex, stack=None) -> None: + self.simplex = simplex # the abstract representation of the setup + self.name = simplex.name + + self.scene = DummyScene() + self.mesh = None + self.ctrl = None + self.shapeNode = None + self.op = None + + self._live = True + self._revision = 0 + # self._shapes = {} # hold the shapes from the .smpx file as a dict + # self._faces = None # Faces for the mesh (Alembic-style) + # self._counts = None # Face counts for the mesh (Alembic-style) + # self._uvs = None # UV data for the mesh + # self._numVerts = None + self._falloffs = {} # weightPerVert values + self.sliderMul = self.simplex.sliderMul + + def dummyLoad(self, other, pBar=None) -> None: + """Method to copy the information in a DCC to a DummyDCC + + Parameters + ---------- + other : DCC + The DCC to load into the dummy + pBar : QProgressDialog, optional + An optional progress dialog (Default value = None) + + Returns + ------- + : DummyDCC : + The new DummyDCC + """ + assert self.simplex is not None + other.getAllShapeVertices(other.simplex.shapes, pBar=pBar) + + # Load all the data onto the new dummy mesh + points, faces, counts, uvs, uvFaces = other.getMeshTopology(other.mesh) + dummyMesh = self.buildRawTopology( + other.name, points, faces, counts, uvs, uvFaces + ) + self.loadNodes(self.simplex, dummyMesh) + + # Re-create the dummy shapes and sliders + for shape in self.simplex.shapes: + shape.thing = DummyShape(shape.name, self.shapeNode) + + for oShape, nShape in zip(other.simplex.shapes, self.simplex.shapes): + nShape.verts = copy.copy(oShape.verts) + + self.pushAllShapeVertices(self.simplex.shapes) + restVerts = self.simplex.restShape.verts + + for slider in self.simplex.sliders: + slider.thing = DummyAttr(slider.name, 0.0, self.ctrl) + + for fo in self.simplex.falloffs: + fo.thing = DummyFalloff(fo.name, self.scene) + fo.verts = restVerts + + def preLoad(self, simp, simpDict, create: bool = True, pBar=None) -> None: + """Code to execute before loading a simplex system into a dcc + + Parameters + ---------- + simp : Simplex + The simplex system that will be created + simpDict : dict + The dictionary definition of the simplex to be created + create : bool + Whether to create the missing objects in the DCC (Default value = True) + pBar : QProgressDialog, optional + An optional progress dialog (Default value = None) + + Returns + ------- + : object : + A generic python object to be used in the post-load + """ + return None + + def postLoad(self, simp, preRet) -> None: + """Code to execute after loading a simplex system into a dcc + + Parameters + ---------- + simp : Simplex + The simplex system that was created + preRet : object + The object returnd from the preload method + """ + pass + + def checkForErrors(self, window) -> None: + """Check for any DCC specific errors + + Parameters + ---------- + window : QMainWindow + The simplex window + """ + pass + + # System IO + @undoable + def loadNodes(self, simp, thing, create=True, pBar=None) -> None: + """Load the nodes from a simplex system onto a thing + + Parameters + ---------- + simp : Simplex + The system we're loading for + thing : object + The DCC thing we're reading + create : bool + Whether to create the missing nodes (Default value = True) + pBar : QProgressDialog, optional + An optional progress dialog (Default value = None) + """ + if thing is None: + thing = DummyMesh(simp.name) + + self.name = simp.name + self.mesh = thing + self.scene.add(self.mesh) + + self.shapeNode = self.scene.get(DummyBlendshape, self.name) + + if self.shapeNode is None: + if not create: + raise RuntimeError( + f"Blendshape operator not found with creation turned off: {self.name}" + ) + self.shapeNode = DummyBlendshape(self.name, self.mesh, self.scene) + + self.op = self.scene.get(DummySimplex, self.name) + if self.op is None: + if not create: + raise RuntimeError( + "Simplex operator not found with creation turned off" + ) + self.op = DummySimplex(self.name, self.mesh, self.scene) + + self.ctrl = self.scene.get(DummyNode, self.name) + if self.ctrl is None: + if not create: + raise RuntimeError("Control object not found with creation turned off") + self.ctrl = DummyNode(self.name, self.scene) + + def loadConnections(self, simp, pBar=None) -> None: + """Load the connections that exist in a simplex system + + Parameters + ---------- + simp : Simplex + The simplex system to load connections for + pBar : QProgressDialog, optional + An optional progress dialog (Default value = None) + """ + pass + + def getShapeThing(self, shapeName): + """Get the DCC Thing for a given shape name + + Parameters + ---------- + shapeName : str + The name of the shape to get + + Returns + ------- + : object : + The DCC Thing + """ + return self.shapeNode.shapes.get(shapeName) + + def getSliderThing(self, sliderName): + """Get the DCC Thing for a given slider name + + Parameters + ---------- + sliderName : str + The name of the slider to get + + Returns + ------- + : object : + The DCC Thing + """ + return self.ctrl.attrs.get(sliderName) + + @staticmethod + @undoable + def buildRestAbc(abcMesh, name) -> DummyMesh: + """Build the rest Alembic node in the dcc + + Parameters + ---------- + abcMesh : IPolyMesh + The Alembic mesh object + name : str + The name of the object to create + """ + mesh = DummyMesh(name) # don't add it to a scene + mesh.verts = getSampleArrayIndex(abcMesh, index=0) + faces, counts = getStaticMeshData(abcMesh) + uvs = getUvSample(abcMesh) + + aryType = list if np is None else np.array + if uvs is not None: + mesh.uvs = aryType(uvs.getVals()) + mesh.uvFaces = aryType(uvs.getIndices()) + mesh.faces = aryType(faces) + mesh.counts = aryType(counts) + + return mesh + + @staticmethod + @undoable + def buildRawTopology( + name, points, faces, counts, uvs=None, uvFaces=None + ) -> DummyMesh: + """Build a mesh directly from raw numerical data""" + # TODO: Move this guy out to the rest of the DCC's + mesh = DummyMesh(name) # don't add it to a scene + + aryType = list if np is None else np.array + mesh.verts = aryType(points) + mesh.faces = aryType(faces) + mesh.counts = aryType(counts) + if uvs is not None and uvFaces is not None: + mesh.uvs = aryType(uvs) + mesh.uvFaces = aryType(uvFaces) + return mesh + + @staticmethod + def vertCount(mesh) -> int: + """Get the vert count of the given DCC Object + + Parameters + ---------- + mesh : object + The mesh to check + + Returns + ------- + : int : + The Number of verts + """ + return len(mesh.verts) + + @undoable + def loadAbcPoses(self, abcMesh, js, pBar=None) -> None: + """Load the joints/skin from an alembic file onto an already-created system + + Parameters + ---------- + abcMesh : IPolyMesh + The Alembic mesh to load shapes from + js : dict + The simplex definition dictionary + pBar : QProgressDialog, optional + An optional progress dialog (Default value = None) + """ + pass + + @undoable + def loadAbc(self, abcMesh, js, pBar=None) -> None: + """Load the shapes from an alembic file onto an already-created system + + Parameters + ---------- + abcMesh : IPolyMesh + The Alembic mesh to load shapes from + js : dict + The simplex definition dictionary + pBar : QProgressDialog, optional + An optional progress dialog (Default value = None) + """ + shapes = js["shapes"] + if js["encodingVersion"] > 1: + shapes = [i["name"] for i in shapes] + pointPositions = getSampleArray(abcMesh) + for name, ppos in zip(shapes, pointPositions): + dummyShape = self.shapeNode.shapes[name] + dummyShape.points = ppos + + def getAllShapeVertices(self, shapes, pBar=None) -> None: + """Load all shape vertices into the simplex system for processing + + Parameters + ---------- + shapes : [Shape, ...] + A list of simplex Shape objects to get positions for + + pBar : QProgressDialog, optional + An optional progress dialog (Default value = None) + """ + for shape in shapes: + verts = self.getShapeVertices(shape) + shape.verts = verts + + def getShapeVertices(self, shape): + """Get the point positions of a shape + + Parameters + ---------- + shape : Shape + A simplex Shape object to get the vertices for + + Returns + ------- + : np.array : + A numpy array of the point positions + """ + return shape.thing.points + + def pushAllShapeVertices(self, shapes, pBar=None) -> None: + """Push the computed vertex positions for the given shapes back to the DCC + + Parameters + ---------- + shapes : [Shape, ...] + A list of simplex Shape objects + + pBar : QProgressDialog, optional + An optional progress dialog (Default value = None) + """ + for shape in shapes: + self.pushShapeVertices(shape) + + def pushShapeVertices(self, shape) -> None: + """Push the computed vertex positions for the given shape back to the DCC + Parameters + ---------- + shape : Shape + The Simplex Shape object to update + """ + shape.thing.points = shape.verts + + def loadMeshTopology(self) -> None: + """Load the mesh topology from the DCC into the simplex interface""" + # Here in Dummy I either have the data already or I don't, So nothing to do + pass + + @staticmethod + def getMeshTopology(mesh, uvName=None): + """Get the topology of a mesh + + Parameters + ---------- + mesh : object + The DCC Mesh to read + uvName : str, optional + The name of the uv set to read + + Returns + ------- + : np.array : + The vertex array + : np.array : + The "faces" array + : np.array : + The "counts" array + : np.array : + The uv positions + : np.array : + The "uvFaces" array + """ + return mesh.verts, mesh.faces, mesh.counts, mesh.uvs, mesh.uvFaces + + def exportAbc( + self, dccMesh, abcMesh, js, world=False, ensureCorrect=False, pBar=None + ) -> None: + """Export a .smpx file + + Parameters + ---------- + dccMesh : object + The DCC Mesh to export + abcMesh : OPolyMesh + The Alembic output mesh + js : dict + The definition dictionary + world : bool + Do the export in worldspace (Default value = False) + pBar : QProgressDialog, optional + An optional progress dialog (Default value = None) + """ + # export the data to alembic + if dccMesh is None: + dccMesh = self.mesh + + shapeDict = {i.name: i for i in self.simplex.shapes} + + shapeNames = js["shapes"] + if js["encodingVersion"] > 1: + shapeNames = [i["name"] for i in shapeNames] + shapes = [shapeDict[i] for i in shapeNames] + schema = abcMesh.getSchema() + + if pBar is not None: + pBar.show() + pBar.setMaximum(len(shapes)) + spacerName = "_" * max(list(map(len, shapeNames))) + pBar.setLabelText(f"Exporting:\n{spacerName}") + QApplication.processEvents() + + faces = mkSampleIntArray(self.mesh.faces) + counts = mkSampleIntArray(self.mesh.counts) + uvs = None + if self.mesh.uvs is not None and self.mesh.uvFaces is not None: + uvs = mkUvSample(self.mesh.uvs, self.mesh.uvFaces) + + for i, shape in enumerate(shapes): + if pBar is not None: + pBar.setLabelText(f"Exporting:\n{shape.name}") + pBar.setValue(i) + pBar.repaint() # Required to properly show the percentage. Don't know why + QApplication.processEvents() + if pBar.wasCanceled(): + return + verts = mkSampleVertexPoints(shape.thing.points) + if uvs is not None: + # Alembic doesn't allow for uvs=None for some reason + abcSample = OPolyMeshSchemaSample(verts, faces, counts, uvs) + else: + abcSample = OPolyMeshSchemaSample(verts, faces, counts) + schema.set(abcSample) + + def exportOtherAbc( + self, dccMesh, abcMesh, js, world: bool = False, pBar=None + ) -> None: + """Export a .smpx file of a mesh other than self.mesh + + Parameters + ---------- + dccMesh : object + The DCC Mesh to export + abcMesh : OPolyMesh + The Alembic output mesh + js : dict + The definition dictionary + world : bool + Do the export in worldspace (Default value = False) + pBar : QProgressDialog, optional + An optional progress dialog (Default value = None) + """ + if dccMesh is None: + raise ValueError( + "Export Other requires an explicitly defined mesh to export" + ) + self.exportAbc( + dccMesh, abcMesh, js, world=world, ensureCorrect=False, pBar=pBar + ) + + def deleteObj(self, dccMesh, path) -> None: + """Export a mesh to the given path""" + pass + + def exportMesh(self, dccMesh, path) -> None: + """Export a mesh to the given path""" + pass + + # Revision tracking + def getRevision(self) -> int: + """Get the simplex revision number""" + return self._revision + + def incrementRevision(self) -> int: + """Increment the revision number""" + self._revision += 1 + return self._revision + + def setRevision(self, val) -> None: + """Manually set the revision numer + + Parameters + ---------- + val : int + The value to set + """ + self._revision = val + + # System level + @undoable + def renameSystem(self, name) -> None: + """Rename a simplex system + + Parameters + ---------- + name : str + The new name + """ + # TODO + # oldName = self.name + self.name = name + # for dd in (DB.nodes, DB.ops, DB.bss, DB.meshes): + # oo = dd.get(oldName) + # if oo is not None: + # oo.name = self.name + # dd[self.name] = oo + # dd.pop(oldName, None) + + @undoable + def deleteSystem(self) -> None: + """Delete the current system""" + # for dd in (DB.nodes, DB.ops, DB.bss, DB.meshes): + # if self.name in dd: + # dd.pop(self.name, None) + # TODO + self.name = None + self.simplex = None + + # Shapes + @undoable + def createShape(self, shape, live: bool = False, offset: int = 10) -> DummyShape: + """Create a dcc shape + + Parameters + ---------- + shape : Shape + A simplex shape object + live : bool + Whether this shape is live-connected (Default value = False) + offset : float + The offset of the created shape (Default value = 10) + """ + newShape = DummyShape(shape.name, self.shapeNode) + newShape.points = copy.copy(self.mesh.verts) + return newShape + + @undoable + def extractWithDeltaShape( + self, shape, live: bool = True, offset: float = 10.0 + ) -> None: + """Make a mesh representing a shape. Can be live or not. + Also, make a shapenode that is the delta of the change being made + """ + pass + + @undoable + def extractWithDeltaConnection( + self, shape, delta, value, live: bool = True, offset: float = 10.0 + ) -> None: + """Extract a shape with a live partial delta added in. + Useful for updating progressive shapes + """ + pass + + @undoable + def extractShape(self, shape, live: bool = True, offset: float = 10.0) -> None: + """Make a mesh representing a shape. Can be live or not. + Can also store its starting shape and delta data + """ + pass + + @undoable + def connectShape( + self, shape, mesh=None, live: bool = False, delete: bool = False + ) -> None: + """Force a shape to match a mesh + The "connect shape" button is: + mesh=None, delete=True + The "match shape" button is: + mesh=someMesh, delete=False + There is a possibility of a "make live" button: + live=True, delete=False + """ + pass + + @undoable + def extractPosedShape(self, shape) -> None: + pass + + @undoable + def zeroShape(self, shape) -> None: + """Set a shape back to rest + + Parameters + ---------- + shape : Shape + The simplex shpae to zero out + """ + shape.thing.points = self.getShapeVertices(self.simplex.restShape) + + @undoable + def deleteShape(self, toDelShape) -> None: + """Delete a shape from the system + + Parameters + ---------- + toDelShape : + """ + self.shapeNode.shapes.pop(toDelShape.name, None) + + @undoable + def renameShape(self, shape, name) -> None: + """Rename a shape + + Parameters + ---------- + shape : Shape + The simplex Shape object to rename + name : str + The new name + """ + self.shapeNode.shapes.pop(shape.thing.name, None) + shape.thing.name = name + self.shapeNode.shapes[name] = shape.thing + + @undoable + def convertShapeToCorrective(self, shape) -> None: + pass + + # Falloffs + def createFalloff(self, falloff) -> None: + """Create a per-vert falloff weightmap + + Parameters + ---------- + falloff : Falloff + The simplex Falloff object to create + """ + fo = DummyFalloff(falloff.name, self.scene) + fo.weights = np.zeros(len(self.mesh.verts)) + + def duplicateFalloff(self, falloff, newFalloff) -> None: + """Create a new falloff from an already existing one + + Parameters + ---------- + falloff : Falloff + The already existing falloff + newFalloff : Falloff + The newly created falloff to store the newly duplicated data + """ + fo = DummyFalloff(newFalloff.name, self.scene) + fo.weights = copy.copy(falloff.thing.weights) + + def deleteFalloff(self, falloff) -> None: + """Delete a falloff object + + Parameters + ---------- + falloff : Falloff + The Falloff object to delete + """ + self.scene.remove(falloff.thing) + + def setFalloffData( + self, falloff, splitType, axis, minVal, minHandle, maxHandle, maxVal, mapName + ) -> None: + """Set the data of a falloff object""" + # TODO: set the per-vert falloffs + pass # for eventual live splits + + def getFalloffThing(self, falloff): + """Get the thing for a given falloff + + Parameters + ---------- + falloff : Falloff + The simplex falloff object to get + """ + return self.scene.get(DummyFalloff, falloff.name) + + # Sliders + @undoable + def createSlider(self, slider) -> DummyAttr: + """Create a slider object + + Parameters + ---------- + slider : Slider + The simplex slider object to create + """ + return DummyAttr(slider.name, 0.0, self.ctrl) + + @undoable + def renameSlider(self, slider, name) -> None: + """Rename a slider + + Parameters + ---------- + slider : Slider + The slider to rename + name : str + The new name + """ + self.ctrl.attrs.pop(slider.thing.name, None) + self.ctrl.attrs[name] = slider.thing + slider.thing.name = name + + @undoable + def setSliderRange(self, slider) -> None: + """Set the min and max of a slider + + Parameters + ---------- + slider : Slider + The slider to set + """ + pass + + @undoable + def deleteSlider(self, toDelSlider) -> None: + """Delete a slider + + Parameters + ---------- + toDelSlider : Slider + The slider to delete + """ + self.ctrl.attrs.pop(toDelSlider.name, None) + + @undoable + def addProgFalloff(self, prog, falloff) -> None: + pass # for eventual live splits + + @undoable + def removeProgFalloff(self, prog, falloff) -> None: + pass # for eventual live splits + + @undoable + def setSlidersWeights(self, sliders, weights) -> None: + """Set the values for the given sliders + + Parameters + ---------- + sliders : [Slider, ...] + The sliders to set values for + weights : [float, ...] + The values to set + """ + for slider, val in zip(sliders, weights): + slider.thing.value = val + + @undoable + def setSliderWeight(self, slider, weight) -> None: + """Set the value for a given slider + + Parameters + ---------- + slider : Slider + The slider + weight : float + The value + """ + slider.thing.value = weight + + @undoable + def updateSlidersRange(self, sliders) -> None: + """Update the range of the given sliders""" + pass + + @undoable + def extractTraversalShape( + self, trav, shape, live: bool = True, offset: float = 10.0 + ) -> None: + """Extract a shape from a traversal progression""" + pass + + @undoable + def connectTraversalShape( + self, trav, shape, mesh=None, live: bool = True, delete: bool = False + ) -> None: + """Connect a shape to a traversal progression""" + pass + + # Combos + @undoable + def extractComboShape( + self, combo, shape, live: bool = True, offset: float = 10.0 + ) -> None: + """Extract a shape from a combo progression""" + pass + + @undoable + def connectComboShape( + self, combo, shape, mesh=None, live: bool = True, delete: bool = False + ) -> None: + """Connect a shape to a combo progression""" + pass + + @staticmethod + def setDisabled(op) -> None: + """Disable the output of any simplex systems + + Parameters + ---------- + op : The operator to disable + + Returns + ------- + : object : + Some object that will allow us to re-enable the system + """ + return None + + @staticmethod + def reEnable(helpers) -> None: + """Re-enable a simplex system + + Parameters + ---------- + helpers : object + The helper object returned from setDisabled + """ + pass + + @undoable + def renameCombo(self, combo, name) -> None: + """Set the name of a Combo + + Parameters + ---------- + combo : Combo + The combo to rename + name : str + The new name + """ + pass + + # Data Access + @staticmethod + def getSimplexOperators(): + """Get all simplex operators in the DCC""" + return list(DB.ops.values()) + + @staticmethod + def getSimplexOperatorsByName(name): + """Get a simplex operator + + Parameters + ---------- + name : str + The name to search for + + Returns + ------- + : object : + The simplex operator for the DCC + """ + return DB.ops.get(name) + + @staticmethod + def getSimplexOperatorsOnObject(thing) -> list[DummySimplex]: + """Get all simplex operators controlling an object + + Parameters + ---------- + thing : object + A dcc object to check for simplex operators + + Returns + ------- + : [object, ...] : + A list of simplex operators + """ + return [o for o in thing.ops if isinstance(o, DummySimplex)] + + @staticmethod + def getSimplexString(op): + """Get the simplex string from the given operator + + Parameters + ---------- + op : object + The Simplex operator to get the definition from + + Returns + ------- + : str : + The simplex definition + """ + return op.definition + + @staticmethod + def getSimplexStringOnThing(thing, systemName): + """Get the definition on an object by name + + Parameters + ---------- + thing : object + The DCC object to check for a simplex operator + systemName : str + The system name to check for + + Returns + ------- + : str : + The simplex definition + """ + for op in thing.ops: + if op.name == systemName: + return op.definition + return None + + @staticmethod + def setSimplexString(op, val) -> None: + """Set the definition string on an object + + Parameters + ---------- + op : object + The operator to set the definition on + val : str + The definition to set + """ + op.definition = val + + @staticmethod + def selectObject(thing) -> None: + """Select an object in the DCC""" + pass + + def selectCtrl(self) -> None: + """Select the system's control object""" + pass + + @staticmethod + def getObjectByName(name): + """Get an object by name + + Parameters + ---------- + name : str + The name to search for + + Returns + ------- + : object : + The found object + """ + # TODO: maybe also filter by type?? + # return DB.meshes.get(name) + return DB.get(DummyMesh, name) + + @staticmethod + def getObjectName(thing): + """Get the name of an object + + Parameters + ---------- + thing : object + The dcc object to get the name for + + Returns + ------- + : str : + The Object Name + """ + return thing.name + + @staticmethod + def staticUndoOpen() -> None: + """Open an undo chunk without knowledge of Simplex""" + pass + + @staticmethod + def staticUndoClose() -> None: + """Close an undo chunk without knowledge of Simplex""" + pass + + def undoOpen(self) -> None: + """Open an undo chunk with knowledge of Simplex""" + pass + + def undoClose(self) -> None: + """Close an undo chunk with knowledge of Simplex""" + pass + + @classmethod + def getPersistentFalloff(cls, thing): + """Get a representation of the given object that won't get deleted or garbage collected + + Parameters + ---------- + thing : object + The thing to get a persistent representation of + + Returns + ------- + : object : + The requested persistent object + """ + return cls.getObjectName(thing) + + @classmethod + def loadPersistentFalloff(cls, thing): + """Get the usable representation of the given persistent thing + + Parameters + ---------- + thing : object + A persistent representation + + Returns + ------- + : object : + The requested volatile object + """ + return cls.getObjectByName(thing) + + @classmethod + def getPersistentShape(cls, thing): + """Get a representation of the given object that won't get deleted or garbage collected + + Parameters + ---------- + thing : object + The thing to get a persistent representation of + + Returns + ------- + : object : + The requested persistent object + """ + return cls.getObjectName(thing) + + @classmethod + def loadPersistentShape(cls, thing): + """Get the usable representation of the given persistent thing + + Parameters + ---------- + thing : object + A persistent representation + + Returns + ------- + : object : + The requested volatile object + """ + return cls.getObjectByName(thing) + + @classmethod + def getPersistentSlider(cls, thing): + """Get a representation of the given object that won't get deleted or garbage collected + + Parameters + ---------- + thing : object + The thing to get a persistent representation of + + Returns + ------- + : object : + The requested persistent object + """ + return cls.getObjectName(thing) + + @classmethod + def loadPersistentSlider(cls, thing): + """Get the usable representation of the given persistent thing + + Parameters + ---------- + thing : object + A persistent representation + + Returns + ------- + : object : + The requested volatile object + """ + return cls.getObjectByName(thing) + + @staticmethod + def getSelectedObjects() -> list[DummyNode]: + """Get the selected objects""" + # Here in the dummy interface, we short-circuit this + # And return a default selected object called "thing" + return [DummyNode("thing")] + + def getFreezeThing(self, combo): + return [] + + +class SliderDispatch(QtCore.QObject): + valueChanged = Signal() + + def __init__(self, node, parent=None) -> None: + super().__init__(parent) + + def emitValueChanged(self, *args, **kwargs) -> None: + self.valueChanged.emit() + + +class Dispatch(QtCore.QObject): + beforeNew = Signal() + afterNew = Signal() + beforeOpen = Signal() + afterOpen = Signal() + undo = Signal() + redo = Signal() + + def __init__(self, parent=None) -> None: + super().__init__(parent) + + def connectCallbacks(self) -> None: + pass + + def disconnectCallbacks(self) -> None: + pass + + def emitBeforeNew(self, *args, **kwargs) -> None: + self.beforeNew.emit() + + def emitAfterNew(self, *args, **kwargs) -> None: + self.afterNew.emit() + + def emitBeforeOpen(self, *args, **kwargs) -> None: + self.beforeOpen.emit() + + def emitAfterOpen(self, *args, **kwargs) -> None: + self.afterOpen.emit() + + def emitUndo(self, *args, **kwargs) -> None: + self.undo.emit() + + def emitRedo(self, *args, **kwargs) -> None: + self.redo.emit() + + +DISPATCH = Dispatch() + + +def rootWindow() -> None: + return None diff --git a/src/python/simplexui/interface/mayaInterface.py b/src/python/simplexui/interface/mayaInterface.py index 54a01916..2d1b9ebe 100644 --- a/src/python/simplexui/interface/mayaInterface.py +++ b/src/python/simplexui/interface/mayaInterface.py @@ -1,3271 +1,2294 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# pylint: disable=invalid-name -from __future__ import annotations -import json -import re -from contextlib import contextmanager -from functools import wraps -from typing import TYPE_CHECKING - -import maya.cmds as cmds -import maya.OpenMaya as om -from alembic.AbcGeom import GeometryScope, OPolyMeshSchemaSample, OV2fGeomParamSample -from imath import IntArray, UnsignedIntArray, V2fArray, V3fArray - -from ..commands.alembicCommon import mkSampleVertexPoints, buildAbc -from Qt import QtCore -from Qt.QtCore import Signal -from Qt.QtWidgets import ( - QApplication, - QDialog, - QMainWindow, - QMessageBox, - QSplashScreen, -) - -if TYPE_CHECKING: - from ..items.simplex import Simplex - -try: - import numpy as np - from ..commands.numpytoimath import numpyToImath - from ..commands.mayatonumpy import mayaToNumpy -except ImportError: - np = None - numpyToImath = None - mayaToNumpy = None - - -# UNDO STACK INTEGRATION -@contextmanager -def undoContext(inst=None): - """ - - Parameters - ---------- - inst : - (Default value = None) - - Returns - ------- - - """ - if inst is None: - DCC.staticUndoOpen() - else: - inst.undoOpen() - try: - yield - finally: - if inst is None: - DCC.staticUndoClose() - else: - inst.undoClose() - - -def undoable(f): - """ - - Parameters - ---------- - f : - - - Returns - ------- - - """ - - @wraps(f) - def stacker(*args, **kwargs): - """ - - Parameters - ---------- - *args : - - **kwargs : - - - Returns - ------- - - """ - inst = None - if args and isinstance(args[0], DCC): - inst = args[0] - with undoContext(inst): - return f(*args, **kwargs) - - return stacker - - -def doDisconnect(targets, testCnxType=("double", "float")): - """Temporarily disconnect inputs from a list of nodes and plugs - - Parameters - ---------- - targets : - - testCnxType : - (Default value = ("double") - "float") : - - - Returns - ------- - - """ - if not isinstance(targets, (list, tuple)): - targets = [targets] - targets = list(set(targets)) - cnxs = {} - for target in targets: - tcnx = {} - cnxs[target] = tcnx - - cnx = cmds.listConnections( - target, plugs=True, destination=False, source=True, connections=True - ) - if cnx is None: - cnx = [] - - for i in range(0, len(cnx), 2): - cnxType = cmds.getAttr(cnx[i], type=True) - if cnxType not in testCnxType: - continue - tcnx[cnx[i + 1]] = cnx[i] - cmds.disconnectAttr(cnx[i + 1], cnx[i]) - return cnxs - - -def doReconnect(cnxs): - """ - - Parameters - ---------- - cnxs : - - - Returns - ------- - - """ - for tdict in cnxs.values(): - for s, d in tdict.items(): - if not cmds.isConnected(s, d): - cmds.connectAttr(s, d, force=True) - - -@contextmanager -def disconnected(targets, testCnxType=("double", "float")): - """ - - Parameters - ---------- - targets : - - testCnxType : - (Default value = ("double") - "float") : - - - Returns - ------- - - """ - cnxs = doDisconnect(targets, testCnxType=testCnxType) - try: - yield cnxs - finally: - doReconnect(cnxs) - - -def split_trailing_digits(s: str) -> tuple[str, str]: - """Split trailing digits from a string into (prefix, digits).""" - match = re.match(r"^(.*?)(\d+)?$", s) - if match: - return match.group(1), match.group(2) or "" - return s, "" - - -class DCC(object): - """ """ - - program = "maya" - - def __init__(self, simplex, stack=None): - if not cmds.pluginInfo("simplex_maya", query=True, loaded=True): - cmds.loadPlugin("simplex_maya") - self.undoDepth: int = 0 - self.name: str = "" # the name of the system - self.mesh: str = "" # the mesh object with the system - self.ctrl: str = "" # the object that has all the controllers on it - self.shapeNode: str = "" # the deformer object - self.hasPoseNode = False - self.poseNode: str = "" # the pose deformer object - self.op: str = "" # the simplex object - self.simplex: Simplex = simplex # the abstract representation of the setup - self._live: bool = True - self.sliderMul: float = self.simplex.sliderMul - - # def __deepcopy__(self, memo): - # ''' - # I don't actually need to define this here because I know that - # all of the maya "objects" store here are just strings - # But if they *weren't* (like in XSI) I would need to skip - # the maya objects when deepcopying, otherwise I might access - # a deleted scene node and crash everything - # And if we did skip things, I would also need to store a - # persistent accessor to use in case we get back to here - # through an undo - # ''' - # pass - - def _checkAllShapeValidity(self, shapeNames): - """Check shapes to see if they exist, and either gather the missing files, or - Load the proper data onto the shapes - - Parameters - ---------- - shapeNames : - - - Returns - ------- - - """ - # Keep the set ordered, but make a set for quick checking - missingNameSet = set() - missingNames = [] - seen = set() - - # Get the blendshape weight names - try: - # GOOD GOD. This is because in maya 2016.5, if you delete a multi-instance - # then listAttr, *it lists the deleted ones, and skips the ones at the end* - # So I have to use aliasAttr and filter for the weights - aliases = cmds.aliasAttr(self.shapeNode, query=True) or [] - attrs = [ - aliases[i] - for i in range(0, len(aliases), 2) - if aliases[i + 1].startswith("weight[") - ] - attrs = set(attrs) - - except ValueError: - attrs = set() - - for shapeName in shapeNames: - if shapeName in seen: - continue - seen.add(shapeName) - if shapeName not in attrs: - if shapeName not in missingNameSet: - missingNameSet.add(shapeName) - missingNames.append(shapeName) - return missingNames, len(attrs) - - @classmethod - def _removeExtraShapeNodes(cls, tfm): - shapeNodes = cmds.listRelatives(tfm, shapes=True, noIntermediate=True) - if len(shapeNodes) > 1: - keeper = None - todel = [] - for sn in shapeNodes: - tfmChk = "".join(sn.rsplit("Shape", 1)) - if tfmChk == tfm: - keeper = sn - else: - todel.append(sn) - if keeper is not None: - cmds.delete(todel) - - def preLoad(self, simp, simpDict, create=True, pBar=None): - """ - - Parameters - ---------- - simp : - - simpDict : - - create : - (Default value = True) - pBar : - (Default value = None) - - Returns - ------- - - """ - cmds.undoInfo(state=False) - try: - if pBar is not None: - pBar.setLabelText("Loading Connections") - QApplication.processEvents() - ev = simpDict["encodingVersion"] - - shapeNames = simpDict.get("shapes") - if not shapeNames: - return - - if ev > 1: - shapeNames = [i["name"] for i in shapeNames] - - toMake, nextIndex = self._checkAllShapeValidity(shapeNames) - - if not toMake: - return - - if not create: - if pBar is not None: - msg = "Some shapes are Missing:\n{}\n\nCreate them?" - msg = msg.format(", ".join(toMake)) - btns = ( - QMessageBox.StandardButton.Yes - | QMessageBox.StandardButton.Cancel - ) - bret = QMessageBox.question(pBar, "Missing Shapes", msg, btns) - if not bret & QMessageBox.StandardButton.Yes: - raise RuntimeError("Missing Shapes: {}".format(toMake)) - else: - raise RuntimeError("Missing Shapes: {}".format(toMake)) - - if pBar is not None: - spacer = "_" * max(list(map(len, toMake))) - pBar.setMaximum(len(toMake)) - pBar.setLabelText("Creating Empty Shape:\n{0}".format(spacer)) - pBar.setValue(0) - QApplication.processEvents() - - baseShape = cmds.duplicate(self.mesh)[0] - cmds.delete(baseShape, constructionHistory=True) - self._removeExtraShapeNodes(baseShape) - - for i, shapeName in enumerate(toMake): - if pBar is not None: - pBar.setLabelText("Creating Empty Shape:\n{0}".format(shapeName)) - pBar.setValue(i) - QApplication.processEvents() - - baseShape = cmds.rename(baseShape, shapeName) - - index = self._firstAvailableIndex() - cmds.blendShape( - self.shapeNode, edit=True, target=(self.mesh, index, baseShape, 1.0) - ) - weightAttr = "{0}.weight[{1}]".format(self.shapeNode, index) - thing = cmds.ls(weightAttr)[0] - - cmds.connectAttr("{0}.weights[{1}]".format(self.op, nextIndex), thing) - nextIndex += 1 - - cmds.delete(baseShape) - except Exception: - cmds.undoInfo(state=True) - raise - - def postLoad(self, simp, preRet): - """ - - Parameters - ---------- - simp : - - preRet : - - Returns - ------- - - """ - cmds.undoInfo(state=True) - - def checkForErrors(self, window): - """Check for any DCC specific errors - - Parameters - ---------- - window : QMainWindow - The simplex window - """ - shapeNodes = cmds.listRelatives(self.mesh, shapes=True, noIntermediate=True) - if len(shapeNodes) > 1: - msg = ( - "The current mesh has multiple shape nodes.", - "The UI will still mostly work, but extracting/connecting shapes" - "may fail in unexpected ways.", - ) - QMessageBox.warning(window, "Multiple Shape Nodes", "\n".join(msg)) - - def _getOpNodes(self, thing: str): - hist: list[str] = cmds.listHistory(thing) - rawShapeNodes = cmds.ls(hist, type="blendShape") - rawPoseNodes = cmds.ls(hist, type="blendPose") - - # Find any simplex ops connected to the history - # that have the given name - ops: list[str] = [] - rawOps: list[str] = [] - for sn in rawShapeNodes + rawPoseNodes: - op = ( - cmds.listConnections( - "{0}.{1}".format(sn, "message"), - source=False, - destination=True, - type="simplex_maya", - ) - or [] - ) - rawOps.extend(op) - rawOps = list(set(rawOps)) - - for op in rawOps: - js = cmds.getAttr(op + ".definition") or "" - sn = json.loads(js).get("systemName") - if sn == self.name: - ops.append(op) - - if len(ops) > 1: - raise RuntimeError( - "Found too many Simplex systems with the same name on the same object" - ) - return ops - - def _getShapeNodes(self, ops): - shapeNodes = [] - for op in ops: - try: - sn = cmds.listConnections( - "{0}.{1}".format(op, "shapeMsg"), - source=True, - destination=False, - type="blendShape", - ) - except ValueError: - continue - if sn: - shapeNodes.append(sn[0]) - return shapeNodes - - def _getPoseNodes(self, ops): - poseNodes = [] - for op in ops: - try: - sn = cmds.listConnections( - "{0}.{1}".format(op, "poseMsg"), - source=True, - destination=False, - type="blendPose", - ) - except ValueError: - continue - if sn: - poseNodes.append(sn[0]) - return poseNodes - - def _getCtrlNodes(self, ops): - ctrlCnx = [] - for op in ops: - ccnx = cmds.listConnections( - "{0}.{1}".format(op, "ctrlMsg"), - source=True, - destination=False, - ) - if ccnx: - ctrlCnx.append(ccnx[0]) - return ctrlCnx - - def _createShapeNode(self, name, mesh) -> str: - intermediates = [ - shp - for shp in cmds.listRelatives(mesh, shapes=True, path=True) - if cmds.getAttr(shp + ".intermediateObject") - ] - meshToFreeze = mesh if not intermediates else intermediates[0] - isIntermediate = cmds.getAttr(meshToFreeze + ".intermediateObject") - - # Unlock the normals on the rest head because blendshapes don't work with locked normals - # and you can't really do this after the blendshape has been created - cmds.polyNormalPerVertex(meshToFreeze, unFreezeNormal=True) - cmds.polySoftEdge(meshToFreeze, angle=180, constructionHistory=True) - cmds.setAttr(meshToFreeze + ".intermediateObject", 0) - cmds.delete(meshToFreeze, constructionHistory=True) - cmds.setAttr(meshToFreeze + ".intermediateObject", isIntermediate) - - name = "{}_BS".format(name) - return cmds.blendShape(mesh, name=name, frontOfChain=True)[0] - - def _createPoseNode(self, name) -> str: - name = "{}_BP".format(name) - return cmds.createNode("blendPose", name=name) - - def _createSimplexNode(self, name): - op = cmds.createNode("simplex_maya", name=name) - cmds.addAttr(op, longName="revision", attributeType="long") - cmds.addAttr(op, longName="shapeMsg", attributeType="message") - cmds.addAttr(op, longName="poseMsg", attributeType="message") - cmds.addAttr(op, longName="ctrlMsg", attributeType="message") - return op - - def _createControlNode(self, name, op): - tfmAttrs = [".tx", ".ty", ".tz", ".rx", ".ry", ".rz", ".sx", ".sy", ".sz", ".v"] - ctrl = cmds.group(empty=True, name="{0}_CTRL".format(name)) - for attr in tfmAttrs: - cmds.setAttr(ctrl + attr, keyable=False, channelBox=False) - cmds.addAttr(ctrl, longName="solver", attributeType="message") - cmds.connectAttr( - "{0}.{1}".format(ctrl, "solver"), - "{0}.{1}".format(op, "ctrlMsg"), - ) - return ctrl - - # System IO - @undoable - def loadNodes(self, simp, thing, create=True, pBar=None): - """Create a new system based on the simplex tree - Build any DCC objects that are missing if create=True - Raises a runtime error if missing objects are found and - create=False - - Parameters - ---------- - simp : - - thing : - - create : - (Default value = True) - pBar : - (Default value = None) - - Returns - ------- - - """ - self.name = simp.name - self.mesh = thing - - ops = self._getOpNodes(thing) - sns = self._getShapeNodes(ops) - cc = self._getCtrlNodes(ops) - - if not create and (not sns or not ops or not cc): - types = [] - if not sns: - types.append("blendShape") - if not ops: - types.append("simplex_maya") - if not cc: - types.append("CTRL") - - raise RuntimeError( - "Creation turned off and some objects are missing: {}".format( - ", ".join(types) - ) - ) - - self.op = ops[0] if ops else self._createSimplexNode(self.name) - self.ctrl = cc[0] if cc else self._createControlNode(self.name, self.op) - - self.shapeNode = sns[0] if sns else self._createShapeNode(self.name, self.mesh) - if not cmds.isConnected(f"{self.shapeNode}.message", f"{self.op}.shapeMsg"): - cmds.connectAttr(f"{self.shapeNode}.message", f"{self.op}.shapeMsg") - - if self.hasPoseNode: - pns = self._getPoseNodes(ops) - self.poseNode = pns[0] if pns else self._createPoseNode(self.name) - if not cmds.isConnected(f"{self.poseNode}.message", f"{self.op}.poseMsg"): - cmds.connectAttr(f"{self.poseNode}.message", f"{self.op}.poseMsg") - - def getShapeThing(self, shapeName): - """ - - Parameters - ---------- - shapeName : - - - Returns - ------- - - """ - s = cmds.ls("{0}.{1}".format(self.shapeNode, shapeName)) - if not s: - return None - return s[0] - - def getSliderThing(self, sliderName): - """ - - Parameters - ---------- - sliderName : - - - Returns - ------- - - """ - things = cmds.ls("{0}.{1}".format(self.ctrl, sliderName)) - if not things: - return None - return things[0] - - @classmethod - def buildDummyMesh(cls, name: str): - importHeadShape = cmds.createNode("mesh", name=name + "Shape") - badPar = cmds.listRelatives(importHeadShape, parent=True)[0] - importHead = cmds.rename(badPar, name) - return importHead, importHeadShape - - @classmethod - @undoable - def buildRestAbc(cls, abcMesh, name): - """ - - Parameters - ---------- - abcMesh : - - name : - - - Returns - ------- - - """ - if not cmds.pluginInfo("AbcImport", query=True, loaded=True): - cmds.loadPlugin("AbcImport") - if not cmds.pluginInfo("AbcImport", query=True, loaded=True): - raise RuntimeError("Unable to load the AbcImport plugin") - - abcPath = str(abcMesh.getArchive()) - - abcNode = cmds.createNode("AlembicNode") - cmds.setAttr(abcNode + ".abc_File", abcPath, type="string") - cmds.setAttr(abcNode + ".speed", 24) # Is this needed anymore? - cmds.setAttr(abcNode + ".time", 0) - - importHead, importHeadShape = cls.buildDummyMesh("{0}_SIMPLEX".format(name)) - - importHead = "{0}_SIMPLEX".format(name) - importHeadShape = cmds.createNode("mesh", name=importHead + "Shape") - badPar = cmds.listRelatives(importHeadShape, parent=True)[0] - importHead = cmds.rename(badPar, importHead) - - cmds.connectAttr(abcNode + ".outPolyMesh[0]", importHeadShape + ".inMesh") - cmds.polyEvaluate(importHead, vertex=True) # Force a refresh - cmds.disconnectAttr(abcNode + ".outPolyMesh[0]", importHeadShape + ".inMesh") - cmds.sets(importHead, edit=True, forceElement="initialShadingGroup") - cmds.delete(abcNode) - return importHead - - @classmethod - def vertCount(cls, mesh): - return cmds.polyEvaluate(mesh, vertex=True) - - @undoable - def loadAbcPoses(self, abcMesh, js, pBar=None): - pass - - @undoable - def loadAbc(self, abcMesh, js, pBar=None): - """ - - Parameters - ---------- - abcMesh : - - js : - - pBar : - (Default value = None) - - Returns - ------- - - """ - # UGH, I *REALLY* hate that this is faster - # But if I want to be "pure" about it, I should just bite the bullet - # and do the direct alembic manipulation in C++ - - if not cmds.pluginInfo("AbcImport", query=True, loaded=True): - cmds.loadPlugin("AbcImport") - if not cmds.pluginInfo("AbcImport", query=True, loaded=True): - raise RuntimeError("Unable to load the AbcImport plugin") - - abcPath = str(abcMesh.getArchive()) - - abcNode = cmds.createNode("AlembicNode") - cmds.setAttr(abcNode + ".abc_File", abcPath, type="string") - - timeUnits = { - "game": 15, - "film": 24, - "pal": 25, - "ntsc": 30, - "show": 48, - "palf": 50, - "ntscf": 60, - } - - fps = cmds.currentUnit(time=True, query=True) - if isinstance(fps, str): - if fps.endswith("fps"): - fps = fps[:-3] - if fps in timeUnits: - fps = timeUnits[fps] - fps = float(fps) - - cmds.setAttr(abcNode + ".speed", fps) - - shapes = js["shapes"] - shapeDict = {i.name: i for i in self.simplex.shapes} - - if js["encodingVersion"] > 1: - shapes = [i["name"] for i in shapes] - - importHead, importHeadShape = self.buildDummyMesh("importHead") - - cmds.connectAttr(abcNode + ".outPolyMesh[0]", importHeadShape + ".inMesh") - cmds.polyEvaluate(importHead, vertex=True) # Force a refresh - cmds.disconnectAttr(abcNode + ".outPolyMesh[0]", importHeadShape + ".inMesh") - - importRest = cmds.duplicate(self.mesh, name="importRest")[0] - cmds.delete(importRest, constructionHistory=True) - self._removeExtraShapeNodes(importRest) - - importBS: str = cmds.blendShape(importRest, importHead)[0] - cmds.blendShape(importBS, edit=True, weight=[(0, 1.0)]) - # Maybe get shapeNode from self.mesh?? - importOrig = [ - i for i in cmds.listRelatives(importHead, shapes=True) if i.endswith("Orig") - ][0] - cmds.connectAttr(abcNode + ".outPolyMesh[0]", importOrig + ".inMesh") - cmds.delete(importRest) - - if pBar is not None: - pBar.show() - pBar.setMaximum(len(shapes)) - longName = max(shapes, key=len) - pBar.setValue(1) - pBar.setLabelText("Loading:\n{0}".format("_" * len(longName))) - - for i, shapeName in enumerate(shapes): - if pBar is not None: - pBar.setValue(i) - pBar.setLabelText("Loading:\n{0}".format(shapeName)) - QApplication.processEvents() - if pBar.wasCanceled(): - return - index = self.getShapeIndex(shapeDict[shapeName]) - cmds.setAttr(abcNode + ".time", i) - - outAttr = "{0}.worldMesh[0]".format(importHead) - tgn = "{0}.inputTarget[0].inputTargetGroup[{1}]".format( - self.shapeNode, index - ) - inAttr = "{0}.inputTargetItem[6000].inputGeomTarget".format(tgn) - - cmds.connectAttr(outAttr, inAttr, force=True) - cmds.disconnectAttr(outAttr, inAttr) - cmds.delete(abcNode) - cmds.delete(importHead) - - def getAllShapeVertices(self, shapes, pBar=None): - """ - - Parameters - ---------- - shapes : - - pBar : - (Default value = None) - - Returns - ------- - - """ - sl = om.MSelectionList() - sl.add(self.mesh) - thing = om.MDagPath() - sl.getDagPath(0, thing) - meshFn = om.MFnMesh(thing) - _ptCount = meshFn.numVertices() - with disconnected(self.shapeNode) as cnx: - shapeCnx = cnx[self.shapeNode] - for v in shapeCnx.values(): - cmds.setAttr(v, 0.0) - - if pBar is not None: - # find the longest name for displaying stuff - sns = "_" * max(list(map(len, [s.name for s in shapes]))) - pBar.setLabelText("Getting Shape:\n{0}".format(sns)) - pBar.setMaximum(len(shapes)) - QApplication.processEvents() - - for i, shape in enumerate(shapes): - if pBar is not None: - pBar.setLabelText("Getting Shape:\n{0}".format(shape.name)) - pBar.setValue(i) - QApplication.processEvents() - - cmds.setAttr(shape.thing, 1.0) - - if np is not None and mayaToNumpy is not None: - out = mayaToNumpy(meshFn.getRawPoints()) - else: - flatverts = cmds.xform( - "{0}.vtx[*]".format(self.mesh), - translation=1, - query=1, - worldSpace=False, - ) - args = [iter(flatverts)] * 3 - out = list(zip(*args)) - - cmds.setAttr(shape.thing, 0.0) - shape.verts = out - - def getShapeVertices(self, shape): - """ - - Parameters - ---------- - shape : - - Returns - ------- - - """ - with disconnected(self.shapeNode) as cnx: - shapeCnx = cnx[self.shapeNode] - for v in shapeCnx.values(): - cmds.setAttr(v, 0.0) - cmds.setAttr(shape.thing, 1.0) - if np is None: - flatverts = cmds.xform( - "{0}.vtx[*]".format(self.mesh), - translation=1, - query=1, - worldSpace=False, - ) - args = [iter(flatverts)] * 3 - out = list(zip(*args)) - else: - out = self.getNumpyShape(self.mesh) - return out - - def pushAllShapeVertices(self, shapes, pBar=None): - """ - - Parameters - ---------- - shapes : - - pBar : - (Default value = None) - - Returns - ------- - - """ - # take all the verts stored on the shapes - # and push them back to the DCC - for shape in shapes: - self.pushShapeVertices(shape) - - def pushShapeVertices(self, shape): - """ - - Parameters - ---------- - shape : - - - Returns - ------- - - """ - # Push the vertices for a specific shape back to the DCC - pass - - @classmethod - def getMeshTopology(cls, mesh, uvName=None): - """Get the topology of a mesh - - Parameters - ---------- - mesh : object - The DCC Mesh to read - uvName : str, optional - The name of the uv set to read - - Returns - ------- - np.array : - The vertex array - np.array : - The "faces" array - np.array : - The "counts" array - np.array : - The uv positions - np.array : - The "uvFaces" array - """ - # Get the MDagPath from the name of the mesh - sl = om.MSelectionList() - sl.add(mesh) - thing = om.MDagPath() - sl.getDagPath(0, thing) - meshFn = om.MFnMesh(thing) - - vts = om.MPointArray() - meshFn.getPoints(vts, om.MSpace.kObject) - verts = [(vts[i].x, vts[i].y, vts[i].z) for i in range(vts.length())] - - faces = [] - counts = [] - rawUvFaces = [] - - vIdx = om.MIntArray() - - util = om.MScriptUtil() - util.createFromInt(0) - uvIdxPtr = util.asIntPtr() - uArray = om.MFloatArray() - vArray = om.MFloatArray() - meshFn.getUVs(uArray, vArray) - hasUvs = uArray.length() > 0 - - for i in range(meshFn.numPolygons()): - meshFn.getPolygonVertices(i, vIdx) - face = [] - for j in reversed(range(vIdx.length())): - face.append(vIdx[j]) - if hasUvs: - meshFn.getPolygonUVid(i, j, uvIdxPtr) - uvIdx = util.getInt(uvIdxPtr) - if uvIdx >= uArray.length() or uvIdx < 0: - uvIdx = 0 - rawUvFaces.append(uvIdx) - - face = [vIdx[j] for j in reversed(range(vIdx.length()))] - faces.extend(face) - counts.append(vIdx.length()) - - if hasUvs: - uvs = [(uArray[i], vArray[i]) for i in range(len(vArray))] - uvFaces = rawUvFaces - else: - uvs = None - uvFaces = None - - return verts, faces, counts, uvs, uvFaces - - def loadMeshTopology(self): - """ """ - self._faces, self._counts, self._uvs = self.getAbcFaces(self.mesh) - - @classmethod - def getNumpyShape(cls, mesh, world=False): - """Get the np.array shape of the mesh connected to the smpx - - Parameters - ---------- - mesh : str - The name of the maya shape object - world : bool - Whether to get the points in worldspace, or local space - - Returns - ------- - : np.array - The point positions of the mesh - - """ - if np is None or mayaToNumpy is None: - raise RuntimeError("Can't do numpy stuff if its not importable") - vts = cls._getMeshVertices(mesh, world=world) - ret = mayaToNumpy(vts) - return ret[..., :3].copy() - - @classmethod - def _getMeshVertices(cls, mesh, world=False): - """ """ - # Get the MDagPath from the name of the mesh - sl = om.MSelectionList() - sl.add(mesh) - thing = om.MDagPath() - sl.getDagPath(0, thing) - meshFn = om.MFnMesh(thing) - vts = om.MPointArray() - if world: - space = om.MSpace.kWorld - else: - space = om.MSpace.kObject - meshFn.getPoints(vts, space) - return vts - - @classmethod - def _exportAbcVertices(cls, mesh, world=False): - """ """ - if np is None or numpyToImath is None: - vts = cls._getMeshVertices(mesh, world=world) - vertices = V3fArray(vts.length()) - for i in range(vts.length()): - vertices[i] = (vts[i].x, vts[i].y, vts[i].z) - else: - vts = cls.getNumpyShape(mesh, world=world) - vertices = mkSampleVertexPoints(vts) - return vertices - - @classmethod - def getAbcFaces(cls, mesh): - """ """ - # Get the MDagPath from the name of the mesh - sl = om.MSelectionList() - sl.add(mesh) - thing = om.MDagPath() - sl.getDagPath(0, thing) - meshFn = om.MFnMesh(thing) - - faces = [] - faceCounts = [] - # uvArray = [] - uvIdxArray = [] - vIdx = om.MIntArray() - - util = om.MScriptUtil() - util.createFromInt(0) - uvIdxPtr = util.asIntPtr() - uArray = om.MFloatArray() - vArray = om.MFloatArray() - meshFn.getUVs(uArray, vArray) - hasUvs = uArray.length() > 0 - - for i in range(meshFn.numPolygons()): - meshFn.getPolygonVertices(i, vIdx) - face = [] - for j in reversed(range(vIdx.length())): - face.append(vIdx[j]) - if hasUvs: - meshFn.getPolygonUVid(i, j, uvIdxPtr) - uvIdx = util.getInt(uvIdxPtr) - if uvIdx >= uArray.length() or uvIdx < 0: - uvIdx = 0 - uvIdxArray.append(uvIdx) - - face = [vIdx[j] for j in reversed(range(vIdx.length()))] - faces.extend(face) - faceCounts.append(vIdx.length()) - - abcFaceIndices = IntArray(len(faces)) - for i in range(len(faces)): - abcFaceIndices[i] = faces[i] - - abcFaceCounts = IntArray(len(faceCounts)) - for i in range(len(faceCounts)): - abcFaceCounts[i] = faceCounts[i] - - if hasUvs: - abcUVArray = V2fArray(len(uArray)) - for i in range(len(vArray)): - abcUVArray[i] = (uArray[i], vArray[i]) - abcUVIdxArray = UnsignedIntArray(len(uvIdxArray)) - for i in range(len(uvIdxArray)): - abcUVIdxArray[i] = uvIdxArray[i] - uv = OV2fGeomParamSample( - abcUVArray, abcUVIdxArray, GeometryScope.kFacevaryingScope - ) - else: - uv = None - - return abcFaceIndices, abcFaceCounts, uv - - def exportAbc( - self, dccMesh, abcMesh, js, world=False, ensureCorrect=False, pBar=None - ): - """ """ - # export the data to alembic - if dccMesh is None: - dccMesh = self.mesh - - shapeDict = {i.name: i for i in self.simplex.shapes} - - shapeNames = js["shapes"] - if js["encodingVersion"] > 1: - shapeNames = [i["name"] for i in shapeNames] - shapes = [shapeDict[i] for i in shapeNames] - - faces, counts, uvs = self.getAbcFaces(dccMesh) - schema = abcMesh.getSchema() - - if pBar is not None: - pBar.show() - pBar.setMaximum(len(shapes)) - spacerName = "_" * max(list(map(len, shapeNames))) - pBar.setLabelText("Exporting:\n{0}".format(spacerName)) - QApplication.processEvents() - - if ensureCorrect: - # Since this code is used to both export and exportOther - # I only want to ensure that everything is correct only if - # I'm doing a normal export - envelope = cmds.getAttr(self.shapeNode + ".envelope") - cmds.setAttr(self.shapeNode + ".envelope", 1.0) - - with disconnected(self.shapeNode) as cnx: - shapeCnx = cnx[self.shapeNode] - for v in shapeCnx.values(): - cmds.setAttr(v, 0.0) - for i, shape in enumerate(shapes): - if pBar is not None: - pBar.setLabelText("Exporting:\n{0}".format(shape.name)) - pBar.setValue(i) - QApplication.processEvents() - if pBar.wasCanceled(): - return - cmds.setAttr(shape.thing, 1.0) - verts = self._exportAbcVertices(dccMesh, world=world) - if uvs is not None: - abcSample = OPolyMeshSchemaSample(verts, faces, counts, uvs) - else: - abcSample = OPolyMeshSchemaSample(verts, faces, counts) - schema.set(abcSample) - cmds.setAttr(shape.thing, 0.0) - - if ensureCorrect: - cmds.setAttr(self.shapeNode + ".envelope", envelope) - - def exportOtherAbc(self, dccMesh, abcMesh, js, world=False, pBar=None): - """ """ - shapeNames = js["shapes"] - if js["encodingVersion"] > 1: - shapeNames = [i["name"] for i in shapeNames] - - if pBar is not None: - pBar.show() - pBar.setMaximum(len(shapeNames)) - spacerName = "_" * max(list(map(len, shapeNames))) - pBar.setLabelText("Exporting:\n{0}".format(spacerName)) - QApplication.processEvents() - - # Get all the sliderVecs - shapeNames, inVecs, keyIdxs = self.simplex.buildInputVectors() - sliderVecs = [ - [0.0] * len(self.simplex.sliders) for i in range(len(self.simplex.shapes)) - ] - for iv, idx in zip(inVecs, keyIdxs): - sliderVecs[idx] = iv - - # Get all the fully expanded shapes, and the activations per shape - with disconnected(self.op) as allSliderCnx: - sliderCnx = allSliderCnx[self.op] - # zero all slider vals on the op to get the rest shape - for a in sliderCnx.values(): - cmds.setAttr(a, 0.0) - restVerts = self.getNumpyShape(dccMesh, world=world) - - fullShapes = np.zeros((len(self.simplex.shapes), len(restVerts), 3)) - shpValArray = np.zeros((len(self.simplex.shapes), len(self.simplex.shapes))) - for shpIdx, shape in enumerate(self.simplex.shapes): - if pBar is not None: - pBar.setLabelText("Reading Full Shapes:\n{0}".format(shape.name)) - pBar.setValue(shpIdx) - QApplication.processEvents() - if pBar.wasCanceled(): - raise RuntimeError("Cancelled!") - - # Set the full vec for this shape - inVec = sliderVecs[shpIdx] - for vi, vv in enumerate(inVec): - cmds.setAttr(sliderCnx[self.simplex.sliders[vi].thing], vv) - - fullShapes[shpIdx] = self.getNumpyShape(dccMesh, world=world) - - ary = np.array(cmds.getAttr(self.op + ".weights")[0]) - # Get rid of some floating point inaccuracies - ary[np.isclose(ary, 1.0)] = 1.0 - ary[np.isclose(ary, 0.0)] = 0.0 - shpValArray[shpIdx] = ary - - # Figure out what order to build the deltas - # so that the deltas exist when I try to combine them - ctrlOrder = self.simplex.controllersByDepth() - shapeOrder = [pp.shape for ctrl in ctrlOrder for pp in ctrl.prog.pairs] - shapeOrder = [i for i in shapeOrder if not i.isRest] - - # Incrementally Build the numpy array of delta shapes - # build deltaShapeArray as a 2d array because numpy is like 10x faster on 2d arrays - indexByShape = {v: k for k, v in enumerate(self.simplex.shapes)} - deltaShapeArray = np.zeros((len(self.simplex.shapes), len(restVerts) * 3)) - for shpOrderIdx, shape in enumerate(shapeOrder): - if pBar is not None: - pBar.setLabelText("Collapsing to Deltas:\n{0}".format(shape.name)) - pBar.setValue(shpOrderIdx) - QApplication.processEvents() - if pBar.wasCanceled(): - raise RuntimeError("Cancelled!") - - shpIdx = indexByShape[shape] - base = np.dot(shpValArray[shpIdx], deltaShapeArray) - deltaShapeArray[shpIdx] = ( - fullShapes[shpIdx] - restVerts - base.reshape((-1, 3)) - ).flatten() - - # And move that 2d array back into 3d - deltaShapeArray = deltaShapeArray.reshape((len(self.simplex.shapes), -1, 3)) - - # Finally write the outputs - faces, counts, uvs = self.getAbcFaces(dccMesh) - schema = abcMesh.getSchema() - for shpIdx, shape in enumerate(self.simplex.shapes): - if pBar is not None: - pBar.setLabelText("writing:\n{0}".format(shape.name)) - pBar.setValue(shpIdx) - QApplication.processEvents() - if pBar.wasCanceled(): - raise RuntimeError("Cancelled!") - shpVerts = restVerts + deltaShapeArray[shpIdx] - shpVerts = mkSampleVertexPoints(shpVerts) - if uvs is not None: - abcSample = OPolyMeshSchemaSample(shpVerts, faces, counts, uvs) - else: - abcSample = OPolyMeshSchemaSample(shpVerts, faces, counts) - schema.set(abcSample) - - def deleteObj(self, thing): - """Delete the given object""" - cmds.delete(thing) - - def exportMesh(self, mesh, path): - """Export a mesh to the given path""" - faces, counts, uvs = self.getAbcFaces(mesh) - shape = self.getNumpyShape(mesh) - name = mesh.split('|')[-1] - buildAbc(path, shape, faces, counts, uvs, name=name) - - # Revision tracking - def getRevision(self): - """ """ - try: - return cmds.getAttr("{0}.{1}".format(self.op, "revision")) - except ValueError: - # object does not exist - return None - - @undoable - def incrementRevision(self): - """ """ - value = self.getRevision() - if value is None: - return - cmds.setAttr("{0}.{1}".format(self.op, "revision"), value + 1) - jsString = self.simplex.dump() - self.setSimplexString(self.op, jsString) - return value + 1 - - @undoable - def setRevision(self, val): - """ - - Parameters - ---------- - val : - - - Returns - ------- - - """ - cmds.setAttr("{0}.{1}".format(self.op, "revision"), val) - - # System level - @undoable - def renameSystem(self, name): - """ - - Parameters - ---------- - name : - - - Returns - ------- - - """ - if ( - self.mesh is None - or self.ctrl is None - or self.shapeNode is None - or self.op is None - or self.simplex is None - ): - raise ValueError("System is not set up. Cannot rename") - - nn = self.mesh.replace(self.name, name) - self.mesh = cmds.rename(self.mesh, nn) - - nn = self.ctrl.replace(self.name, name) - self.ctrl = cmds.rename(self.ctrl, nn) - - oldNodeName = self.shapeNode - nn = self.shapeNode.replace(self.name, name) - self.shapeNode = cmds.rename(self.shapeNode, nn) - - nn = self.op.replace(self.name, name) - self.op = cmds.rename(self.op, nn) - - for shape in self.simplex.shapes: - shape.thing = shape.thing.replace(oldNodeName, self.shapeNode) - - self.name = name - - @undoable - def deleteSystem(self): - """ """ - cmds.delete(self.ctrl) - cmds.delete(self.shapeNode) - cmds.delete(self.op) - self.ctrl = None # the object that has all the controllers on it - self.shapeNode = None # the deformer object - self.op = None # the simplex object - self.simplex = None - - # Shapes - @undoable - def createShape(self, shape, live=False, offset=10): - """ - - Parameters - ---------- - shape : - - live : - (Default value = False) - offset : - (Default value = 10) - - Returns - ------- - - """ - with disconnected(self.shapeNode): - try: - attrs = cmds.listAttr("{0}.weight[*]".format(self.shapeNode)) - except ValueError: - pass - # Maya throws an error if there aren't any instead of - # just returning an empty list - else: - for attr in attrs: - cmds.setAttr("{0}.{1}".format(self.shapeNode, attr), 0.0) - newShape = cmds.duplicate(self.mesh, name=shape.name)[0] - - cmds.delete(newShape, constructionHistory=True) - index = self._firstAvailableIndex() - cmds.blendShape( - self.shapeNode, edit=True, target=(self.mesh, index, newShape, 1.0) - ) - weightAttr = "{0}.weight[{1}]".format(self.shapeNode, index) - thing = cmds.ls(weightAttr)[0] - - shapeIndex = len(shape.simplex.shapes) - 1 - cmds.connectAttr("{0}.weights[{1}]".format(self.op, shapeIndex), thing) - - if live: - cmds.xform(newShape, relative=True, translation=[offset, 0, 0]) - else: - cmds.delete(newShape) - - return thing - - def _firstAvailableIndex(self): - """ """ - aliases = cmds.aliasAttr(self.shapeNode, query=True) - idxs = set() - if not aliases: - return 0 - for alias in aliases: - match = re.search(r"\[\d+\]", alias) - if not match: - continue # No index found for the current shape - idxs.add(int(match.group().strip("[]"))) - - for i in range(len(idxs) + 1): - if i not in idxs: - return i - # there should be no way to get here, but just in case: - return len(idxs) + 1 - - def getShapeIndex(self, shape): - """ - - Parameters - ---------- - shape : - - - Returns - ------- - - """ - aName = cmds.attributeName(shape.thing) - aliases = cmds.aliasAttr(self.shapeNode, query=True) - idx = aliases.index(aName) - raw = aliases[idx + 1] - matches = re.findall(r"\[\d+\]", raw) - if not matches: - raise IndexError("No index found for the current shape") - return int(matches[-1].strip("[]")) - - @undoable - def extractWithDeltaShape(self, shape, live=True, offset=10.0): - """Make a mesh representing a shape. Can be live or not. - Also, make a shapenode that is the delta of the change being made - - Parameters - ---------- - shape : - - live : - (Default value = True) - offset : - (Default value = 10.0) - - Returns - ------- - - """ - with disconnected(self.shapeNode) as cnx: - shapeCnx = cnx[self.shapeNode] - for v in shapeCnx.values(): - cmds.setAttr(v, 0.0) - - # store the delta shape - delta = cmds.duplicate(self.mesh, name="{0}_Delta".format(shape.name))[0] - - # Extract the shape - cmds.setAttr(shape.thing, 1.0) - extracted = cmds.duplicate( - self.mesh, name="{0}_Extract".format(shape.name) - )[0] - - # Store the initial shape - init = cmds.duplicate(extracted, name="{0}_Init".format(shape.name))[0] - - # clear old orig objects - for item in [delta, extracted, init]: - self._clearShapes(item, doOrig=True) - - # build the deltaObj system - bs = cmds.blendShape(delta, name="{0}_DeltaBS".format(shape.name))[0] - - cmds.blendShape(bs, edit=True, target=(delta, 0, init, 1.0)) - cmds.blendShape(bs, edit=True, target=(delta, 1, extracted, 1.0)) - - cmds.setAttr("{0}.{1}".format(bs, init), -1.0) - cmds.setAttr("{0}.{1}".format(bs, extracted), 1.0) - - # Cleanup - nodeDict = {"Delta": delta, "Init": init} - repDict = self._reparentDeltaShapes(extracted, nodeDict, bs) - - # Shift the extracted shape to the side - cmds.xform(extracted, relative=True, translation=(offset, 0, 0)) - - if live: - self.connectShape(shape, extracted, live, delete=False) - - return extracted, repDict["Delta"] - - @undoable - def extractWithDeltaConnection(self, shape, delta, value, live=True, offset=10.0): - """Extract a shape with a live partial delta added in. - Useful for updating progressive shapes - - Parameters - ---------- - shape : - - delta : - - value : - - live : - (Default value = True) - offset : - (Default value = 10.0) - - Returns - ------- - - """ - with disconnected(self.shapeNode): - for attr in cmds.listAttr("{0}.weight[*]".format(self.shapeNode)): - cmds.setAttr("{0}.{1}".format(self.shapeNode, attr), 0.0) - - # Pull out the rest shape. we will blend this guy to the extraction - extracted = cmds.duplicate( - self.mesh, name="{0}_Extract".format(shape.name) - )[0] - - cmds.setAttr(shape.thing, 1.0) - # Store the initial shape - init = cmds.duplicate(self.mesh, name="{0}_Init".format(shape.name))[0] - - # clear old orig objects - for item in [init, extracted]: - self._clearShapes(item, doOrig=True) - - deltaPar = cmds.listRelatives(delta, parent=True)[0] - - # build the restObj system - cmds.select(clear=True) # 'cause maya - bs = cmds.blendShape(extracted, name="{0}_DeltaBS".format(shape.name))[0] - cmds.blendShape(bs, edit=True, target=(extracted, 0, init, 1.0)) - cmds.blendShape(bs, edit=True, target=(extracted, 1, deltaPar, 1.0)) - - cmds.setAttr("{0}.{1}".format(bs, init), 1.0) - cmds.setAttr("{0}.{1}".format(bs, deltaPar), value) - - outCnx = "{0}.worldMesh[0]".format(delta) - inCnx = "{0}.inputTarget[0].inputTargetGroup[{1}].inputTargetItem[6000].inputGeomTarget".format( - bs, 1 - ) - cmds.connectAttr(outCnx, inCnx, force=True) - cmds.aliasAttr(delta, "{0}.{1}".format(bs, deltaPar)) - - # Cleanup - nodeDict = {"Init": init} - self._reparentDeltaShapes(extracted, nodeDict, bs) - - # Remove the tweak node, otherwise editing the input progressives - # *inverts* the shape - exShape = cmds.listRelatives(extracted, noIntermediate=1, shapes=1)[0] - tweak = cmds.listConnections( - exShape + ".tweakLocation", source=1, destination=0 - ) - if tweak: - cmds.delete(tweak) - - # Shift the extracted shape to the side - cmds.xform(extracted, relative=True, translation=(offset, 0, 0)) - self.connectShape(shape, extracted, live, delete=False) - - return extracted - - @undoable - def extractShape(self, shape, live=True, offset=10.0): - """Make a mesh representing a shape. Can be live or not. - Can also store its starting shape and delta data - - Parameters - ---------- - shape : - - live : - (Default value = True) - offset : - (Default value = 10.0) - - Returns - ------- - - """ - with disconnected(self.shapeNode): - for attr in cmds.listAttr("{0}.weight[*]".format(self.shapeNode)): - cmds.setAttr("{0}.{1}".format(self.shapeNode, attr), 0.0) - - cmds.setAttr(shape.thing, 1.0) - extracted = cmds.duplicate( - self.mesh, name="{0}_Extract".format(shape.name) - )[0] - - # Shift the extracted shape to the side - cmds.xform(extracted, relative=True, translation=(offset, 0, 0)) - if live: - self.connectShape(shape, extracted, live, delete=False) - return extracted - - @undoable - def connectShape(self, shape, mesh=None, live=False, delete=False): - """Force a shape to match a mesh - The "connect shape" button is: - mesh=None, delete=True - The "match shape" button is: - mesh=someMesh, delete=False - There is a possibility of a "make live" button: - live=True, delete=False - - Parameters - ---------- - shape : - - mesh : - (Default value = None) - live : - (Default value = False) - delete : - (Default value = False) - - Returns - ------- - - """ - if mesh is None: - attrName = cmds.attributeName(shape.thing, long=True) - mesh = "{0}_Extract".format(attrName) - - chk = cmds.ls(mesh) - if not chk: - return - if len(chk) > 1: - msg = "Multiple objects with the same name found in file:\n" - msg += '\n'.join(chk) - raise ValueError(msg) - - index = self.getShapeIndex(shape) - tgn = "{0}.inputTarget[0].inputTargetGroup[{1}]".format(self.shapeNode, index) - cnx = mesh + "Shape" if cmds.nodeType(mesh) == "transform" else mesh - - outAttr = "{0}.worldMesh[0]".format( - cnx - ) # Make sure to check the right shape object - inAttr = "{0}.inputTargetItem[6000].inputGeomTarget".format(tgn) - - if not cmds.isConnected(outAttr, inAttr): - cmds.connectAttr(outAttr, inAttr, force=True) - - if not live: - cmds.disconnectAttr(outAttr, inAttr) - - if delete: - cmds.delete(mesh) - - @undoable - def extractPosedShape(self, shape): - """ - - Parameters - ---------- - shape : - - - Returns - ------- - - """ - pass - - @undoable - def zeroShape(self, shape): - """Set the shape to be completely zeroed - - Parameters - ---------- - shape : - - - Returns - ------- - - """ - index = self.getShapeIndex(shape) - tgn = "{0}.inputTarget[0].inputTargetGroup[{1}]".format(self.shapeNode, index) - shapeInput = "{0}.inputTargetItem[6000]".format(tgn) - cmds.setAttr( - "{0}.inputPointsTarget".format(shapeInput), 0, (), type="pointArray" - ) - cmds.setAttr( - "{0}.inputComponentsTarget".format(shapeInput), 0, "", type="componentList" - ) - - @undoable - def deleteShape(self, toDelShape): - """Remove a shape from the system - - Parameters - ---------- - toDelShape : - - - Returns - ------- - - """ - index = self.getShapeIndex(toDelShape) - tgn = "{0}.inputTarget[0].inputTargetGroup[{1}]".format(self.shapeNode, index) - cmds.removeMultiInstance(toDelShape.thing, b=True) - cmds.removeMultiInstance(tgn, b=True) - cmds.aliasAttr(toDelShape.thing, remove=True) - self._rebuildShapeConnections() - - def _rebuildShapeConnections(self): - """ """ - # Rebuild the shape connections in the proper order - cnxs = ( - cmds.listConnections( - self.op, plugs=True, source=False, destination=True, connections=True - ) - or [] - ) - for i, cnx in enumerate(cnxs): - if i % 2 == 0 and cnx.startswith("{0}.weights[".format(self.op)): - cmds.disconnectAttr(cnxs[i], cnxs[i + 1]) - - for i, shape in enumerate(self.simplex.shapes): - cmds.connectAttr( - "{0}.weights[{1}]".format(self.op, i), shape.thing, force=True - ) - - @undoable - def forceRebuildShapeConnections(self): - """ """ - self._rebuildShapeConnections() - - @undoable - def renameShape(self, shape, name): - """Change the name of the shape - - Parameters - ---------- - shape : - - name : - - - Returns - ------- - - """ - cmds.aliasAttr(name, shape.thing) - shape.thing = "{0}.{1}".format(self.shapeNode, name) - - @undoable - def convertShapeToCorrective(self, shape): - """ - - Parameters - ---------- - shape : - - - Returns - ------- - - """ - pass - - # Falloffs - def createFalloff(self, name): - """ - - Parameters - ---------- - name : - - - Returns - ------- - - """ - pass # for eventual live splits - - def duplicateFalloff(self, falloff, newFalloff, newName): - """ - - Parameters - ---------- - falloff : - - newFalloff : - - newName : - - - Returns - ------- - - """ - pass # for eventual live splits - - def deleteFalloff(self, falloff): - """ - - Parameters - ---------- - falloff : - - - Returns - ------- - - """ - pass # for eventual live splits - - def setFalloffData( - self, falloff, splitType, axis, minVal, minHandle, maxHandle, maxVal, mapName - ): - """ - - Parameters - ---------- - falloff : - - splitType : - - axis : - - minVal : - - minHandle : - - maxHandle : - - maxVal : - - mapName : - - - Returns - ------- - - """ - pass # for eventual live splits - - def getFalloffThing(self, falloff): - """ - - Parameters - ---------- - falloff : - - - Returns - ------- - - """ - shape = cmds.listRelatives(self.mesh, shapes=True)[0] - return shape + "." + falloff.name - - # Sliders - @undoable - def createSlider(self, slider): - """ - - Parameters - ---------- - slider : - - - Returns - ------- - - """ - index = slider.simplex.sliders.index(slider) - cmds.addAttr( - self.ctrl, - longName=slider.name, - attributeType="double", - keyable=True, - min=slider.minValue * self.sliderMul, - max=slider.maxValue * self.sliderMul, - ) - thing = "{0}.{1}".format(self.ctrl, slider.name) - cmds.connectAttr(thing, "{0}.sliders[{1}]".format(self.op, index)) - return thing - - @undoable - def renameSlider(self, slider, name): - """Set the name of a slider - - Parameters - ---------- - slider : - - name : - - - Returns - ------- - - """ - vals = [v.value for v in slider.prog.pairs] - cnx = cmds.listConnections( - slider.thing, plugs=True, source=False, destination=True - ) - cmds.deleteAttr(slider.thing) - cmds.addAttr( - self.ctrl, - longName=name, - attributeType="double", - keyable=True, - min=self.sliderMul * min(vals), - max=self.sliderMul * max(vals), - ) - newThing = "{0}.{1}".format(self.ctrl, name) - slider.thing = newThing - for c in cnx: - cmds.connectAttr(newThing, c) - - @undoable - def setSliderRange(self, slider): - """Set the range of a slider - - Parameters - ---------- - slider : - - - Returns - ------- - - """ - vals = [v.value for v in slider.prog.pairs] - attrName = "{0}.{1}".format(self.ctrl, slider.name) - cmds.addAttr( - attrName, - edit=True, - min=self.sliderMul * min(vals), - max=self.sliderMul * max(vals), - ) - - @undoable - def deleteSlider(self, toDelSlider): - """ - - Parameters - ---------- - toDelSlider : - - - Returns - ------- - - """ - cmds.deleteAttr(toDelSlider.thing) - - # Rebuild the slider connections in the proper order - # Get the sliders connections - cnxs = cmds.listConnections( - self.op, plugs=True, source=True, destination=False, connections=True - ) - for i, cnx in enumerate(cnxs): - if cnx.startswith("{0}.sliders".format(self.op)): - cmds.disconnectAttr(cnxs[i + 1], cnxs[i]) - - for i, slider in enumerate(self.simplex.sliders): - cmds.connectAttr(slider.thing, "{0}.sliders[{1}]".format(self.op, i)) - - @undoable - def addProgFalloff(self, prog, falloff): - """ - - Parameters - ---------- - prog : - - falloff : - - - Returns - ------- - - """ - pass # for eventual live splits - - @undoable - def removeProgFalloff(self, prog, falloff): - """ - - Parameters - ---------- - prog : - - falloff : - - - Returns - ------- - - """ - pass # for eventual live splits - - @undoable - def setSlidersWeights(self, sliders, weights): - """Set the weight of a slider. This does not change the definition - - Parameters - ---------- - sliders : - - weights : - - - Returns - ------- - - """ - for slider, weight in zip(sliders, weights): - try: - cmds.setAttr(slider.thing, weight) - except RuntimeError: - # Probably locked or connected. Just skip it - pass - - @undoable - def setSliderWeight(self, slider, weight): - """ - - Parameters - ---------- - slider : - - weight : - - - Returns - ------- - - """ - try: - cmds.setAttr(slider.thing, weight) - except RuntimeError: - # Probably locked or connected. Just skip it - pass - - @undoable - def updateSlidersRange(self, sliders): - """ - - Parameters - ---------- - sliders : - - - Returns - ------- - - """ - for slider in sliders: - vals = [v.value for v in slider.prog.pairs] - cmds.addAttr( - slider.thing, - edit=True, - min=min(vals) * self.sliderMul, - max=max(vals) * self.sliderMul, - ) - - def _doesDeltaExist(self, combo, target): - """ - - Parameters - ---------- - combo : - - target : - - - Returns - ------- - - """ - dshape = "{0}_DeltaShape".format(combo.name) - if not cmds.ls(dshape): - return None - par = cmds.listRelatives(dshape, allParents=1) - if not par: - # there is apparently a transform object with the name - return None - - par = cmds.ls(par[0], absoluteName=1) - tar = cmds.ls(target, absoluteName=1) - - if par != tar: - # the shape exists under a different transform ... ugh - return None - return par + "|" + dshape - - def _clearShapes(self, item, doOrig=False): - """ - - Parameters - ---------- - item : - - doOrig : - (Default value = False) - - Returns - ------- - - """ - aname = cmds.ls(item, long=1)[0] - shapes = cmds.ls(cmds.listRelatives(item, shapes=1), long=1) - baseName = aname.split("|")[-1] - baseName, digits = split_trailing_digits(baseName) - - primary = "{0}|{1}Shape{2}".format(aname, baseName, digits) - - origs = [] - others = [] - for shape in shapes: - base, digits = split_trailing_digits(shape) - if base.endswith('Orig'): - origs.append(shape) - else: - others.append(shape) - - for shape in others: - if shape == primary: - continue - cmds.delete(shape) - - if doOrig: - cmds.delete(origs) - else: - # Don't delete the first orig - if len(origs) > 1: - origs = sorted(origs) - cmds.delete(origs[1:]) - - @undoable - def forceRebuildSliderConnections(self): - """ """ - self._rebuildSliderConnections() - - def _rebuildSliderConnections(self): - # disconnect all outputs from the ctrl - rcnx = cmds.listConnections( - self.ctrl, source=False, plugs=True, connections=True - ) - for i in range(0, len(rcnx), 2): - src, dst = rcnx[i], rcnx[i + 1] - if cmds.getAttr(src, type=True) != "double": - # only disconnect doubles - continue - cmds.disconnectAttr(src, dst) - - # Reconnect by name - for i, sli in enumerate(self.simplex.sliders): - thing = self.getSliderThing(sli.name) - cmds.connectAttr(thing, self.op + ".sliders[{0}]".format(i)) - - # Combos - def _reparentDeltaShapes(self, par, nodeDict, bsNode, toDelete=None): - """Reparent and clean up a single-transform delta system - - Put all the relevant shape nodes from the nodeDict under the par, - and rename the shapes to maya's convention. Then build a callback - to ensure the blendshape node isn't left floating - - par: The parent transform node - nodeDict: A {simpleName: node} dictionary. - bsNode: The blendshape node. - toDelete: Any extra nodes to delte after all the node twiddling - - Parameters - ---------- - par : - - nodeDict : - - bsNode : - - toDelete : - (Default value = None) - - Returns - ------- - - """ - # Get the shapes and origs - shapeDict = {} - origDict = {} - - for name, node in nodeDict.items(): - shape = cmds.listRelatives(node, noIntermediate=1, shapes=1)[0] - shape = cmds.ls(shape, absoluteName=1)[0] - if shape: - shapeDict[name] = shape - - orig = shape + "Orig" - orig = cmds.ls(orig) - if orig: - origDict[name] = orig - - for name in nodeDict: - for d, fmt in [(shapeDict, "{0}Shape{1}"), (origDict, "{0}Shape{1}Orig")]: - shape = d.get(name) - if shape is None: - continue - shapeUUID = cmds.ls(shape, uuid=1)[0] - cmds.parent(shape, par, shape=True, relative=True) - newShape = cmds.rename(cmds.ls(shapeUUID)[0], fmt.format(par, name)) - d[name] = newShape - cmds.setAttr(newShape + ".intermediateObject", 1) - cmds.hide(newShape) - - cmds.delete(nodeDict[name]) - - if toDelete: - cmds.delete(toDelete) - - # Use the simplexDelete message attribute to keep track of what nodes - # will need to be delete-linked when the file is reopened - sdNode = par + ".simplexDelete" - if not cmds.ls(sdNode): - cmds.addAttr(par, longName="simplexDelete", attributeType="message") - cmds.connectAttr(bsNode + ".message", sdNode) - - # build the callback setup so the blendshape is deleted with the delta setup - # along with a persistent scriptjob - buildDeleterCallback(par, bsNode) - buildDeleterScriptJob() - - return shapeDict - - def _createTravDelta(self, trav, target, tVal, doReparent=True): - """Part of the traversal extraction process. - Very similar to the combo extraction - - Parameters - ---------- - trav : - - target : - - tVal : - - - Returns - ------- - - """ - exists = self._doesDeltaExist(trav, target) - if exists is not None: - return exists - - # Traversals *MAY* depend on floaters, but that's complicated - # I'm just gonna ignore them for now - floatShapes = [i.thing for i in self.simplex.getFloatingShapes()] - - # Get all traversal shapes - tShapes = [] - for oTrav in self.simplex.traversals: - tShapes.extend([i.thing for i in oTrav.prog.getShapes()]) - - with disconnected(self.op) as cnx: - sliderCnx = cnx[self.op] - - # zero all slider vals on the op - for a in sliderCnx.values(): - cmds.setAttr(a, 0.0) - - with disconnected(floatShapes + tShapes): - # pull out the rest shape - rest = cmds.duplicate(self.mesh, name="{0}_Rest".format(trav.name))[0] - - sliDict = {} - for pair in trav.startPoint.pairs: - sliDict[pair.slider] = [pair.value] - for pair in trav.endPoint.pairs: - sliDict[pair.slider].append(pair.value) - - for slider, (start, end) in sliDict.items(): - vv = start + tVal * (end - start) - cmds.setAttr(sliderCnx[slider.thing], vv) - - deltaObj = cmds.duplicate( - self.mesh, name="{0}_Delta".format(trav.name) - )[0] - base = cmds.duplicate(deltaObj, name="{0}_Base".format(trav.name))[0] - - # clear out all non-primary shapes so we don't have those 'Orig1' things floating around - for item in [rest, deltaObj, base]: - self._clearShapes(item, doOrig=True) - - # Build the delta blendshape setup - bs = cmds.blendShape(deltaObj, name="{0}_DeltaBS".format(trav.name))[0] - cmds.blendShape(bs, edit=True, target=(deltaObj, 0, target, 1.0)) - cmds.blendShape(bs, edit=True, target=(deltaObj, 1, base, 1.0)) - cmds.blendShape(bs, edit=True, target=(deltaObj, 2, rest, 1.0)) - cmds.setAttr("{0}.{1}".format(bs, target), 1.0) - cmds.setAttr("{0}.{1}".format(bs, base), 1.0) - cmds.setAttr("{0}.{1}".format(bs, rest), 1.0) - - # Cleanup - if doReparent: - nodeDict = {"Delta": deltaObj} - repDict = self._reparentDeltaShapes(target, nodeDict, bs, [rest, base]) - return repDict["Delta"] - return deltaObj - - @undoable - def extractTraversalShape(self, trav, shape, live=True, offset=10.0): - """Extract a shape from a Traversal progression - - Parameters - ---------- - trav : - shape : - live : - (Default value = True) - offset : - (Default value = 10.0) - - Returns - ------- - - """ - floatShapes = self.simplex.getFloatingShapes() - floatShapes = [i.thing for i in floatShapes] - - shapeIdx = trav.prog.getShapeIndex(shape) - val = trav.prog.pairs[shapeIdx].value - - # TODO: Do traversals interact? Should I turn off any other traversals? - # For now, no, but it may be a thing - # tShapes = [] - # for oTrav in self.simplex.traversals: - # if oTrav is trav: continue - # tShapes.extend([i.thing for i in oTrav.prog.getShapes()]) - - with disconnected(self.op) as cnx: - sliderCnx = cnx[self.op] - # zero all slider vals on the op - for a in sliderCnx.values(): - cmds.setAttr(a, 0.0) - - with disconnected(floatShapes): # tShapes - sliDict = {} - for pair in trav.startPoint.pairs: - sliDict[pair.slider] = [pair.value] - for pair in trav.endPoint.pairs: - sliDict[pair.slider].append(pair.value) - - for slider, (start, end) in sliDict.items(): - vv = start + val * (end - start) - cmds.setAttr(sliderCnx[slider.thing], vv) - - extracted = cmds.duplicate( - self.mesh, name="{0}_Extract".format(shape.name) - ) - extracted = extracted[0] - self._clearShapes(extracted) - cmds.xform(extracted, relative=True, translation=(offset, 0, 0)) - if live: - self.connectTraversalShape(trav, shape, extracted, live=live, delete=False) - cmds.select(extracted) - return extracted - - @undoable - def connectTraversalShape(self, trav, shape, mesh=None, live=True, delete=False): - """Connect a shape into a Traversal progression - - Parameters - ---------- - trav : - - shape : - - mesh : - (Default value = None) - live : - (Default value = True) - delete : - (Default value = False) - - Returns - ------- - - """ - if mesh is None: - attrName = cmds.attributeName(shape.thing, long=True) - mesh = "{0}_Extract".format(attrName) - - chk = cmds.ls(mesh) - if not chk: - return - if len(chk) > 1: - msg = "Multiple objects with the same name found in file:\n" - msg += '\n'.join(chk) - raise ValueError(msg) - - shapeIdx = trav.prog.getShapeIndex(shape) - tVal = trav.prog.pairs[shapeIdx].value - delta = self._createTravDelta(trav, mesh, tVal) - - if live: - self.connectShape(shape, delta, live, delete) - - if delete: - cmds.delete(mesh) - - def _createComboDelta(self, combo, target, tVal, doReparent=True): - """Part of the combo extraction process. - Combo shapes are fixit shapes added on top of any sliders. - This means that the actual combo-shape by itself will not look good by itself, - and that's bad for artist interaction. - So we must create a setup to take the final sculpted shape, and subtract - the any direct slider deformations to get the actual "combo shape" as a delta - It is this delta shape that is then plugged into the system - - Parameters - ---------- - combo : - - target : - - tVal : - - - Returns - ------- - - """ - exists = self._doesDeltaExist(combo, target) - if exists is not None: - return exists - - # get floaters - # As floaters can appear anywhere along any combo, they must - # always be evaluated in isolation. For this reason, we will - # always disconnect all floaters - floatShapes = [i.thing for i in self.simplex.getFloatingShapes()] - - # get my shapes - myShapes = [i.thing for i in combo.prog.getShapes()] - - with disconnected([self.op] + floatShapes + myShapes) as cnx: - sliderCnx = cnx[self.op] - - # zero all slider vals on the op - for a in sliderCnx.values(): - cmds.setAttr(a, 0.0) - - # pull out the rest shape - rest = cmds.duplicate(self.mesh, name="{0}_Rest".format(combo.name))[0] - - # set the combo values - for pair in combo.pairs: - cmds.setAttr(sliderCnx[pair.slider.thing], pair.value * tVal) - - # Get the resulting slider values for later - # weightPairs = [] - # self.shapeNode = None # the deformer object - - deltaObj = cmds.duplicate(self.mesh, name="{0}_Delta".format(combo.name))[0] - base = cmds.duplicate(deltaObj, name="{0}_Base".format(combo.name))[0] - - # clear out all non-primary shapes so we don't have those 'Orig1' things floating around - for item in [rest, deltaObj, base]: - self._clearShapes(item, doOrig=True) - - # Build the delta blendshape setup - bs = cmds.blendShape(deltaObj, name="{0}_DeltaBS".format(combo.name))[0] - cmds.blendShape(bs, edit=True, target=(deltaObj, 0, target, 1.0)) - cmds.blendShape(bs, edit=True, target=(deltaObj, 1, base, 1.0)) - cmds.blendShape(bs, edit=True, target=(deltaObj, 2, rest, 1.0)) - cmds.setAttr("{0}.{1}".format(bs, target), 1.0) - cmds.setAttr("{0}.{1}".format(bs, base), 1.0) - cmds.setAttr("{0}.{1}".format(bs, rest), 1.0) - - # Cleanup - if doReparent: - nodeDict = {"Delta": deltaObj} - repDict = self._reparentDeltaShapes(target, nodeDict, bs, [rest, base]) - return repDict["Delta"] - return deltaObj - - @undoable - def extractComboShape(self, combo, shape, live=True, offset=10.0): - """Extract a shape from a combo progression - - Parameters - ---------- - combo : - - shape : - - live : - (Default value = True) - offset : - (Default value = 10.0) - - Returns - ------- - - """ - floatShapes = self.simplex.getFloatingShapes() - floatShapes = [i.thing for i in floatShapes] - - shapeIdx = combo.prog.getShapeIndex(shape) - tVal = combo.prog.pairs[shapeIdx].value - - with disconnected(self.op) as cnx: - sliderCnx = cnx[self.op] - # zero all slider vals on the op - for a in sliderCnx.values(): - cmds.setAttr(a, 0.0) - - with disconnected(floatShapes): - # set the combo values - for pair in combo.pairs: - cmds.setAttr(sliderCnx[pair.slider.thing], pair.value * tVal) - - extracted = cmds.duplicate( - self.mesh, name="{0}_Extract".format(shape.name) - )[0] - - self._clearShapes(extracted) - cmds.xform(extracted, relative=True, translation=(offset, 0, 0)) - - if live: - self.connectComboShape(combo, shape, extracted, live=live, delete=False) - - cmds.select(extracted) - return extracted - - @undoable - def connectComboShape(self, combo, shape, mesh=None, live=True, delete=False): - """Connect a shape into a combo progression - - Parameters - ---------- - combo : - - shape : - - mesh : - (Default value = None) - live : - (Default value = True) - delete : - (Default value = False) - - Returns - ------- - - """ - if mesh is None: - attrName = cmds.attributeName(shape.thing, long=True) - mesh = "{0}_Extract".format(attrName) - - chk = cmds.ls(mesh) - if not chk: - return - if len(chk) > 1: - msg = "Multiple objects with the same name found in file:\n" - msg += '\n'.join(chk) - raise ValueError(msg) - mesh = chk[0] - - progShapeIdx = combo.prog.getShapeIndex(shape) - tVal = combo.prog.pairs[progShapeIdx].value - - delta = self._createComboDelta(combo, mesh, tVal) - if live: - self.connectShape(shape, delta, live, delete) - - if delete: - cmds.delete(mesh) - - @classmethod - def setDisabled(cls, op): - """ - - Parameters - ---------- - op : - - - Returns - ------- - - """ - bss = list(set(cmds.listConnections(op, type="blendShape"))) - helpers = [] - for bs in bss: - prop = "{0}.envelope".format(bs) - val = cmds.getAttr(prop) - cmds.setAttr(prop, 0.0) - if val != 0.0: - helpers.append((prop, val)) - return helpers - - @classmethod - def reEnable(cls, helpers): - """ - - Parameters - ---------- - helpers : - - - Returns - ------- - - """ - for prop, val in helpers: - cmds.setAttr(prop, val) - - @undoable - def renameCombo(self, combo, name): - """Set the name of a Combo - - Parameters - ---------- - combo : - - name : - - - Returns - ------- - - """ - pass - - # Data Access - @classmethod - def getSimplexOperators(cls): - """ """ - return cmds.ls(type="simplex_maya") - - @classmethod - def getSimplexOperatorsByName(cls, name): - """ - - Parameters - ---------- - name : - - - Returns - ------- - type - - - """ - return cmds.ls(name, type="simplex_maya") - - @classmethod - def getSimplexOperatorsOnObject(cls, thing): - """ - - Parameters - ---------- - thing : - - - Returns - ------- - type - - - """ - ops = cmds.ls(type="simplex_maya") - out = [] - for op in ops: - shapeNode = cmds.listConnections( - "{0}.{1}".format(op, "shapeMsg"), source=True, destination=False - ) - if not shapeNode: - continue - - # Now that I've got the connected blendshape node, I can check the deformer history - # to see if I find it. Eventually, I should probably set this up to deal with - # multi-objects, or branched hierarchies. But for now, it works - if shapeNode[0] in (cmds.listHistory(thing, pruneDagObjects=True) or []): - out.append(op) - return out - - @classmethod - def getSimplexString(cls, op): - """ - - Parameters - ---------- - op : - - - Returns - ------- - type - - - """ - return cmds.getAttr(op + ".definition") - - @classmethod - def getSimplexStringOnThing(cls, thing, systemName): - """ - - Parameters - ---------- - thing : - - systemName : - - - Returns - ------- - type - - - """ - ops = DCC.getSimplexOperatorsOnObject(thing) - for op in ops: - js = DCC.getSimplexString(op) - jdict = json.loads(js) - if jdict["systemName"] == systemName: - return js - return None - - @classmethod - def setSimplexString(cls, op, val): - """ - - Parameters - ---------- - op : - - val : - - - Returns - ------- - type - - - """ - return cmds.setAttr(op + ".definition", val, type="string") - - @classmethod - def selectObject(cls, thing): - """Select an object in the DCC - - Parameters - ---------- - thing : - - - Returns - ------- - - """ - cmds.select([thing]) - - def selectCtrl(self): - """Select the system's control object""" - if self.ctrl: - self.selectObject(self.ctrl) - - @classmethod - def getObjectByName(cls, name): - """ - - Parameters - ---------- - name : - - - Returns - ------- - type - - - """ - objs = cmds.ls(name) - if not objs: - return None - if len(objs) > 1: - raise ValueError("Multiple objects with the same name found") - return objs[0] - - @classmethod - def getObjectName(cls, thing): - """ - - Parameters - ---------- - thing : - - - Returns - ------- - type - - - """ - return thing - - @classmethod - def staticUndoOpen(cls): - """ """ - cmds.undoInfo(chunkName="SimplexOperation", openChunk=True) - - @classmethod - def staticUndoClose(cls): - """ """ - cmds.undoInfo(closeChunk=True) - - def undoOpen(self): - """ """ - if self.undoDepth == 0: - self.staticUndoOpen() - self.undoDepth += 1 - - def undoClose(self): - """ """ - self.undoDepth -= 1 - if self.undoDepth == 0: - self.staticUndoClose() - - @classmethod - def getPersistentFalloff(cls, thing): - """ - - Parameters - ---------- - thing : - - - Returns - ------- - - """ - return cls.getObjectName(thing) - - @classmethod - def loadPersistentFalloff(cls, thing): - """ - - Parameters - ---------- - thing : - - - Returns - ------- - - """ - return cls.getObjectByName(thing) - - @classmethod - def getPersistentShape(cls, thing): - """ - - Parameters - ---------- - thing : - - - Returns - ------- - - """ - return cls.getObjectName(thing) - - @classmethod - def loadPersistentShape(cls, thing): - """ - - Parameters - ---------- - thing : - - - Returns - ------- - - """ - return cls.getObjectByName(thing) - - @classmethod - def getPersistentSlider(cls, thing): - """ - - Parameters - ---------- - thing : - - - Returns - ------- - - """ - return cls.getObjectName(thing) - - @classmethod - def loadPersistentSlider(cls, thing): - """ - - Parameters - ---------- - thing : - - - Returns - ------- - - """ - return cls.getObjectByName(thing) - - @classmethod - def getSelectedObjects(cls): - """ """ - # For maya, only return transform nodes - return cmds.ls(sl=True, transforms=True) - - @undoable - def importObj(self, path): - """ - - Parameters - ---------- - path : - - - Returns - ------- - - """ - current = set(cmds.ls(transforms=True)) - cmds.file(path, i=True, type="OBJ", ignoreVersion=True) - new = set(cmds.ls(transforms=True)) - shapes = set(cmds.ls(shapes=True)) - new = new - current - shapes - imp = new.pop() - return imp - - @classmethod - def _getDeformerChain(cls, chkObj): - # Get a deformer chain - memo = [] - while chkObj and chkObj not in memo: - memo.append(chkObj) - - typ = cmds.nodeType(chkObj) - if typ == "mesh": - cnx = cmds.listConnections(chkObj + ".inMesh") or [None] - chkObj = cnx[0] - elif typ == "groupParts": - cnx = cmds.listConnections( - chkObj + ".inputGeometry", destination=False, shapes=True - ) or [None] - chkObj = cnx[0] - else: - cnx = cmds.ls(chkObj, type="geometryFilter") or [None] - chkObj = cnx[0] - if chkObj: # we have a deformer - cnx = cmds.listConnections(chkObj + ".input[0].inputGeometry") or [ - None - ] - chkObj = cnx[0] - return memo - - # Freezing stuff - def primeShapes(self, combo): - """Make sure the upstream shapes of this combo are primed and ready. - Priming here means the deltas are stored and available on the blendshape node - """ - # Maya doesn't populate the delta plugs on the blendshape node unless - # you have a mesh connection while the value for that shape is turned to 1 - upstreams = [] - comboUps = self.simplex.getComboUpstreams(combo) - for u in comboUps: - upstreams.append(u.prog.getShapeAtValue(1.0)) - for pair in combo.pairs: - sli, val = pair.slider, pair.value - upstreams.append(sli.prog.getShapeAtValue(val)) - - with disconnected(self.shapeNode) as cnx: - shapeCnx = cnx[self.shapeNode] - for v in shapeCnx.values(): - cmds.setAttr(v, 0.0) - - for shape in upstreams: - cmds.setAttr(shape.thing, 1.0) - try: - # Make sure to check for any already incoming connections - index = self.getShapeIndex(shape) - tgn = "{0}.inputTarget[0].inputTargetGroup[{1}]".format( - self.shapeNode, index - ) - isConnected = cmds.listConnections( - tgn, source=True, destination=False - ) - - if not isConnected: - shapeGeo = cmds.duplicate(self.mesh, name=shape.name)[0] - shape.connectShape(mesh=shapeGeo, live=False, delete=True) - - finally: - cmds.setAttr(shape.thing, 0.0) - - def getFreezeThing(self, combo): - # If the blendshape shape has an incoming connection whose shape name - # ends with 'FreezeShape' and the shape's parent is the ctrl - ret = [] - shapes = combo.prog.getShapes() - shapes = [i for i in shapes if not i.isRest] - - shapePlugFmt = ( - ".inputTarget[{meshIdx}].inputTargetGroup[{shapeIdx}].inputTargetItem[6000]" - ) - - for shape in shapes: - shpIdx = self.getShapeIndex(shape) - shpPlug = ( - self.shapeNode - + shapePlugFmt.format(meshIdx=0, shapeIdx=shpIdx) - + ".inputGeomTarget" - ) - - cnx = cmds.listConnections(shpPlug, shapes=True, destination=False) or [] - for cc in cnx: - if not cc.endswith("FreezeShape"): - continue - par = cmds.listRelatives(cc, parent=True) - if par and par[0] == self.ctrl: - # Can't use list history to get the chain because it's a pseudo-cycle - ret.extend(self._getDeformerChain(cc)) - - if ret: - self.primeShapes(combo) - - return ret - - -class SliderDispatch(QtCore.QObject): - valueChanged = Signal() - - def __init__(self, node, parent=None): - super(SliderDispatch, self).__init__(parent) - mObject = getMObject(node) - self.callbackID = om.MNodeMessage.addAttributeChangedCallback( - mObject, self.emitValueChanged - ) - - def emitValueChanged(self, *args, **kwargs): - self.valueChanged.emit() - - def disconnectCallbacks(self): - om.MMessage.removeCallback(self.callbackID) - self.callbackID = None - - def __del__(self): - self.disconnectCallbacks() - - -class Dispatch(QtCore.QObject): - beforeNew = Signal() - afterNew = Signal() - beforeOpen = Signal() - afterOpen = Signal() - undo = Signal() - redo = Signal() - - def __init__(self, parent=None): - super(Dispatch, self).__init__(parent) - self.callbackIDs = [] - self.connectCallbacks() - - def connectCallbacks(self): - if self.callbackIDs: - self.disconnectCallbacks() - - self.callbackIDs.append( - om.MSceneMessage.addCallback( - om.MSceneMessage.kBeforeNew, self.emitBeforeNew - ) - ) - self.callbackIDs.append( - om.MSceneMessage.addCallback(om.MSceneMessage.kAfterNew, self.emitAfterNew) - ) - self.callbackIDs.append( - om.MSceneMessage.addCallback( - om.MSceneMessage.kBeforeOpen, self.emitBeforeOpen - ) - ) - self.callbackIDs.append( - om.MSceneMessage.addCallback( - om.MSceneMessage.kAfterOpen, self.emitAfterOpen - ) - ) - self.callbackIDs.append( - om.MEventMessage.addEventCallback("Undo", self.emitUndo) - ) - self.callbackIDs.append( - om.MEventMessage.addEventCallback("Redo", self.emitRedo) - ) - - def disconnectCallbacks(self): - for i in self.callbackIDs: - om.MMessage.removeCallback(i) - self.callbackIDs = [] - - def emitBeforeNew(self, *args, **kwargs): - self.beforeNew.emit() - - def emitAfterNew(self, *args, **kwargs): - self.afterNew.emit() - - def emitBeforeOpen(self, *args, **kwargs): - self.beforeOpen.emit() - - def emitAfterOpen(self, *args, **kwargs): - self.afterOpen.emit() - - def emitUndo(self, *args, **kwargs): - self.undo.emit() - - def emitRedo(self, *args, **kwargs): - self.redo.emit() - - def __del__(self): - self.disconnectCallbacks() - - -DISPATCH = Dispatch() - - -def rootWindow(): - """Returns the currently active QT main window - Only works for QT UI's like Maya - """ - # for MFC apps there should be no root window - window = None - if QApplication.instance(): - inst = QApplication.instance() - window = inst.activeWindow() - # Ignore QSplashScreen's, they should never be considered the root window. - if isinstance(window, QSplashScreen): - return None - # If the application does not have focus try to find A top level widget - # that doesn't have a parent and is a QMainWindow or QDialog - if window is None: - windows = [] - dialogs = [] - for w in QApplication.instance().topLevelWidgets(): - if w.parent() is None: - if isinstance(w, QMainWindow): - windows.append(w) - elif isinstance(w, QDialog): - dialogs.append(w) - if windows: - window = windows[0] - elif dialogs: - window = dialogs[0] - - # grab the root window - if window: - while True: - parent = window.parent() - if not parent: - break - if isinstance(parent, QSplashScreen): - break - window = parent - - return window - - -SIMPLEX_RESET_SCRIPTJOB = """ -import maya.cmds as cmds -import maya.OpenMaya as om - -def simplexDelCB(node, dgMod, clientData): - xNode, dName = clientData - dNode = getMObject(dName) - if dNode and not dNode.isNull(): - dgMod.deleteNode(dNode) - -def getMObject(name): - selected = om.MSelectionList() - try: - selected.add(name, True) - except RuntimeError: - return None - if selected.isEmpty(): - return None - thing = om.MObject() - selected.getDependNode(0, thing) - return thing - -# get all .simplexDelete message attributes -delAttrs = cmds.ls("*.simplexDelete") -if delAttrs: - # get all their connections - cnx = cmds.listConnections(delAttrs, plugs=True, connections=True, destination=False) - - # Set up the deletion callback - mmIds = [] - for i in range(0, len(cnx), 2): - parName, delName = cmds.ls(cnx[i:i+2], long=True, objectsOnly=True) - pNode = getMObject(parName) - dNode = getMObject(delName) - om.MNodeMessage.addNodeAboutToDeleteCallback(pNode, simplexDelCB, (dNode, delName)) -""" - - -def buildDeleterScriptJob(): - dcbName = "SimplexDeleterCallback" - if not cmds.ls(dcbName): - cmds.scriptNode( - scriptType=1, - beforeScript=SIMPLEX_RESET_SCRIPTJOB, - name=dcbName, - sourceType="python", - ) - - -def simplexDelCB(_node, dgMod, clientData): - _xNode, dName = clientData - dNode = getMObject(dName) - if dNode and not dNode.isNull(): - dgMod.deleteNode(dNode) - - -def getMObject(name): - selected = om.MSelectionList() - try: - selected.add(name, True) - except RuntimeError: - return None - if selected.isEmpty(): - return None - thing = om.MObject() - selected.getDependNode(0, thing) - return thing - - -def buildDeleterCallback(parName, delName): - pNode = getMObject(parName) - dNode = getMObject(delName) - idNum = om.MNodeMessage.addNodeAboutToDeleteCallback( - pNode, simplexDelCB, (dNode, delName) - ) - return idNum +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +import json +import re +from contextlib import contextmanager +from functools import wraps +from typing import TYPE_CHECKING + +import maya.cmds as cmds +import maya.OpenMaya as om +import numpy as np +from alembic.AbcGeom import GeometryScope, OPolyMeshSchemaSample, OV2fGeomParamSample +from imath import IntArray, UnsignedIntArray, V2fArray, V3fArray +from Qt import QtCore +from Qt.QtCore import Signal +from Qt.QtWidgets import ( + QApplication, + QDialog, + QMainWindow, + QMessageBox, + QSplashScreen, +) + +from ..commands.alembicCommon import buildAbc, mkSampleVertexPoints +from ..commands.mayatonumpy import mayaToNumpy + + +if TYPE_CHECKING: + from ..items.simplex import Simplex, Shape + + +# UNDO STACK INTEGRATION +@contextmanager +def undoContext(inst: DCC | None = None): + if inst is None: + DCC.staticUndoOpen() + else: + inst.undoOpen() + try: + yield + finally: + if inst is None: + DCC.staticUndoClose() + else: + inst.undoClose() + + +def undoable(f): + @wraps(f) + def stacker(*args, **kwargs): + inst = None + if args and isinstance(args[0], DCC): + inst = args[0] + with undoContext(inst): + return f(*args, **kwargs) + + return stacker + + +def doDisconnect(targets, testCnxType=("double", "float")): + """Temporarily disconnect inputs from a list of nodes and plugs""" + if not isinstance(targets, (list, tuple)): + targets = [targets] + targets = list(set(targets)) + cnxs = {} + for target in targets: + tcnx = {} + cnxs[target] = tcnx + + cnx = cmds.listConnections( + target, plugs=True, destination=False, source=True, connections=True + ) + if cnx is None: + cnx = [] + + for i in range(0, len(cnx), 2): + cnxType = cmds.getAttr(cnx[i], type=True) + if cnxType not in testCnxType: + continue + tcnx[cnx[i + 1]] = cnx[i] + cmds.disconnectAttr(cnx[i + 1], cnx[i]) + return cnxs + + +def doReconnect(cnxs) -> None: + for tdict in cnxs.values(): + for s, d in tdict.items(): + if not cmds.isConnected(s, d): + cmds.connectAttr(s, d, force=True) + + +@contextmanager +def disconnected(targets, testCnxType=("double", "float")): + cnxs = doDisconnect(targets, testCnxType=testCnxType) + try: + yield cnxs + finally: + doReconnect(cnxs) + + +def split_trailing_digits(s: str) -> tuple[str, str]: + """Split trailing digits from a string into (prefix, digits).""" + match = re.match(r"^(.*?)(\d+)?$", s) + if match: + return match.group(1), match.group(2) or "" + return s, "" + + +class DCC: + program = "maya" + + def __init__(self, simplex, stack=None) -> None: + if not cmds.pluginInfo("simplex_maya", query=True, loaded=True): + cmds.loadPlugin("simplex_maya") + self.undoDepth: int = 0 + self.name: str = "" # the name of the system + self.mesh: str = "" # the mesh object with the system + self.ctrl: str = "" # the object that has all the controllers on it + self.shapeNode: str = "" # the deformer object + self.hasPoseNode = False + self.poseNode: str = "" # the pose deformer object + self.op: str = "" # the simplex object + self.simplex: Simplex = simplex # the abstract representation of the setup + self._live: bool = True + self.sliderMul: float = self.simplex.sliderMul + + # def __deepcopy__(self, memo): + # I don't actually need to define this here because I know that + # all of the maya "objects" store here are just strings + # But if they *weren't* (like in XSI) I would need to skip + # the maya objects when deepcopying, otherwise I might access + # a deleted scene node and crash everything + # And if we did skip things, I would also need to store a + # persistent accessor to use in case we get back to here + # through an undo + + def _checkAllShapeValidity(self, shapeNames): + """Check shapes to see if they exist, and either gather the missing files, or + Load the proper data onto the shapes + """ + # Keep the set ordered, but make a set for quick checking + missingNameSet = set() + missingNames = [] + seen = set() + + # Get the blendshape weight names + try: + # GOOD GOD. This is because in maya 2016.5, if you delete a multi-instance + # then listAttr, *it lists the deleted ones, and skips the ones at the end* + # So I have to use aliasAttr and filter for the weights + aliases = cmds.aliasAttr(self.shapeNode, query=True) or [] + attrs = [ + aliases[i] + for i in range(0, len(aliases), 2) + if aliases[i + 1].startswith("weight[") + ] + attrs = set(attrs) + + except ValueError: + attrs = set() + + for shapeName in shapeNames: + if shapeName in seen: + continue + seen.add(shapeName) + if shapeName not in attrs: + if shapeName not in missingNameSet: + missingNameSet.add(shapeName) + missingNames.append(shapeName) + return missingNames, len(attrs) + + @classmethod + def _removeExtraShapeNodes(cls, tfm) -> None: + shapeNodes = cmds.listRelatives(tfm, shapes=True, noIntermediate=True) + if len(shapeNodes) > 1: + keeper = None + todel = [] + for sn in shapeNodes: + tfmChk = "".join(sn.rsplit("Shape", 1)) + if tfmChk == tfm: + keeper = sn + else: + todel.append(sn) + if keeper is not None: + cmds.delete(todel) + + def preLoad(self, simp, simpDict, create: bool = True, pBar=None) -> None: + cmds.undoInfo(state=False) + try: + if pBar is not None: + pBar.setLabelText("Loading Connections") + QApplication.processEvents() + ev = simpDict["encodingVersion"] + + shapeNames = simpDict.get("shapes") + if not shapeNames: + return + + if ev > 1: + shapeNames = [i["name"] for i in shapeNames] + + toMake, nextIndex = self._checkAllShapeValidity(shapeNames) + + if not toMake: + return + + if not create: + if pBar is not None: + msg = "Some shapes are Missing:\n{}\n\nCreate them?" + msg = msg.format(", ".join(toMake)) + btns = ( + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.Cancel + ) + bret = QMessageBox.question(pBar, "Missing Shapes", msg, btns) + if not bret & QMessageBox.StandardButton.Yes: + raise RuntimeError(f"Missing Shapes: {toMake}") + else: + raise RuntimeError(f"Missing Shapes: {toMake}") + + if pBar is not None: + spacer = "_" * max(list(map(len, toMake))) + pBar.setMaximum(len(toMake)) + pBar.setLabelText(f"Creating Empty Shape:\n{spacer}") + pBar.setValue(0) + QApplication.processEvents() + + baseShape = cmds.duplicate(self.mesh)[0] + cmds.delete(baseShape, constructionHistory=True) + self._removeExtraShapeNodes(baseShape) + + for i, shapeName in enumerate(toMake): + if pBar is not None: + pBar.setLabelText(f"Creating Empty Shape:\n{shapeName}") + pBar.setValue(i) + QApplication.processEvents() + + baseShape = cmds.rename(baseShape, shapeName) + + index = self._firstAvailableIndex() + cmds.blendShape( + self.shapeNode, edit=True, target=(self.mesh, index, baseShape, 1.0) + ) + weightAttr = f"{self.shapeNode}.weight[{index}]" + thing = cmds.ls(weightAttr)[0] + + cmds.connectAttr(f"{self.op}.weights[{nextIndex}]", thing) + nextIndex += 1 + + cmds.delete(baseShape) + except Exception: + cmds.undoInfo(state=True) + raise + + def postLoad(self, simp, preRet) -> None: + cmds.undoInfo(state=True) + + def checkForErrors(self, window) -> None: + """Check for any DCC specific errors + + Parameters + ---------- + window : QMainWindow + The simplex window + """ + shapeNodes = cmds.listRelatives(self.mesh, shapes=True, noIntermediate=True) + if len(shapeNodes) > 1: + msg = ( + "The current mesh has multiple shape nodes.", + "The UI will still mostly work, but extracting/connecting shapes" + "may fail in unexpected ways.", + ) + QMessageBox.warning(window, "Multiple Shape Nodes", "\n".join(msg)) + + def _getOpNodes(self, thing: str) -> list[str]: + hist: list[str] = cmds.listHistory(thing) + rawShapeNodes = cmds.ls(hist, type="blendShape") + rawPoseNodes = cmds.ls(hist, type="blendPose") + + # Find any simplex ops connected to the history + # that have the given name + ops: list[str] = [] + rawOps: list[str] = [] + for sn in rawShapeNodes + rawPoseNodes: + op = ( + cmds.listConnections( + f"{sn}.message", + source=False, + destination=True, + type="simplex_maya", + ) + or [] + ) + rawOps.extend(op) + rawOps = list(set(rawOps)) + + for op in rawOps: + js = cmds.getAttr(op + ".definition") or "" + sn = json.loads(js).get("systemName") + if sn == self.name: + ops.append(op) + + if len(ops) > 1: + raise RuntimeError( + "Found too many Simplex systems with the same name on the same object" + ) + return ops + + def _getShapeNodes(self, ops: list[str]): + shapeNodes = [] + for op in ops: + try: + sn = cmds.listConnections( + f"{op}.shapeMsg", + source=True, + destination=False, + type="blendShape", + ) + except ValueError: + continue + if sn: + shapeNodes.append(sn[0]) + return shapeNodes + + def _getPoseNodes(self, ops: list[str]): + poseNodes = [] + for op in ops: + try: + sn = cmds.listConnections( + f"{op}.poseMsg", + source=True, + destination=False, + type="blendPose", + ) + except ValueError: + continue + if sn: + poseNodes.append(sn[0]) + return poseNodes + + def _getCtrlNodes(self, ops: list[str]): + ctrlCnx = [] + for op in ops: + ccnx = cmds.listConnections( + f"{op}.ctrlMsg", + source=True, + destination=False, + ) + if ccnx: + ctrlCnx.append(ccnx[0]) + return ctrlCnx + + def _createShapeNode(self, name, mesh) -> str: + intermediates = [ + shp + for shp in cmds.listRelatives(mesh, shapes=True, path=True) + if cmds.getAttr(shp + ".intermediateObject") + ] + meshToFreeze = mesh if not intermediates else intermediates[0] + isIntermediate = cmds.getAttr(meshToFreeze + ".intermediateObject") + + # Unlock the normals on the rest head because blendshapes don't work with locked normals + # and you can't really do this after the blendshape has been created + cmds.polyNormalPerVertex(meshToFreeze, unFreezeNormal=True) + cmds.polySoftEdge(meshToFreeze, angle=180, constructionHistory=True) + cmds.setAttr(meshToFreeze + ".intermediateObject", 0) + cmds.delete(meshToFreeze, constructionHistory=True) + cmds.setAttr(meshToFreeze + ".intermediateObject", isIntermediate) + + name = f"{name}_BS" + return cmds.blendShape(mesh, name=name, frontOfChain=True)[0] + + def _createPoseNode(self, name) -> str: + name = f"{name}_BP" + return cmds.createNode("blendPose", name=name) + + def _createSimplexNode(self, name): + op = cmds.createNode("simplex_maya", name=name) + cmds.addAttr(op, longName="revision", attributeType="long") + cmds.addAttr(op, longName="shapeMsg", attributeType="message") + cmds.addAttr(op, longName="poseMsg", attributeType="message") + cmds.addAttr(op, longName="ctrlMsg", attributeType="message") + return op + + def _createControlNode(self, name, op): + tfmAttrs = [".tx", ".ty", ".tz", ".rx", ".ry", ".rz", ".sx", ".sy", ".sz", ".v"] + ctrl = cmds.group(empty=True, name=f"{name}_CTRL") + for attr in tfmAttrs: + cmds.setAttr(ctrl + attr, keyable=False, channelBox=False) + cmds.addAttr(ctrl, longName="solver", attributeType="message") + cmds.connectAttr( + f"{ctrl}.solver", + f"{op}.ctrlMsg", + ) + return ctrl + + # System IO + @undoable + def loadNodes(self, simp, thing, create: bool = True, pBar=None) -> None: + """Create a new system based on the simplex tree + Build any DCC objects that are missing if create=True + Raises a runtime error if missing objects are found and + create=False + """ + self.name = simp.name + self.mesh = thing + + ops = self._getOpNodes(thing) + sns = self._getShapeNodes(ops) + cc = self._getCtrlNodes(ops) + + if not create and (not sns or not ops or not cc): + types = [] + if not sns: + types.append("blendShape") + if not ops: + types.append("simplex_maya") + if not cc: + types.append("CTRL") + + raise RuntimeError( + "Creation turned off and some objects are missing: {}".format( + ", ".join(types) + ) + ) + + self.op = ops[0] if ops else self._createSimplexNode(self.name) + self.ctrl = cc[0] if cc else self._createControlNode(self.name, self.op) + + self.shapeNode = sns[0] if sns else self._createShapeNode(self.name, self.mesh) + if not cmds.isConnected(f"{self.shapeNode}.message", f"{self.op}.shapeMsg"): + cmds.connectAttr(f"{self.shapeNode}.message", f"{self.op}.shapeMsg") + + if self.hasPoseNode: + pns = self._getPoseNodes(ops) + self.poseNode = pns[0] if pns else self._createPoseNode(self.name) + if not cmds.isConnected(f"{self.poseNode}.message", f"{self.op}.poseMsg"): + cmds.connectAttr(f"{self.poseNode}.message", f"{self.op}.poseMsg") + + def getShapeThing(self, shapeName): + s = cmds.ls(f"{self.shapeNode}.{shapeName}") + if not s: + return None + return s[0] + + def getSliderThing(self, sliderName): + things = cmds.ls(f"{self.ctrl}.{sliderName}") + if not things: + return None + return things[0] + + @classmethod + def buildDummyMesh(cls, name: str): + importHeadShape = cmds.createNode("mesh", name=name + "Shape") + badPar = cmds.listRelatives(importHeadShape, parent=True)[0] + importHead = cmds.rename(badPar, name) + return importHead, importHeadShape + + @classmethod + @undoable + def buildRestAbc(cls, abcMesh, name): + if not cmds.pluginInfo("AbcImport", query=True, loaded=True): + cmds.loadPlugin("AbcImport") + if not cmds.pluginInfo("AbcImport", query=True, loaded=True): + raise RuntimeError("Unable to load the AbcImport plugin") + + abcPath = str(abcMesh.getArchive()) + + abcNode = cmds.createNode("AlembicNode") + cmds.setAttr(abcNode + ".abc_File", abcPath, type="string") + cmds.setAttr(abcNode + ".speed", 24) # Is this needed anymore? + cmds.setAttr(abcNode + ".time", 0) + + importHead, importHeadShape = cls.buildDummyMesh("{0}_SIMPLEX".format(name)) + + cmds.connectAttr(abcNode + ".outPolyMesh[0]", importHeadShape + ".inMesh") + cmds.polyEvaluate(importHead, vertex=True) # Force a refresh + cmds.disconnectAttr(abcNode + ".outPolyMesh[0]", importHeadShape + ".inMesh") + cmds.sets(importHead, edit=True, forceElement="initialShadingGroup") + cmds.delete(abcNode) + return importHead + + @classmethod + def vertCount(cls, mesh: str): + return cmds.polyEvaluate(mesh, vertex=True) + + @undoable + def loadAbcPoses(self, abcMesh, js, pBar=None) -> None: + pass + + @undoable + def loadAbc(self, abcMesh, js, pBar=None) -> None: + # UGH, I *REALLY* hate that this is faster + # But if I want to be "pure" about it, I should just bite the bullet + # and do the direct alembic manipulation in C++ + + if not cmds.pluginInfo("AbcImport", query=True, loaded=True): + cmds.loadPlugin("AbcImport") + if not cmds.pluginInfo("AbcImport", query=True, loaded=True): + raise RuntimeError("Unable to load the AbcImport plugin") + + abcPath = str(abcMesh.getArchive()) + + abcNode = cmds.createNode("AlembicNode") + cmds.setAttr(abcNode + ".abc_File", abcPath, type="string") + + timeUnits = { + "game": 15, + "film": 24, + "pal": 25, + "ntsc": 30, + "show": 48, + "palf": 50, + "ntscf": 60, + } + + fps = cmds.currentUnit(time=True, query=True) + if isinstance(fps, str): + if fps.endswith("fps"): + fps = fps[:-3] + if fps in timeUnits: + fps = timeUnits[fps] + fps = float(fps) + + cmds.setAttr(abcNode + ".speed", fps) + + shapes = js["shapes"] + shapeDict = {i.name: i for i in self.simplex.shapes} + + if js["encodingVersion"] > 1: + shapes = [i["name"] for i in shapes] + + importHead, importHeadShape = self.buildDummyMesh("importHead") + + cmds.connectAttr(abcNode + ".outPolyMesh[0]", importHeadShape + ".inMesh") + cmds.polyEvaluate(importHead, vertex=True) # Force a refresh + cmds.disconnectAttr(abcNode + ".outPolyMesh[0]", importHeadShape + ".inMesh") + + importRest = cmds.duplicate(self.mesh, name="importRest")[0] + cmds.delete(importRest, constructionHistory=True) + self._removeExtraShapeNodes(importRest) + + importBS: str = cmds.blendShape(importRest, importHead)[0] + cmds.blendShape(importBS, edit=True, weight=[(0, 1.0)]) + # Maybe get shapeNode from self.mesh?? + importOrig = [ + i for i in cmds.listRelatives(importHead, shapes=True) if i.endswith("Orig") + ][0] + cmds.connectAttr(abcNode + ".outPolyMesh[0]", importOrig + ".inMesh") + cmds.delete(importRest) + + if pBar is not None: + pBar.show() + pBar.setMaximum(len(shapes)) + longName = max(shapes, key=len) + pBar.setValue(1) + pBar.setLabelText("Loading:\n{}".format("_" * len(longName))) + + for i, shapeName in enumerate(shapes): + if pBar is not None: + pBar.setValue(i) + pBar.setLabelText(f"Loading:\n{shapeName}") + QApplication.processEvents() + if pBar.wasCanceled(): + return + index = self.getShapeIndex(shapeDict[shapeName]) + cmds.setAttr(abcNode + ".time", i) + + outAttr = f"{importHead}.worldMesh[0]" + tgn = f"{self.shapeNode}.inputTarget[0].inputTargetGroup[{index}]" + inAttr = f"{tgn}.inputTargetItem[6000].inputGeomTarget" + + cmds.connectAttr(outAttr, inAttr, force=True) + cmds.disconnectAttr(outAttr, inAttr) + cmds.delete(abcNode) + cmds.delete(importHead) + + def getAllShapeVertices(self, shapes, pBar=None) -> None: + sl = om.MSelectionList() + sl.add(self.mesh) + thing = om.MDagPath() + sl.getDagPath(0, thing) + meshFn = om.MFnMesh(thing) + _ptCount = meshFn.numVertices() + with disconnected(self.shapeNode) as cnx: + shapeCnx = cnx[self.shapeNode] + for v in shapeCnx.values(): + cmds.setAttr(v, 0.0) + + if pBar is not None: + # find the longest name for displaying stuff + sns = "_" * max(list(map(len, [s.name for s in shapes]))) + pBar.setLabelText(f"Getting Shape:\n{sns}") + pBar.setMaximum(len(shapes)) + QApplication.processEvents() + + for i, shape in enumerate(shapes): + if pBar is not None: + pBar.setLabelText(f"Getting Shape:\n{shape.name}") + pBar.setValue(i) + QApplication.processEvents() + + cmds.setAttr(shape.thing, 1.0) + mpts = om.MPointArray() + meshFn.getPoints(mpts) + out = mayaToNumpy(mpts)[:, :3] + + cmds.setAttr(shape.thing, 0.0) + shape.verts = out + + def getShapeVertices(self, shape): + with disconnected(self.shapeNode) as cnx: + shapeCnx = cnx[self.shapeNode] + for v in shapeCnx.values(): + cmds.setAttr(v, 0.0) + cmds.setAttr(shape.thing, 1.0) + out = self.getNumpyShape(self.mesh) + return out + + def pushAllShapeVertices(self, shapes, pBar=None) -> None: + # take all the verts stored on the shapes + # and push them back to the DCC + for shape in shapes: + self.pushShapeVertices(shape) + + def pushShapeVertices(self, shape) -> None: + # Push the vertices for a specific shape back to the DCC + pass + + @classmethod + def getMeshTopology(cls, mesh, uvName=None): + """Get the topology of a mesh + + Parameters + ---------- + mesh : object + The DCC Mesh to read + uvName : str, optional + The name of the uv set to read + + Returns + ------- + np.array : + The vertex array + np.array : + The "faces" array + np.array : + The "counts" array + np.array : + The uv positions + np.array : + The "uvFaces" array + """ + # Get the MDagPath from the name of the mesh + sl = om.MSelectionList() + sl.add(mesh) + thing = om.MDagPath() + sl.getDagPath(0, thing) + meshFn = om.MFnMesh(thing) + + vts = om.MPointArray() + meshFn.getPoints(vts, om.MSpace.kObject) + verts = [(vts[i].x, vts[i].y, vts[i].z) for i in range(vts.length())] + + faces = [] + counts = [] + rawUvFaces = [] + + vIdx = om.MIntArray() + + util = om.MScriptUtil() + util.createFromInt(0) + uvIdxPtr = util.asIntPtr() + uArray = om.MFloatArray() + vArray = om.MFloatArray() + meshFn.getUVs(uArray, vArray) + hasUvs = uArray.length() > 0 + + for i in range(meshFn.numPolygons()): + meshFn.getPolygonVertices(i, vIdx) + face = [] + for j in reversed(range(vIdx.length())): + face.append(vIdx[j]) + if hasUvs: + meshFn.getPolygonUVid(i, j, uvIdxPtr) + uvIdx = util.getInt(uvIdxPtr) + if uvIdx >= uArray.length() or uvIdx < 0: + uvIdx = 0 + rawUvFaces.append(uvIdx) + + face = [vIdx[j] for j in reversed(range(vIdx.length()))] + faces.extend(face) + counts.append(vIdx.length()) + + if hasUvs: + uvs = [(uArray[i], vArray[i]) for i in range(len(vArray))] + uvFaces = rawUvFaces + else: + uvs = None + uvFaces = None + + return verts, faces, counts, uvs, uvFaces + + def loadMeshTopology(self) -> None: + self._faces, self._counts, self._uvs = self.getAbcFaces(self.mesh) + + @classmethod + def getNumpyShape(cls, mesh: str, world=False): + """Get the np.array shape of the mesh connected to the smpx + + Parameters + ---------- + mesh : str + The name of the maya shape object + world : bool + Whether to get the points in worldspace, or local space + + Returns + ------- + : np.array + The point positions of the mesh + """ + vts = cls._getMeshVertices(mesh, world=world) + ret = mayaToNumpy(vts) + return ret[..., :3].copy() + + @classmethod + def _getMeshVertices(cls, mesh: str, world=False): + """ """ + # Get the MDagPath from the name of the mesh + sl = om.MSelectionList() + sl.add(mesh) + thing = om.MDagPath() + sl.getDagPath(0, thing) + meshFn = om.MFnMesh(thing) + vts = om.MPointArray() + if world: + space = om.MSpace.kWorld + else: + space = om.MSpace.kObject + meshFn.getPoints(vts, space) + return vts + + @classmethod + def _exportAbcVertices(cls, mesh, world=False): + vts = cls._getMeshVertices(mesh, world=world) + vertices = V3fArray(vts.length()) + for i in range(vts.length()): + vertices[i] = (vts[i].x, vts[i].y, vts[i].z) + return vertices + + @classmethod + def getAbcFaces(cls, mesh: str): + # Get the MDagPath from the name of the mesh + sl = om.MSelectionList() + sl.add(mesh) + thing = om.MDagPath() + sl.getDagPath(0, thing) + meshFn = om.MFnMesh(thing) + + faces = [] + faceCounts = [] + # uvArray = [] + uvIdxArray = [] + vIdx = om.MIntArray() + + util = om.MScriptUtil() + util.createFromInt(0) + uvIdxPtr = util.asIntPtr() + uArray = om.MFloatArray() + vArray = om.MFloatArray() + meshFn.getUVs(uArray, vArray) + hasUvs = uArray.length() > 0 + + for i in range(meshFn.numPolygons()): + meshFn.getPolygonVertices(i, vIdx) + face = [] + for j in reversed(range(vIdx.length())): + face.append(vIdx[j]) + if hasUvs: + meshFn.getPolygonUVid(i, j, uvIdxPtr) + uvIdx = util.getInt(uvIdxPtr) + if uvIdx >= uArray.length() or uvIdx < 0: + uvIdx = 0 + uvIdxArray.append(uvIdx) + + face = [vIdx[j] for j in reversed(range(vIdx.length()))] + faces.extend(face) + faceCounts.append(vIdx.length()) + + abcFaceIndices = IntArray(len(faces)) + for i in range(len(faces)): + abcFaceIndices[i] = faces[i] + + abcFaceCounts = IntArray(len(faceCounts)) + for i in range(len(faceCounts)): + abcFaceCounts[i] = faceCounts[i] + + if hasUvs: + abcUVArray = V2fArray(len(uArray)) + for i in range(len(vArray)): + abcUVArray[i] = (uArray[i], vArray[i]) + abcUVIdxArray = UnsignedIntArray(len(uvIdxArray)) + for i in range(len(uvIdxArray)): + abcUVIdxArray[i] = uvIdxArray[i] + uv = OV2fGeomParamSample( + abcUVArray, abcUVIdxArray, GeometryScope.kFacevaryingScope + ) + else: + uv = None + + return abcFaceIndices, abcFaceCounts, uv + + def exportAbc( + self, + dccMesh, + abcMesh, + js, + world: bool = False, + ensureCorrect: bool = False, + pBar=None, + ) -> None: + # export the data to alembic + if dccMesh is None: + dccMesh = self.mesh + + shapeDict = {i.name: i for i in self.simplex.shapes} + + shapeNames = js["shapes"] + if js["encodingVersion"] > 1: + shapeNames = [i["name"] for i in shapeNames] + shapes = [shapeDict[i] for i in shapeNames] + + faces, counts, uvs = self.getAbcFaces(dccMesh) + schema = abcMesh.getSchema() + + if pBar is not None: + pBar.show() + pBar.setMaximum(len(shapes)) + spacerName = "_" * max(list(map(len, shapeNames))) + pBar.setLabelText(f"Exporting:\n{spacerName}") + QApplication.processEvents() + + if ensureCorrect: + # Since this code is used to both export and exportOther + # I only want to ensure that everything is correct only if + # I'm doing a normal export + envelope = cmds.getAttr(self.shapeNode + ".envelope") + cmds.setAttr(self.shapeNode + ".envelope", 1.0) + + with disconnected(self.shapeNode) as cnx: + shapeCnx = cnx[self.shapeNode] + for v in shapeCnx.values(): + cmds.setAttr(v, 0.0) + for i, shape in enumerate(shapes): + if pBar is not None: + pBar.setLabelText(f"Exporting:\n{shape.name}") + pBar.setValue(i) + QApplication.processEvents() + if pBar.wasCanceled(): + return + cmds.setAttr(shape.thing, 1.0) + verts = self._exportAbcVertices(dccMesh, world=world) + if uvs is not None: + abcSample = OPolyMeshSchemaSample(verts, faces, counts, uvs) + else: + abcSample = OPolyMeshSchemaSample(verts, faces, counts) + schema.set(abcSample) + cmds.setAttr(shape.thing, 0.0) + + if ensureCorrect: + cmds.setAttr(self.shapeNode + ".envelope", envelope) + + def exportOtherAbc(self, dccMesh, abcMesh, js, world=False, pBar=None): + """ """ + shapeNames = js["shapes"] + if js["encodingVersion"] > 1: + shapeNames = [i["name"] for i in shapeNames] + + if pBar is not None: + pBar.show() + pBar.setMaximum(len(shapeNames)) + spacerName = "_" * max(list(map(len, shapeNames))) + pBar.setLabelText("Exporting:\n{0}".format(spacerName)) + QApplication.processEvents() + + # Get all the sliderVecs + shapeNames, inVecs, keyIdxs = self.simplex.buildInputVectors() + sliderVecs = [ + [0.0] * len(self.simplex.sliders) for i in range(len(self.simplex.shapes)) + ] + for iv, idx in zip(inVecs, keyIdxs): + sliderVecs[idx] = iv + + # Get all the fully expanded shapes, and the activations per shape + with disconnected(self.op) as allSliderCnx: + sliderCnx = allSliderCnx[self.op] + # zero all slider vals on the op to get the rest shape + for a in sliderCnx.values(): + cmds.setAttr(a, 0.0) + restVerts = self.getNumpyShape(dccMesh, world=world) + + fullShapes = np.zeros((len(self.simplex.shapes), len(restVerts), 3)) + shpValArray = np.zeros((len(self.simplex.shapes), len(self.simplex.shapes))) + for shpIdx, shape in enumerate(self.simplex.shapes): + if pBar is not None: + pBar.setLabelText("Reading Full Shapes:\n{0}".format(shape.name)) + pBar.setValue(shpIdx) + QApplication.processEvents() + if pBar.wasCanceled(): + raise RuntimeError("Cancelled!") + + # Set the full vec for this shape + inVec = sliderVecs[shpIdx] + for vi, vv in enumerate(inVec): + cmds.setAttr(sliderCnx[self.simplex.sliders[vi].thing], vv) + + fullShapes[shpIdx] = self.getNumpyShape(dccMesh, world=world) + + ary = np.array(cmds.getAttr(self.op + ".weights")[0]) + # Get rid of some floating point inaccuracies + ary[np.isclose(ary, 1.0)] = 1.0 + ary[np.isclose(ary, 0.0)] = 0.0 + shpValArray[shpIdx] = ary + + deltaShapeArray = self._collapseDeltas( + self.simplex, restVerts, shpValArray, fullShapes, pBar=pBar + ) + + # Finally write the outputs + faces, counts, uvs = self.getAbcFaces(dccMesh) + schema = abcMesh.getSchema() + for shpIdx, shape in enumerate(self.simplex.shapes): + if pBar is not None: + pBar.setLabelText("writing:\n{0}".format(shape.name)) + pBar.setValue(shpIdx) + QApplication.processEvents() + if pBar.wasCanceled(): + raise RuntimeError("Cancelled!") + shpVerts = restVerts + deltaShapeArray[shpIdx] + shpVerts = mkSampleVertexPoints(shpVerts) + if uvs is not None: + abcSample = OPolyMeshSchemaSample(shpVerts, faces, counts, uvs) + else: + abcSample = OPolyMeshSchemaSample(shpVerts, faces, counts) + schema.set(abcSample) + + @classmethod + def _collapseDeltas(cls, smpx, restVerts, shpValArray, fullShapes, pBar=None): + # Figure out what order to build the deltas + # so that the deltas exist when I try to combine them + ctrlOrder = smpx.controllersByDepth() + shapeOrder = [pp.shape for ctrl in ctrlOrder for pp in ctrl.prog.pairs] + shapeOrder = [i for i in shapeOrder if not i.isRest] + + # Incrementally Build the numpy array of delta shapes + # build deltaShapeArray as a 2d array because numpy is like 10x faster on 2d arrays + indexByShape = {v: k for k, v in enumerate(smpx.shapes)} + deltaShapeArray = np.zeros((len(smpx.shapes), len(restVerts) * 3)) + for shpOrderIdx, shape in enumerate(shapeOrder): + if pBar is not None: + pBar.setLabelText("Collapsing to Deltas:\n{0}".format(shape.name)) + pBar.setValue(shpOrderIdx) + QApplication.processEvents() + if pBar.wasCanceled(): + raise RuntimeError("Cancelled!") + + shpIdx = indexByShape[shape] + base = np.dot(shpValArray[shpIdx], deltaShapeArray) + deltaShapeArray[shpIdx] = ( + fullShapes[shpIdx] - restVerts - base.reshape((-1, 3)) + ).flatten() + + # And move that 2d array back into 3d + deltaShapeArray = deltaShapeArray.reshape((len(smpx.shapes), -1, 3)) + return deltaShapeArray + + def deleteObj(self, thing) -> None: + """Delete the given object""" + cmds.delete(thing) + + def exportMesh(self, mesh, path) -> None: + """Export a mesh to the given path""" + faces, counts, uvs = self.getAbcFaces(mesh) + shape = self.getNumpyShape(mesh) + name = mesh.split('|')[-1] + buildAbc(path, shape, faces, counts, uvs, name=name) + + # Revision tracking + def getRevision(self): + try: + return cmds.getAttr(f"{self.op}.revision") + except ValueError: + # object does not exist + return None + + @undoable + def incrementRevision(self): + value = self.getRevision() + if value is None: + return + cmds.setAttr(f"{self.op}.revision", value + 1) + jsString = self.simplex.dump() + self.setSimplexString(self.op, jsString) + return value + 1 + + @undoable + def setRevision(self, val) -> None: + cmds.setAttr(f"{self.op}.revision", val) + + # System level + @undoable + def renameSystem(self, name) -> None: + if ( + self.mesh is None + or self.ctrl is None + or self.shapeNode is None + or self.op is None + or self.simplex is None + ): + raise ValueError("System is not set up. Cannot rename") + + nn = self.mesh.replace(self.name, name) + self.mesh = cmds.rename(self.mesh, nn) + + nn = self.ctrl.replace(self.name, name) + self.ctrl = cmds.rename(self.ctrl, nn) + + oldNodeName = self.shapeNode + nn = self.shapeNode.replace(self.name, name) + self.shapeNode = cmds.rename(self.shapeNode, nn) + + nn = self.op.replace(self.name, name) + self.op = cmds.rename(self.op, nn) + + for shape in self.simplex.shapes: + shape.thing = shape.thing.replace(oldNodeName, self.shapeNode) + + self.name = name + + @undoable + def deleteSystem(self) -> None: + cmds.delete(self.ctrl) + cmds.delete(self.shapeNode) + cmds.delete(self.op) + self.ctrl = None # the object that has all the controllers on it + self.shapeNode = None # the deformer object + self.op = None # the simplex object + self.simplex = None + + # Shapes + @undoable + def createShape(self, shape, live: bool = False, offset: int = 10): + with disconnected(self.shapeNode): + try: + attrs = cmds.listAttr(f"{self.shapeNode}.weight[*]") + except ValueError: + pass + # Maya throws an error if there aren't any instead of + # just returning an empty list + else: + for attr in attrs: + cmds.setAttr(f"{self.shapeNode}.{attr}", 0.0) + newShape = cmds.duplicate(self.mesh, name=shape.name)[0] + + cmds.delete(newShape, constructionHistory=True) + index = self._firstAvailableIndex() + cmds.blendShape( + self.shapeNode, edit=True, target=(self.mesh, index, newShape, 1.0) + ) + weightAttr = f"{self.shapeNode}.weight[{index}]" + thing = cmds.ls(weightAttr)[0] + + shapeIndex = len(shape.simplex.shapes) - 1 + cmds.connectAttr(f"{self.op}.weights[{shapeIndex}]", thing) + + if live: + cmds.xform(newShape, relative=True, translation=[offset, 0, 0]) + else: + cmds.delete(newShape) + + return thing + + def _firstAvailableIndex(self) -> int: + aliases = cmds.aliasAttr(self.shapeNode, query=True) + idxs = set() + if not aliases: + return 0 + for alias in aliases: + match = re.search(r"\[\d+\]", alias) + if not match: + continue # No index found for the current shape + idxs.add(int(match.group().strip("[]"))) + + for i in range(len(idxs) + 1): + if i not in idxs: + return i + # there should be no way to get here, but just in case: + return len(idxs) + 1 + + def getShapeIndex(self, shape: Shape) -> int: + aName = cmds.attributeName(shape.thing) + aliases = cmds.aliasAttr(self.shapeNode, query=True) + idx = aliases.index(aName) + raw = aliases[idx + 1] + matches = re.findall(r"\[\d+\]", raw) + if not matches: + raise IndexError("No index found for the current shape") + return int(matches[-1].strip("[]")) + + @undoable + def extractWithDeltaShape(self, shape, live: bool = True, offset: float = 10.0): + """Make a mesh representing a shape. Can be live or not. + Also, make a shapenode that is the delta of the change being made + """ + with disconnected(self.shapeNode) as cnx: + shapeCnx = cnx[self.shapeNode] + for v in shapeCnx.values(): + cmds.setAttr(v, 0.0) + + # store the delta shape + delta = cmds.duplicate(self.mesh, name=f"{shape.name}_Delta")[0] + + # Extract the shape + cmds.setAttr(shape.thing, 1.0) + extracted = cmds.duplicate(self.mesh, name=f"{shape.name}_Extract")[0] + + # Store the initial shape + init = cmds.duplicate(extracted, name=f"{shape.name}_Init")[0] + + # clear old orig objects + for item in [delta, extracted, init]: + self._clearShapes(item, doOrig=True) + + # build the deltaObj system + bs = cmds.blendShape(delta, name=f"{shape.name}_DeltaBS")[0] + + cmds.blendShape(bs, edit=True, target=(delta, 0, init, 1.0)) + cmds.blendShape(bs, edit=True, target=(delta, 1, extracted, 1.0)) + + cmds.setAttr(f"{bs}.{init}", -1.0) + cmds.setAttr(f"{bs}.{extracted}", 1.0) + + # Cleanup + nodeDict = {"Delta": delta, "Init": init} + repDict = self._reparentDeltaShapes(extracted, nodeDict, bs) + + # Shift the extracted shape to the side + cmds.xform(extracted, relative=True, translation=(offset, 0, 0)) + + if live: + self.connectShape(shape, extracted, live, delete=False) + + return extracted, repDict["Delta"] + + @undoable + def extractWithDeltaConnection( + self, shape, delta, value, live: bool = True, offset: float = 10.0 + ): + """Extract a shape with a live partial delta added in. + Useful for updating progressive shapes + """ + with disconnected(self.shapeNode): + for attr in cmds.listAttr(f"{self.shapeNode}.weight[*]"): + cmds.setAttr(f"{self.shapeNode}.{attr}", 0.0) + + # Pull out the rest shape. we will blend this guy to the extraction + extracted = cmds.duplicate(self.mesh, name=f"{shape.name}_Extract")[0] + + cmds.setAttr(shape.thing, 1.0) + # Store the initial shape + init = cmds.duplicate(self.mesh, name=f"{shape.name}_Init")[0] + + # clear old orig objects + for item in [init, extracted]: + self._clearShapes(item, doOrig=True) + + deltaPar = cmds.listRelatives(delta, parent=True)[0] + + # build the restObj system + cmds.select(clear=True) # 'cause maya + bs = cmds.blendShape(extracted, name=f"{shape.name}_DeltaBS")[0] + cmds.blendShape(bs, edit=True, target=(extracted, 0, init, 1.0)) + cmds.blendShape(bs, edit=True, target=(extracted, 1, deltaPar, 1.0)) + + cmds.setAttr(f"{bs}.{init}", 1.0) + cmds.setAttr(f"{bs}.{deltaPar}", value) + + outCnx = f"{delta}.worldMesh[0]" + inCnx = f"{bs}.inputTarget[0].inputTargetGroup[{1}].inputTargetItem[6000].inputGeomTarget" + cmds.connectAttr(outCnx, inCnx, force=True) + cmds.aliasAttr(delta, f"{bs}.{deltaPar}") + + # Cleanup + nodeDict = {"Init": init} + self._reparentDeltaShapes(extracted, nodeDict, bs) + + # Remove the tweak node, otherwise editing the input progressives + # *inverts* the shape + exShape = cmds.listRelatives(extracted, noIntermediate=1, shapes=1)[0] + tweak = cmds.listConnections( + exShape + ".tweakLocation", source=1, destination=0 + ) + if tweak: + cmds.delete(tweak) + + # Shift the extracted shape to the side + cmds.xform(extracted, relative=True, translation=(offset, 0, 0)) + self.connectShape(shape, extracted, live, delete=False) + + return extracted + + @undoable + def extractShape(self, shape, live: bool = True, offset: float = 10.0): + """Make a mesh representing a shape. Can be live or not. + Can also store its starting shape and delta data + """ + with disconnected(self.shapeNode): + for attr in cmds.listAttr(f"{self.shapeNode}.weight[*]"): + cmds.setAttr(f"{self.shapeNode}.{attr}", 0.0) + + cmds.setAttr(shape.thing, 1.0) + extracted = cmds.duplicate(self.mesh, name=f"{shape.name}_Extract")[0] + + # Shift the extracted shape to the side + cmds.xform(extracted, relative=True, translation=(offset, 0, 0)) + if live: + self.connectShape(shape, extracted, live, delete=False) + return extracted + + @undoable + def connectShape(self, shape, mesh=None, live=False, delete=False) -> None: + """Force a shape to match a mesh + The "connect shape" button is: + mesh=None, delete=True + The "match shape" button is: + mesh=someMesh, delete=False + There is a possibility of a "make live" button: + live=True, delete=False + """ + if mesh is None: + attrName = cmds.attributeName(shape.thing, long=True) + mesh = f"{attrName}_Extract" + + chk = cmds.ls(mesh) + if not chk: + return + if len(chk) > 1: + msg = "Multiple objects with the same name found in file:\n" + msg += '\n'.join(chk) + raise ValueError(msg) + + index = self.getShapeIndex(shape) + tgn = f"{self.shapeNode}.inputTarget[0].inputTargetGroup[{index}]" + cnx = mesh + "Shape" if cmds.nodeType(mesh) == "transform" else mesh + + outAttr = f"{cnx}.worldMesh[0]" # Make sure to check the right shape object + inAttr = f"{tgn}.inputTargetItem[6000].inputGeomTarget" + + if not cmds.isConnected(outAttr, inAttr): + cmds.connectAttr(outAttr, inAttr, force=True) + + if not live: + cmds.disconnectAttr(outAttr, inAttr) + + if delete: + cmds.delete(mesh) + + @undoable + def extractPosedShape(self, shape) -> None: + pass + + @undoable + def zeroShape(self, shape) -> None: + """Set the shape to be completely zeroed""" + index = self.getShapeIndex(shape) + tgn = f"{self.shapeNode}.inputTarget[0].inputTargetGroup[{index}]" + shapeInput = f"{tgn}.inputTargetItem[6000]" + cmds.setAttr(f"{shapeInput}.inputPointsTarget", 0, (), type="pointArray") + cmds.setAttr(f"{shapeInput}.inputComponentsTarget", 0, "", type="componentList") + + @undoable + def deleteShape(self, toDelShape) -> None: + """Remove a shape from the system""" + index = self.getShapeIndex(toDelShape) + tgn = f"{self.shapeNode}.inputTarget[0].inputTargetGroup[{index}]" + cmds.removeMultiInstance(toDelShape.thing, b=True) + cmds.removeMultiInstance(tgn, b=True) + cmds.aliasAttr(toDelShape.thing, remove=True) + self._rebuildShapeConnections() + + def _rebuildShapeConnections(self) -> None: + # Rebuild the shape connections in the proper order + cnxs = ( + cmds.listConnections( + self.op, plugs=True, source=False, destination=True, connections=True + ) + or [] + ) + for i, cnx in enumerate(cnxs): + if i % 2 == 0 and cnx.startswith(f"{self.op}.weights["): + cmds.disconnectAttr(cnxs[i], cnxs[i + 1]) + + for i, shape in enumerate(self.simplex.shapes): + cmds.connectAttr(f"{self.op}.weights[{i}]", shape.thing, force=True) + + @undoable + def forceRebuildShapeConnections(self) -> None: + self._rebuildShapeConnections() + + @undoable + def renameShape(self, shape, name) -> None: + """Change the name of the shape""" + cmds.aliasAttr(name, shape.thing) + shape.thing = f"{self.shapeNode}.{name}" + + @undoable + def convertShapeToCorrective(self, shape) -> None: + pass + + # Falloffs + def createFalloff(self, name) -> None: + pass # for eventual live splits + + def duplicateFalloff(self, falloff, newFalloff, newName) -> None: + pass # for eventual live splits + + def deleteFalloff(self, falloff) -> None: + pass # for eventual live splits + + def setFalloffData( + self, falloff, splitType, axis, minVal, minHandle, maxHandle, maxVal, mapName + ) -> None: + pass # for eventual live splits + + def getFalloffThing(self, falloff): + shape = cmds.listRelatives(self.mesh, shapes=True)[0] + return shape + "." + falloff.name + + # Sliders + @undoable + def createSlider(self, slider) -> str: + index = slider.simplex.sliders.index(slider) + cmds.addAttr( + self.ctrl, + longName=slider.name, + attributeType="double", + keyable=True, + min=slider.minValue * self.sliderMul, + max=slider.maxValue * self.sliderMul, + ) + thing = f"{self.ctrl}.{slider.name}" + cmds.connectAttr(thing, f"{self.op}.sliders[{index}]") + return thing + + @undoable + def renameSlider(self, slider, name) -> None: + """Set the name of a slider""" + vals = [v.value for v in slider.prog.pairs] + cnx = cmds.listConnections( + slider.thing, plugs=True, source=False, destination=True + ) + cmds.deleteAttr(slider.thing) + cmds.addAttr( + self.ctrl, + longName=name, + attributeType="double", + keyable=True, + min=self.sliderMul * min(vals), + max=self.sliderMul * max(vals), + ) + newThing = f"{self.ctrl}.{name}" + slider.thing = newThing + for c in cnx: + cmds.connectAttr(newThing, c) + + @undoable + def setSliderRange(self, slider) -> None: + """Set the range of a slider""" + vals = [v.value for v in slider.prog.pairs] + attrName = f"{self.ctrl}.{slider.name}" + cmds.addAttr( + attrName, + edit=True, + min=self.sliderMul * min(vals), + max=self.sliderMul * max(vals), + ) + + @undoable + def deleteSlider(self, toDelSlider) -> None: + cmds.deleteAttr(toDelSlider.thing) + + # Rebuild the slider connections in the proper order + # Get the sliders connections + cnxs = cmds.listConnections( + self.op, plugs=True, source=True, destination=False, connections=True + ) + for i, cnx in enumerate(cnxs): + if cnx.startswith(f"{self.op}.sliders"): + cmds.disconnectAttr(cnxs[i + 1], cnxs[i]) + + for i, slider in enumerate(self.simplex.sliders): + cmds.connectAttr(slider.thing, f"{self.op}.sliders[{i}]") + + @undoable + def addProgFalloff(self, prog, falloff) -> None: + pass # for eventual live splits + + @undoable + def removeProgFalloff(self, prog, falloff) -> None: + pass # for eventual live splits + + @undoable + def setSlidersWeights(self, sliders, weights) -> None: + """Set the weight of a slider. This does not change the definition""" + for slider, weight in zip(sliders, weights): + try: + cmds.setAttr(slider.thing, weight) + except RuntimeError: + # Probably locked or connected. Just skip it + pass + + @undoable + def setSliderWeight(self, slider, weight) -> None: + try: + cmds.setAttr(slider.thing, weight) + except RuntimeError: + # Probably locked or connected. Just skip it + pass + + @undoable + def updateSlidersRange(self, sliders) -> None: + for slider in sliders: + vals = [v.value for v in slider.prog.pairs] + cmds.addAttr( + slider.thing, + edit=True, + min=min(vals) * self.sliderMul, + max=max(vals) * self.sliderMul, + ) + + def _doesDeltaExist(self, combo, target): + dshape = f"{combo.name}_DeltaShape" + if not cmds.ls(dshape): + return None + par = cmds.listRelatives(dshape, allParents=1) + if not par: + # there is apparently a transform object with the name + return None + + par = cmds.ls(par[0], absoluteName=1) + tar = cmds.ls(target, absoluteName=1) + + if par != tar: + # the shape exists under a different transform ... ugh + return None + return par + "|" + dshape + + def _clearShapes(self, item, doOrig=False) -> None: + aname = cmds.ls(item, long=1)[0] + shapes = cmds.ls(cmds.listRelatives(item, shapes=1), long=1) + baseName = aname.split("|")[-1] + baseName, digits = split_trailing_digits(baseName) + + primary = f"{aname}|{baseName}Shape{digits}" + + origs = [] + others = [] + for shape in shapes: + base, digits = split_trailing_digits(shape) + if base.endswith('Orig'): + origs.append(shape) + else: + others.append(shape) + + for shape in others: + if shape == primary: + continue + cmds.delete(shape) + + if doOrig: + cmds.delete(origs) + else: + # Don't delete the first orig + if len(origs) > 1: + origs = sorted(origs) + cmds.delete(origs[1:]) + + @undoable + def forceRebuildSliderConnections(self) -> None: + self._rebuildSliderConnections() + + def _rebuildSliderConnections(self) -> None: + # disconnect all outputs from the ctrl + rcnx = cmds.listConnections( + self.ctrl, source=False, plugs=True, connections=True + ) + for i in range(0, len(rcnx), 2): + src, dst = rcnx[i], rcnx[i + 1] + if cmds.getAttr(src, type=True) != "double": + # only disconnect doubles + continue + cmds.disconnectAttr(src, dst) + + # Reconnect by name + for i, sli in enumerate(self.simplex.sliders): + thing = self.getSliderThing(sli.name) + cmds.connectAttr(thing, self.op + f".sliders[{i}]") + + # Combos + def _reparentDeltaShapes(self, par, nodeDict, bsNode, toDelete=None): + """Reparent and clean up a single-transform delta system + + Put all the relevant shape nodes from the nodeDict under the par, + and rename the shapes to maya's convention. Then build a callback + to ensure the blendshape node isn't left floating + + par: The parent transform node + nodeDict: A {simpleName: node} dictionary. + bsNode: The blendshape node. + toDelete: Any extra nodes to delte after all the node twiddling + """ + # Get the shapes and origs + shapeDict = {} + origDict = {} + + for name, node in nodeDict.items(): + shape = cmds.listRelatives(node, noIntermediate=1, shapes=1)[0] + shape = cmds.ls(shape, absoluteName=1)[0] + if shape: + shapeDict[name] = shape + + orig = shape + "Orig" + orig = cmds.ls(orig) + if orig: + origDict[name] = orig + + for name in nodeDict: + for d, fmt in [(shapeDict, "{0}Shape{1}"), (origDict, "{0}Shape{1}Orig")]: + shape = d.get(name) + if shape is None: + continue + shapeUUID = cmds.ls(shape, uuid=1)[0] + cmds.parent(shape, par, shape=True, relative=True) + newShape = cmds.rename(cmds.ls(shapeUUID)[0], fmt.format(par, name)) + d[name] = newShape + cmds.setAttr(newShape + ".intermediateObject", 1) + cmds.hide(newShape) + + cmds.delete(nodeDict[name]) + + if toDelete: + cmds.delete(toDelete) + + # Use the simplexDelete message attribute to keep track of what nodes + # will need to be delete-linked when the file is reopened + sdNode = par + ".simplexDelete" + if not cmds.ls(sdNode): + cmds.addAttr(par, longName="simplexDelete", attributeType="message") + cmds.connectAttr(bsNode + ".message", sdNode) + + # build the callback setup so the blendshape is deleted with the delta setup + # along with a persistent scriptjob + buildDeleterCallback(par, bsNode) + buildDeleterScriptJob() + + return shapeDict + + def _createTravDelta(self, trav, target, tVal, doReparent=True): + """Part of the traversal extraction process. + Very similar to the combo extraction + """ + exists = self._doesDeltaExist(trav, target) + if exists is not None: + return exists + + # Traversals *MAY* depend on floaters, but that's complicated + # I'm just gonna ignore them for now + floatShapes = [i.thing for i in self.simplex.getFloatingShapes()] + + # Get all traversal shapes + tShapes = [] + for oTrav in self.simplex.traversals: + tShapes.extend([i.thing for i in oTrav.prog.getShapes()]) + + with disconnected(self.op) as cnx: + sliderCnx = cnx[self.op] + + # zero all slider vals on the op + for a in sliderCnx.values(): + cmds.setAttr(a, 0.0) + + with disconnected(floatShapes + tShapes): + # pull out the rest shape + rest = cmds.duplicate(self.mesh, name=f"{trav.name}_Rest")[0] + + sliDict = {} + for pair in trav.startPoint.pairs: + sliDict[pair.slider] = [pair.value] + for pair in trav.endPoint.pairs: + sliDict[pair.slider].append(pair.value) + + for slider, (start, end) in sliDict.items(): + vv = start + tVal * (end - start) + cmds.setAttr(sliderCnx[slider.thing], vv) + + deltaObj = cmds.duplicate(self.mesh, name=f"{trav.name}_Delta")[0] + base = cmds.duplicate(deltaObj, name=f"{trav.name}_Base")[0] + + # clear out all non-primary shapes so we don't have those 'Orig1' things floating around + for item in [rest, deltaObj, base]: + self._clearShapes(item, doOrig=True) + + # Build the delta blendshape setup + bs = cmds.blendShape(deltaObj, name=f"{trav.name}_DeltaBS")[0] + cmds.blendShape(bs, edit=True, target=(deltaObj, 0, target, 1.0)) + cmds.blendShape(bs, edit=True, target=(deltaObj, 1, base, 1.0)) + cmds.blendShape(bs, edit=True, target=(deltaObj, 2, rest, 1.0)) + cmds.setAttr(f"{bs}.{target}", 1.0) + cmds.setAttr(f"{bs}.{base}", 1.0) + cmds.setAttr(f"{bs}.{rest}", 1.0) + + # Cleanup + if doReparent: + nodeDict = {"Delta": deltaObj} + repDict = self._reparentDeltaShapes(target, nodeDict, bs, [rest, base]) + return repDict["Delta"] + return deltaObj + + @undoable + def extractTraversalShape( + self, trav, shape, live: bool = True, offset: float = 10.0 + ): + """Extract a shape from a Traversal progression""" + floatShapes = self.simplex.getFloatingShapes() + floatShapes = [i.thing for i in floatShapes] + + shapeIdx = trav.prog.getShapeIndex(shape) + val = trav.prog.pairs[shapeIdx].value + + # TODO: Do traversals interact? Should I turn off any other traversals? + # For now, no, but it may be a thing + # tShapes = [] + # for oTrav in self.simplex.traversals: + # if oTrav is trav: continue + # tShapes.extend([i.thing for i in oTrav.prog.getShapes()]) + + with disconnected(self.op) as cnx: + sliderCnx = cnx[self.op] + # zero all slider vals on the op + for a in sliderCnx.values(): + cmds.setAttr(a, 0.0) + + with disconnected(floatShapes): # tShapes + sliDict = {} + for pair in trav.startPoint.pairs: + sliDict[pair.slider] = [pair.value] + for pair in trav.endPoint.pairs: + sliDict[pair.slider].append(pair.value) + + for slider, (start, end) in sliDict.items(): + vv = start + val * (end - start) + cmds.setAttr(sliderCnx[slider.thing], vv) + + extracted = cmds.duplicate(self.mesh, name=f"{shape.name}_Extract") + extracted = extracted[0] + self._clearShapes(extracted) + cmds.xform(extracted, relative=True, translation=(offset, 0, 0)) + if live: + self.connectTraversalShape(trav, shape, extracted, live=live, delete=False) + cmds.select(extracted) + return extracted + + @undoable + def connectTraversalShape( + self, trav, shape, mesh=None, live=True, delete=False + ) -> None: + """Connect a shape into a Traversal progression""" + if mesh is None: + attrName = cmds.attributeName(shape.thing, long=True) + mesh = f"{attrName}_Extract" + + chk = cmds.ls(mesh) + if not chk: + return + if len(chk) > 1: + msg = "Multiple objects with the same name found in file:\n" + msg += '\n'.join(chk) + raise ValueError(msg) + + shapeIdx = trav.prog.getShapeIndex(shape) + tVal = trav.prog.pairs[shapeIdx].value + delta = self._createTravDelta(trav, mesh, tVal) + + if live: + self.connectShape(shape, delta, live, delete) + + if delete: + cmds.delete(mesh) + + def _createComboDelta(self, combo, target, tVal, doReparent=True): + """Part of the combo extraction process. + Combo shapes are fixit shapes added on top of any sliders. + This means that the actual combo-shape by itself will not look good by itself, + and that's bad for artist interaction. + So we must create a setup to take the final sculpted shape, and subtract + the any direct slider deformations to get the actual "combo shape" as a delta + It is this delta shape that is then plugged into the system + """ + exists = self._doesDeltaExist(combo, target) + if exists is not None: + return exists + + # get floaters + # As floaters can appear anywhere along any combo, they must + # always be evaluated in isolation. For this reason, we will + # always disconnect all floaters + floatShapes = [i.thing for i in self.simplex.getFloatingShapes()] + + # get my shapes + myShapes = [i.thing for i in combo.prog.getShapes()] + + with disconnected([self.op] + floatShapes + myShapes) as cnx: + sliderCnx = cnx[self.op] + + # zero all slider vals on the op + for a in sliderCnx.values(): + cmds.setAttr(a, 0.0) + + # pull out the rest shape + rest = cmds.duplicate(self.mesh, name=f"{combo.name}_Rest")[0] + + # set the combo values + for pair in combo.pairs: + cmds.setAttr(sliderCnx[pair.slider.thing], pair.value * tVal) + + # Get the resulting slider values for later + # weightPairs = [] + # self.shapeNode = None # the deformer object + + deltaObj = cmds.duplicate(self.mesh, name=f"{combo.name}_Delta")[0] + base = cmds.duplicate(deltaObj, name=f"{combo.name}_Base")[0] + + # clear out all non-primary shapes so we don't have those 'Orig1' things floating around + for item in [rest, deltaObj, base]: + self._clearShapes(item, doOrig=True) + + # Build the delta blendshape setup + bs = cmds.blendShape(deltaObj, name=f"{combo.name}_DeltaBS")[0] + cmds.blendShape(bs, edit=True, target=(deltaObj, 0, target, 1.0)) + cmds.blendShape(bs, edit=True, target=(deltaObj, 1, base, 1.0)) + cmds.blendShape(bs, edit=True, target=(deltaObj, 2, rest, 1.0)) + cmds.setAttr(f"{bs}.{target}", 1.0) + cmds.setAttr(f"{bs}.{base}", 1.0) + cmds.setAttr(f"{bs}.{rest}", 1.0) + + # Cleanup + if doReparent: + nodeDict = {"Delta": deltaObj} + repDict = self._reparentDeltaShapes(target, nodeDict, bs, [rest, base]) + return repDict["Delta"] + return deltaObj + + @undoable + def extractComboShape(self, combo, shape, live: bool = True, offset: float = 10.0): + """Extract a shape from a combo progression""" + floatShapes = self.simplex.getFloatingShapes() + floatShapes = [i.thing for i in floatShapes] + + shapeIdx = combo.prog.getShapeIndex(shape) + tVal = combo.prog.pairs[shapeIdx].value + + with disconnected(self.op) as cnx: + sliderCnx = cnx[self.op] + # zero all slider vals on the op + for a in sliderCnx.values(): + cmds.setAttr(a, 0.0) + + with disconnected(floatShapes): + # set the combo values + for pair in combo.pairs: + cmds.setAttr(sliderCnx[pair.slider.thing], pair.value * tVal) + + extracted = cmds.duplicate(self.mesh, name=f"{shape.name}_Extract")[0] + + self._clearShapes(extracted) + cmds.xform(extracted, relative=True, translation=(offset, 0, 0)) + + if live: + self.connectComboShape(combo, shape, extracted, live=live, delete=False) + + cmds.select(extracted) + return extracted + + @undoable + def connectComboShape( + self, combo, shape, mesh=None, live=True, delete=False + ) -> None: + """Connect a shape into a combo progression""" + if mesh is None: + attrName = cmds.attributeName(shape.thing, long=True) + mesh = f"{attrName}_Extract" + + chk = cmds.ls(mesh) + if not chk: + return + if len(chk) > 1: + msg = "Multiple objects with the same name found in file:\n" + msg += '\n'.join(chk) + raise ValueError(msg) + mesh = chk[0] + + progShapeIdx = combo.prog.getShapeIndex(shape) + tVal = combo.prog.pairs[progShapeIdx].value + + delta = self._createComboDelta(combo, mesh, tVal) + if live: + self.connectShape(shape, delta, live, delete) + + if delete: + cmds.delete(mesh) + + @classmethod + def setDisabled(cls, op): + bss = list(set(cmds.listConnections(op, type="blendShape"))) + helpers = [] + for bs in bss: + prop = f"{bs}.envelope" + val = cmds.getAttr(prop) + cmds.setAttr(prop, 0.0) + if val != 0.0: + helpers.append((prop, val)) + return helpers + + @classmethod + def reEnable(cls, helpers) -> None: + for prop, val in helpers: + cmds.setAttr(prop, val) + + @undoable + def renameCombo(self, combo, name) -> None: + """Set the name of a Combo""" + pass + + # Data Access + @classmethod + def getSimplexOperators(cls): + """ """ + return cmds.ls(type="simplex_maya") + + @classmethod + def getSimplexOperatorsByName(cls, name): + return cmds.ls(name, type="simplex_maya") + + @classmethod + def getSimplexOperatorsOnObject(cls, thing): + ops = cmds.ls(type="simplex_maya") + out = [] + for op in ops: + shapeNode = cmds.listConnections( + f"{op}.shapeMsg", source=True, destination=False + ) + if not shapeNode: + continue + + # Now that I've got the connected blendshape node, I can check the deformer history + # to see if I find it. Eventually, I should probably set this up to deal with + # multi-objects, or branched hierarchies. But for now, it works + if shapeNode[0] in (cmds.listHistory(thing, pruneDagObjects=True) or []): + out.append(op) + return out + + @classmethod + def getSimplexString(cls, op): + return cmds.getAttr(op + ".definition") + + @classmethod + def getSimplexStringOnThing(cls, thing, systemName): + ops = DCC.getSimplexOperatorsOnObject(thing) + for op in ops: + js = DCC.getSimplexString(op) + jdict = json.loads(js) + if jdict["systemName"] == systemName: + return js + return None + + @classmethod + def setSimplexString(cls, op: str, val: str): + return cmds.setAttr(op + ".definition", val, type="string") + + @classmethod + def selectObject(cls, thing: str) -> None: + """Select an object in the DCC""" + cmds.select([thing]) + + def selectCtrl(self) -> None: + """Select the system's control object""" + if self.ctrl: + self.selectObject(self.ctrl) + + @classmethod + def getObjectByName(cls, name): + objs = cmds.ls(name) + if not objs: + return None + if len(objs) > 1: + raise ValueError("Multiple objects with the same name found") + return objs[0] + + @classmethod + def getObjectName(cls, thing): + return thing + + @classmethod + def staticUndoOpen(cls) -> None: + cmds.undoInfo(chunkName="SimplexOperation", openChunk=True) + + @classmethod + def staticUndoClose(cls) -> None: + cmds.undoInfo(closeChunk=True) + + def undoOpen(self) -> None: + if self.undoDepth == 0: + self.staticUndoOpen() + self.undoDepth += 1 + + def undoClose(self) -> None: + self.undoDepth -= 1 + if self.undoDepth == 0: + self.staticUndoClose() + + @classmethod + def getPersistentFalloff(cls, thing): + return cls.getObjectName(thing) + + @classmethod + def loadPersistentFalloff(cls, thing): + return cls.getObjectByName(thing) + + @classmethod + def getPersistentShape(cls, thing): + return cls.getObjectName(thing) + + @classmethod + def loadPersistentShape(cls, thing): + return cls.getObjectByName(thing) + + @classmethod + def getPersistentSlider(cls, thing): + return cls.getObjectName(thing) + + @classmethod + def loadPersistentSlider(cls, thing): + return cls.getObjectByName(thing) + + @classmethod + def getSelectedObjects(cls): + """ """ + # For maya, only return transform nodes + return cmds.ls(sl=True, transforms=True) + + @undoable + def importObj(self, path): + current = set(cmds.ls(transforms=True)) + cmds.file(path, i=True, type="OBJ", ignoreVersion=True) + new = set(cmds.ls(transforms=True)) + shapes = set(cmds.ls(shapes=True)) + new = new - current - shapes + imp = new.pop() + return imp + + @classmethod + def _getDeformerChain(cls, chkObj): + # Get a deformer chain + memo = [] + while chkObj and chkObj not in memo: + memo.append(chkObj) + + typ = cmds.nodeType(chkObj) + if typ == "mesh": + cnx = cmds.listConnections(chkObj + ".inMesh") or [None] + chkObj = cnx[0] + elif typ == "groupParts": + cnx = cmds.listConnections( + chkObj + ".inputGeometry", destination=False, shapes=True + ) or [None] + chkObj = cnx[0] + else: + cnx = cmds.ls(chkObj, type="geometryFilter") or [None] + chkObj = cnx[0] + if chkObj: # we have a deformer + cnx = cmds.listConnections(chkObj + ".input[0].inputGeometry") or [ + None + ] + chkObj = cnx[0] + return memo + + # Freezing stuff + def primeShapes(self, combo) -> None: + """Make sure the upstream shapes of this combo are primed and ready. + Priming here means the deltas are stored and available on the blendshape node + """ + # Maya doesn't populate the delta plugs on the blendshape node unless + # you have a mesh connection while the value for that shape is turned to 1 + upstreams = [] + comboUps = self.simplex.getComboUpstreams(combo) + for u in comboUps: + upstreams.append(u.prog.getShapeAtValue(1.0)) + for pair in combo.pairs: + sli, val = pair.slider, pair.value + upstreams.append(sli.prog.getShapeAtValue(val)) + + with disconnected(self.shapeNode) as cnx: + shapeCnx = cnx[self.shapeNode] + for v in shapeCnx.values(): + cmds.setAttr(v, 0.0) + + for shape in upstreams: + cmds.setAttr(shape.thing, 1.0) + try: + # Make sure to check for any already incoming connections + index = self.getShapeIndex(shape) + tgn = f"{self.shapeNode}.inputTarget[0].inputTargetGroup[{index}]" + isConnected = cmds.listConnections( + tgn, source=True, destination=False + ) + + if not isConnected: + shapeGeo = cmds.duplicate(self.mesh, name=shape.name)[0] + shape.connectShape(mesh=shapeGeo, live=False, delete=True) + + finally: + cmds.setAttr(shape.thing, 0.0) + + def getFreezeThing(self, combo): + # If the blendshape shape has an incoming connection whose shape name + # ends with 'FreezeShape' and the shape's parent is the ctrl + ret = [] + shapes = combo.prog.getShapes() + shapes = [i for i in shapes if not i.isRest] + + shapePlugFmt = ( + ".inputTarget[{meshIdx}].inputTargetGroup[{shapeIdx}].inputTargetItem[6000]" + ) + + for shape in shapes: + shpIdx = self.getShapeIndex(shape) + shpPlug = ( + self.shapeNode + + shapePlugFmt.format(meshIdx=0, shapeIdx=shpIdx) + + ".inputGeomTarget" + ) + + cnx = cmds.listConnections(shpPlug, shapes=True, destination=False) or [] + for cc in cnx: + if not cc.endswith("FreezeShape"): + continue + par = cmds.listRelatives(cc, parent=True) + if par and par[0] == self.ctrl: + # Can't use list history to get the chain because it's a pseudo-cycle + ret.extend(self._getDeformerChain(cc)) + + if ret: + self.primeShapes(combo) + + return ret + + +class SliderDispatch(QtCore.QObject): + valueChanged = Signal() + + def __init__(self, node, parent=None) -> None: + super().__init__(parent) + mObject = getMObject(node) + self.callbackID = om.MNodeMessage.addAttributeChangedCallback( + mObject, self.emitValueChanged + ) + + def emitValueChanged(self, *args, **kwargs) -> None: + self.valueChanged.emit() + + def disconnectCallbacks(self) -> None: + om.MMessage.removeCallback(self.callbackID) + self.callbackID = None + + def __del__(self) -> None: + self.disconnectCallbacks() + + +class Dispatch(QtCore.QObject): + beforeNew = Signal() + afterNew = Signal() + beforeOpen = Signal() + afterOpen = Signal() + undo = Signal() + redo = Signal() + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.callbackIDs = [] + self.connectCallbacks() + + def connectCallbacks(self) -> None: + if self.callbackIDs: + self.disconnectCallbacks() + + self.callbackIDs.append( + om.MSceneMessage.addCallback( + om.MSceneMessage.kBeforeNew, self.emitBeforeNew + ) + ) + self.callbackIDs.append( + om.MSceneMessage.addCallback(om.MSceneMessage.kAfterNew, self.emitAfterNew) + ) + self.callbackIDs.append( + om.MSceneMessage.addCallback( + om.MSceneMessage.kBeforeOpen, self.emitBeforeOpen + ) + ) + self.callbackIDs.append( + om.MSceneMessage.addCallback( + om.MSceneMessage.kAfterOpen, self.emitAfterOpen + ) + ) + self.callbackIDs.append( + om.MEventMessage.addEventCallback("Undo", self.emitUndo) + ) + self.callbackIDs.append( + om.MEventMessage.addEventCallback("Redo", self.emitRedo) + ) + + def disconnectCallbacks(self) -> None: + for i in self.callbackIDs: + om.MMessage.removeCallback(i) + self.callbackIDs = [] + + def emitBeforeNew(self, *args, **kwargs) -> None: + self.beforeNew.emit() + + def emitAfterNew(self, *args, **kwargs) -> None: + self.afterNew.emit() + + def emitBeforeOpen(self, *args, **kwargs) -> None: + self.beforeOpen.emit() + + def emitAfterOpen(self, *args, **kwargs) -> None: + self.afterOpen.emit() + + def emitUndo(self, *args, **kwargs) -> None: + self.undo.emit() + + def emitRedo(self, *args, **kwargs) -> None: + self.redo.emit() + + def __del__(self) -> None: + self.disconnectCallbacks() + + +DISPATCH = Dispatch() + + +def rootWindow(): + """Returns the currently active QT main window + Only works for QT UI's like Maya + """ + # for MFC apps there should be no root window + window = None + if QApplication.instance(): + inst = QApplication.instance() + window = inst.activeWindow() + # Ignore QSplashScreen's, they should never be considered the root window. + if isinstance(window, QSplashScreen): + return None + # If the application does not have focus try to find A top level widget + # that doesn't have a parent and is a QMainWindow or QDialog + if window is None: + windows = [] + dialogs = [] + for w in QApplication.instance().topLevelWidgets(): + if w.parent() is None: + if isinstance(w, QMainWindow): + windows.append(w) + elif isinstance(w, QDialog): + dialogs.append(w) + if windows: + window = windows[0] + elif dialogs: + window = dialogs[0] + + # grab the root window + if window: + while True: + parent = window.parent() + if not parent: + break + if isinstance(parent, QSplashScreen): + break + window = parent + + return window + + +SIMPLEX_RESET_SCRIPTJOB = """ +import maya.cmds as cmds +import maya.OpenMaya as om + +def simplexDelCB(node, dgMod, clientData): + xNode, dName = clientData + dNode = getMObject(dName) + if dNode and not dNode.isNull(): + dgMod.deleteNode(dNode) + +def getMObject(name): + selected = om.MSelectionList() + try: + selected.add(name, True) + except RuntimeError: + return None + if selected.isEmpty(): + return None + thing = om.MObject() + selected.getDependNode(0, thing) + return thing + +# get all .simplexDelete message attributes +delAttrs = cmds.ls("*.simplexDelete") +if delAttrs: + # get all their connections + cnx = cmds.listConnections(delAttrs, plugs=True, connections=True, destination=False) + + # Set up the deletion callback + mmIds = [] + for i in range(0, len(cnx), 2): + parName, delName = cmds.ls(cnx[i:i+2], long=True, objectsOnly=True) + pNode = getMObject(parName) + dNode = getMObject(delName) + om.MNodeMessage.addNodeAboutToDeleteCallback(pNode, simplexDelCB, (dNode, delName)) +""" + + +def buildDeleterScriptJob() -> None: + dcbName = "SimplexDeleterCallback" + if not cmds.ls(dcbName): + cmds.scriptNode( + scriptType=1, + beforeScript=SIMPLEX_RESET_SCRIPTJOB, + name=dcbName, + sourceType="python", + ) + + +def simplexDelCB(_node, dgMod, clientData) -> None: + _xNode, dName = clientData + dNode = getMObject(dName) + if dNode and not dNode.isNull(): + dgMod.deleteNode(dNode) + + +def getMObject(name): + selected = om.MSelectionList() + try: + selected.add(name, True) + except RuntimeError: + return None + if selected.isEmpty(): + return None + thing = om.MObject() + selected.getDependNode(0, thing) + return thing + + +def buildDeleterCallback(parName, delName): + pNode = getMObject(parName) + dNode = getMObject(delName) + idNum = om.MNodeMessage.addNodeAboutToDeleteCallback( + pNode, simplexDelCB, (dNode, delName) + ) + return idNum diff --git a/src/python/simplexui/interfaceModel.py b/src/python/simplexui/interfaceModel.py index e89370e9..52c6c526 100644 --- a/src/python/simplexui/interfaceModel.py +++ b/src/python/simplexui/interfaceModel.py @@ -1,1053 +1,655 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# pylint:disable=missing-docstring,unused-argument,no-self-use,too-many-return-statements -import re -from contextlib import contextmanager - -from .items import ( - Combo, - ComboPair, - Group, - ProgPair, - Progression, - Slider, - Traversal, - TravPair, -) -from Qt import IsPyQt5, IsPySide2 -from Qt.QtCore import QAbstractItemModel, QModelIndex, QSortFilterProxyModel, Qt - - -# Hierarchy Helpers -def coerceIndexToType(indexes, typ): - """Get a list of indices of a specific type based on a given index list - Items containing parents of the type fall down to their children - Items containing children of the type climb up to their parents - - Parameters - ---------- - indexes : [QModelIndex, ...] - A list of indexes to coerce - typ : Type - The type to coerce to - - Returns - ------- - [QModelIndex, ...] - The coerced list - """ - targetDepth = typ.classDepth - - children = [] - parents = [] - out = [] - for idx in indexes: - item = idx.model().itemFromIndex(idx) - depth = item.classDepth - if depth < targetDepth: - parents.append(idx) - elif depth > targetDepth: - children.append(idx) - else: - out.append(idx) - - out.extend(coerceIndexToChildType(parents, typ)) - out.extend(coerceIndexToParentType(children, typ)) - out = list(set(out)) - return out - - -def coerceIndexToChildType(indexes, typ): - """Get a list of indices of a specific type based on a given index list - Lists containing parents of the type fall down to their children - - Parameters - ---------- - indexes : [QModelIndex, ...] - A list of indexes to coerce - typ : Type - The type to coerce to - - Returns - ------- - [QModelIndex, ...] - The coerced list - """ - targetDepth = typ.classDepth - out = [] - - for idx in indexes: - model = idx.model() - item = idx.model().itemFromIndex(idx) - if item.classDepth < targetDepth: - # Too high up, grab children - queue = [idx] - depthIdxs = [] - while queue: - checkIdx = queue.pop() - checkItem = checkIdx.model().itemFromIndex(checkIdx) - if checkItem.classDepth < targetDepth: - for row in range(model.rowCount(checkIdx)): - queue.append(model.index(row, 0, checkIdx)) - elif checkItem.classDepth == targetDepth: - depthIdxs.append(checkIdx) - out.extend(depthIdxs) - elif item.classDepth == targetDepth: - out.append(idx) - - out = list(set(out)) - return out - - -def coerceIndexToParentType(indexes, typ): - """Get a list of indices of a specific type based on a given index list - Lists containing children of the type climb up to their parents - - Parameters - ---------- - indexes : [QModelIndex, ...] - A list of indexes to coerce - typ : Type - The type to coerce to - - Returns - ------- - [QModelIndex, ...] - The coerced list - """ - targetDepth = typ.classDepth - out = [] - for idx in indexes: - item = idx.model().itemFromIndex(idx) - depth = item.classDepth - if depth > targetDepth: - parIdx = idx - parItem = parIdx.model().itemFromIndex(parIdx) - while parItem.classDepth > targetDepth: - parIdx = parIdx.parent() - parItem = parIdx.model().itemFromIndex(parIdx) - if parItem.classDepth == targetDepth: - out.append(parIdx) - elif depth == targetDepth: - out.append(idx) - - out = list(set(out)) - return out - - -def coerceIndexToRoots(indexes): - """Get the topmost indexes for each brach in the hierarchy - - Parameters - ---------- - indexes : [QModelIndex, ...] - A list of indexes to coerce - - Returns - ------- - [QModelIndex, ...] - The coerced list - """ - indexes = [i for i in indexes if i.column() == 0] - indexes = sorted( - indexes, key=lambda x: x.model().itemFromIndex(x).classDepth, reverse=True - ) - # Check each item to see if any of it's ancestors - # are in the selection list. If not, it's a root - roots = [] - for idx in indexes: - par = idx.parent() - while par.isValid(): - if par in indexes: - break - par = par.parent() - else: - roots.append(idx) - - return roots - - -# BASE MODEL -class ContextModel(QAbstractItemModel): - """A sub-class of QAbstractItemModel with built-in contextmanagers - that handle calling the begin/end signals for adding/removing/moving/resettting - """ - - @contextmanager - def insertItemManager(self, parent, row=-1): - """ContextManager for inserting items into the model - - Parameters - ---------- - parent : object - The item in the tree that will be the parent - row : int - The row to insert into. Pass -1 to append to the list (Default value = -1) - """ - parIdx = self.indexFromItem(parent) - if row == -1: - row = self.getItemAppendRow(parent) - self.beginInsertRows(parIdx, row, row) - try: - yield - finally: - self.endInsertRows() - - @contextmanager - def removeItemManager(self, item): - """ContextManager for removing items from the model - - Parameters - ---------- - item : object - The item to remove from the model - """ - idx = self.indexFromItem(item) - valid = idx.isValid() - if valid: - parIdx = idx.parent() - self.beginRemoveRows(parIdx, idx.row(), idx.row()) - try: - yield - finally: - if valid: - self.endRemoveRows() - - @contextmanager - def moveItemManager(self, item, destPar, destRow=-1): - """ContextManager for moving items within the model - - Parameters - ---------- - item : object - The item to move in the model - destPar : object - The object that will be the new parent - destRow : int - The row to move to. Pass -1 to move to the end (Default value = -1) - """ - itemIdx = self.indexFromItem(item) - destParIdx = self.indexFromItem(destPar) - handled = False - if itemIdx.isValid() and destParIdx.isValid(): - handled = True - srcParIdx = itemIdx.parent() - row = itemIdx.row() - if destRow == -1: - destRow = self.getItemAppendRow(destPar) - self.beginMoveRows(srcParIdx, row, row, destParIdx, destRow) - try: - yield - finally: - if handled: - self.endMoveRows() - - @contextmanager - def resetModelManager(self): - """ContextManager for resetting the entire model""" - self.beginResetModel() - try: - yield - finally: - self.endResetModel() - - def indexFromItem(self, item, column=0): - """Return the index for the given item - - Parameters - ---------- - item : object - The item to move in the model - column : int - The column to get the index for. Defaults to 0 - - Returns - ------- - QModelIndex - The index of the item - """ - row = self.getItemRow(item) - if row is None: - return QModelIndex() - return self.createIndex(row, column, item) - - def itemFromIndex(self, index): - """Return the item for the given index - - Parameters - ---------- - index : QModelIndex - The index to get the item of - - Returns - ------- - object - The item in the tree - """ - return index.internalPointer() - - def itemDataChanged(self, item): - """Emit the itemDataChanged signal. - - This must be done through this interface because, unfortunately, I can't quite figure out how - to make the empty `roles` list pass properly for Qt5. So I have to change behavior based - on the Qt backend - - Parameters - ---------- - item : object - The object whose data has changed - """ - idx = self.indexFromItem(item) - self.emitDataChanged(idx) - - def _emitDataChangedQt5(self, index): - if index.isValid(): - self.dataChanged.emit(index, index, []) - - def _emitDataChangedQt4(self, index): - if index.isValid(): - self.dataChanged.emit(index, index) - - emitDataChanged = ( - _emitDataChangedQt5 if IsPySide2 or IsPyQt5 else _emitDataChangedQt4 - ) - - -class SimplexModel(ContextModel): - """The base model for all interaction with a simplex system. - All ui interactions with a simplex system must go through this model - Any special requirements, or reorganizations of the trees will only - be implemented as proxy models. - - There will be little documentation for this class, as all methods - are virtual overrides of the underlying Qt class - - Parameters - ---------- - simplex : Simplex - The Simplex system for this model - parent : QObject - The parent for this model - - """ - - def __init__(self, simplex, parent): - super(SimplexModel, self).__init__(parent) - self.simplex = simplex - self.simplex.models.append(self) - - def index(self, row, column, parIndex): - par = parIndex.internalPointer() - child = self.getChildItem(par, row) - if child is None: - return QModelIndex() - return self.createIndex(row, column, child) - - def parent(self, index): - if not index.isValid(): - return QModelIndex() - item = index.internalPointer() - if item is None: - return QModelIndex() - par = self.getParentItem(item) - if par is None: - return QModelIndex() - row = self.getItemRow(par) - if row is None: - return QModelIndex() - return self.createIndex(row, 0, par) - - def rowCount(self, parIndex): - parent = parIndex.internalPointer() - ret = self.getItemRowCount(parent) - return ret - - def columnCount(self, parIndex): - return 3 - - def data(self, index, role): - if not index.isValid(): - return None - item = index.internalPointer() - return self.getItemData(item, index.column(), role) - - def flags(self, index): - if not index.isValid(): - return Qt.ItemFlag.ItemIsEnabled - if index.column() == 0: - item = index.internalPointer() - if isinstance(item, (Slider, Combo, Traversal)): - return ( - Qt.ItemFlag.ItemIsEnabled - | Qt.ItemFlag.ItemIsSelectable - | Qt.ItemFlag.ItemIsEditable - | Qt.ItemFlag.ItemIsUserCheckable - ) - # TODO: make the SHAPES object under a combo or traversal not-editable - return ( - Qt.ItemFlag.ItemIsEnabled - | Qt.ItemFlag.ItemIsSelectable - | Qt.ItemFlag.ItemIsEditable - ) - - def setData(self, index, value, role=Qt.ItemDataRole.EditRole): - if not index.isValid(): - return False - if role == Qt.ItemDataRole.CheckStateRole: - item = index.internalPointer() - if index.column() == 0: - if isinstance(item, (Slider, Combo, Traversal)): - item.enabled = value == Qt.CheckState.Checked - return True - elif role == Qt.ItemDataRole.EditRole: - item = index.internalPointer() - if index.column() == 0: - if isinstance(item, (Group, Slider, Combo, Traversal, ProgPair)): - item.name = value - return True - - elif index.column() == 1: - if isinstance(item, Slider): - item.value = value - elif isinstance(item, ComboPair): - item.value = value - elif isinstance(item, TravPair): - item.value = value - - elif index.column() == 2: - if isinstance(item, ProgPair): - item.value = value - return False - - def headerData(self, section, orientation, role): - if orientation == Qt.Orientation.Horizontal: - if role == Qt.ItemDataRole.DisplayRole: - sects = ("Items", "Slide", "Value") - return sects[section] - return None - - # Methods for dealing with items only - # These will be used to build the indexes - # and will be public for utility needs - def getChildItem(self, parent, row): - if parent is None: - if row == 0: - return self.simplex - else: - return None - return parent.treeChild(row) - - def getItemRow(self, item): - if item is None: - return None - return item.treeRow() - - def getParentItem(self, item): - if item is None: - return None - return item.treeParent() - - def getItemRowCount(self, item): - # Null parent means 1 row that is the simplex object - if item is None: - ret = 1 - else: - ret = item.treeChildCount() - return ret - - def getItemData(self, item, column, role): - if item is None: - return None - - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - return item.treeData(column) - - elif role == Qt.ItemDataRole.CheckStateRole: - chk = None - if column == 0: - chk = item.treeChecked() - if chk is not None: - chk = Qt.CheckState.Checked if chk else Qt.CheckState.Unchecked - return chk - elif role == Qt.ItemDataRole.DecorationRole: - if column == 0: - return item.icon() - return None - - def getItemAppendRow(self, item): - if isinstance(item, Combo): - # insert before the special "SHAPES" item - # getItemRowCount returns len(item.pairs) + 1 - return len(item.pairs) - return self.getItemRowCount(item) - - -# VIEW MODELS -class BaseProxyModel(QSortFilterProxyModel): - """Holds the common item/index translation code for my filter models - Again, This is just a concrete implementation of a Qt base class, so - documentation will be lacking - """ - - def __init__(self, model, parent=None): - super(BaseProxyModel, self).__init__(parent) - self.setSourceModel(model) - - def indexFromItem(self, item, column=0): - sourceModel = self.sourceModel() - sourceIndex = sourceModel.indexFromItem(item, column) - return self.mapFromSource(sourceIndex) - - def itemFromIndex(self, index): - sourceModel = self.sourceModel() - sIndex = self.mapToSource(index) - return sourceModel.itemFromIndex(sIndex) - - def invalidate(self): - source = self.sourceModel() - if isinstance(source, QSortFilterProxyModel): - source.invalidate() - super(BaseProxyModel, self).invalidate() - - def invalidateFilter(self): - source = self.sourceModel() - if isinstance(source, QSortFilterProxyModel): - source.invalidateFilter() - super(BaseProxyModel, self).invalidateFilter() - - def filterAcceptsRow(self, sourceRow, sourceParent): - return True - - -class SliderModel(BaseProxyModel): - def filterAcceptsRow(self, sourceRow, sourceParent): - sourceIndex = self.sourceModel().index(sourceRow, 0, sourceParent) - if sourceIndex.isValid(): - item = self.sourceModel().itemFromIndex(sourceIndex) - if isinstance(item, Group): - if item.groupType is not Slider: - return False - return super(SliderModel, self).filterAcceptsRow(sourceRow, sourceParent) - - -class ComboModel(BaseProxyModel): - def filterAcceptsRow(self, sourceRow, sourceParent): - sourceIndex = self.sourceModel().index(sourceRow, 0, sourceParent) - if sourceIndex.isValid(): - item = self.sourceModel().itemFromIndex(sourceIndex) - if isinstance(item, Group): - if item.groupType is not Combo: - return False - return super(ComboModel, self).filterAcceptsRow(sourceRow, sourceParent) - - -class TraversalModel(BaseProxyModel): - def filterAcceptsRow(self, sourceRow, sourceParent): - sourceIndex = self.sourceModel().index(sourceRow, 0, sourceParent) - if sourceIndex.isValid(): - item = self.sourceModel().itemFromIndex(sourceIndex) - if isinstance(item, Group): - if item.groupType is not Traversal: - return False - return super(TraversalModel, self).filterAcceptsRow(sourceRow, sourceParent) - - -# FILTER MODELS -class SimplexFilterModel(BaseProxyModel): - """Filter a model based off of a given string - Set the `filterString` object property to filter the model - """ - - def __init__(self, model, parent=None): - super(SimplexFilterModel, self).__init__(model, parent) - self.setSourceModel(model) - self._filterString = [] - self._filterReg = [] - self.isolateList = [] - - @property - def filterString(self): - return " ".join(self._filterString) - - @filterString.setter - def filterString(self, val): - self._filterString = val.split() - - self._filterReg = [] - for sp in self._filterString: - if sp[0] == "*": - self._filterReg.append(re.compile(sp, flags=re.I)) - else: - self._filterReg.append(re.compile(".*?".join(sp), flags=re.I)) - - def filterAcceptsRow(self, sourceRow, sourceParent): - column = 0 # always sort by the first column #column = self.filterKeyColumn() - sourceIndex = self.sourceModel().index(sourceRow, column, sourceParent) - if sourceIndex.isValid(): - if self.filterString or self.isolateList: - sourceItem = self.sourceModel().itemFromIndex(sourceIndex) - if isinstance( - sourceItem, (ProgPair, Slider, Combo, ComboPair, Progression) - ): - if not self.checkChildren(sourceItem): - return False - - return super(SimplexFilterModel, self).filterAcceptsRow(sourceRow, sourceParent) - - def matchFilterString(self, itemString): - if not self._filterString: - return True - for reg in self._filterReg: - if reg.search(itemString): - return True - return False - - def matchIsolation(self, itemString): - if self.isolateList: - return itemString in self.isolateList - return True - - def checkChildren(self, sourceItem): - itemString = sourceItem.name - if self.matchFilterString(itemString) and self.matchIsolation(itemString): - return True - - sourceModel = self.sourceModel().sourceModel() - for row in range(sourceModel.getItemRowCount(sourceItem)): - childItem = sourceModel.getChildItem(sourceItem, row) - if childItem is not None: - return self.checkChildren(childItem) - - return False - - -class SliderFilterModel(SimplexFilterModel): - """Hide single shapes under a slider""" - - def __init__(self, model, parent=None): - super(SliderFilterModel, self).__init__(model, parent) - self.requires = [] - self.filterRequiresAny = False - self.filterRequiresAll = False - - self.doFilter = True - - def filterAcceptsRow(self, sourceRow, sourceParent): - # always sort by the first column #column = self.filterKeyColumn() - column = 0 - sourceIndex = self.sourceModel().index(sourceRow, column, sourceParent) - if sourceIndex.isValid(): - data = self.sourceModel().itemFromIndex(sourceIndex) - if self.doFilter: - if isinstance(data, ProgPair): - if len(data.prog.pairs) <= 2: - return False - elif data.shape.isRest: - return False - - if (self.filterRequiresAny or self.filterRequiresAll) and self.requires: - # Ignore items that aren't part of the required combos, if requested - if isinstance(data, Slider): - sliGroups = [[i.slider for i in c.pairs] for c in self.requires] - if self.filterRequiresAny: - if not any(data in s for s in sliGroups): - return False - elif self.filterRequiresAll: - if not all(data in s for s in sliGroups): - return False - - return super(SliderFilterModel, self).filterAcceptsRow(sourceRow, sourceParent) - - -class ComboFilterModel(SimplexFilterModel): - """Filter by slider when Show Dependent Combos is checked""" - - def __init__(self, model, parent=None): - super(ComboFilterModel, self).__init__(model, parent) - self.requires = [] - self.filterRequiresAll = False - self.filterRequiresAny = False - self.filterRequiresOnly = False - - self.filterShapes = True - - def filterAcceptsRow(self, sourceRow, sourceParent): - # always sort by the first column #column = self.filterKeyColumn() - column = 0 - sourceIndex = self.sourceModel().index(sourceRow, column, sourceParent) - if sourceIndex.isValid(): - data = self.sourceModel().itemFromIndex(sourceIndex) - if self.filterShapes: - # ignore the SHAPE par if there's nothing under there - if isinstance(data, Progression): - if len(data.pairs) <= 2: - return False - # Ignore shape things if requested - if isinstance(data, ProgPair): - if len(data.prog.pairs) <= 2: - return False - elif data.shape.isRest: - return False - if ( - self.filterRequiresAny - or self.filterRequiresAll - or self.filterRequiresOnly - ) and self.requires: - # Ignore items that don't use the required sliders if requested - if isinstance(data, Combo): - sliders = [i.slider for i in data.pairs] - if self.filterRequiresAll: - if not all(r in sliders for r in self.requires): - return False - elif self.filterRequiresAny: - if not any(r in sliders for r in self.requires): - return False - elif self.filterRequiresOnly: - if not all(r in self.requires for r in sliders): - return False - - return super(ComboFilterModel, self).filterAcceptsRow(sourceRow, sourceParent) - - -class TraversalFilterModel(SimplexFilterModel): - """Hide single shapes under a slider""" - - def __init__(self, model, parent=None): - super(TraversalFilterModel, self).__init__(model, parent) - self.doFilter = True - - def filterAcceptsRow(self, sourceRow, sourceParent): - column = 0 # always sort by the first column #column = self.filterKeyColumn() - sourceIndex = self.sourceModel().index(sourceRow, column, sourceParent) - if sourceIndex.isValid(): - if self.doFilter: - data = self.sourceModel().itemFromIndex(sourceIndex) - if isinstance(data, ProgPair): - if len(data.prog.pairs) <= 2: - return False - elif data.shape.isRest: - return False - - return super(TraversalFilterModel, self).filterAcceptsRow( - sourceRow, sourceParent - ) - - -# SETTINGS MODELS -class SliderGroupModel(ContextModel): - """A model for displaying Group objects that contain Sliders""" - - def __init__(self, simplex, parent): - super(SliderGroupModel, self).__init__(parent) - self.simplex = simplex - self.simplex.models.append(self) - - def getItemRow(self, item): - try: - idx = self.simplex.sliderGroups.index(item) - except ValueError: - return None - return idx + 1 - - def getItemAppendRow(self, item): - return len(self.simplex.sliderGroups) + 1 - - def index(self, row, column=0, parIndex=None): - parIndex = QModelIndex() if parIndex is None else parIndex - if row <= 0: - return self.createIndex(row, column, None) - try: - falloff = self.simplex.sliderGroups[row - 1] - except IndexError: - return QModelIndex() - return self.createIndex(row, column, falloff) - - def parent(self, index): - return QModelIndex() - - def rowCount(self, parent): - return len(self.simplex.sliderGroups) + 1 - - def columnCount(self, parent): - return 1 - - def data(self, index, role): - if not index.isValid(): - return None - group = index.internalPointer() - if group and role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - return group.name - return None - - def flags(self, index): - return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable - - def itemFromIndex(self, index): - return index.internalPointer() - - -class FalloffModel(ContextModel): - """A model for displaying Falloff objects connected to Sliders""" - - def __init__(self, simplex, parent): - super(FalloffModel, self).__init__(parent) - self.simplex = simplex - if self.simplex is not None: - self.simplex.falloffModels.append(self) - self.sliders = [] - self._checks = {} - self.line = "" - - def setSliders(self, sliders): - self.beginResetModel() - self.sliders = sliders - self._checks = {} - for slider in self.sliders: - for fo in slider.prog.falloffs: - self._checks.setdefault(fo, []).append(slider) - self.endResetModel() - self.buildLine() - - def buildLine(self): - if not self.sliders: - self.line = "" - return - fulls = [] - partials = [] - for fo in self.simplex.falloffs: - cs = self._getCheckState(fo) - if cs == Qt.CheckState.Checked: - fulls.append(fo.name) - elif cs == Qt.CheckState.PartiallyChecked: - partials.append(fo.name) - if partials: - title = "{0} <<{1}>>".format(",".join(fulls), ",".join(partials)) - else: - title = ",".join(fulls) - self.line = title - - def getItemRow(self, item): - try: - idx = self.simplex.falloffs.index(item) - except ValueError: - return None - except AttributeError: - return None - return idx + 1 - - def getItemAppendRow(self, item): - try: - return len(self.simplex.falloffs) - except AttributeError: - return 0 - - def index(self, row, column=0, parIndex=None): - parIndex = QModelIndex() if parIndex is None else parIndex - if row <= 0: - return self.createIndex(row, column, None) - try: - falloff = self.simplex.falloffs[row - 1] - except IndexError: - return QModelIndex() - except AttributeError: - return QModelIndex() - return self.createIndex(row, column, falloff) - - def parent(self, index): - return QModelIndex() - - def rowCount(self, parent): - try: - return len(self.simplex.falloffs) + 1 - except AttributeError: - return 0 - - def columnCount(self, parent): - return 1 - - def _getCheckState(self, fo): - sli = self._checks.get(fo, []) - if len(sli) == len(self.sliders): - return Qt.CheckState.Checked - elif len(sli) == 0: - return Qt.CheckState.Unchecked - return Qt.CheckState.PartiallyChecked - - def data(self, index, role): - if not index.isValid(): - return None - falloff = index.internalPointer() - if not falloff: - return None - - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - return falloff.name - elif role == Qt.ItemDataRole.CheckStateRole: - return self._getCheckState(falloff) - return None - - def setData(self, index, value, role): - if role == Qt.ItemDataRole.CheckStateRole: - fo = index.internalPointer() - if not fo: - return - if value == Qt.CheckState.Checked: - for s in self.sliders: - if fo not in s.prog.falloffs: - s.prog.addFalloff(fo) - self._checks.setdefault(fo, []).append(s) - elif value == Qt.CheckState.Unchecked: - for s in self.sliders: - if fo in s.prog.falloffs: - s.prog.removeFalloff(fo) - if s in self._checks[fo]: - self._checks[fo].remove(s) - self.buildLine() - self.emitDataChanged(index) - return True - return False - - def flags(self, index): - return ( - Qt.ItemFlag.ItemIsEnabled - | Qt.ItemFlag.ItemIsEditable - | Qt.ItemFlag.ItemIsUserCheckable - ) - - def itemFromIndex(self, index): - return index.internalPointer() - - -class FalloffDataModel(ContextModel): - """A model for displaying the data of Falloff objects""" - - def __init__(self, simplex, parent): - super(FalloffDataModel, self).__init__(parent) - self.simplex = simplex - if self.simplex is not None: - self.simplex.falloffModels.append(self) - - def getItemAppendRow(self, item): - try: - return len(self.simplex.falloffs) - except AttributeError: - return 0 - - def index(self, row, column=0, parIndex=None): - parIndex = QModelIndex() if parIndex is None else parIndex - if row < 0: - return QModelIndex() - try: - falloff = self.simplex.falloffs[row] - except IndexError: - return QModelIndex() - except AttributeError: - return QModelIndex() - return self.createIndex(row, column, falloff) - - def parent(self, index): - return QModelIndex() - - def rowCount(self, parent): - try: - return len(self.simplex.falloffs) - except AttributeError: - return 0 - - def columnCount(self, parent): - return 8 - - def data(self, index, role): - if not index.isValid(): - return None - falloff = index.internalPointer() - if not falloff: - return None - - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - if index.column() == 0: - return falloff.name - elif index.column() == 1: - st = falloff.splitType - sti = ("planar", "map").index(st.lower()) - return sti - elif index.column() == 2: - ax = falloff.axis - axi = "xyz".index(ax.lower()) - return axi - elif index.column() == 3: - return falloff.maxVal - elif index.column() == 4: - return falloff.maxHandle - elif index.column() == 5: - return falloff.minHandle - elif index.column() == 6: - return falloff.minVal - elif index.column() == 7: - return falloff.mapName - return None - - def setData(self, index, value, role): - if not index.isValid(): - return False - falloff = index.internalPointer() - if not falloff: - return False - if role == Qt.ItemDataRole.EditRole: - if index.column() == 0: - falloff.name = value - elif index.column() == 1: - if value in [0, 1]: - value = ("planar", "map")[value] - falloff.splitType = value - elif index.column() == 2: - if value in [0, 1, 2]: - value = "XYZ"[value] - falloff.axis = value - elif index.column() == 3: - falloff.maxVal = value - elif index.column() == 4: - falloff.maxHandle = value - elif index.column() == 5: - falloff.minHandle = value - elif index.column() == 6: - falloff.minVal = value - elif index.column() == 7: - falloff.mapName = value - return True - return False - - def flags(self, index): - return ( - Qt.ItemFlag.ItemIsEnabled - | Qt.ItemFlag.ItemIsEditable - | Qt.ItemFlag.ItemIsSelectable - ) - - def itemFromIndex(self, index): - return index.internalPointer() - - def getItemRow(self, item): - try: - idx = self.simplex.falloffs.index(item) - except ValueError: - return None - except AttributeError: - return None - return idx +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +from __future__ import annotations + +import re +from typing import Any, TypeVar, cast + +from Qt.QtCore import QModelIndex, QSortFilterProxyModel, Qt +from Qt.QtWidgets import QWidget + +from .items import ( + Combo, + ComboPair, + Falloff, + Group, + ProgPair, + Progression, + Simplex, + Slider, + Traversal, + TravPair, +) +from .items.treeItem import AdapterModel, TreeItem, TreeRootItem + +T = TypeVar('T', bound=TreeItem) + + +# Hierarchy Helpers +def coerceIndexToType(indexes: list[QModelIndex], typ: type[T]) -> list[QModelIndex]: + """Get a list of indices of a specific type based on a given index list + Items containing parents of the type fall down to their children + Items containing children of the type climb up to their parents + + Parameters + ---------- + indexes : [QModelIndex, ...] + A list of indexes to coerce + typ : Type + The type to coerce to + + Returns + ------- + [QModelIndex, ...] + The coerced list + """ + targetDepth = typ.classDepth + + children = [] + parents = [] + out = [] + for idx in indexes: + item = idx.model().itemFromIndex(idx) + depth = item.classDepth + if depth < targetDepth: + parents.append(idx) + elif depth > targetDepth: + children.append(idx) + else: + out.append(idx) + + out.extend(coerceIndexToChildType(parents, typ)) + out.extend(coerceIndexToParentType(children, typ)) + out = list(set(out)) + return out + + +def coerceIndexToChildType( + indexes: list[QModelIndex], typ: type[T] +) -> list[QModelIndex]: + """Get a list of indices of a specific type based on a given index list + Lists containing parents of the type fall down to their children + + Parameters + ---------- + indexes : [QModelIndex, ...] + A list of indexes to coerce + typ : Type + The type to coerce to + + Returns + ------- + [QModelIndex, ...] + The coerced list + """ + targetDepth = typ.classDepth + out = [] + + for idx in indexes: + model = idx.model() + item = idx.model().itemFromIndex(idx) + if item.classDepth < targetDepth: + # Too high up, grab children + queue = [idx] + depthIdxs = [] + while queue: + checkIdx = queue.pop() + checkItem = checkIdx.model().itemFromIndex(checkIdx) + if checkItem.classDepth < targetDepth: + for row in range(model.rowCount(checkIdx)): + queue.append(model.index(row, 0, checkIdx)) + elif checkItem.classDepth == targetDepth: + depthIdxs.append(checkIdx) + out.extend(depthIdxs) + elif item.classDepth == targetDepth: + out.append(idx) + + out = list(set(out)) + return out + + +def coerceIndexToParentType( + indexes: list[QModelIndex], typ: type[T] +) -> list[QModelIndex]: + """Get a list of indices of a specific type based on a given index list + Lists containing children of the type climb up to their parents + + Parameters + ---------- + indexes : [QModelIndex, ...] + A list of indexes to coerce + typ : Type + The type to coerce to + + Returns + ------- + [QModelIndex, ...] + The coerced list + """ + targetDepth = typ.classDepth + out = [] + for idx in indexes: + item = idx.model().itemFromIndex(idx) + depth = item.classDepth + if depth > targetDepth: + parIdx = idx + parItem = parIdx.model().itemFromIndex(parIdx) + while parItem.classDepth > targetDepth: + parIdx = parIdx.parent() + parItem = parIdx.model().itemFromIndex(parIdx) + if parItem.classDepth == targetDepth: + out.append(parIdx) + elif depth == targetDepth: + out.append(idx) + + out = list(set(out)) + return out + + +def coerceIndexToRoots(indexes: list[QModelIndex]) -> list[QModelIndex]: + """Get the topmost indexes for each brach in the hierarchy + + Parameters + ---------- + indexes : [QModelIndex, ...] + A list of indexes to coerce + + Returns + ------- + [QModelIndex, ...] + The coerced list + """ + indexes = [i for i in indexes if i.column() == 0] + indexes = sorted( + indexes, key=lambda x: x.model().itemFromIndex(x).classDepth, reverse=True + ) + # Check each item to see if any of it's ancestors + # are in the selection list. If not, it's a root + roots = [] + for idx in indexes: + par = idx.parent() + while par.isValid(): + if par in indexes: + break + par = par.parent() + else: + roots.append(idx) + + return roots + + +# BASE MODEL +class SimplexModel(AdapterModel): + """The base model for all interaction with a simplex system. + All ui interactions with a simplex system must go through this model + Any special requirements, or reorganizations of the trees will only + be implemented as proxy models. + + There will be little documentation for this class, as all methods + are virtual overrides of the underlying Qt class + + Parameters + ---------- + simplex : Simplex + The Simplex system for this model + parent : QObject + The parent for this model + + """ + + @property + def simplex(self) -> TreeRootItem | None: + return self._rootItem + + def headerData( + self, section: int, orientation: Qt.Orientation, role: int + ) -> str | None: + if orientation == Qt.Orientation.Horizontal: + if role == Qt.ItemDataRole.DisplayRole: + sects = ("Items", "Slide", "Value") + return sects[section] + return None + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid(): + return Qt.ItemFlag.ItemIsEnabled + if index.column() == 0: + item = index.internalPointer() + if isinstance(item, (Slider, Combo, Traversal)): + return ( + Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsEditable + | Qt.ItemFlag.ItemIsUserCheckable + ) + elif isinstance(item, Progression): + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable + return ( + Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsEditable + ) + + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + if not index.isValid(): + return False + if role == Qt.ItemDataRole.CheckStateRole: + item = index.internalPointer() + if index.column() == 0: + if isinstance(item, (Slider, Combo, Traversal)): + item.enabled = value == Qt.CheckState.Checked.value + return True + elif role == Qt.ItemDataRole.EditRole: + item = index.internalPointer() + if index.column() == 0: + if isinstance(item, (Group, Slider, Combo, Traversal, ProgPair)): + item.name = value + return True + + elif index.column() == 1: + if isinstance(item, Slider): + item.value = value + elif isinstance(item, ComboPair): + item.value = value + elif isinstance(item, TravPair): + item.value = value + + elif index.column() == 2: + if isinstance(item, ProgPair): + item.value = value + return False + + +# VIEW MODELS +class BaseProxyModel(QSortFilterProxyModel): + """Holds the common item/index translation code for my filter models + Again, This is just a concrete implementation of a Qt base class, so + documentation will be lacking + """ + + def __init__(self, model: SimplexModel, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setSourceModel(model) + + def sourceModel(self) -> SimplexModel: + return cast(SimplexModel, super().sourceModel()) + + def indexFromItem(self, item: TreeItem, column: int = 0) -> QModelIndex: + sourceModel = self.sourceModel() + sourceIndex = sourceModel.indexFromItem(item, column) + return self.mapFromSource(sourceIndex) + + def itemFromIndex(self, index: QModelIndex) -> TreeItem | None: + sourceModel = self.sourceModel() + sIndex = self.mapToSource(index) + return sourceModel.itemFromIndex(sIndex) + + def invalidate(self) -> None: + source = self.sourceModel() + if isinstance(source, QSortFilterProxyModel): + source.invalidate() + super().invalidate() + + def invalidateFilter(self) -> None: + source = self.sourceModel() + if isinstance(source, QSortFilterProxyModel): + source.invalidateFilter() + super().invalidateFilter() + + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: + return True + + +class SliderModel(BaseProxyModel): + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: + sourceIndex = self.sourceModel().index(sourceRow, 0, sourceParent) + if sourceIndex.isValid(): + item = self.sourceModel().itemFromIndex(sourceIndex) + if isinstance(item, Group): + if item.groupType is not Slider: + return False + return super().filterAcceptsRow(sourceRow, sourceParent) + + +class ComboModel(BaseProxyModel): + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: + sourceIndex = self.sourceModel().index(sourceRow, 0, sourceParent) + if sourceIndex.isValid(): + item = self.sourceModel().itemFromIndex(sourceIndex) + if isinstance(item, Group): + if item.groupType is not Combo: + return False + return super().filterAcceptsRow(sourceRow, sourceParent) + + +class TraversalModel(BaseProxyModel): + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: + sourceIndex = self.sourceModel().index(sourceRow, 0, sourceParent) + if sourceIndex.isValid(): + item = self.sourceModel().itemFromIndex(sourceIndex) + if isinstance(item, Group): + if item.groupType is not Traversal: + return False + return super().filterAcceptsRow(sourceRow, sourceParent) + + +# FILTER MODELS +class SimplexFilterModel(BaseProxyModel): + """Filter a model based off of a given string + Set the `filterString` object property to filter the model + """ + + def __init__(self, model: SimplexModel, parent: QWidget | None = None) -> None: + super().__init__(model, parent) + self.setSourceModel(model) + self.filterShapes: bool = True + self._filterString: list[str] = [] + self._filterReg: list[re.Pattern[str]] = [] + self.isolateList: list[str] = [] + + @property + def filterString(self) -> str: + return " ".join(self._filterString) + + @filterString.setter + def filterString(self, val: str) -> None: + self._filterString = val.split() + + self._filterReg = [] + for sp in self._filterString: + if sp[0] == "*": + rex = re.compile(sp, flags=re.I) + self._filterReg.append(rex) + else: + rex = re.compile(".*?".join(sp), flags=re.I) + self._filterReg.append(rex) + + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: + column = 0 # always sort by the first column #column = self.filterKeyColumn() + sourceIndex = self.sourceModel().index(sourceRow, column, sourceParent) + if sourceIndex.isValid(): + if self.filterString or self.isolateList: + sourceItem = self.sourceModel().itemFromIndex(sourceIndex) + if isinstance( + sourceItem, (ProgPair, Slider, Combo, ComboPair, Progression) + ): + if not self.checkChildren(sourceItem): + return False + + return super().filterAcceptsRow(sourceRow, sourceParent) + + def matchFilterString(self, itemString: str) -> bool: + if not self._filterString: + return True + for reg in self._filterReg: + if reg.search(itemString): + return True + return False + + def matchIsolation(self, itemString: str) -> bool: + if self.isolateList: + return itemString in self.isolateList + return True + + def checkChildren(self, sourceItem: TreeItem) -> bool: + if hasattr(sourceItem, 'name'): + itemString = sourceItem.name # type: ignore + if self.matchFilterString(itemString) and self.matchIsolation(itemString): + return True + + sourceModel = self.sourceModel().sourceModel() + for row in range(sourceModel.getItemRowCount(sourceItem)): + childItem = sourceModel.getChildItem(sourceItem, row) + if childItem is not None: + return self.checkChildren(childItem) + + return False + + +class SliderFilterModel(SimplexFilterModel): + """Hide single shapes under a slider""" + + def __init__(self, model: SimplexModel, parent: QWidget | None = None) -> None: + super().__init__(model, parent) + self.requires: list[Combo] = [] + self.filterRequiresAny: bool = False + self.filterRequiresAll: bool = False + self.doFilter: bool = True + + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: + # always sort by the first column #column = self.filterKeyColumn() + column = 0 + sourceIndex = self.sourceModel().index(sourceRow, column, sourceParent) + if sourceIndex.isValid(): + data = self.sourceModel().itemFromIndex(sourceIndex) + if self.doFilter: + if isinstance(data, ProgPair): + if data.prog is None: + return False + elif len(data.prog.pairs) <= 2: + return False + elif data.shape.isRest: + return False + + if (self.filterRequiresAny or self.filterRequiresAll) and self.requires: + # Ignore items that aren't part of the required combos, if requested + if isinstance(data, Slider): + sliGroups = [[i.slider for i in c.pairs] for c in self.requires] + if self.filterRequiresAny: + if not any(data in s for s in sliGroups): + return False + elif self.filterRequiresAll: + if not all(data in s for s in sliGroups): + return False + + return super().filterAcceptsRow(sourceRow, sourceParent) + + +class ComboFilterModel(SimplexFilterModel): + """Filter by slider when Show Dependent Combos is checked""" + + def __init__(self, model: SimplexModel, parent: QWidget | None = None) -> None: + super().__init__(model, parent) + self.requires: list[Slider] = [] + self.filterRequiresAll: bool = False + self.filterRequiresAny: bool = False + self.filterRequiresOnly: bool = False + + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: + # always sort by the first column #column = self.filterKeyColumn() + column = 0 + sourceIndex = self.sourceModel().index(sourceRow, column, sourceParent) + if sourceIndex.isValid(): + data = self.sourceModel().itemFromIndex(sourceIndex) + if self.filterShapes: + # ignore the SHAPE par if there's nothing under there + if isinstance(data, Progression): + if len(data.pairs) <= 2: + return False + # Ignore shape things if requested + if isinstance(data, ProgPair): + if data.prog is None: + return False + elif len(data.prog.pairs) <= 2: + return False + elif data.shape.isRest: + return False + if ( + self.filterRequiresAny + or self.filterRequiresAll + or self.filterRequiresOnly + ) and self.requires: + # Ignore items that don't use the required sliders if requested + if isinstance(data, Combo): + sliders = [i.slider for i in data.pairs] + if self.filterRequiresAll: + if not all(r in sliders for r in self.requires): + return False + elif self.filterRequiresAny: + if not any(r in sliders for r in self.requires): + return False + elif self.filterRequiresOnly: + if not all(r in self.requires for r in sliders): + return False + + return super().filterAcceptsRow(sourceRow, sourceParent) + + +class TraversalFilterModel(SimplexFilterModel): + """Hide single shapes under a slider""" + + def __init__(self, model: SimplexModel, parent: QWidget | None = None) -> None: + super().__init__(model, parent) + self.doFilter = True + + def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: + column = 0 # always sort by the first column #column = self.filterKeyColumn() + sourceIndex = self.sourceModel().index(sourceRow, column, sourceParent) + if sourceIndex.isValid(): + if self.doFilter: + data = self.sourceModel().itemFromIndex(sourceIndex) + if isinstance(data, ProgPair): + if data.prog is None: + return False + elif len(data.prog.pairs) <= 2: + return False + elif data.shape.isRest: + return False + + return super().filterAcceptsRow(sourceRow, sourceParent) + + +class FalloffDataModel(AdapterModel): + """A model for displaying the data of Falloff objects""" + + def __init__(self, simplex: Simplex, parent: QWidget | None) -> None: + super().__init__(simplex, parent) + self.simplex = simplex + + def getItemAppendRow(self, item: TreeItem) -> int: + try: + return len(self.simplex.falloffs) + except AttributeError: + return 0 + + def index( + self, row: int, column: int = 0, parIndex: QModelIndex | None = None + ) -> QModelIndex: + parIndex = QModelIndex() if parIndex is None else parIndex + if row < 0: + return QModelIndex() + try: + falloff = self.simplex.falloffs[row] + except IndexError: + return QModelIndex() + except AttributeError: + return QModelIndex() + return self.createIndex(row, column, falloff) + + def parent(self, index: QModelIndex) -> QModelIndex: + return QModelIndex() + + def rowCount(self, parent) -> int: + try: + return len(self.simplex.falloffs) + except AttributeError: + return 0 + + def columnCount(self, parent: QModelIndex) -> int: + return 8 + + def data(self, index: QModelIndex, role: int) -> Any: + if not index.isValid(): + return None + falloff = index.internalPointer() + if not falloff: + return None + + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + if index.column() == 0: + return falloff.name + elif index.column() == 1: + st = falloff.splitType + sti = ("planar", "map").index(st.lower()) + return sti + elif index.column() == 2: + ax = falloff.axis + axi = "xyz".index(ax.lower()) + return axi + elif index.column() == 3: + return falloff.maxVal + elif index.column() == 4: + return falloff.maxHandle + elif index.column() == 5: + return falloff.minHandle + elif index.column() == 6: + return falloff.minVal + elif index.column() == 7: + return falloff.mapName + return None + + def setData(self, index: QModelIndex, value: Any, role: int) -> bool: + if not index.isValid(): + return False + falloff = index.internalPointer() + if not falloff: + return False + if role == Qt.ItemDataRole.EditRole: + if index.column() == 0: + falloff.name = value + elif index.column() == 1: + if value in [0, 1]: + value = ("planar", "map")[value] + falloff.splitType = value + elif index.column() == 2: + if value in [0, 1, 2]: + value = "XYZ"[value] + falloff.axis = value + elif index.column() == 3: + falloff.maxVal = value + elif index.column() == 4: + falloff.maxHandle = value + elif index.column() == 5: + falloff.minHandle = value + elif index.column() == 6: + falloff.minVal = value + elif index.column() == 7: + falloff.mapName = value + return True + return False + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + return ( + Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsEditable + | Qt.ItemFlag.ItemIsSelectable + ) + + def itemFromIndex(self, index: QModelIndex) -> TreeItem: + return index.internalPointer() + + def getItemRow(self, item: Falloff) -> int | None: + try: + idx = self.simplex.falloffs.index(item) + except ValueError: + return None + except AttributeError: + return None + return idx diff --git a/src/python/simplexui/interfaceModelTrees.py b/src/python/simplexui/interfaceModelTrees.py index f11d7439..18c18656 100644 --- a/src/python/simplexui/interfaceModelTrees.py +++ b/src/python/simplexui/interfaceModelTrees.py @@ -1,441 +1,456 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -from .dragFilter import DragFilter -from .items import Group -from Qt.QtCore import QItemSelection, QItemSelectionModel, QModelIndex, QRegExp, Qt -from Qt.QtGui import QRegExpValidator -from Qt.QtWidgets import QApplication, QLineEdit, QMenu, QStyledItemDelegate, QTreeView - - -class SimplexNameDelegate(QStyledItemDelegate): - """An QStyledItemDelegate subclass that implements a Regex validator""" - - def __init__(self, parent=None): - super(SimplexNameDelegate, self).__init__(parent) - self._rx = QRegExp(r"[A-Za-z][A-Za-z0-9_]*") - - def createEditor(self, parent, option, index): - editor = QLineEdit(parent) - rxv = QRegExpValidator(self._rx, editor) - editor.setValidator(rxv) - return editor - - -class SimplexTree(QTreeView): - """Abstract base tree displaying Simplex objects""" - - def __init__(self, parent): - super(SimplexTree, self).__init__(parent) - - self.expandModifier = Qt.KeyboardModifier.ControlModifier - self.depthModifier = Qt.KeyboardModifier.ShiftModifier - - self._menu = None - self._plugins = [] - - self.expanded.connect(self.expandTree) - self.collapsed.connect(self.collapseTree) - self.connectMenus() - - self.dragFilter = DragFilter(self.viewport()) - self.viewport().installEventFilter(self.dragFilter) - - self.dragFilter.dragTick.connect(self.dragTick) - - self.delegate = SimplexNameDelegate(self) - self.setItemDelegateForColumn(0, self.delegate) - - self.setColumnWidth(1, 50) - self.setColumnWidth(2, 20) - - def setPlugins(self, plugins): - """Set the right-click menu plugins for the tree - - Parameters - ---------- - plugins : list - The list of plugins for the tree - """ - self._plugins = plugins - - def unifySelection(self): - """Handle selection across multiple Trees. - The other tree's selectionChanged signal will be connected to this - And it will clear the selection on this tree if no modifiers are being held - """ - mods = QApplication.keyboardModifiers() - if not mods & ( - Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier - ): - selModel = self.selectionModel() - selModel.blockSignals(True) - try: - selModel.clearSelection() - finally: - selModel.blockSignals(False) - self.viewport().update() - - def hideRedundant(self, check): - """Update the filter model to show/hide single shapes - - Parameters - ---------- - check : bool - Whether to hid redundant - """ - model = self.model() - model.filterShapes = check - model.invalidateFilter() - - def stringFilter(self, filterString): - """Update the filter model with a filter string - - Parameters - ---------- - filterString : str - The string to filter on - """ - model = self.model() - model.filterString = str(filterString) - model.invalidateFilter() - - def isolate(self, sliderNames): - """Update the filter model with a whitelist of names - - Parameters - ---------- - sliderNames : [str, ...] - The names items to show - """ - model = self.model() - model.isolateList = sliderNames - model.invalidateFilter() - - def isolateSelected(self): - """Isolate the selected items""" - items = self.getSelectedItems() - isoList = [i.name for i in items] - self.isolate(isoList) - - def exitIsolate(self): - """Remove all items from isolation""" - self.isolate([]) - - # Tree expansion/collapse code - def expandTree(self, index): - """Expand all items under index - - Parameters - ---------- - index : QModelIndex - The index to recursively expand - """ - self.toggleTree(index, True) - - def collapseTree(self, index): - """Collapse all items under index - - Parameters - ---------- - index : QModelIndex - The index to recursively collapse - """ - self.toggleTree(index, False) - - def resizeColumns(self): - '''Resize all columns to their contents "smartly"''' - model = self.model() - for i in range(model.columnCount() - 1): - oldcw = self.columnWidth(i) - self.resizeColumnToContents(i) - newcw = self.columnWidth(i) + 10 - self.setColumnWidth(i, max(oldcw, newcw, 30)) - self.setColumnWidth(model.columnCount() - 1, 5) - - def toggleTree(self, index, expand): - """Recursively expand or collapse an entire sub-tree of an - index. If certain modifiers are held, then only a partial - sub-tree will be expanded - - Parameters - ---------- - index : QModelIndex - The index to change expansion - expand : bool - Whether to expand or collapse the item - """ - # Separate function to deal with filtering capability - if not index.isValid(): - return - - model = self.model() - mods = QApplication.keyboardModifiers() - thing = model.itemFromIndex(index) - thing.expanded[id(self)] = expand - - if mods & (self.expandModifier | self.depthModifier): - queue = [index] - self.blockSignals(True) - try: - while queue: - idx = queue.pop() - thing = model.itemFromIndex(idx) - thing.expanded[id(self)] = expand - self.setExpanded(idx, expand) - if mods & self.depthModifier: - if isinstance(thing, Group): - continue - for i in range(model.rowCount(idx)): - child = model.index(i, 0, idx) - if child and child.isValid(): - queue.append(child) - finally: - self.blockSignals(False) - - if expand: - self.resizeColumns() - - def expandToItem(self, item): - """Make sure that all parents leading to `item` are expanded - - Parameters - ---------- - item : object - The item to expand to - """ - model = self.model() - index = model.indexFromItem(item) - self.expandToIndex(index) - - def expandToIndex(self, index): - """Make sure that all parents leading to `index` are expanded - - Parameters - ---------- - index : QModelIndex - The index to expand to - """ - model = self.model() - while index and index.isValid(): - self.setExpanded(index, True) - thing = model.itemFromIndex(index) - thing.expanded[id(self)] = True - index = index.parent() - self.resizeColumns() - - def scrollToItem(self, item): - """Ensure that the item is scrolled to in the tree - - Parameters - ---------- - item : object - The Item to expand to - """ - model = self.model() - index = model.indexFromItem(item) - self.scrollToIndex(index) - - def scrollToIndex(self, index): - """Ensure that the index is scrolled to in the tree - - Parameters - ---------- - index : QModelIndex - The Index to expand to - - """ - self.expandToIndex(index) - self.scrollTo(index) - - def storeExpansion(self): - """Store the expansion state of the tree for the undo stack""" - model = self.model() - queue = [model.index(0, 0, QModelIndex())] - while queue: - index = queue.pop() - item = model.itemFromIndex(index) - item.expanded[id(self)] = self.isExpanded(index) - for row in range(model.rowCount(index)): - queue.append(model.index(row, 0, index)) - - def setItemExpansion(self): - """Load the stored expansions onto the tree""" - model = self.model() - queue = [model.index(0, 0, QModelIndex())] - self.blockSignals(True) - try: - while queue: - index = queue.pop() - item = model.itemFromIndex(index) - exp = item.expanded.get(id(self), False) - self.setExpanded(index, exp) - for row in range(model.rowCount(index)): - queue.append(model.index(row, 0, index)) - finally: - self.blockSignals(False) - - def dragTick(self, ticks, mul): - """Deal with the ticks coming from the drag handler - - Parameters - ---------- - ticks : int - The number and direction of update ticks coming from the drag handler - mul : float - The multiplier based on user hotkeys - """ - selModel = self.selectionModel() - if not selModel: - return - items = self.getSelectedItems() - for item in items: - if hasattr(item, "valueTick"): - item.valueTick(ticks, mul) - self.viewport().update() - - # Menus and Actions - def connectMenus(self): - """Setup the QT signal/slot connections to the context menus""" - self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect(self.openMenu) - - def openMenu(self, pos): - """Handle getting the data to show the context menu - - Parameters - ---------- - pos : QPoint - The position of the click - """ - clickIdx = self.indexAt(pos) - selIdxs = self.getSelectedIndexes() - if self.window().simplex is None: - return - self.showContextMenu(clickIdx, selIdxs, self.viewport().mapToGlobal(pos)) - - def showContextMenu(self, clickIdx, indexes, pos): - """Handle showing the context menu items from the plugins - - Parameters - ---------- - clickIdx : QModelIndex - The model index that was clicked - indexes : [QModelIndex, ...] - The indexes that were selected - pos : QPoint - The position of the click - """ - menu = QMenu() - for plug in self._plugins: - plug.registerContext(self, clickIdx, indexes, menu) - menu.exec_(pos) - - # Selection - def getSelectedItems(self, typ=None): - """Get the selected tree items - - Parameters - ---------- - typ : Type - Only return selected items of this type. Optional - - Returns - ------- - [object, ...] - A list of selected tree items - """ - selModel = self.selectionModel() - if not selModel: - return [] - selIdxs = selModel.selectedIndexes() - selIdxs = [i for i in selIdxs if i.column() == 0] - model = self.model() - items = [model.itemFromIndex(i) for i in selIdxs] - if typ is not None: - items = [i for i in items if isinstance(i, typ)] - return items - - def getSelectedIndexes(self, filtered=False): - """Get selected indexes for either the filtered or unfiltered models - - Parameters - ---------- - filtered : bool - Whether the model is filtered or not. Defaults to False - - Returns - ------- - [QModelIndex, ...] - A list of selected indexes - """ - selModel = self.selectionModel() - if not selModel: - return [] - selIdxs = selModel.selectedIndexes() - if filtered: - return selIdxs - - model = self.model() - indexes = [model.mapToSource(i) for i in selIdxs] - return indexes - - def setItemSelection(self, items): - """Set the selection based on a list of items - - Parameters - ---------- - items : [object, ...] - List of items to select - """ - model = self.model() - idxs = [model.indexFromItem(i) for i in items] - idxs = [i for i in idxs if i and i.isValid()] - - toSel = QItemSelection() - for idx in idxs: - toSel.merge( - QItemSelection(idx, idx), QItemSelectionModel.SelectionFlag.Select - ) - - for idx in idxs: - par = idx.parent() - if par.isValid(): - self.scrollToIndex(par) - - selModel = self.selectionModel() - selModel.select(toSel, QItemSelectionModel.SelectionFlag.ClearAndSelect) - - -# Currently, there's no difference between these -# Later on, though, they may be different -class SliderTree(SimplexTree): - """A SimplexTree sub-class for sliders""" - - pass - - -class ComboTree(SimplexTree): - """A SimplexTree sub-class for combos""" - - pass - - -class TraversalTree(SimplexTree): - """A SimplexTree sub-class for traversals""" - - pass +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +from typing import cast + +from Qt.QtCore import ( + QItemSelection, + QItemSelectionModel, + QModelIndex, + Qt, +) +from Qt.QtGui import QValidator +from Qt.QtWidgets import QApplication, QLineEdit, QMenu, QStyledItemDelegate, QTreeView, QWidget + +from .dragFilter import DragFilter +from .interfaceModel import SimplexFilterModel +from .items import Group +from .items.dragItem import Draggable +from .items.treeItem import CustomRoles +from .utils import execmenu + + +class NameValidator(QValidator): + def __init__(self, parent: QLineEdit | None = None) -> None: + super().__init__(parent) + + def validate(self, input_str: str, pos: int) -> tuple[QValidator.State, str, int]: + if not input_str: + return QValidator.State.Intermediate, input_str, pos + if input_str.isidentifier(): + return QValidator.State.Acceptable, input_str, pos + return QValidator.State.Invalid, input_str, pos + + +class SimplexNameDelegate(QStyledItemDelegate): + """An QStyledItemDelegate subclass that implements a Regex validator""" + + def createEditor(self, parent, option, index) -> QLineEdit: + editor = QLineEdit(parent) + validator = NameValidator(editor) + editor.setValidator(validator) + return editor + + +class SimplexTree(QTreeView): + """Abstract base tree displaying Simplex objects""" + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + + self.expandModifier = Qt.KeyboardModifier.ControlModifier + self.depthModifier = Qt.KeyboardModifier.ShiftModifier + self._expansions: set[str] = set() + + self._menu = None + self._plugins = [] + + self.expanded.connect(self.expandTree) + self.collapsed.connect(self.collapseTree) + self.connectMenus() + + self.dragFilter = DragFilter(self.viewport()) + self.viewport().installEventFilter(self.dragFilter) + + self.dragFilter.dragTick.connect(self.dragTick) + + self.delegate = SimplexNameDelegate(self) + self.setItemDelegateForColumn(0, self.delegate) + + self.setColumnWidth(1, 50) + self.setColumnWidth(2, 20) + + def setModel(self, model: SimplexFilterModel) -> None: + super().setModel(model) + + def model(self) -> SimplexFilterModel: + return cast(SimplexFilterModel, super().model()) + + def setPlugins(self, plugins) -> None: + """Set the right-click menu plugins for the tree + + Parameters + ---------- + plugins : list + The list of plugins for the tree + """ + self._plugins = plugins + + def unifySelection(self) -> None: + """Handle selection across multiple Trees. + The other tree's selectionChanged signal will be connected to this + And it will clear the selection on this tree if no modifiers are being held + """ + mods = QApplication.keyboardModifiers() + if not mods & ( + Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier + ): + selModel = self.selectionModel() + selModel.blockSignals(True) + try: + selModel.clearSelection() + finally: + selModel.blockSignals(False) + self.viewport().update() + + def hideRedundant(self, check) -> None: + """Update the filter model to show/hide single shapes + + Parameters + ---------- + check : bool + Whether to hid redundant + """ + model = self.model() + model.filterShapes = check + model.invalidateFilter() + + def stringFilter(self, filterString) -> None: + """Update the filter model with a filter string + + Parameters + ---------- + filterString : str + The string to filter on + """ + model = self.model() + model.filterString = str(filterString) + model.invalidateFilter() + + def isolate(self, sliderNames) -> None: + """Update the filter model with a whitelist of names + + Parameters + ---------- + sliderNames : [str, ...] + The names items to show + """ + model = self.model() + model.isolateList = sliderNames + model.invalidateFilter() + + def isolateSelected(self) -> None: + """Isolate the selected items""" + items = self.getSelectedItems() + isoList = [i.name for i in items] + self.isolate(isoList) + + def exitIsolate(self) -> None: + """Remove all items from isolation""" + self.isolate([]) + + # Tree expansion/collapse code + def expandTree(self, index) -> None: + """Expand all items under index + + Parameters + ---------- + index : QModelIndex + The index to recursively expand + """ + self.toggleTree(index, True) + + def collapseTree(self, index) -> None: + """Collapse all items under index + + Parameters + ---------- + index : QModelIndex + The index to recursively collapse + """ + self.toggleTree(index, False) + + def resizeColumns(self) -> None: + '''Resize all columns to their contents "smartly"''' + model = self.model() + for i in range(model.columnCount() - 1): + oldcw = self.columnWidth(i) + self.resizeColumnToContents(i) + newcw = self.columnWidth(i) + 10 + self.setColumnWidth(i, max(oldcw, newcw, 30)) + self.setColumnWidth(model.columnCount() - 1, 5) + + def toggleTree(self, index, expand: bool) -> None: + """Recursively expand or collapse an entire sub-tree of an + index. If certain modifiers are held, then only a partial + sub-tree will be expanded + + Parameters + ---------- + index : QModelIndex + The index to change expansion + expand : bool + Whether to expand or collapse the item + """ + # Separate function to deal with filtering capability + if not index.isValid(): + return + + model = self.model() + mods = QApplication.keyboardModifiers() + thing = model.itemFromIndex(index) + + if mods & (self.expandModifier | self.depthModifier): + queue = [index] + self.blockSignals(True) + try: + while queue: + idx = queue.pop() + thing = model.itemFromIndex(idx) + self.setExpanded(idx, expand) + if mods & self.depthModifier: + if isinstance(thing, Group): + continue + for i in range(model.rowCount(idx)): + child = model.index(i, 0, idx) + if child and child.isValid(): + queue.append(child) + finally: + self.blockSignals(False) + + if expand: + self.resizeColumns() + + def expandToItem(self, item) -> None: + """Make sure that all parents leading to `item` are expanded + + Parameters + ---------- + item : object + The item to expand to + """ + model = self.model() + index = model.indexFromItem(item) + self.expandToIndex(index) + + def expandToIndex(self, index: QModelIndex) -> None: + """Make sure that all parents leading to `index` are expanded + + Parameters + ---------- + index : QModelIndex + The index to expand to + """ + while index and index.isValid(): + self.setExpanded(index, True) + index = index.parent() + self.resizeColumns() + + def scrollToItem(self, item) -> None: + """Ensure that the item is scrolled to in the tree + + Parameters + ---------- + item : object + The Item to expand to + """ + model = self.model() + index = model.indexFromItem(item) + self.scrollToIndex(index) + + def scrollToIndex(self, index: QModelIndex) -> None: + """Ensure that the index is scrolled to in the tree + + Parameters + ---------- + index : QModelIndex + The Index to expand to + + """ + self.expandToIndex(index) + self.scrollTo(index) + + def storeExpansion(self) -> None: + """Store the expansion state of the tree for the undo stack""" + expanded_uids = set() + model = self.model() + for index in model.iterindices(pred=lambda x: self.isExpanded(x)): + uid = model.data(index, CustomRoles.UID_ROLE) + if uid: + expanded_uids.add(uid) + self._expansions = expanded_uids + + def setItemExpansion(self) -> None: + """Load the stored expansions onto the tree""" + model = self.model() + for index in model.iterindices( + pred=lambda x: model.data(x, CustomRoles.UID_ROLE) in self._expansions + ): + self.setExpanded(index, True) + + def dragTick(self, ticks, mul) -> None: + """Deal with the ticks coming from the drag handler + + Parameters + ---------- + ticks : int + The number and direction of update ticks coming from the drag handler + mul : float + The multiplier based on user hotkeys + """ + selModel = self.selectionModel() + if not selModel: + return + items = self.getSelectedItems() + for item in items: + if isinstance(item, Draggable): + item.valueTick(ticks, mul) + self.viewport().update() + + # Menus and Actions + def connectMenus(self) -> None: + """Setup the QT signal/slot connections to the context menus""" + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.openMenu) + + def openMenu(self, pos) -> None: + """Handle getting the data to show the context menu + + Parameters + ---------- + pos : QPoint + The position of the click + """ + clickIdx = self.indexAt(pos) + selIdxs = self.getSelectedIndexes() + if self.window().simplex is None: + return + self.showContextMenu(clickIdx, selIdxs, self.viewport().mapToGlobal(pos)) + + def showContextMenu(self, clickIdx: QModelIndex, indexes, pos) -> None: + """Handle showing the context menu items from the plugins + + Parameters + ---------- + clickIdx : QModelIndex + The model index that was clicked + indexes : [QModelIndex, ...] + The indexes that were selected + pos : QPoint + The position of the click + """ + menu = QMenu() + for plug in self._plugins: + plug.registerContext(self, clickIdx, indexes, menu) + + execmenu(menu, pos) + + # Selection + def getSelectedItems(self, typ=None): + """Get the selected tree items + + Parameters + ---------- + typ : Type + Only return selected items of this type. Optional + + Returns + ------- + [object, ...] + A list of selected tree items + """ + selModel = self.selectionModel() + if not selModel: + return [] + selIdxs = selModel.selectedIndexes() + selIdxs = [i for i in selIdxs if i.column() == 0] + model = self.model() + items = [model.itemFromIndex(i) for i in selIdxs] + if typ is not None: + items = [i for i in items if isinstance(i, typ)] + return items + + def getSelectedIndexes(self, filtered: bool = False): + """Get selected indexes for either the filtered or unfiltered models + + Parameters + ---------- + filtered : bool + Whether the model is filtered or not. Defaults to False + + Returns + ------- + [QModelIndex, ...] + A list of selected indexes + """ + selModel = self.selectionModel() + if not selModel: + return [] + selIdxs = selModel.selectedIndexes() + if filtered: + return selIdxs + + model = self.model() + indexes = [model.mapToSource(i) for i in selIdxs] + return indexes + + def setItemSelection(self, items) -> None: + """Set the selection based on a list of items + + Parameters + ---------- + items : [object, ...] + List of items to select + """ + model = self.model() + idxs = [model.indexFromItem(i) for i in items] + idxs = [i for i in idxs if i and i.isValid()] + + toSel = QItemSelection() + for idx in idxs: + toSel.merge( + QItemSelection(idx, idx), QItemSelectionModel.SelectionFlag.Select + ) + + for idx in idxs: + par = idx.parent() + if par.isValid(): + self.scrollToIndex(par) + + selModel = self.selectionModel() + selModel.select(toSel, QItemSelectionModel.SelectionFlag.ClearAndSelect) + + +# Currently, there's no difference between these +# Later on, though, they may be different +class SliderTree(SimplexTree): + """A SimplexTree sub-class for sliders""" + + pass + + +class ComboTree(SimplexTree): + """A SimplexTree sub-class for combos""" + + pass + + +class TraversalTree(SimplexTree): + """A SimplexTree sub-class for traversals""" + + pass diff --git a/src/python/simplexui/items/__init__.py b/src/python/simplexui/items/__init__.py index 0284cd84..e4c135d7 100644 --- a/src/python/simplexui/items/__init__.py +++ b/src/python/simplexui/items/__init__.py @@ -15,20 +15,25 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + from .combo import Combo, ComboPair -from .falloff import Falloff +from .falloff import Falloff, MapFalloff, PlanarFalloff from .group import Group from .progression import ProgPair, Progression from .shape import Shape from .simplex import Simplex from .slider import Slider from .stack import Stack, stackable -from .traversal import Traversal, TravPair +from .traversal import Traversal, TravPair, TravPoint, TravSide +from .treeItem import TreeItem __all__ = [ "Combo", "ComboPair", "Falloff", + "MapFalloff", + "PlanarFalloff", "Group", "ProgPair", "Progression", @@ -38,5 +43,8 @@ "Stack", "stackable", "Traversal", + "TravPoint", "TravPair", + "TravSide", + "TreeItem", ] diff --git a/src/python/simplexui/items/accessor.py b/src/python/simplexui/items/accessor.py index c9c0d1fb..1ffff98f 100644 --- a/src/python/simplexui/items/accessor.py +++ b/src/python/simplexui/items/accessor.py @@ -1,213 +1,145 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -import copy - - -class SimplexAccessor(object): - """The base object for all Simplex System object types - This class provides access to the simplex system, draggability, - name getters/setters/unifiers, proper deepcopying, and abstract tree lookup - - Parameters - ---------- - - Returns - ------- - - """ - - def __init__(self, simplex): - self.simplex = simplex - self._name = None - self._splitApplied = set() - - self.dragStep = 0.05 - self.maxValue = 1.0 - self.minValue = 0.0 - - def valueTick(self, ticks, mul): - """Change the value of the current object by some number of ticks - with some given multiplier. This is the interface for the MMB drag - - Parameters - ---------- - ticks : int - The number of dragStep ticks to apply - mul : float - An overall multiplier - """ - try: - val = self.value - except AttributeError: - return - val += self.dragStep * ticks * mul - if abs(val) < 1.0e-5: - val = 0.0 - val = max(min(val, self.maxValue), self.minValue) - self.value = val - - @property - def name(self): - """ """ - return self._name - - @name.setter - def name(self, val): - """The name of the current object""" - self._name = val - - @property - def models(self): - """ """ - return self.simplex.models - - @property - def falloffModels(self): - """ """ - return self.simplex.falloffModels - - @property - def DCC(self): - """ """ - return self.simplex.DCC - - @property - def stack(self): - """ """ - return self.simplex.stack - - def __deepcopy__(self, memo): - cls = self.__class__ - result = cls.__new__(cls) - memo[id(self)] = result - for k, v in self.__dict__.items(): - if k == "_thing" and self.DCC.program != "dummy": - # DO NOT make a copy of the DCC thing (unless its a dummy dcc) - # as it may or may not be a persistent object - # setattr(result, k, self._thing) - setattr(result, k, None) - elif k == "expanded": - # Skip the expanded dict because it deals with the Qt models - setattr(result, k, {}) - else: - setattr(result, k, copy.deepcopy(v, memo)) - return result - - def _buildLinkedRename(self, newName, maxDepth, currentLinks): - """Build the proposed set of renames specifically for this object - This allows sub-classes to override the linked name behavior - - Parameters - ---------- - newName : - - maxDepth : - - currentLinks : - - - Returns - ------- - - """ - return currentLinks - - def buildLinkedRename(self, newName, maxDepth=5, currentLinks=None): - """For the Shape, Slider, Combo, and Traversal items, build a linked rename - dictionary like {itemType: {newName: (item, maxDepth)}} recursively up to a - maximum given depth - - The dictionary is structured like that to easily check for name clashes - - Parameters - ---------- - newName : str - The new suggested name for this object - maxDepth : int - The maximum depth of recursion when resolving names (Default value = 5) - currentLinks : dict - The current set of proposed renames. (Default value = None) - - Returns - ------- - dict : - A new set of propsed renames - - """ - # Build the output dict if not done already - if currentLinks is None: - currentLinks = {} - - # return at depth - if maxDepth <= 0: - return currentLinks - - # If this doesn't need renamed, then we can prune this branch - if self.name == newName: - return currentLinks - - # Check for conflicts, or other short circuits - typeLinks = currentLinks.setdefault(type(self), {}) - if newName in typeLinks: - tlPair = typeLinks[newName] - if tlPair[0] is not self: - # Error out if a name conflict is found. - msg = "Linked rename produced a conflict: Trying to rename {0} {1} and {2} to {3}" - msg = msg.format( - type(self), typeLinks[newName].name, self.name, newName - ) - raise ValueError(msg) - elif tlPair[1] >= maxDepth: - # If we've been here before with more available depth - # then we can just return because we've done this before - return currentLinks - - # Finally add myself to the rename - typeLinks[newName] = (self, maxDepth) - - # And now handle the type-specific stuff - return self._buildLinkedRename(newName, maxDepth, currentLinks) - - def treeChild(self, row): - """ """ - return None - - def treeRow(self): - """ """ - return None - - def treeParent(self): - """ """ - return None - - def treeChildCount(self): - """ """ - return 0 - - def treeData(self, column): - """ """ - if column == 0: - return self.name - return None - - def treeChecked(self): - """ """ - return None - - def icon(self): - return None +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +from .treeItem import TreeItem + +if TYPE_CHECKING: + from .simplex import Simplex + from .stack import Stack + + +class SimplexAccessor: + """The base object for all Simplex System object types + This class provides access to the simplex system + name getters/setters/unifiers, proper deepcopying, and abstract tree lookup + """ + + def __init__(self, simplex: Simplex) -> None: + self.simplex: Simplex = simplex + self._name: str = "" + self._splitApplied = set() + + @property + def name(self) -> str: + return self._name + + @property + def DCC(self): + return self.simplex.DCC + + @property + def stack(self) -> Stack: + return self.simplex.stack + + def __deepcopy__(self, memo): + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + for k, v in self.__dict__.items(): + if k == "_thing" and self.DCC.program != "dummy": + # DO NOT make a copy of the DCC thing (unless its a dummy dcc) + # as it may or may not be a persistent object + # setattr(result, k, self._thing) + setattr(result, k, None) + elif k == "expanded": + # Skip the expanded dict because it deals with the Qt models + setattr(result, k, {}) + else: + setattr(result, k, copy.deepcopy(v, memo)) + return result + + def _buildLinkedRename( + self, + newName: str, + maxDepth: int, + currentLinks: dict[type, dict[str, tuple[SimplexAccessor, int]]], + ): + """Build the proposed set of renames specifically for this object + This allows sub-classes to override the linked name behavior + """ + return currentLinks + + def buildLinkedRename( + self, + newName: str, + maxDepth: int = 5, + currentLinks: dict[type, dict[str, tuple[SimplexAccessor, int]]] | None = None, + ) -> dict[type, dict[str, tuple[SimplexAccessor, int]]]: + """For the Shape, Slider, Combo, and Traversal items, build a linked rename + dictionary like {itemType: {newName: (item, maxDepth)}} recursively up to a + maximum given depth + + The dictionary is structured like that to easily check for name clashes + + Parameters + ---------- + newName : str + The new suggested name for this object + maxDepth : int + The maximum depth of recursion when resolving names (Default value = 5) + currentLinks : dict + The current set of proposed renames. (Default value = None) + + Returns + ------- + dict : + A new set of propsed renames + """ + # Build the output dict if not done already + if currentLinks is None: + currentLinks = {} + + # return at depth + if maxDepth <= 0: + return currentLinks + + # If this doesn't need renamed, then we can prune this branch + if self.name == newName: + return currentLinks + + # Check for conflicts, or other short circuits + typeLinks = currentLinks.setdefault(type(self), {}) + if newName in typeLinks: + tlPair = typeLinks[newName] + if tlPair[0] is not self: + # Error out if a name conflict is found. + msg = "Linked rename produced a conflict: Trying to rename {0} {1} and {2} to {3}" + msg = msg.format( + type(self), typeLinks[newName][0].name, self.name, newName + ) + raise ValueError(msg) + elif tlPair[1] >= maxDepth: + # If we've been here before with more available depth + # then we can just return because we've done this before + return currentLinks + + # Finally add myself to the rename + typeLinks[newName] = (self, maxDepth) + + # And now handle the type-specific stuff + return self._buildLinkedRename(newName, maxDepth, currentLinks) + + +class SimplexTreeAccessor(SimplexAccessor, TreeItem): + def __init__(self, simplex: Simplex) -> None: + # Explicitly + SimplexAccessor.__init__(self, simplex) + TreeItem.__init__(self, simplex) diff --git a/src/python/simplexui/items/combo.py b/src/python/simplexui/items/combo.py index 4a040853..9a0331be 100644 --- a/src/python/simplexui/items/combo.py +++ b/src/python/simplexui/items/combo.py @@ -1,852 +1,645 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -from ..interface import undoContext - -# pylint:disable=missing-docstring,unused-argument,no-self-use -from Qt.QtGui import QColor -from ..utils import getIcon, nested -from .accessor import SimplexAccessor -from .stack import stackable - - -# Abstract Items -class ComboPair(SimplexAccessor): - """A Slider/Value pair for use in Combos""" - - classDepth = 6 - - def __init__(self, slider, value): - simplex = slider.simplex - super(ComboPair, self).__init__(simplex) - self.slider = slider - self._value = float(value) - self.minValue = -1.0 - self.maxValue = 1.0 - self.combo = None - self.expanded = {} - - @property - def models(self): - """ """ - return self.combo.simplex.models - - @property - def name(self): - """ """ - return self.slider.name - - @property - def value(self): - """ """ - return self._value - - @value.setter - @stackable - def value(self, val): - """ - - Parameters - ---------- - val : - - - Returns - ------- - - """ - self._value = val - for model in self.models: - model.itemDataChanged(self) - - def buildDefinition(self, simpDict, legacy): - """ - - Parameters - ---------- - simpDict : - - legacy : - - - Returns - ------- - - """ - sIdx = self.slider.buildDefinition(simpDict, legacy) - return sIdx, self.value - - def treeRow(self): - """ """ - return self.combo.pairs.index(self) - - def treeParent(self): - """ """ - return self.combo - - def treeData(self, column): - """ - - Parameters - ---------- - column : - - - Returns - ------- - - """ - if column == 0: - return self.name - if column == 1: - return self.value - return None - - -class Combo(SimplexAccessor): - """A group of Slider/Value pairs that control a Progression through some solver - - Combos allow for fixit shapes to be created for any number of user inputs. - They also allow for fixits along the progression, and in "floating space" where - the inputs are not -1 or 1 - - Parameters - ---------- - name : str - The name of this Combo - simplex : Simplex - The parent Simplex system - pairs : [ComboPair - The Slider/Value pairs that make up this combo - prog : Progression - The Progression that this Combo controls - group : Group - The Group to create this combo in - solveType : str - The solve type for this combo. See Combo.solveTypes for a list - color : QColor - The color of this item in the UI - - Returns - ------- - - """ - - classDepth = 5 - solveTypes = ( - ("Minimum", "min"), - ("Multiply All", "allMul"), - ("Multiply Extremes", "extMul"), - ("Multiply Avg of Extremes", "mulAvgExt"), - ("Multiply Avg", "mulAvgAll"), - ("None", "min"), - ) - _freezeIcon = None - - def __init__(self, name, simplex, pairs, prog, group, solveType, color=None): - super(Combo, self).__init__(simplex) - color = QColor(128, 128, 128) if color is None else color - - with self.stack.store(self): - if group.groupType is not type(self): - raise ValueError("Cannot add this slider to a combo group") - self._name = name - self.pairs = pairs - self.prog = prog - self._solveType = solveType - self._buildIdx = None - self.expanded = {} - self._enabled = True - self.color = color - - self._freezeThing = None - - mgrs = [model.insertItemManager(group) for model in self.models] - with nested(*mgrs): - self.group = group - for p in self.pairs: - p.combo = self - self.prog.controller = self - self.group.items.append(self) - self.simplex.combos.append(self) - - @property - def enabled(self): - """Get whether this Combo is evaluated in the solver""" - return self._enabled - - @enabled.setter - @stackable - def enabled(self, value): - """Set whether this Combo is evaluated in the solver""" - self._enabled = value - for model in self.models: - model.itemDataChanged(self) - - @property - def frozen(self): - """Get whether this Combo is frozen""" - return bool(self.freezeThing) - - @property - def freezeThing(self): - """Get whether this Combo is frozen""" - if self._freezeThing is None: - self._freezeThing = self.DCC.getFreezeThing(self) - return self._freezeThing - - @freezeThing.setter - def freezeThing(self, value): - self._freezeThing = value - for model in self.models: - model.itemDataChanged(self) - - def icon(self): - if self.frozen: - if self._freezeIcon is None: - type(self)._freezeIcon = getIcon("frozen.png") - return self._freezeIcon - return None - - @classmethod - def comboAlreadyExists(cls, simplex, sliders, values): - """Classmethod to check whether a combo already exists with these sliders and values - - Parameters - ---------- - simplex : Simplex - The system to check within - sliders : [Slider - The Sliders to check - values : [float - The values to zip with the sliders - - Returns - ------- - : Combo or None - The combo that exists with the given values, or None if none exist - - """ - checker = {(s.name, v) for s, v in zip(sliders, values)} - for combo in simplex.combos: - tester = {(p.slider.name, p.value) for p in combo.pairs} - if checker == tester: - return combo - return None - - @classmethod - def createCombo( - cls, - name, - simplex, - sliders, - values, - group=None, - shape=None, - solveType=None, - tVal=1.0, - ): - """Classmethod to create Combo with some hard-coded defaults - - Parameters - ---------- - name : str - The name of the Combo - simplex : Simplex - The Simplex system - sliders : [Slider - The Sliders that will control this combo - values : [float - The values at which the sliders will activate this combo - group : Group or None - A Group to organize this combo. - If None, the combo will be sorted into a "DEPTH" group (Default value = None) - shape : Shape or None - A Shape for this Combo's Progression. If None, then a default shape will be created - solveType : str or None - The solve type for this Combo. if None, defaults to 'min' in the solver - tVal : float - The slideValue where the Shape will be created/added. Defaults to 1.0 - - Returns - ------- - : Combo - The newly created Combo - - """ - if simplex.restShape is None: - raise RuntimeError("Simplex system is missing rest shape") - - # Make sure to check if this combo already exists. If so, just return it - exist = cls.comboAlreadyExists(simplex, sliders, values) - if exist is not None: - return exist - from .group import Group - from .progression import ProgPair, Progression - - if group is None: - gname = "DEPTH_{0}".format(len(sliders)) - matches = [i for i in simplex.comboGroups if i.name == gname] - if matches: - group = matches[0] - else: - group = Group(gname, simplex, Combo) - - cPairs = [ComboPair(slider, value) for slider, value in zip(sliders, values)] - prog = Progression(name, simplex) - if shape: - prog.pairs.append(ProgPair(simplex, shape, tVal)) - - cmb = Combo(name, simplex, cPairs, prog, group, solveType) - - if shape is None: - pp = prog.createShape(name, tVal) - simplex.DCC.zeroShape(pp.shape) - - return cmb - - @staticmethod - def buildComboName(sliders, values): - """Build the name for a combo based on the input Sliders and values - The sliders will be alphabetically sorted - Values not at Shape increments within the progression will get numeric - suffixes. Negative values will have suffixes like "n75" - - Parameters - ---------- - sliders : [Slider - The sliders to check - values : [float - The values for the sliders - - Returns - ------- - : str - The suggested combo name - - """ - pairs = list(zip(sliders, values)) - pairs = sorted(pairs, key=lambda x: x[0].name) - parts = [] - for slider, value in pairs: - shape = slider.prog.getShapeAtValue(value) - if shape is not None: - parts.append(shape.name) - else: - # get the extreme shape and percentage-ize its name - extVal = 1.0 if value > 0.0 else -1.0 - - valName = "{}".format(abs(int(value * 100))) - valName = "n" + valName if value < 0.0 else valName - - shape = slider.prog.getShapeAtValue(extVal) - if shape is not None: - sn = shape.name - sn = sn.split("_") - if sn[-1].isnumeric() or ( - sn[-1][0] == "n" and sn[-1][1:].isnumeric() - ): - sn[-1] = valName - else: - sn.append(valName) - nsn = "_".join(sn) - parts.append(nsn) - else: - parts.append(slider.name) - - return "_".join(parts) - - @property - def name(self): - """Get the name of a combo""" - return self._name - - @name.setter - @stackable - def name(self, value): - """Set the name of a combo - - Parameters - ---------- - value : - - - Returns - ------- - - """ - self._name = value - self.prog.name = value - self.DCC.renameCombo(self, value) - for model in self.models: - model.itemDataChanged(self) - - @property - def solveType(self): - """ """ - return self._solveType - - @solveType.setter - @stackable - def solveType(self, newType): - """Set the solveType of the combo - - Parameters - ---------- - newType : - - - Returns - ------- - - """ - stNames, stVals = list(zip(*self.solveTypes)) - if newType not in stVals: - raise ValueError( - "Solve Type {0} not in allowed types {1}".format(newType, stVals) - ) - self._solveType = newType - for model in self.models: - model.itemDataChanged(self) - - def treeChild(self, row): - """ - - Parameters - ---------- - row : - - - Returns - ------- - type - - - """ - if row == len(self.pairs): - return self.prog - return self.pairs[row] - - def treeRow(self): - """ """ - return self.group.items.index(self) - - def treeParent(self): - """ """ - return self.group - - def treeChildCount(self): - """ """ - return len(self.pairs) + 1 - - def treeChecked(self): - """ """ - return self.enabled - - def sliderNameLinks(self): - """ """ - sliNames = ["_{0}_".format(i.slider.name) for i in self.pairs] - surr = "_{0}_".format(self.name) - return [sn in surr for sn in sliNames] - - def nameLinks(self): - """ - - Parameters - ---------- - - Returns - ------- - : type - progression depends on this slider's name - - """ - # In this case, these names will *NOT* have the possibility of - # a pos/neg name. Only the combo name, and possibly a percentage - shapeNames = [] - shapes = [i.shape for i in self.prog.pairs] - for s in shapes: - x = s.name.rsplit("_", 1) - if len(x) == 2: - base, sfx = x - x = base if sfx.isdigit() else s.name - shapeNames.append(x) - return [i == self.name for i in shapeNames] - - def getSliderIndex(self, slider): - """ - - Parameters - ---------- - slider : - - - Returns - ------- - type - - - """ - for i, p in enumerate(self.pairs): - if p.slider == slider: - return i - raise ValueError("Provided slider:{0} is not in the list".format(slider.name)) - - def isFloating(self): - """ - - Parameters - ---------- - - Returns - ------- - : type - Floating combos are combos that Slider values that are between 0 and 1 - - """ - for pair in self.pairs: - if abs(pair.value) != 1.0: - return True - return False - - def getSliders(self): - """ """ - return [i.slider for i in self.pairs] - - @classmethod - def loadV2(cls, simplex, progs, data): - """Load the data from a version2 formatted json dictionary - - Parameters - ---------- - simplex : Simplex - The Simplex system that's being built - progs : [Progression - The progressions that have already been built - data : dict - The chunk of the json dict used to build this object - - Returns - ------- - : Combo - The specified combo - - """ - name = data["name"] - prog = progs[data["prog"]] - group = simplex.groups[data.get("group", 1)] - color = QColor(*data.get("color", (128, 128, 128))) - pairs = [ComboPair(simplex.sliders[s], v) for s, v in data["pairs"]] - solveType = data.get("solveType") - return cls(name, simplex, pairs, prog, group, solveType, color=color) - - def buildDefinition(self, simpDict, legacy): - """Output a dictionary definition of this object - - Parameters - ---------- - simpDict : dict - The dictionary that is being built - legacy : bool - Whether to write out the legacy definition, or the newer one - - Returns - ------- - - """ - if self._buildIdx is None: - self._buildIdx = len(simpDict["combos"]) - if legacy: - gIdx = self.group.buildDefinition(simpDict, legacy) - pIdx = self.prog.buildDefinition(simpDict, legacy) - idxPairs = [p.buildDefinition(simpDict, legacy) for p in self.pairs] - x = [self.name, pIdx, idxPairs, gIdx] - simpDict.setdefault("combos", []).append(x) - else: - x = { - "name": self.name, - "prog": self.prog.buildDefinition(simpDict, legacy), - "pairs": [p.buildDefinition(simpDict, legacy) for p in self.pairs], - "group": self.group.buildDefinition(simpDict, legacy), - "color": self.color.getRgb()[:3], - "enabled": self._enabled, - "solveType": str(self._solveType), - } - simpDict.setdefault("combos", []).append(x) - return self._buildIdx - - def clearBuildIndex(self): - """Clear the build index of this object - - The buildIndex is stored when building a definition dictionary - that keeps track of its index for later referencing - - Parameters - ---------- - - Returns - ------- - - """ - self._buildIdx = None - self.prog.clearBuildIndex() - self.group.clearBuildIndex() - - def extractProgressive(self, live=True, offset=10.0, separation=5.0): - """ - - Parameters - ---------- - live : - (Default value = True) - offset : - (Default value = 10.0) - separation : - (Default value = 5.0) - - Returns - ------- - - """ - raise RuntimeError("Currently just copied from Sliders, Not actually real") - with undoContext(self.DCC): - pos, neg = [], [] - for pp in sorted(self.prog.pairs): - if pp.value < 0.0: - neg.append((pp.value, pp.shape, offset)) - offset += separation - elif pp.value > 0.0: - pos.append((pp.value, pp.shape, offset)) - offset += separation - # skip the rest value at == 0.0 - neg = reversed(neg) - - for prog in [pos, neg]: - xtVal, shape, shift = prog[-1] - ext, deltaShape = self.DCC.extractWithDeltaShape(shape, live, shift) - for value, shape, shift in prog[:-1]: - self.DCC.extractWithDeltaConnection( - shape, deltaShape, value / xtVal, live, shift - ) - - def extractShape(self, shape, live=True, offset=10.0): - """Extract a shape from a combo progression - - Parameters - ---------- - shape : Shape - The Shape object to extract as a mesh - live : bool - Whether to maintain a live connection to the extracted mesh in the DCC (Default value = True) - offset : float - The offset to give the extracted mesh in the DCC (Default value = 10.0) - - Returns - ------- - : object - The DCC mesh just created - - """ - return self.DCC.extractComboShape(self, shape, live, offset) - - def connectShape(self, shape, mesh=None, live=False, delete=False): - """Connect a shape into a combo progression - - Parameters - ---------- - shape : Shape - The shape to connect the mesh to - mesh : object or None - A DCC mesh to connect into a Shape - If None, tries to connect by name (Default value = None) - live : bool - Whether to maintain a live connecto to the mesh in the DCC (Default value = False) - delete : bool - Whether to delete the DCC mesh after it was connected (Default value = False) - - Returns - ------- - - """ - self.DCC.connectComboShape(self, shape, mesh, live, delete) - - @stackable - def delete(self): - """Delete this combo and any shapes it contains""" - self.simplex.deleteDownstream(self) - mgrs = [model.removeItemManager(self) for model in self.models] - with nested(*mgrs): - g = self.group - if self not in g.items: - return # Can happen when deleting multiple groups - g.items.remove(self) - self.group = None - self.simplex.combos.remove(self) - pairs = self.prog.pairs[:] # gotta make a copy - for pp in pairs: - if not pp.shape.isRest: - self.simplex.shapes.remove(pp.shape) - self.DCC.deleteShape(pp.shape) - - @stackable - def setInterpolation(self, interp): - """Set the interpolation of a combo - - Parameters - ---------- - interp : str - The interpolation for this combo's progression - - Returns - ------- - - """ - self.prog.interp = interp - for model in self.models: - model.itemDataChanged(self) - - @stackable - def setComboValue(self, slider, value): - """Set the Slider/value pairs for a combo - - Parameters - ---------- - slider : Slider - The slider to set the value for - value : float - The value to set the Slider to - - Returns - ------- - - """ - idx = self.getSliderIndex(slider) - pair = self.pairs[idx] - pair.value = value - for model in self.models: - model.itemDataChanged(pair) - - @stackable - def appendComboValue(self, slider, value): - """Append a Slider/value pair for a combo - - Parameters - ---------- - slider : Slider - The slider to insert - value : float - The value to set the Slider to - - Returns - ------- - - """ - cp = ComboPair(slider, value) - mgrs = [model.insertItemManager(self) for model in self.models] - with nested(*mgrs): - self.pairs.append(cp) - cp.combo = self - - @stackable - def deleteComboPair(self, comboPair): - """Delete a Slider/value pair for a combo - - Parameters - ---------- - comboPair : ComboPair - The ComboPair to delete - - Returns - ------- - - """ - mgrs = [model.removeItemManager(comboPair) for model in self.models] - with nested(*mgrs): - # We specifically don't move the combo to the proper depth group - # That way the user can make multiple changes to the combo without - # it popping all over in the heirarchy - self.pairs.remove(comboPair) - comboPair.combo = None - - @stackable - def setGroup(self, grp): - """Set the group for this Combo - - Parameters - ---------- - grp : Group - The group to set - - Returns - ------- - - """ - if grp.groupType is None: - grp.groupType = type(self) - - if not isinstance(self, grp.groupType): - raise ValueError( - "All items in this group must be of type: {}".format(grp.groupType) - ) - - mgrs = [model.moveItemManager(self, grp) for model in self.models] - with nested(*mgrs): - if self.group: - self.group.items.remove(self) - grp.items.append(self) - self.group = grp - - @stackable - def createShape(self, shapeName=None, tVal=None): - """Create a shape and add it to a progression - - Parameters - ---------- - shapeName : str or None - The name of the shape to create. - If None, give it a default name - tVal : float or None - The progression value to set for the new Shape. - If None, it gets a "smart" default value - - Returns - ------- - - """ - pp, idx = self.prog.newProgPair(shapeName, tVal) - mgrs = [model.insertItemManager(self.prog, idx) for model in self.models] - with nested(*mgrs): - pp.prog = self.prog - self.prog.pairs.insert(idx, pp) - return pp - - def getInputVector(self): - """Get the input to the Solver that would fully activate this Combo - - Parameters - ---------- - - Returns - ------- - : [float, ...] - The ordered slider values - - """ - inVec = [0.0] * len(self.simplex.sliders) - for cp in self.pairs: - inVec[self.simplex.sliders.index(cp.slider)] = cp.value - return inVec +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from .accessor import SimplexTreeAccessor +from .dragItem import Draggable +from .stack import stackable +from .treeItem import TreeItem + +if TYPE_CHECKING: + from .group import Group + from .progression import Progression, ProgPair + from .shape import Shape + from .simplex import Simplex + from .slider import Slider + + +# Abstract Items +class ComboPair(SimplexTreeAccessor, Draggable): + """A Slider/Value pair for use in Combos""" + + classDepth: int = 6 + + def __init__(self, slider: Slider, value: float) -> None: + super().__init__(slider.simplex) + self.slider: Slider = slider + self._value: float = float(value) + self.combo: Combo | None = None + + @property + def name(self) -> str: + return self.slider.name + + @property + def value(self) -> float: + return self._value + + @value.setter + @stackable + def value(self, val: float) -> None: + self._value = val + + def buildDefinition(self, simpDict: dict, legacy: bool) -> tuple[int, float]: + sIdx = self.slider.buildDefinition(simpDict, legacy) + return sIdx, self.value + + def treeRow(self) -> int: + if self.combo is None: + return 0 + return self.combo.pairs.index(self) + + def treeParent(self) -> TreeItem | None: + return self.combo + + def treeData(self, column: int) -> Any | None: + if column == 0: + return self.name + if column == 1: + return self.value + return None + + +class Combo(SimplexTreeAccessor): + """A group of Slider/Value pairs that control a Progression through some solver + + Combos allow for fixit shapes to be created for any number of user inputs. + They also allow for fixits along the progression, and in "floating space" where + the inputs are not -1 or 1 + + Parameters + ---------- + name : str + The name of this Combo + simplex : Simplex + The parent Simplex system + pairs : [ComboPair + The Slider/Value pairs that make up this combo + prog : Progression + The Progression that this Combo controls + group : Group + The Group to create this combo in + solveType : str + The solve type for this combo. See Combo.solveTypes for a list + color : QColor + The color of this item in the UI + """ + + classDepth = 5 + solveTypes = ( + ("Minimum", "min"), + ("Multiply All", "allMul"), + ("Multiply Extremes", "extMul"), + ("Multiply Avg of Extremes", "mulAvgExt"), + ("Multiply Avg", "mulAvgAll"), + ("None", "min"), + ) + _freezeIcon = None + + def __init__( + self, + name: str, + simplex: Simplex, + pairs: list[ComboPair], + prog: Progression, + group: Group, + solveType: str | None, + ) -> None: + super().__init__(simplex) + + if group.groupType is not type(self): + raise ValueError("Cannot add this slider to a combo group") + with self.stack.store(self): + self._name: str = name + self.pairs = pairs + self.prog = prog + self.prog.controller = self + self._solveType: str | None = solveType + self._buildIdx = None + self.expanded = {} + self._enabled = True + self._freezeThing = None + for p in self.pairs: + p.combo = self + self.simplex.combos.append(self) + + with self.insertItemManager(group): + self.group: Group = group + self.group.items.append(self) + + @property + def enabled(self) -> bool: + """Get whether this Combo is evaluated in the solver""" + return self._enabled + + @enabled.setter + @stackable + def enabled(self, value: bool) -> None: + """Set whether this Combo is evaluated in the solver""" + self._enabled = value + + @property + def frozen(self) -> bool: + """Get whether this Combo is frozen""" + return bool(self.freezeThing) + + @property + def freezeThing(self) -> Any: + """Get whether this Combo is frozen""" + if self._freezeThing is None: + self._freezeThing = self.DCC.getFreezeThing(self) + return self._freezeThing + + @freezeThing.setter + def freezeThing(self, value: Any) -> None: + self._freezeThing = value + + @classmethod + def comboAlreadyExists( + cls, simplex: Simplex, sliders: list[Slider], values: list[float] + ) -> Combo | None: + """Classmethod to check whether a combo already exists with these sliders and values + + Parameters + ---------- + simplex : Simplex + The system to check within + sliders : [Slider + The Sliders to check + values : [float + The values to zip with the sliders + + Returns + ------- + : Combo or None + The combo that exists with the given values, or None if none exist + """ + checker = {(s.name, v) for s, v in zip(sliders, values)} + for combo in simplex.combos: + tester = {(p.slider.name, p.value) for p in combo.pairs} + if checker == tester: + return combo + return None + + @classmethod + def createCombo( + cls, + name: str, + simplex: Simplex, + sliders: list[Slider], + values: list[float], + group: Group | None = None, + shape: Shape | None = None, + solveType: str | None = None, + tVal: float = 1.0, + ) -> Combo: + """Classmethod to create Combo with some hard-coded defaults + + Parameters + ---------- + name : str + The name of the Combo + simplex : Simplex + The Simplex system + sliders : [Slider + The Sliders that will control this combo + values : [float + The values at which the sliders will activate this combo + group : Group or None + A Group to organize this combo. + If None, the combo will be sorted into a "DEPTH" group (Default value = None) + shape : Shape or None + A Shape for this Combo's Progression. If None, then a default shape will be created + solveType : str or None + The solve type for this Combo. if None, defaults to 'min' in the solver + tVal : float + The slideValue where the Shape will be created/added. Defaults to 1.0 + + Returns + ------- + : Combo + The newly created Combo + """ + if simplex.restShape is None: + raise RuntimeError("Simplex system is missing rest shape") + + # Make sure to check if this combo already exists. If so, just return it + exist = cls.comboAlreadyExists(simplex, sliders, values) + if exist is not None: + return exist + from .group import Group + from .progression import ProgPair, Progression + + if group is None: + gname = f"DEPTH_{len(sliders)}" + matches = [i for i in simplex.comboGroups if i.name == gname] + if matches: + group = matches[0] + else: + group = Group(gname, simplex, Combo) + + cPairs = [ComboPair(slider, value) for slider, value in zip(sliders, values)] + prog = Progression(name, simplex) + if shape: + prog.pairs.append(ProgPair(simplex, shape, tVal)) + + cmb = Combo(name, simplex, cPairs, prog, group, solveType) + + if shape is None: + pp = prog.createShape(name, tVal) + simplex.DCC.zeroShape(pp.shape) + + return cmb + + @staticmethod + def buildComboName(sliders, values): + """Build the name for a combo based on the input Sliders and values + The sliders will be alphabetically sorted + Values not at Shape increments within the progression will get numeric + suffixes. Negative values will have suffixes like "n75" + + Parameters + ---------- + sliders : [Slider + The sliders to check + values : [float + The values for the sliders + + Returns + ------- + : str + The suggested combo name + """ + pairs = list(zip(sliders, values)) + pairs = sorted(pairs, key=lambda x: x[0].name) + parts = [] + for slider, value in pairs: + shape = slider.prog.getShapeAtValue(value) + if shape is not None: + parts.append(shape.name) + else: + # get the extreme shape and percentage-ize its name + extVal = 1.0 if value > 0.0 else -1.0 + + valName = f"{abs(int(value * 100))}" + valName = "n" + valName if value < 0.0 else valName + + shape = slider.prog.getShapeAtValue(extVal) + if shape is not None: + sn = shape.name + sn = sn.split("_") + if sn[-1].isnumeric() or ( + sn[-1][0] == "n" and sn[-1][1:].isnumeric() + ): + sn[-1] = valName + else: + sn.append(valName) + nsn = "_".join(sn) + parts.append(nsn) + else: + parts.append(slider.name) + + return "_".join(parts) + + @property + def name(self) -> str: + """Get the name of a combo""" + return self._name + + @name.setter + @stackable + def name(self, value) -> None: + """Set the name of a combo""" + self._name = value + self.prog.name = value + self.DCC.renameCombo(self, value) + + @property + def solveType(self) -> str | None: + """Get the solveType of the combo""" + return self._solveType + + @solveType.setter + @stackable + def solveType(self, newType: str) -> None: + """Set the solveType of the combo""" + stNames, stVals = list(zip(*self.solveTypes)) + if newType not in stVals: + raise ValueError(f"Solve Type {newType} not in allowed types {stVals}") + self._solveType = newType + + def sliderNameLinks(self) -> list[bool]: + """ """ + sliNames = [f"_{i.slider.name}_" for i in self.pairs] + surr = f"_{self.name}_" + return [sn in surr for sn in sliNames] + + def nameLinks(self): + """ + + Parameters + ---------- + + Returns + ------- + : type + progression depends on this slider's name + """ + # In this case, these names will *NOT* have the possibility of + # a pos/neg name. Only the combo name, and possibly a percentage + shapeNames = [] + shapes = [i.shape for i in self.prog.pairs] + for s in shapes: + x = s.name.rsplit("_", 1) + if len(x) == 2: + base, sfx = x + x = base if sfx.isdigit() else s.name + shapeNames.append(x) + return [i == self.name for i in shapeNames] + + def getSliderIndex(self, slider) -> int: + for i, p in enumerate(self.pairs): + if p.slider == slider: + return i + raise ValueError(f"Provided slider:{slider.name} is not in the list") + + def isFloating(self) -> bool: + """Floating combos are combos that Slider values that are between 0 and 1""" + for pair in self.pairs: + if abs(pair.value) != 1.0: + return True + return False + + def getSliders(self) -> list[Slider]: + """ """ + return [i.slider for i in self.pairs] + + @classmethod + def loadV2(cls, simplex, progs, data): + """Load the data from a version2 formatted json dictionary + + Parameters + ---------- + simplex : Simplex + The Simplex system that's being built + progs : [Progression + The progressions that have already been built + data : dict + The chunk of the json dict used to build this object + + Returns + ------- + : Combo + The specified combo + """ + name = data["name"] + prog = progs[data["prog"]] + group = simplex.groups[data.get("group", 1)] + pairs = [ComboPair(simplex.sliders[s], v) for s, v in data["pairs"]] + solveType = data.get("solveType") + return cls(name, simplex, pairs, prog, group, solveType) + + def buildDefinition(self, simpDict, legacy): + """Output a dictionary definition of this object + + Parameters + ---------- + simpDict : dict + The dictionary that is being built + legacy : bool + Whether to write out the legacy definition, or the newer one + """ + if self._buildIdx is None: + self._buildIdx = len(simpDict["combos"]) + if legacy: + gIdx = self.group.buildDefinition(simpDict, legacy) + pIdx = self.prog.buildDefinition(simpDict, legacy) + idxPairs = [p.buildDefinition(simpDict, legacy) for p in self.pairs] + x = [self.name, pIdx, idxPairs, gIdx] + simpDict.setdefault("combos", []).append(x) + else: + x = { + "name": self.name, + "prog": self.prog.buildDefinition(simpDict, legacy), + "pairs": [p.buildDefinition(simpDict, legacy) for p in self.pairs], + "group": self.group.buildDefinition(simpDict, legacy), + "enabled": self._enabled, + "solveType": str(self._solveType), + } + simpDict.setdefault("combos", []).append(x) + return self._buildIdx + + def clearBuildIndex(self) -> None: + """Clear the build index of this object + + The buildIndex is stored when building a definition dictionary + that keeps track of its index for later referencing + """ + self._buildIdx = None + self.prog.clearBuildIndex() + self.group.clearBuildIndex() + + def extractShape(self, shape, live: bool = True, offset: float = 10.0): + """Extract a shape from a combo progression + + Parameters + ---------- + shape : Shape + The Shape object to extract as a mesh + live : bool + Whether to maintain a live connection to the extracted mesh in the DCC (Default value = True) + offset : float + The offset to give the extracted mesh in the DCC (Default value = 10.0) + + Returns + ------- + : object + The DCC mesh just created + """ + return self.DCC.extractComboShape(self, shape, live, offset) + + def connectShape( + self, shape, mesh=None, live: bool = False, delete: bool = False + ) -> None: + """Connect a shape into a combo progression + + Parameters + ---------- + shape : Shape + The shape to connect the mesh to + mesh : object or None + A DCC mesh to connect into a Shape + If None, tries to connect by name (Default value = None) + live : bool + Whether to maintain a live connecto to the mesh in the DCC (Default value = False) + delete : bool + Whether to delete the DCC mesh after it was connected (Default value = False) + """ + self.DCC.connectComboShape(self, shape, mesh, live, delete) + + @stackable + def delete(self) -> None: + """Delete this combo and any shapes it contains""" + if self not in self.group.items: + return # Can happen when deleting multiple groups + + with self.removeItemManager(self): + self.group.items.remove(self) + self.group = None # type: ignore # Just clearing out references + self.simplex.combos.remove(self) + pairs = self.prog.pairs[:] # gotta make a copy + for pp in pairs: + if not pp.shape.isRest: + self.simplex.shapes.remove(pp.shape) + self.DCC.deleteShape(pp.shape) + + @stackable + def setInterpolation(self, interp) -> None: + """Set the interpolation of a combo + + Parameters + ---------- + interp : str + The interpolation for this combo's progression + """ + self.prog.interp = interp + + @stackable + def setComboValue(self, slider, value) -> None: + """Set the Slider/value pairs for a combo + + Parameters + ---------- + slider : Slider + The slider to set the value for + value : float + The value to set the Slider to + """ + idx = self.getSliderIndex(slider) + pair = self.pairs[idx] + pair.value = value + + @stackable + def appendComboValue(self, slider, value) -> None: + """Append a Slider/value pair for a combo + + Parameters + ---------- + slider : Slider + The slider to insert + value : float + The value to set the Slider to + """ + cp = ComboPair(slider, value) + with self.insertItemManager(self): + self.pairs.append(cp) + cp.combo = self + + @stackable + def deleteComboPair(self, comboPair) -> None: + """Delete a Slider/value pair for a combo + + Parameters + ---------- + comboPair : ComboPair + The ComboPair to delete + """ + # We specifically don't move the combo to the proper depth group + # That way the user can make multiple changes to the combo without + # it popping all over in the heirarchy + with self.removeItemManager(comboPair): + self.pairs.remove(comboPair) + comboPair.combo = None + + @stackable + def setGroup(self, grp) -> None: + """Set the group for this Combo + + Parameters + ---------- + grp : Group + The group to set + """ + if grp.groupType is None: + grp.groupType = type(self) + + if not isinstance(self, grp.groupType): + raise ValueError( + f"All items in this group must be of type: {grp.groupType}" + ) + + with self.moveItemManager(self, grp): + if self.group: + self.group.items.remove(self) + grp.items.append(self) + self.group = grp + + @stackable + def createShape(self, shapeName=None, tVal=None) -> ProgPair: + """Create a shape and add it to a progression + + Parameters + ---------- + shapeName : str or None + The name of the shape to create. + If None, give it a default name + tVal : float or None + The progression value to set for the new Shape. + If None, it gets a "smart" default value + """ + pp, idx = self.prog.newProgPair(shapeName, tVal) + with self.insertItemManager(self.prog, row=idx): + pp.prog = self.prog + self.prog.pairs.insert(idx, pp) + return pp + + def getInputVector(self) -> list[float]: + """Get the input to the Solver that would fully activate this Combo + + Returns + ------- + : [float, ...] + The ordered slider values + """ + inVec = [0.0] * len(self.simplex.sliders) + for cp in self.pairs: + inVec[self.simplex.sliders.index(cp.slider)] = cp.value + return inVec + + def treeChild(self, row: int) -> TreeItem: + if row == len(self.pairs): + return self.prog + return self.pairs[row] + + def treeRow(self) -> int: + return self.group.items.index(self) + + def treeParent(self) -> TreeItem: + return self.group + + def treeChildCount(self) -> int: + return len(self.pairs) + 1 + + def treeChecked(self) -> bool: + return self.enabled + + def treeData(self, column: int) -> Any | None: + if column == 0: + return self.name + return None diff --git a/src/python/simplexui/items/dragItem.py b/src/python/simplexui/items/dragItem.py new file mode 100644 index 00000000..6c585018 --- /dev/null +++ b/src/python/simplexui/items/dragItem.py @@ -0,0 +1,44 @@ +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +from __future__ import annotations + + +class Draggable: + """A mixin to make an item middle-click draggable""" + + # This is in a separate file from the DragFilter so that no Qt imports + # are required for the core Simplex items + dragStep: float = 0.05 + maxValue: float = 1.0 + minValue: float = 0.0 + + def valueTick(self, ticks: int, mul: float) -> None: + """Change the value of the current object by some number of ticks + with some given multiplier. This is the interface for the MMB drag + + Parameters + ---------- + ticks : int + The number of dragStep ticks to apply + mul : float + An overall multiplier + """ + val = self.value + (self.dragStep * ticks * mul) + val = 0.0 if abs(val) < 1e-5 else val + val = max(min(val, self.maxValue), self.minValue) + self.value = val diff --git a/src/python/simplexui/items/falloff.py b/src/python/simplexui/items/falloff.py index f92a1d1a..abfe24f0 100644 --- a/src/python/simplexui/items/falloff.py +++ b/src/python/simplexui/items/falloff.py @@ -1,778 +1,748 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# pylint:disable=missing-docstring,unused-argument,no-self-use -import copy -import math - -try: - import numpy as np -except ImportError: - np = None -from Qt.QtGui import QColor -from ..utils import nested -from .accessor import SimplexAccessor -from .stack import stackable - - -class Falloff(SimplexAccessor): - """Falloffs define how shapes are split left/right, front/back, top/bottom, or however else - - Falloffs are the core of the splitting algorithm built into Simplex. - When splitting, they define how shapes are duplicated and renamed, as well as - how the deltas are multiplied. - - Currently only axis-aligned planar falloffs are fully defined and supported, but there are plans - for per-point falloffs (weightmap), oriented planes, and other implicit geometric shapes. - - A planar falloff is a worldspace value field where any point past falloff min has the min value, - any point past the max has the max value, and any point in between is defined by user-controlled curve - Maya users might think of this as a planar projection of a ramp. - - A group of static variables controls how splits are detected and renamed. Names are split by - Falloff.SEP and the split chunks are matched to the items below. In some cases, rather than - lists of characters, I just use a string. - - These define the strings identifying their eponymous sides: - LEFTSIDE, RIGHTSIDE, TOPSIDE, BOTTOMSIDE, FRONTSIDE, BACKSIDE - - This is a character list definint the single-character values that define centered shapes: - CENTERS - - These define the strings that are detected to find splits. - For instance, if HORIZONTAL_SPLIT="X", then cornerPuller_X would split across the X axis, - and the new items would be given names - - :: - "cornerPuller_{}".format(LEFTSIDE) - "cornerPuller_{}".format(RIGHTSIDE) - - VERTICAL_SPLIT, VERTICAL_AXIS, VERTICAL_AXISINDEX - HORIZONTAL_SPLIT, HORIZONTAL_AXIS, HORIZONTAL_AXISINDEX - DEPTH_SPLIT, DEPTH_AXIS, DEPTH_AXISINDEX, - - When UNsplitting a simplex system, this value controls the tolerance - UNSPLIT_GUESS_TOLERANCE - - Parameters - ---------- - name : str - The name of the falloff - simplex : Simplex - The Simplex system - *data : list - The data used to build this falloff. - You should use one of the classmethod like Falloff.createPlanar or Falloff.createMap instead - - Returns - ------- - - """ - - LEFTSIDE = "L" - RIGHTSIDE = "R" - TOPSIDE = "U" - BOTTOMSIDE = "D" - FRONTSIDE = "F" - BACKSIDE = "B" - ALLSIDES = LEFTSIDE + RIGHTSIDE + TOPSIDE + BOTTOMSIDE + FRONTSIDE + BACKSIDE - - CENTERS = "MC" - - VERTICAL_SPLIT = "V" - VERTICAL_RESULTS = TOPSIDE + BOTTOMSIDE - VERTICAL_AXIS = "Y" - VERTICAL_AXISINDEX = 1 - - HORIZONTAL_SPLIT = "X" - HORIZONTAL_RESULTS = LEFTSIDE + RIGHTSIDE - HORIZONTAL_AXIS = "X" - HORIZONTAL_AXISINDEX = 0 - - DEPTH_SPLIT = "Z" - DEPTH_RESULTS = FRONTSIDE + BACKSIDE - DEPTH_AXIS = "Z" - DEPTH_AXISINDEX = 2 - - RESTNAME = "Rest" - SEP = "_" - - UNSPLIT_GUESS_TOLERANCE = 0.33 - - def __init__(self, name, simplex, *data): - super(Falloff, self).__init__(simplex) - with self.stack.store(self): - self._splitType = str(data[0]).lower() - self._axis = None - self._maxVal = None - self._maxHandle = None - self._minHandle = None - self._minVal = None - self._mapName = None - - self._bezier = None - self._search = None - self._rep = None - self._weights = None - self._verts = None - self._thing = None - self._thingRepr = None - - if self._splitType == "planar": - self._axis = data[1] - self._maxVal = data[2] - self._maxHandle = data[3] - self._minHandle = data[4] - self._minVal = data[5] - elif self._splitType == "map": - self._mapName = data[1] - self._axis = data[2] - - self._name = name - self.children = [] - self._buildIdx = None - self.expanded = {} - self.color = QColor(128, 128, 128) - - mgrs = [model.insertItemManager(None) for model in self.falloffModels] - with nested(*mgrs): - self.simplex.falloffs.append(self) - - # newThing = self.DCC.getFalloffThing(self) - # if newThing is None: - # self.thing = self.DCC.createFalloff(self) - # else: - # self.thing = newThing - - # @property - # def thing(self): - ## if this is a deepcopied object, then self._thing will - ## be None. Rebuild the thing connection by its representation - # if self._thing is None and self._thingRepr: - # self._thing = self.DCC.loadPersistentFalloff(self._thingRepr) - # return self._thing - - # @thing.setter - # def thing(self, value): - # self._thing = value - # self._thingRepr = self.DCC.getPersistentFalloff(value) - - @property - def name(self): - """Get the name of a Falloff""" - return self._name - - @name.setter - @stackable - def name(self, value): - """Set the name of a Falloff - - Parameters - ---------- - value : - - - Returns - ------- - - """ - self._name = value - for model in self.falloffModels: - model.itemDataChanged(self) - - @classmethod - def createPlanar(cls, name, simplex, axis, maxVal, maxHandle, minHandle, minVal): - """Create a planar falloff - - Parameters - ---------- - name : str - The name to give the falloff - simplex : Simplex - The Simplex system - axis : str - The axis to align the falloff to. X, Y, or Z - maxVal : float - The value past which the falloff is 1.0 - maxHandle : float - The (0, 1) range of the max cubic falloff handle - minHandle : float - The (0, 1) range of the min cubic falloff handle - minVal : float - The value past which the falloff is 0.0 - - Returns - ------- - - """ - return cls(name, simplex, "planar", axis, maxVal, maxHandle, minHandle, minVal) - - @classmethod - def createMap(cls, name, simplex, mapName, axis): - """Create a weightmap falloff - - Parameters - ---------- - name : str - The name to give the falloff - simplex : Simplex - The Simplex system - mapName : str - The name of the weightmap - axis : str - The axis to align the falloff to. X, Y, or Z - - Returns - ------- - - """ - return cls(name, simplex, "map", mapName, axis) - - @classmethod - def loadV2(cls, simplex, data): - """Load the falloff from the version 2 json specification - - Parameters - ---------- - simplex : Simplex - The Simplex system - data : dict - The data to load - - Returns - ------- - : Falloff - The specified Falloff - - """ - tpe = data["type"] - name = data["name"] - axis = data["axis"] - if tpe == "map": - return cls.createMap(name, simplex, data["mapName"], axis) - elif tpe == "planar": - maxVal = data["maxVal"] - maxHandle = data["maxHandle"] - minHandle = data["minHandle"] - minVal = data["minVal"] - return cls.createPlanar( - name, simplex, axis, maxVal, maxHandle, minHandle, minVal - ) - - raise ValueError("Bad data passed to Falloff creation") - - def buildDefinition(self, simpDict, legacy): - """Output a dictionary definition of this object - - Parameters - ---------- - simpDict : dict - The dictionary that is being built - legacy : bool - Whether to write out the legacy definition, or the newer one - - Returns - ------- - - """ - if self._buildIdx is None: - self._buildIdx = len(simpDict["falloffs"]) - if legacy: - if self.splitType == "planar": - line = [ - "planar", - self.axis, - self.maxVal, - self.maxHandle, - self.minHandle, - self.minVal, - ] - else: - line = ["map", self.mapName] - simpDict.setdefault("falloffs", []).append([self.name] + line) - else: - x = { - "name": self.name, - "type": self.splitType, - "axis": self.axis, - "maxVal": self.maxVal, - "maxHandle": self.maxHandle, - "minHandle": self.minHandle, - "minVal": self.minVal, - "mapName": self.mapName, - "color": self.color.getRgb()[:3], - } - simpDict.setdefault("falloffs", []).append(x) - return self._buildIdx - - def clearBuildIndex(self): - """Clear the build index of this object - - The buildIndex is stored when building a definition dictionary - that keeps track of its index for later referencing - - Parameters - ---------- - - Returns - ------- - - """ - self._buildIdx = None - - @stackable - def duplicate(self, newName): - """Duplicate a Falloff with a new name - - Parameters - ---------- - newName : str - The name to give the new Falloff - - Returns - ------- - : Falloff - The newly duplicated Falloff - - """ - nf = copy.copy(self) - nf.name = newName - nf.children = [] - nf.clearBuildIndex() - mgrs = [model.insertItemManager(self) for model in self.falloffModels] - with nested(*mgrs): - self.simplex.falloffs.append(nf) - self.DCC.duplicateFalloff(self, nf) - return nf - - @stackable - def delete(self): - """Delete the Falloff""" - fIdx = self.simplex.falloffs.index(self) - for child in self.children: - child.falloff = None - - mgrs = [model.removeItemManager(self) for model in self.falloffModels] - with nested(*mgrs): - self.simplex.falloffs.pop(fIdx) - self.DCC.deleteFalloff(self) - - @stackable - def setPlanarData(self, axis, minVal, minHandle, maxHandle, maxVal): - """Set the type/data for a planar Falloff - - Parameters - ---------- - axis : str - The axis to align the falloff to. X, Y, or Z - maxVal : float - The value past which the falloff is 1.0 - maxHandle : float - The (0, 1) range of the max cubic falloff handle - minHandle : float - The (0, 1) range of the min cubic falloff handle - minVal : float - The value past which the falloff is 0.0 - - Returns - ------- - - """ - self.splitType = "planar" - self.axis = axis - self.minVal = minVal - self.minHandle = minHandle - self.maxHandle = maxHandle - self.maxVal = maxVal - self.mapName = None - self._updateDCC() - - @stackable - def setMapData(self, mapName): - """Set the type/data for a map Falloff - - Parameters - ---------- - mapName : str - The name of the weightmap - - Returns - ------- - - """ - self.splitType = "map" - self.axis = None - self.minVal = None - self.minHandle = None - self.maxHandle = None - self.maxVal = None - self.mapName = mapName - self._updateDCC() - - @property - def splitType(self): - return self._splitType - - @splitType.setter - @stackable - def splitType(self, value): - self._splitType = str(value).lower() - for model in self.falloffModels: - model.itemDataChanged(self) - self._updateDCC() - - @property - def axis(self): - return self._axis - - @axis.setter - @stackable - def axis(self, value): - self._axis = value - for model in self.falloffModels: - model.itemDataChanged(self) - self._updateDCC() - - @property - def maxVal(self): - return self._maxVal - - @maxVal.setter - @stackable - def maxVal(self, value): - self._maxVal = value - for model in self.falloffModels: - model.itemDataChanged(self) - self._updateDCC() - - @property - def maxHandle(self): - return self._maxHandle - - @maxHandle.setter - @stackable - def maxHandle(self, value): - self._maxHandle = value - for model in self.falloffModels: - model.itemDataChanged(self) - self._updateDCC() - - @property - def minHandle(self): - return self._minHandle - - @minHandle.setter - @stackable - def minHandle(self, value): - self._minHandle = value - for model in self.falloffModels: - model.itemDataChanged(self) - self._updateDCC() - - @property - def minVal(self): - return self._minVal - - @minVal.setter - @stackable - def minVal(self, value): - self._minVal = value - for model in self.falloffModels: - model.itemDataChanged(self) - self._updateDCC() - - @property - def mapName(self): - return self._mapName - - @mapName.setter - @stackable - def mapName(self, value): - self._mapName = value - for model in self.falloffModels: - model.itemDataChanged(self) - self._updateDCC() - - def _updateDCC(self): - """ """ - self.DCC.setFalloffData( - self, - self.splitType, - self.axis, - self.minVal, - self.minHandle, - self.maxHandle, - self.maxVal, - self.mapName, - ) - - # Split code - @property - def bezier(self): - """Pre-build a factorization of the cubic bezier curve that is being used for a falloff - Based on method described at https://pomax.github.io/bezierinfo/#yforx - - Parameters - ---------- - - Returns - ------- - - """ - if self._bezier is None: - p0x = 0.0 - p1x = self.minHandle - p2x = self.maxHandle - p3x = 1.0 - - f = p1x - p0x - g = p3x - p2x - d = 3 * f + 3 * g - 2 - n = 2 * f + g - 1 - r = (n * n - f * d) / (d * d) - qq = (3 * f * d * n - 2 * n * n * n) / (d * d * d) - self._bezier = (qq, r, d, n) - return self._bezier - - def getMultiplier(self, xVal): - """Get the weight value for the given X - - Parameters - ---------- - xVal : float - The value to get the weight for - - Returns - ------- - : float - The weight - - """ - # Vertices are assumed to be at (0,0) and (1,1) - if xVal <= self.minVal: - return 0.0 - if xVal >= self.maxVal: - return 1.0 - - tVal = float(xVal - self.minVal) / float(self.maxVal - self.minVal) - qq, r, d, n = self.bezier - q = qq - tVal / d - discriminant = q * q - 4 * r * r * r - if discriminant >= 0: - pm = (discriminant**0.5) / 2 - w = (-q / 2 + pm) ** (1 / 3.0) - u = w + r / w - else: - theta = math.acos(-q / (2 * r ** (3 / 2.0))) - phi = theta / 3 + 4 * math.pi / 3 - u = 2 * r ** (0.5) * math.cos(phi) - t = u + n / d - t1 = 1 - t - return 3 * t1 * t**2 * 1 + t**3 * 1 - - def _setSearchRep(self): - """ """ - if self.axis.lower() == self.HORIZONTAL_AXIS.lower(): - self._search = self.HORIZONTAL_SPLIT - self._rep = self.HORIZONTAL_RESULTS - elif self.axis.lower() == self.VERTICAL_AXIS.lower(): - self._search = self.VERTICAL_SPLIT - self._rep = self.VERTICAL_RESULTS - elif self.axis.lower() == self.DEPTH_AXIS.lower(): - self._search = self.DEPTH_SPLIT - self._rep = self.DEPTH_RESULTS - - @property - def search(self): - """The values this fallof searches for""" - if self._search is None: - self._setSearchRep() - return self._search - - @property - def rep(self): - """The values this falloff replaces with""" - if self._rep is None: - self._setSearchRep() - return self._rep - - @property - def verts(self): - """Get the stored vertex values""" - return self._verts - - @verts.setter - def verts(self, vals): - """Input the vertices into this falloff and compute the weights - - Parameters - ---------- - vals : np.array - A (Nx3) numpy array of vertices - - Returns - ------- - - """ - if self.splitType != "map": - # Clear out any auto-computed weights - # when setting verts on a non-map falloff - self._weights = None - self._verts = vals - - @property - def weights(self): - """Get the per-vertex weight values""" - - if self._weights is None: - if self.splitType == "map": - raise ValueError( - "Attempted to auto-compute weights of a map falloff: {}".format( - self.name - ) - ) - - if self._verts is None: - raise ValueError( - "Attempted to auto-compute weights of a procedural falloff without setting verts: {0}".format( - self.name - ) - ) - - if self.axis.lower() == self.HORIZONTAL_AXIS.lower(): - component = self.HORIZONTAL_AXISINDEX - elif self.axis.lower() == self.VERTICAL_AXIS.lower(): - component = self.VERTICAL_AXISINDEX - elif self.axis.lower() == self.DEPTH_AXIS.lower(): - component = self.DEPTH_AXISINDEX - else: - raise ValueError("Falloff found with no axis set") - - self._weights = np.array( - [self.getMultiplier(v[component]) for v in self._verts] - ) - - return self._weights - - @weights.setter - def weights(self, val): - """Set the per-vertex weight values - - Parameters - ---------- - val : A list or numpy array of values between 0 and 1 - """ - self._weights = np.asarray(val) - - def getSidedName(self, name, sIdx): - """Take name to split along some axis, and replace the fields based on the index - For instance, this could take cp_X and return cp_L for sIdx=1 and cp_R for sIdx=2 - - Parameters - ---------- - name : str - The name to "split" with this falloff - sIdx : int - The index of the replacement value - - Returns - ------- - : str - The newly sided name - - """ - search = self.search - replace = self.rep[sIdx] - - nn = name - s = "{0}{1}{0}".format(self.SEP, search) - r = "{0}{1}{0}".format(self.SEP, replace) - nn = nn.replace(s, r) - - s = "{0}{1}".format(self.SEP, search) # handle Postfix - r = "{0}{1}".format(self.SEP, replace) - if nn.endswith(s): - nn = r.join(nn.rsplit(s, 1)) - - s = "{1}{0}".format(self.SEP, search) # handle Prefix - r = "{1}{0}".format(self.SEP, replace) - if nn.startswith(s): - nn = nn.replace(s, r, 1) - return nn - - def canRename(self, item): - """Check if the item can be renamed by this Falloff - - Parameters - ---------- - item : object - The named simplex object to check - - Returns - ------- - : bool - Whether this object can be renamed - - """ - nn = self.getSidedName(item.name, 0) - return nn != item.name - - def splitRename(self, item, sIdx): - """Actually run the rename for a particular item - - Parameters - ---------- - item : object - The named Simplex Item - sIdx : int - The replacement index - - Returns - ------- - - """ - from .combo import Combo - from .shape import Shape - from .slider import Slider - from .traversal import Traversal - - if isinstance(item, (Shape, Slider, Combo, Traversal)): - item.name = self.getSidedName(item.name, sIdx) - - def applyFalloff(self, shape, sIdx): - """Apply the falloff to the vertices of a shape - - Parameters - ---------- - shape : Shape - The shape to apply to - sIdx : int - The replacement index - - Returns - ------- - - """ - rest = self.simplex.restShape - restVerts = rest.verts - - weights = self.weights - if sIdx == 1: - weights = 1 - weights - - weightedDeltas = (shape.verts - restVerts) * weights[:, None] - shape.verts = weightedDeltas + restVerts +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +import copy +import math +from typing import TYPE_CHECKING, Any + +import numpy as np +from numpy import typing as npt + +from .accessor import SimplexAccessor +from .stack import stackable + +if TYPE_CHECKING: + from .progression import Progression + from .shape import Shape + from .simplex import DCCObject, Simplex + + +class SplitDefinition: + """A class defining how splits are applied to a system""" + + LEFTSIDE: str = "L" + RIGHTSIDE: str = "R" + TOPSIDE: str = "U" + BOTTOMSIDE: str = "D" + FRONTSIDE: str = "F" + BACKSIDE: str = "B" + ALLSIDES: str = LEFTSIDE + RIGHTSIDE + TOPSIDE + BOTTOMSIDE + FRONTSIDE + BACKSIDE + + CENTERS: str = "MC" + + VERTICAL_SPLIT: str = "V" + VERTICAL_RESULTS: str = TOPSIDE + BOTTOMSIDE + VERTICAL_AXIS: str = "Y" + VERTICAL_AXISINDEX: int = 1 + + HORIZONTAL_SPLIT: str = "X" + HORIZONTAL_RESULTS: str = LEFTSIDE + RIGHTSIDE + HORIZONTAL_AXIS: str = "X" + HORIZONTAL_AXISINDEX: int = 0 + + DEPTH_SPLIT: str = "Z" + DEPTH_RESULTS: str = FRONTSIDE + BACKSIDE + DEPTH_AXIS: str = "Z" + DEPTH_AXISINDEX: int = 2 + + RESTNAME: str = "Rest" + SEP: str = "_" + + def getComponentIndex(self, axis: str) -> int: + """Get the axis index. Usually x->1 y->2 z->3""" + axis = axis.lower() + if axis == self.HORIZONTAL_AXIS.lower(): + return self.HORIZONTAL_AXISINDEX + elif axis == self.VERTICAL_AXIS.lower(): + return self.VERTICAL_AXISINDEX + elif axis == self.DEPTH_AXIS.lower(): + return self.DEPTH_AXISINDEX + raise ValueError(f"Invalid Axis: {axis}") + + def getSearchRep(self, axis: str) -> tuple[str, str]: + if axis.lower() == self.HORIZONTAL_AXIS.lower(): + return self.HORIZONTAL_SPLIT, self.HORIZONTAL_RESULTS + elif axis.lower() == self.VERTICAL_AXIS.lower(): + return self.VERTICAL_SPLIT, self.VERTICAL_RESULTS + elif axis.lower() == self.DEPTH_AXIS.lower(): + return self.DEPTH_SPLIT, self.DEPTH_RESULTS + raise ValueError(f"Invalid Axis: {axis}") + + +class Falloff(SimplexAccessor): + """Falloffs define how shapes are split left/right, front/back, top/bottom, or however else + + Falloffs are the core of the splitting algorithm built into Simplex. + When splitting, they define how shapes are duplicated and renamed, as well as + how the deltas are multiplied. + + Currently only axis-aligned planar falloffs are fully defined and supported, but there are plans + for per-point falloffs (weightmap), oriented planes, and other implicit geometric shapes. + + A planar falloff is a worldspace value field where any point past falloff min has the min value, + any point past the max has the max value, and any point in between is defined by user-controlled curve + Maya users might think of this as a planar projection of a ramp. + + A group of static variables controls how splits are detected and renamed. Names are split by + Falloff.SEP and the split chunks are matched to the items below. In some cases, rather than + lists of characters, I just use a string. + + These define the strings identifying their eponymous sides: + LEFTSIDE, RIGHTSIDE, TOPSIDE, BOTTOMSIDE, FRONTSIDE, BACKSIDE + + This is a character list definint the single-character values that define centered shapes: + CENTERS + + These define the strings that are detected to find splits. + For instance, if HORIZONTAL_SPLIT="X", then cornerPuller_X would split across the X axis, + and the new items would be given names + + :: + "cornerPuller_{}".format(LEFTSIDE) + "cornerPuller_{}".format(RIGHTSIDE) + + VERTICAL_SPLIT, VERTICAL_AXIS, VERTICAL_AXISINDEX + HORIZONTAL_SPLIT, HORIZONTAL_AXIS, HORIZONTAL_AXISINDEX + DEPTH_SPLIT, DEPTH_AXIS, DEPTH_AXISINDEX, + + Parameters + ---------- + name : str + The name of the falloff + simplex : Simplex + The Simplex system + *data : list + The data used to build this falloff. + You should use one of the classmethod like Falloff.createPlanar or Falloff.createMap instead + """ + splitType = None + + def __init__(self, name: str, simplex: Simplex, axis: str) -> None: + super().__init__(simplex) + with self.stack.store(self): + self._search: str | None = None + self._rep: str | None = None + self._weights: npt.NDArray | None = None + self._verts: npt.NDArray | None = None + self._thing: DCCObject | None = None + self._thingRepr: str | None = None + + self._axis: str = axis + self._name: str = name + self.children: list[Progression] = [] + self._buildIdx: int | None = None + self.simplex.falloffs.append(self) + + @property + def name(self) -> str: + """Get the name of a Falloff""" + return self._name + + @name.setter + @stackable + def name(self, value: str) -> None: + """Set the name of a Falloff""" + self._name = value + + @classmethod + def createMap( + cls, name: str, simplex: Simplex, mapName: str, axis: str + ) -> MapFalloff: + # Note that MapName and Axis are swapped + # This is to provide backward compatibility. + # You should just make the MapFalloff object directly + assert len(axis) == 1, "Looks like you got mapName and axis swapped" + return MapFalloff(name, simplex, axis, mapName) + + @classmethod + def createPlanar( + cls, + name: str, + simplex: Simplex, + axis: str, + maxVal: float, + maxHandle: float, + minHandle: float, + minVal: float, + ) -> PlanarFalloff: + return PlanarFalloff( + name, + simplex, + axis, + maxVal, + maxHandle, + minHandle, + minVal, + ) + + @classmethod + def loadV2(cls, simplex: Simplex, data: dict[str, Any]) -> Falloff: + """Load the falloff from the version 2 json specification + + Parameters + ---------- + simplex : Simplex + The Simplex system + data : dict + The data to load + + Returns + ------- + : Falloff + The specified Falloff + """ + tpe = data["type"] + name = data["name"] + axis = data["axis"] + if tpe == "map": + return MapFalloff.createMap(name, simplex, data["mapName"], axis) + elif tpe == "planar": + maxVal = data["maxVal"] + maxHandle = data["maxHandle"] + minHandle = data["minHandle"] + minVal = data["minVal"] + return PlanarFalloff.createPlanar( + name, simplex, axis, maxVal, maxHandle, minHandle, minVal + ) + + raise ValueError("Bad data passed to Falloff creation") + + def buildDefinition(self, simpDict: dict[str, Any], legacy: bool) -> int: + """Output a dictionary definition of this object + + Parameters + ---------- + simpDict : dict + The dictionary that is being built + legacy : bool + Whether to write out the legacy definition, or the newer one + """ + raise NotImplementedError("Can't build definition on an untyped falloff") + + def clearBuildIndex(self) -> None: + """Clear the build index of this object + + The buildIndex is stored when building a definition dictionary + that keeps track of its index for later referencing + """ + self._buildIdx = None + + @stackable + def duplicate(self, newName: str) -> Falloff: + """Duplicate a Falloff with a new name + + Parameters + ---------- + newName : str + The name to give the new Falloff + + Returns + ------- + : Falloff + The newly duplicated Falloff + """ + nf = copy.copy(self) + nf.name = newName + nf.children = [] + nf.clearBuildIndex() + self.simplex.falloffs.append(nf) + self.DCC.duplicateFalloff(self, nf) + return nf + + @stackable + def delete(self) -> None: + """Delete the Falloff""" + fIdx = self.simplex.falloffs.index(self) + for child in self.children: + child.removeFalloff(self) + + self.simplex.falloffs.pop(fIdx) + self.DCC.deleteFalloff(self) + + @property + def axis(self) -> str: + return self._axis + + @axis.setter + @stackable + def axis(self, value: str) -> None: + self._axis = value + # TODO: Does this need to update the dcc?? + + @property + def verts(self) -> npt.NDArray | None: + """Get the stored vertex values""" + return self._verts + + @verts.setter + def verts(self, vals: npt.NDArray) -> None: + """Input the vertices into this falloff and compute the weights + + Parameters + ---------- + vals : np.array + A (Nx3) numpy array of vertices + """ + self._verts = vals + + @property + def weights(self) -> npt.NDArray | None: + """Get the per-vertex weight values""" + return self._weights + + @weights.setter + def weights(self, val: npt.ArrayLike) -> None: + """Set the per-vertex weight values + + Parameters + ---------- + val : A list or numpy array of values between 0 and 1 + """ + self._weights = np.asarray(val) + + def getSidedName(self, name: str, sIdx: int) -> str: + """Take name to split along some axis, and replace the fields based on the index + For instance, this could take cp_X and return cp_L for sIdx=1 and cp_R for sIdx=2 + + Parameters + ---------- + name : str + The name to "split" with this falloff + sIdx : int + The index of the replacement value + + Returns + ------- + : str + The newly sided name + """ + sdef = self.simplex.sdef + search, replaces = sdef.getSearchRep(self.axis) + replace = replaces[sIdx] + + nn = name + s = f"{sdef.SEP}{search}{sdef.SEP}" + r = f"{sdef.SEP}{replace}{sdef.SEP}" + nn = nn.replace(s, r) + + s = f"{sdef.SEP}{search}" # handle Postfix + r = f"{sdef.SEP}{replace}" + if nn.endswith(s): + nn = r.join(nn.rsplit(s, 1)) + + s = f"{search}{sdef.SEP}" # handle Prefix + r = f"{replace}{sdef.SEP}" + if nn.startswith(s): + nn = nn.replace(s, r, 1) + return nn + + def canRename(self, item: SimplexAccessor) -> bool: + """Check if the item can be renamed by this Falloff + + Parameters + ---------- + item : object + The named simplex object to check + + Returns + ------- + : bool + Whether this object can be renamed + """ + nn = self.getSidedName(item.name, 0) + return nn != item.name + + def splitRename(self, item: SimplexAccessor, sIdx: int) -> None: + """Actually run the rename for a particular item + + Parameters + ---------- + item : object + The named Simplex Item + sIdx : int + The replacement index + """ + from .combo import Combo + from .shape import Shape + from .slider import Slider + from .traversal import Traversal + + if isinstance(item, (Shape, Slider, Combo, Traversal)): + item.name = self.getSidedName(item.name, sIdx) + + def applyFalloff(self, shape: Shape, sIdx: int) -> None: + """Apply the falloff to the vertices of a shape + + Parameters + ---------- + shape : Shape + The shape to apply to + sIdx : int + The replacement index + """ + rest = self.simplex.restShape + if rest is None: + raise ValueError("Trying to apply a falloff on system with no rest shape") + if rest.verts is None: + raise ValueError( + "Trying to apply a falloff to a shape with unset rest verts" + ) + weights = self.weights + if weights is None: + raise ValueError("Weights are not properly set for this falloff") + + if sIdx == 1: + weights = 1 - weights + + if shape.verts is None: + raise ValueError("Trying to apply a falloff to a shape with unset verts") + + weightedDeltas = (shape.verts - rest.verts) * weights[:, None] + + shape.verts = weightedDeltas + rest.verts + + +class PlanarFalloff(Falloff): + splitType: str = "planar" + + def __init__( + self, + name: str, + simplex: Simplex, + axis: str, + maxVal: float, + maxHandle: float, + minHandle: float, + minVal: float, + ) -> None: + self._maxVal = maxVal + self._maxHandle = maxHandle + self._minHandle = minHandle + self._minVal = minVal + self._bezier = None + super().__init__(name, simplex, axis) + + @stackable + def setPlanarData( + self, + axis: str, + minVal: float, + minHandle: float, + maxHandle: float, + maxVal: float, + ) -> None: + """Set the type/data for a planar Falloff + + Parameters + ---------- + axis : str + The axis to align the falloff to. X, Y, or Z + maxVal : float + The value past which the falloff is 1.0 + maxHandle : float + The (0, 1) range of the max cubic falloff handle + minHandle : float + The (0, 1) range of the min cubic falloff handle + minVal : float + The value past which the falloff is 0.0 + """ + self.axis = axis + self.minVal = minVal + self.minHandle = minHandle + self.maxHandle = maxHandle + self.maxVal = maxVal + self._updateDCC() + + @property + def maxVal(self) -> float: + return self._maxVal + + @maxVal.setter + @stackable + def maxVal(self, value: float) -> None: + self._maxVal = value + self._updateDCC() + + @property + def maxHandle(self) -> float: + return self._maxHandle + + @maxHandle.setter + @stackable + def maxHandle(self, value: float) -> None: + self._maxHandle = value + self._updateDCC() + + @property + def minHandle(self) -> float: + return self._minHandle + + @minHandle.setter + @stackable + def minHandle(self, value: float) -> None: + self._minHandle = value + self._updateDCC() + + @property + def minVal(self) -> float: + return self._minVal + + @minVal.setter + @stackable + def minVal(self, value: float) -> None: + self._minVal = value + self._updateDCC() + + @property + def verts(self) -> npt.NDArray | None: + """Get the stored vertex values""" + return self._verts + + @verts.setter + def verts(self, vals: npt.NDArray) -> None: + """Input the vertices into this falloff and compute the weights + + Parameters + ---------- + vals : np.array + A (Nx3) numpy array of vertices + """ + # Clear out the weights since they're calculated + self._weights = None + self._verts = vals + + def _updateDCC(self) -> None: + """ """ + # TODO: Separate Map and Planar falloff data + self.DCC.setFalloffData( + self, + 'planar', + self.axis, + self.minVal, + self.minHandle, + self.maxHandle, + self.maxVal, + None, + ) + + @property + def bezier(self) -> tuple[float, float, float, float]: + """Pre-build a factorization of the cubic bezier curve that is being used for a falloff + Based on method described at https://pomax.github.io/bezierinfo/#yforx + """ + if self._bezier is None: + p0x = 0.0 + p1x = self.minHandle + p2x = self.maxHandle + p3x = 1.0 + + f = p1x - p0x + g = p3x - p2x + d = 3 * f + 3 * g - 2 + n = 2 * f + g - 1 + r = (n * n - f * d) / (d * d) + qq = (3 * f * d * n - 2 * n * n * n) / (d * d * d) + self._bezier = (qq, r, d, n) + return self._bezier + + # Split code + def getMultiplier(self, xVal: float) -> float: + """Get the weight value for the given X + + Parameters + ---------- + xVal : float + The value to get the weight for + + Returns + ------- + : float + The weight + """ + # Vertices are assumed to be at (0,0) and (1,1) + if xVal <= self.minVal: + return 0.0 + if xVal >= self.maxVal: + return 1.0 + + tVal = float(xVal - self.minVal) / float(self.maxVal - self.minVal) + qq, r, d, n = self.bezier + q = qq - tVal / d + discriminant = q * q - 4 * r * r * r + if discriminant >= 0: + pm = (discriminant**0.5) / 2 + w = (-q / 2 + pm) ** (1 / 3.0) + u = w + r / w + else: + theta = math.acos(-q / (2 * r ** (3 / 2.0))) + phi = theta / 3 + 4 * math.pi / 3 + u = 2 * r ** (0.5) * math.cos(phi) + t = u + n / d + t1 = 1 - t + return 3 * t1 * t**2 * 1 + t**3 * 1 + + @property + def weights(self) -> npt.NDArray: + """Get the per-vertex weight values""" + + if self._weights is None: + if self._verts is None: + raise ValueError( + f"Attempted to auto-compute weights of a procedural falloff without setting verts: {self.name}" + ) + component = self.simplex.sdef.getComponentIndex(self.axis) + self._weights = np.array( + [self.getMultiplier(v[component]) for v in self._verts] + ) + + return self._weights + + @weights.setter + def weights(self, val: npt.ArrayLike) -> None: + """Set the per-vertex weight values + + Parameters + ---------- + val : A list or numpy array of values between 0 and 1 + """ + self._weights = np.asarray(val) + + def buildDefinition(self, simpDict: dict[str, Any], legacy: bool) -> int: + """Output a dictionary definition of this object + + Parameters + ---------- + simpDict : dict + The dictionary that is being built + legacy : bool + Whether to write out the legacy definition, or the newer one + """ + if self._buildIdx is None: + self._buildIdx = len(simpDict["falloffs"]) + if legacy: + line = [ + "planar", + self.axis, + self.maxVal, + self.maxHandle, + self.minHandle, + self.minVal, + ] + simpDict.setdefault("falloffs", []).append([self.name] + line) + else: + x = { + "name": self.name, + "type": "planar", + "axis": self.axis, + "maxVal": self.maxVal, + "maxHandle": self.maxHandle, + "minHandle": self.minHandle, + "minVal": self.minVal, + } + simpDict.setdefault("falloffs", []).append(x) + return self._buildIdx + + +class MapFalloff(Falloff): + splitType: str = "map" + + def __init__(self, name: str, simplex: Simplex, axis: str, mapName: str) -> None: + self._mapName = mapName + super().__init__(name, simplex, axis) + + @stackable + def setMapData(self, axis: str, mapName: str) -> None: + """Set the type/data for a map Falloff + + Parameters + ---------- + mapName : str + The name of the weightmap + """ + self.axis = axis + self.mapName = mapName + self._updateDCC() + + @property + def mapName(self) -> str: + return self._mapName + + @mapName.setter + @stackable + def mapName(self, value: str) -> None: + self._mapName = value + self._updateDCC() + + def _updateDCC(self) -> None: + """ """ + # TODO: Separate Map and Planar falloff data + self.DCC.setFalloffData( + self, + "map", + self.axis, + None, + None, + None, + None, + self.mapName, + ) + + @property + def weights(self) -> npt.NDArray: + """Get the per-vertex weight values""" + + if self._weights is None: + raise ValueError( + f"Attempted to auto-compute weights of a map falloff: {self.name}" + ) + return self._weights + + @weights.setter + def weights(self, val: npt.ArrayLike) -> None: + """Set the per-vertex weight values + + Parameters + ---------- + val : A list or numpy array of values between 0 and 1 + """ + self._weights = np.asarray(val) + + def buildDefinition(self, simpDict: dict[str, Any], legacy: bool) -> int: + """Output a dictionary definition of this object + + Parameters + ---------- + simpDict : dict + The dictionary that is being built + legacy : bool + Whether to write out the legacy definition, or the newer one + """ + if self._buildIdx is None: + self._buildIdx = len(simpDict["falloffs"]) + if legacy: + line = ["map", self.axis, self.mapName] # Just default it to x axis + simpDict.setdefault("falloffs", []).append([self.name] + line) + else: + x = { + "name": self.name, + "type": "map", + "axis": self.axis, + "mapName": self.mapName, + } + simpDict.setdefault("falloffs", []).append(x) + return self._buildIdx diff --git a/src/python/simplexui/items/group.py b/src/python/simplexui/items/group.py index f6ba556e..a3c4bf5d 100644 --- a/src/python/simplexui/items/group.py +++ b/src/python/simplexui/items/group.py @@ -1,297 +1,267 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# pylint:disable=missing-docstring,unused-argument,no-self-use -from Qt.QtGui import QColor -from ..utils import nested -from .accessor import SimplexAccessor -from .stack import stackable - - -class Group(SimplexAccessor): - """Groups organize Simplex items - - Groups have no purpose in the solver. They don't do anything other than organize - the items in a system. - - Each group can hold only one type of item (Slider, Combo, or Traversal) - - Parameters - ---------- - name : str - The name of the group - simplex : Simplex - The Simplex system - groupType : type - The type that this group can hold - color : QColor - The color of this item in the Ui - - Returns - ------- - - """ - - classDepth = 1 - - def __init__(self, name, simplex, groupType, color=None): - super(Group, self).__init__(simplex) - from .combo import Combo - from .slider import Slider - from .traversal import Traversal - - color = QColor(128, 128, 128) if color is None else color - - with self.stack.store(self): - self._name = name - self.items = [] - self._buildIdx = None - self.expanded = {} - self.color = color - self.groupType = groupType - - mgrs = [ - model.insertItemManager(simplex, row=self._getInsertionRow()) - for model in self.models - ] - with nested(*mgrs): - if self.groupType is Slider: - self.simplex.sliderGroups.append(self) - elif self.groupType is Combo: - self.simplex.comboGroups.append(self) - elif self.groupType is Traversal: - self.simplex.traversalGroups.append(self) - - def _getInsertionRow(self): - """ """ - from .combo import Combo - from .slider import Slider - from .traversal import Traversal - - c = len(self.simplex.sliderGroups) - if self.groupType is Slider: - return c - c += len(self.simplex.comboGroups) - if self.groupType is Combo: - return c - c += len(self.simplex.traversalGroups) - if self.groupType is Traversal: - return c - - @property - def name(self): - """Get the name of the Group""" - return self._name - - @name.setter - @stackable - def name(self, value): - """Set the name of the Group - - Parameters - ---------- - value : - - - Returns - ------- - - """ - self._name = value - for model in self.models: - model.itemDataChanged(self) - - def treeChild(self, row): - """ - - Parameters - ---------- - row : - - - Returns - ------- - - """ - return self.items[row] - - def treeRow(self): - """ """ - return self.simplex.groups.index(self) - - def treeParent(self): - """ """ - return self.simplex - - def treeChildCount(self): - """ """ - return len(self.items) - - @classmethod - def createGroup(cls, name, simplex, things=None, groupType=None): - """Convenience method for creating a group - - Parameters - ---------- - name : str - The name to give the new Group - simpelx : Simplex - The Simplex system - things : [object - The things to add to this Group (Default value = None) - groupType : type - The type that the new Group can hold (Default value = None) - simplex : - - - Returns - ------- - - """ - g = cls(name, simplex, groupType) - if things is not None: - g.take(things) - return g - - @classmethod - def loadV2(cls, simplex, data): - """Load the data from a version2 formatted json dictionary - - Parameters - ---------- - simplex : Simplex - The Simplex system that's being built - data : dict - The chunk of the json dict used to build this object - - Returns - ------- - : Group - The specified Group - - """ - from .combo import Combo - from .slider import Slider - from .traversal import Traversal - - name = data["name"] - color = data.get("color", (0, 0, 0)) - typeName = data["type"] - if typeName == "Slider": - groupType = Slider - elif typeName == "Combo": - groupType = Combo - elif typeName == "Traversal": - groupType = Traversal - else: - raise RuntimeError("Malformed simplex json string: Improper group type") - return cls(name, simplex, groupType, QColor(*color)) - - def buildDefinition(self, simpDict, legacy): - """Output a dictionary definition of this object - - Parameters - ---------- - simpDict : dict - The dictionary that is being built - legacy : bool - Whether to write out the legacy definition, or the newer one - - Returns - ------- - - """ - if self._buildIdx is None: - self._buildIdx = len(simpDict["groups"]) - if legacy: - simpDict.setdefault("groups", []).append(self.name) - else: - x = { - "name": self.name, - "color": self.color.getRgb()[:3], - "type": self.groupType.__name__, - } - simpDict.setdefault("groups", []).append(x) - return self._buildIdx - - def clearBuildIndex(self): - """Clear the build index of this object - - The buildIndex is stored when building a definition dictionary - that keeps track of its index for later referencing - - Parameters - ---------- - - Returns - ------- - - """ - self._buildIdx = None - - @stackable - def delete(self): - """Delete a group. Any objects in this group will be deleted""" - from .combo import Combo - from .slider import Slider - - if self.groupType is Slider: - if len(self.simplex.sliderGroups) == 1: - return - gList = self.simplex.sliderGroups - elif self.groupType is Combo: - if len(self.simplex.comboGroups) == 1: - return - gList = self.simplex.comboGroups - else: - raise RuntimeError("Somehow this group has no type") - - mgrs = [model.removeItemManager(self) for model in self.models] - with nested(*mgrs): - # Delete the children first - # Gotta iterate over copies of the lists - # as .delete removes the items from the list - for item in self.items[:]: - item.delete() - - gList.remove(self) - - @stackable - def take(self, things): - """Remove some items from their current groups and put them in this one - - Parameters - ---------- - things : [object - A list of things to put in this group - - Returns - ------- - - """ - if self.groupType is None: - self.groupType = type(things[0]) - - if not all(isinstance(i, self.groupType) for i in things): - raise ValueError( - "All items in this group must be of type: {}".format(self.groupType) - ) - - # do it this way instead of using set() to keep order - for thing in things: - if thing not in self.items: - thing.setGroup(self) +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Union + +from .accessor import SimplexTreeAccessor +from .stack import stackable +from .treeItem import TreeItem + +if TYPE_CHECKING: + from .combo import Combo + from .simplex import Simplex + from .slider import Slider + from .traversal import Traversal + + GroupType = Union[type[Combo], type[Slider], type[Traversal]] + GroupMember = Union[Combo, Slider, Traversal] + + +class Group(SimplexTreeAccessor): + """Groups organize Simplex items + + Groups have no purpose in the solver. They don't do anything other than organize + the items in a system. + + Each group can hold only one type of item (Slider, Combo, or Traversal) + + Parameters + ---------- + name : str + The name of the group + simplex : Simplex + The Simplex system + groupType : type + The type that this group can hold + """ + + classDepth: int = 1 + + def __init__( + self, + name: str, + simplex: Simplex, + groupType: GroupType, + ) -> None: + super().__init__(simplex) + from .combo import Combo + from .slider import Slider + from .traversal import Traversal + + with self.stack.store(self): + self._name: str = name + self.items: list[Combo | Slider | Traversal] = [] + self._buildIdx: int | None = None + self.groupType: GroupType = groupType + + with self.insertItemManager(simplex, row=self._getInsertionRow()): + if self.groupType is Slider: + self.simplex.sliderGroups.append(self) + elif self.groupType is Combo: + self.simplex.comboGroups.append(self) + elif self.groupType is Traversal: + self.simplex.traversalGroups.append(self) + + def _getInsertionRow(self) -> int: + from .combo import Combo + from .slider import Slider + from .traversal import Traversal + + c = len(self.simplex.sliderGroups) + if self.groupType is Slider: + return c + c += len(self.simplex.comboGroups) + if self.groupType is Combo: + return c + c += len(self.simplex.traversalGroups) + if self.groupType is Traversal: + return c + return 0 + + @property + def name(self) -> str: + """Get the name of the Group""" + return self._name + + @name.setter + @stackable + def name(self, value: str) -> None: + """Set the name of the Group""" + self._name = value + + @classmethod + def createGroup( + cls, + name: str, + simplex: Simplex, + things: list[GroupMember] | None = None, + groupType: GroupType | None = None, + ) -> Group: + """Convenience method for creating a group + + Parameters + ---------- + name : str + The name to give the new Group + simpelx : Simplex + The Simplex system + things : [object + The things to add to this Group (Default value = None) + groupType : type + The type that the new Group can hold (Default value = None) + simplex : + """ + + if groupType is None: + if not things: + raise ValueError( + "Cannot build a group without setting the type, or giving an object to infer from" + ) + groupType = type(things[0]) + + g = cls(name, simplex, groupType) + if things is not None: + g.take(things) + return g + + @classmethod + def loadV2(cls, simplex: Simplex, data: dict[str, Any]) -> Group: + """Load the data from a version2 formatted json dictionary + + Parameters + ---------- + simplex : Simplex + The Simplex system that's being built + data : dict + The chunk of the json dict used to build this object + + Returns + ------- + : Group + The specified Group + """ + from .combo import Combo + from .slider import Slider + from .traversal import Traversal + + name = data["name"] + typeName = data["type"] + if typeName == "Slider": + groupType = Slider + elif typeName == "Combo": + groupType = Combo + elif typeName == "Traversal": + groupType = Traversal + else: + raise RuntimeError("Malformed simplex json string: Improper group type") + return cls(name, simplex, groupType) + + def buildDefinition(self, simpDict: dict[str, Any], legacy: bool) -> int: + """Output a dictionary definition of this object + + Parameters + ---------- + simpDict : dict + The dictionary that is being built + legacy : bool + Whether to write out the legacy definition, or the newer one + """ + if self._buildIdx is None: + self._buildIdx = len(simpDict["groups"]) + if legacy: + simpDict.setdefault("groups", []).append(self.name) + else: + x = { + "name": self.name, + "type": self.groupType.__name__, + } + simpDict.setdefault("groups", []).append(x) + return self._buildIdx + + def clearBuildIndex(self) -> None: + """Clear the build index of this object + + The buildIndex is stored when building a definition dictionary + that keeps track of its index for later referencing + """ + self._buildIdx = None + + @stackable + def delete(self) -> None: + """Delete a group. Any objects in this group will be deleted""" + from .combo import Combo + from .slider import Slider + + if self.groupType is Slider: + if len(self.simplex.sliderGroups) == 1: + return + gList = self.simplex.sliderGroups + elif self.groupType is Combo: + if len(self.simplex.comboGroups) == 1: + return + gList = self.simplex.comboGroups + else: + raise RuntimeError("Somehow this group has no type") + + with self.removeItemManager(self): + # Delete the children first + # Gotta iterate over copies of the lists + # as .delete removes the items from the list + for item in self.items[:]: + item.delete() + + gList.remove(self) + + @stackable + def take(self, things: list[GroupMember]) -> None: + """Remove some items from their current groups and put them in this one + + Parameters + ---------- + things : [object + A list of things to put in this group + """ + if self.groupType is None: + self.groupType = type(things[0]) + + if not all(isinstance(i, self.groupType) for i in things): + raise ValueError( + f"All items in this group must be of type: {self.groupType}" + ) + + # do it this way instead of using set() to keep order + for thing in things: + if thing not in self.items: + thing.setGroup(self) + + def treeChild(self, row) -> TreeItem: + return self.items[row] + + def treeRow(self) -> int: + return self.simplex.groups.index(self) + + def treeParent(self) -> TreeItem: + return self.simplex + + def treeChildCount(self) -> int: + return len(self.items) + + def treeData(self, column: int) -> str | None: + if column == 0: + return self.name + return None diff --git a/src/python/simplexui/items/progression.py b/src/python/simplexui/items/progression.py index 4b60f250..88361cff 100644 --- a/src/python/simplexui/items/progression.py +++ b/src/python/simplexui/items/progression.py @@ -1,743 +1,613 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - - -# pylint:disable=missing-docstring,unused-argument,no-self-use -from ..utils import getNextName, nested -from .accessor import SimplexAccessor -from .stack import stackable - - -class ProgPair(SimplexAccessor): - """ """ - - classDepth = 9 - - def __init__(self, simplex, shape, value): - super(ProgPair, self).__init__(simplex) - self.shape = shape - self._value = value - self.prog = None - self.minValue = -1.0 - self.maxValue = 1.0 - self.expanded = {} - if not shape.isRest and self not in self.shape.progPairs: - self.shape.progPairs.append(self) - - @property - def name(self): - """ """ - return self.shape.name - - @name.setter - def name(self, value): - """ - - Parameters - ---------- - value : - - - Returns - ------- - - """ - self.shape.name = value - - def buildDefinition(self, simpDict, legacy): - """ - - Parameters - ---------- - simpDict : - - legacy : - - - Returns - ------- - - """ - idx = self.shape.buildDefinition(simpDict, legacy) - return idx, self.value - - def __lt__(self, other): - return self.value < other.value - - @property - def value(self): - """ """ - return self._value - - @value.setter - @stackable - def value(self, val): - """ - - Parameters - ---------- - val : - - - Returns - ------- - - """ - from .slider import Slider - - self._value = val - for model in self.models: - model.itemDataChanged(self) - if isinstance(self.prog.controller, Slider): - self.prog.controller.setRange() - - @stackable - def delete(self): - """ """ - ppairs = self.prog.pairs - ridx = ppairs.index(self) - mgrs = [model.removeItemManager(self) for model in self.models] - with nested(*mgrs): - pp = ppairs.pop(ridx) - if not pp.shape.isRest: - pp.shape.progPairs.remove(pp) - if not pp.shape.progPairs: - self.simplex.shapes.remove(pp.shape) - self.DCC.deleteShape(pp.shape) - - def treeRow(self): - """ """ - return self.prog.pairs.index(self) - - def treeParent(self): - """ """ - from .slider import Slider - - par = self.prog - if isinstance(par.controller, Slider): - par = par.controller - return par - - def treeData(self, column): - """ - - Parameters - ---------- - column : - - - Returns - ------- - - """ - if column == 0: - return self.name - if column == 2: - return self.value - return None - - -class Progression(SimplexAccessor): - """A set of shapes to interpolate between - - A Progression is a collection of shape/value pairs, and an interpolation type. - Progressions don't exist on their own, they are always part of a higher-level object - like a Combo, Slider, or Traversal. The ProgPairs are always sorted by value - - Progressions should always have a shape at 0 (which is almost always the rest shape) - and a shape at either -1 or 1. - They can also have other shapes at any value between 0 and the extremes. - - Sliders give users direct control over the value that is passed to the progression. - Combos and Traversals use input values to control their progressions. - - Progressions can use different interpolations. - The simplest is 'linear', which blends in a straight line between shapes. - The 'spline' interp uses Catmull-Rom spline values. - The 'splitspline' builds separate Catmull-Rom splines for positive and negative values - - Parameters - ---------- - name : str - The name for the Progression. Usually just copies the name of its controller - simplex : Simplex - The Simplex system - pairs : [ProgPair - The ProgPairs that will make up this Progression. - If None, the a default Rest at 0.0 pair will be created. - interp : str - The interpolation for this Progression. Defaults to 'spline' - falloffs : [Falloff - A list of Fallofs to apply to the progression - Defaults to None - - Returns - ------- - - """ - - classDepth = 8 - interpTypes = ( - ("Linear", "linear"), - ("Spline", "spline"), - ("Split Spline", "splitspline"), - ) - - def __init__(self, name, simplex, pairs=None, interp="spline", falloffs=None): - super(Progression, self).__init__(simplex) - with self.stack.store(self): - self._name = name - self._interp = interp - self.falloffs = falloffs or [] - self.controller = None - - if pairs is None: - self.pairs = [ProgPair(self.simplex, self.simplex.restShape, 0.0)] - else: - self.pairs = pairs - - for pair in self.pairs: - pair.prog = self - - for falloff in self.falloffs: - falloff.children.append(self) - self._buildIdx = None - self.expanded = {} - - @property - def interp(self): - """Get the interp for this Progression""" - return self._interp - - @interp.setter - @stackable - def interp(self, value): - """Set the interp for this Progression - - Parameters - ---------- - value : - - - Returns - ------- - - """ - self._interp = value - - def treeChild(self, row): - """ - - Parameters - ---------- - row : - - - Returns - ------- - - """ - return self.pairs[row] - - def treeRow(self): - """ """ - from .combo import Combo - from .traversal import Traversal - - if isinstance(self.controller, Traversal): - # Show the progression after the mult and prog - return 2 - elif isinstance(self.controller, Combo): - # Show the progression after the comboPairs - return len(self.controller.pairs) - return None - - def treeParent(self): - """ """ - return self.controller - - def treeChildCount(self): - """ """ - return len(self.pairs) - - def treeData(self, column): - """ - - Parameters - ---------- - column : - - - Returns - ------- - - """ - if column == 0: - return "SHAPES" - return None - - def getShapeIndex(self, shape): - """Get the index of the given shape in this progression - - Parameters - ---------- - shape : Shape - The shape to get the index of - - Returns - ------- - : int - The index of the ProgPair that contains the given shape - - """ - for i, p in enumerate(self.pairs): - if p.shape == shape: - return i - raise ValueError("Provided shape:{0} is not in the list".format(shape.name)) - - def getShapes(self): - """Return the Shapes in this Progression - - Parameters - ---------- - - Returns - ------- - : type - ([Shape, ....]): The shapes in the Progression - - """ - return [i.shape for i in self.pairs] - - def getValues(self): - """Return the values in this Progression - - Parameters - ---------- - - Returns - ------- - : type - ([float, ....]): The values in the Progression - - """ - return [i.value for i in self.pairs] - - def getInsertIndex(self, tVal): - """Get the index to insert a pair with value tVal - - Parameters - ---------- - tVal : float - The value to get the insertion index for - - Returns - ------- - : int - The insertion index - - """ - values = self.getValues() - if not values: - return 0 - elif tVal <= values[0]: - return 0 - elif tVal >= values[-1]: - return len(self.pairs) - else: - for i in range(1, len(values)): - if values[i - 1] <= tVal < values[i]: - return i - return 0 - - def getShapeAtValue(self, val, tol=0.0001): - """Return the shape at the given value - - Parameters - ---------- - val : - float - tol : - float (Default value = 0.0001) - - Returns - ------- - : type - (Shape or None): The shape found with the given value, or None if nothing was found - - """ - for pp in self.pairs: - if abs(pp.value - val) < tol: - return pp.shape - return None - - @classmethod - def loadV2(cls, simplex, data): - """Load the data from a version2 formatted json dictionary - - Parameters - ---------- - simplex : Simplex - The Simplex system that's being built - data : dict - The chunk of the json dict used to build this object - - Returns - ------- - : Progression - The specified Progression - - """ - name = data["name"] - pairs = data["pairs"] - interp = data.get("interp", "spline") - foIdxs = data.get("falloffs", []) - pairs = [ProgPair(simplex, simplex.shapes[s], v) for s, v in pairs] - fos = [simplex.falloffs[i] for i in foIdxs] - return cls(name, simplex, pairs=pairs, interp=interp, falloffs=fos) - - def buildDefinition(self, simpDict, legacy): - """Output a dictionary definition of this object - - Parameters - ---------- - simpDict : dict - The dictionary that is being built - legacy : bool - Whether to write out the legacy definition, or the newer one - - Returns - ------- - - """ - if self._buildIdx is None: - idxPairs = [pair.buildDefinition(simpDict, legacy) for pair in self.pairs] - idxPairs.sort(key=lambda x: x[1]) - idxs, values = list(zip(*idxPairs)) - foIdxs = [f.buildDefinition(simpDict, legacy) for f in self.falloffs] - self._buildIdx = len(simpDict["progressions"]) - if legacy: - x = [self.name, idxs, values, self.interp, foIdxs] - simpDict.setdefault("progressions", []).append(x) - else: - x = { - "name": self.name, - "pairs": idxPairs, - "interp": self.interp, - "falloffs": foIdxs, - } - simpDict.setdefault("progressions", []).append(x) - return self._buildIdx - - def clearBuildIndex(self): - """Clear the build index of this object - - The buildIndex is stored when building a definition dictionary - that keeps track of its index for later referencing - - Parameters - ---------- - - Returns - ------- - - """ - self._buildIdx = None - for pair in self.pairs: - pair.shape.clearBuildIndex() - for fo in self.falloffs: - fo.clearBuildIndex() - - @stackable - def moveShapeToProgression(self, shapePair): - """Remove the shapePair from its current progression and set it in a new progression - - Parameters - ---------- - shapePair : progPair - The ProgPair to take - shapePair): ### Moves Rows (Slider : - - Combo : - - - Returns - ------- - - """ - oldProg = shapePair.prog - oldProg.pairs.remove(shapePair) - self.pairs.append(shapePair) - shapePair.prog = self - - @stackable - def setShapesValues(self, values): - """Set all the Shape's values - - Parameters - ---------- - values : [float - The values to set - - Returns - ------- - - """ - from .slider import Slider - - for pp, val in zip(self.pairs, values): - pp.value = val - for model in self.models: - model.itemDataChanged(pp) - - if isinstance(self.controller, Slider): - self.controller.updateRange() - for model in self.models: - model.itemDataChanged(self.controller) - - def siblingRename(self, shape, newName, currentLinks): - """ - - Parameters - ---------- - shape : - - newName : - - currentLinks : - - - Returns - ------- - - """ - # This is part of the in-progress linked naming system - # get name change - pass - - @stackable - def addFalloff(self, falloff): - """Add a falloff to a slider's falloff list - - Parameters - ---------- - falloff : Falloff - The falloff to add - - Returns - ------- - - """ - if falloff not in self.falloffs: - self.falloffs.append(falloff) - falloff.children.append(self) - self.DCC.addProgFalloff(self, falloff) - - @stackable - def removeFalloff(self, falloff): - """Remove a falloff from a slider's falloff list - - Parameters - ---------- - falloff : Falloff - The falloff to remove - - Returns - ------- - - """ - if falloff in self.falloffs: - self.falloffs.remove(falloff) - falloff.children.remove(self) - self.DCC.removeProgFalloff(self, falloff) - - @stackable - def createShape(self, shapeName=None, tVal=None): - """Create a shape and add it to a progression - - Parameters - ---------- - shapeName : str or None - The name to give the shape - If None, give it a default value - tVal : float or None - The value to give the new ProgPair - if None, give it a "smart" default - - Returns - ------- - : ProgPair - The newly created ProgPair - - """ - from .slider import Slider - - pp, idx = self.newProgPair(shapeName, tVal) - mgrs = [model.insertItemManager(self, idx) for model in self.models] - with nested(*mgrs): - pp.prog = self - self.pairs.insert(idx, pp) - - if isinstance(self.controller, Slider): - self.controller.updateRange() - - return pp - - def newProgPair(self, shapeName=None, tVal=None): - """Create a shape and DO NOT add it to a progression - - Parameters - ---------- - shapeName : str or None - The name to give the shape - If None, give it a default value - tVal : float or None - The value to give the new ProgPair - if None, give it a "smart" default - - Returns - ------- - : ProgPair - The newly created ProgPair - : int - The insertion index for this ProgPair into this Progression - - """ - from .shape import Shape - - if tVal is None: - tVal = self.guessNextTVal() - - if shapeName is None: - if abs(tVal) == 1.0: - shapeName = self.controller.name - else: - neg = "n" if tVal < 0.0 else "" - shapeName = "{0}_{1}{2}".format( - self.controller.name, neg, int(abs(tVal) * 100) - ) - - currentNames = [i.name for i in self.simplex.shapes] - shapeName = getNextName(shapeName, currentNames) - - idx = self.getInsertIndex(tVal) - shape = Shape(shapeName, self.simplex) - pp = ProgPair(self.simplex, shape, tVal) - return pp, idx - - def guessNextTVal(self): - """Given the current progression values, make an educated guess what's next. - - Parameters - ---------- - - Returns - ------- - : float - The "smart" guess for the next tVal - - """ - # The question remains if negative or - # intermediate values are more important - # I think intermediate - vals = [i.value for i in self.pairs] - mnv = min(vals) - mxv = max(vals) - if mnv == 0.0 and mxv == 1.0: - for c in [0.5, 0.25, 0.75, -1.0]: - if c not in vals: - return c - if mnv == -1.0 and mxv == 1.0: - for c in [0.5, -0.5, 0.25, -0.25, 0.75, -0.75]: - if c not in vals: - return c - return 1.0 - - @stackable - def deleteShape(self, shape): - """Delete a shape from the system and the DCC - - Parameters - ---------- - shape : Shape - The shape to delete - - Returns - ------- - - """ - ridx = None - for i, pp in enumerate(self.pairs): - if pp.shape == shape: - ridx = i - if ridx is None: - raise RuntimeError("Shape does not exist to remove") - - pp = self.pairs[ridx] - mgrs = [model.removeItemManager(pp) for model in self.models] - with nested(*mgrs): - self.pairs.pop(ridx) - if not shape.isRest: - self.simplex.shapes.remove(shape) - self.DCC.deleteShape(shape) - - @stackable - def delete(self): - """Delete the Progression and all its Shapes""" - mgrs = [model.removeItemManager(self) for model in self.models] - with nested(*mgrs): - for pp in self.pairs[:]: - if pp.shape.isRest: - continue - self.simplex.shapes.remove(pp.shape) - self.DCC.deleteShape(pp.shape) - - def getRange(self): - """Get the range for this Progression - - Parameters - ---------- - - Returns - ------- - : float - The minimum value - : float - The maximum value - - """ - vals = [i.value for i in self.pairs] - return min(vals), max(vals) - - def getExtremePairs(self): - """Get the ProgPairs where the value is -1 or 1 - - Parameters - ---------- - - Returns - ------- - : [ProgPair, ...] - ProgPairs whose values are -1 or 1 - - """ - ret = [] - for pp in self.pairs: - if abs(pp.value) != 1.0: - continue - ret.append(pp) - return ret +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..utils import getNextName +from .accessor import SimplexTreeAccessor +from .dragItem import Draggable +from .stack import stackable +from .treeItem import TreeItem + +if TYPE_CHECKING: + from .combo import Combo + from .falloff import Falloff + from .shape import Shape + from .simplex import Simplex + from .slider import Slider + from .traversal import Traversal + + +class ProgPair(SimplexTreeAccessor, Draggable): + classDepth: int = 9 + + def __init__(self, simplex: Simplex, shape: Shape, value: float) -> None: + super().__init__(simplex) + self.shape: Shape = shape + self._value: float = value + self.prog: Progression | None = None + self.minValue: float = -1.0 + self.maxValue: float = 1.0 + if not shape.isRest and self not in self.shape.progPairs: + self.shape.progPairs.append(self) + + @property + def name(self) -> str: + return self.shape.name + + @name.setter + def name(self, value: str) -> None: + self.shape.name = value + + def buildDefinition( + self, simpDict: dict[str, Any], legacy: bool + ) -> tuple[int, float]: + idx = self.shape.buildDefinition(simpDict, legacy) + return idx, self.value + + def __lt__(self, other: Any) -> bool: + return self.value < other.value + + @property + def value(self) -> float: + return self._value + + @value.setter + @stackable + def value(self, val: float) -> None: + from .slider import Slider + + self._value = val + if self.prog is None: + raise ValueError( + "Can't set the value of a progPair that doesn't have a controller" + ) + + if isinstance(self.prog.controller, Slider): + self.prog.controller.setRange() + + @stackable + def delete(self) -> None: + if self.prog is None: + return + with self.removeItemManager(self): + ppairs = self.prog.pairs + ridx = ppairs.index(self) + pp = ppairs.pop(ridx) + if not pp.shape.isRest: + pp.shape.progPairs.remove(pp) + if not pp.shape.progPairs: + self.simplex.shapes.remove(pp.shape) + self.DCC.deleteShape(pp.shape) + + def treeRow(self) -> int: + if self.prog is None: + return 0 + return self.prog.pairs.index(self) + + def treeParent(self) -> TreeItem | None: + from .slider import Slider + + if self.prog is None: + return None + + par = self.prog + if isinstance(par.controller, Slider): + par = par.controller + return par + + def treeData(self, column) -> Any | None: + if column == 0: + return self.name + if column == 2: + return self.value + return None + + +class Progression(SimplexTreeAccessor): + """A set of shapes to interpolate between + + A Progression is a collection of shape/value pairs, and an interpolation type. + Progressions don't exist on their own, they are always part of a higher-level object + like a Combo, Slider, or Traversal. The ProgPairs are always sorted by value + + Progressions should always have a shape at 0 (which is almost always the rest shape) + and a shape at either -1 or 1. + They can also have other shapes at any value between 0 and the extremes. + + Sliders give users direct control over the value that is passed to the progression. + Combos and Traversals use input values to control their progressions. + + Progressions can use different interpolations. + The simplest is 'linear', which blends in a straight line between shapes. + The 'spline' interp uses Catmull-Rom spline values. + The 'splitspline' builds separate Catmull-Rom splines for positive and negative values + + Parameters + ---------- + name : str + The name for the Progression. Usually just copies the name of its controller + simplex : Simplex + The Simplex system + pairs : [ProgPair + The ProgPairs that will make up this Progression. + If None, the a default Rest at 0.0 pair will be created. + interp : str + The interpolation for this Progression. Defaults to 'spline' + falloffs : [Falloff + A list of Fallofs to apply to the progression + Defaults to None + """ + + classDepth: int = 8 + interpTypes: tuple[tuple[str, str], ...] = ( + ("Linear", "linear"), + ("Spline", "spline"), + ("Split Spline", "splitspline"), + ) + + def __init__( + self, + name: str, + simplex: Simplex, + pairs: list[ProgPair] | None = None, + interp: str = "spline", + falloffs: list[Falloff] | None = None, + ) -> None: + super().__init__(simplex) + + if self.simplex.restShape is None: + raise RuntimeError("Simplex is not initialized with a rest shape") + + with self.stack.store(self): + self._name: str = name + self._interp: str = interp + self.falloffs: list[Falloff] = falloffs or [] + self.controller: Slider | Combo | Traversal | None = None + + self.pairs: list[ProgPair] + if pairs is None: + self.pairs = [ProgPair(self.simplex, self.simplex.restShape, 0.0)] + else: + self.pairs = pairs + + for pair in self.pairs: + pair.prog = self + + for falloff in self.falloffs: + falloff.children.append(self) + self._buildIdx: int | None = None + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, value: str) -> None: + self._name = value + + @property + def interp(self) -> str: + """Get the interp for this Progression""" + return self._interp + + @interp.setter + @stackable + def interp(self, value: str) -> None: + """Set the interp for this Progression""" + self._interp = value + + def getShapeIndex(self, shape: Shape) -> int: + """Get the index of the given shape in this progression + + Parameters + ---------- + shape : Shape + The shape to get the index of + + Returns + ------- + : int + The index of the ProgPair that contains the given shape + """ + for i, p in enumerate(self.pairs): + if p.shape == shape: + return i + raise ValueError(f"Provided shape:{shape.name} is not in the list") + + def getShapes(self) -> list[Shape]: + """Return the Shapes in this Progression + + Returns + ------- + : type + ([Shape, ....]): The shapes in the Progression + """ + return [i.shape for i in self.pairs] + + def getValues(self) -> list[float]: + """Return the values in this Progression + + Returns + ------- + : type + ([float, ....]): The values in the Progression + """ + return [i.value for i in self.pairs] + + def getInsertIndex(self, tVal: float) -> int: + """Get the index to insert a pair with value tVal + + Parameters + ---------- + tVal : float + The value to get the insertion index for + + Returns + ------- + : int + The insertion index + """ + values = self.getValues() + if not values: + return 0 + elif tVal <= values[0]: + return 0 + elif tVal >= values[-1]: + return len(self.pairs) + else: + for i in range(1, len(values)): + if values[i - 1] <= tVal < values[i]: + return i + return 0 + + def getShapeAtValue(self, val: float, tol: float = 0.0001) -> Shape | None: + """Return the shape at the given value + + Parameters + ---------- + val : + float + tol : + float (Default value = 0.0001) + + Returns + ------- + : type + (Shape or None): The shape found with the given value, or None if nothing was found + """ + for pp in self.pairs: + if abs(pp.value - val) < tol: + return pp.shape + return None + + @classmethod + def loadV2(cls, simplex: Simplex, data: dict[str, Any]) -> Progression: + """Load the data from a version2 formatted json dictionary + + Parameters + ---------- + simplex : Simplex + The Simplex system that's being built + data : dict + The chunk of the json dict used to build this object + + Returns + ------- + : Progression + The specified Progression + """ + name = data["name"] + pairs = data["pairs"] + interp = data.get("interp", "spline") + foIdxs = data.get("falloffs", []) + pairs = [ProgPair(simplex, simplex.shapes[s], v) for s, v in pairs] + fos = [simplex.falloffs[i] for i in foIdxs] + return cls(name, simplex, pairs=pairs, interp=interp, falloffs=fos) + + def buildDefinition(self, simpDict: dict[str, Any], legacy: bool) -> int: + """Output a dictionary definition of this object + + Parameters + ---------- + simpDict : dict + The dictionary that is being built + legacy : bool + Whether to write out the legacy definition, or the newer one + """ + if self._buildIdx is None: + idxPairs = [pair.buildDefinition(simpDict, legacy) for pair in self.pairs] + idxPairs.sort(key=lambda x: x[1]) + idxs, values = list(zip(*idxPairs)) + foIdxs = [f.buildDefinition(simpDict, legacy) for f in self.falloffs] + self._buildIdx = len(simpDict["progressions"]) + if legacy: + x = [self.name, idxs, values, self.interp, foIdxs] + simpDict.setdefault("progressions", []).append(x) + else: + x = { + "name": self.name, + "pairs": idxPairs, + "interp": self.interp, + "falloffs": foIdxs, + } + simpDict.setdefault("progressions", []).append(x) + return self._buildIdx + + def clearBuildIndex(self) -> None: + """Clear the build index of this object + + The buildIndex is stored when building a definition dictionary + that keeps track of its index for later referencin + """ + self._buildIdx = None + for pair in self.pairs: + pair.shape.clearBuildIndex() + for fo in self.falloffs: + fo.clearBuildIndex() + + @stackable + def moveShapeToProgression(self, shapePair: ProgPair) -> None: + """Remove the shapePair from its current progression and set it in a new progression + + Parameters + ---------- + shapePair : progPair + The ProgPair to take + """ + oldProg = shapePair.prog + if oldProg is not None: + oldProg.pairs.remove(shapePair) + self.pairs.append(shapePair) + shapePair.prog = self + + @stackable + def setShapesValues(self, values: list[float]) -> None: + """Set all the Shape's values + + Parameters + ---------- + values : [float + The values to set + """ + from .slider import Slider + + for pp, val in zip(self.pairs, values): + pp.value = val + + if isinstance(self.controller, Slider): + self.controller.updateRange() + + def siblingRename( + self, shape: Shape, newName: str, currentLinks: dict + ) -> dict[type, dict[str, tuple[SimplexTreeAccessor, int]]]: + # This is part of the in-progress linked naming system + # get name change + return {} + + @stackable + def addFalloff(self, falloff: Falloff) -> None: + """Add a falloff to a slider's falloff list + + Parameters + ---------- + falloff : Falloff + The falloff to add + """ + if falloff not in self.falloffs: + self.falloffs.append(falloff) + falloff.children.append(self) + self.DCC.addProgFalloff(self, falloff) + + @stackable + def removeFalloff(self, falloff: Falloff) -> None: + """Remove a falloff from a slider's falloff list + + Parameters + ---------- + falloff : Falloff + The falloff to remove + """ + if falloff in self.falloffs: + self.falloffs.remove(falloff) + falloff.children.remove(self) + self.DCC.removeProgFalloff(self, falloff) + + @stackable + def createShape( + self, shapeName: str | None = None, tVal: float | None = None + ) -> ProgPair: + """Create a shape and add it to a progression + + Parameters + ---------- + shapeName : str or None + The name to give the shape + If None, give it a default value + tVal : float or None + The value to give the new ProgPair + if None, give it a "smart" default + + Returns + ------- + : ProgPair + The newly created ProgPair + """ + from .slider import Slider + + pp, idx = self.newProgPair(shapeName, tVal) + with self.insertItemManager(self, row=idx): + pp.prog = self + self.pairs.insert(idx, pp) + + if isinstance(self.controller, Slider): + self.controller.updateRange() + + return pp + + def newProgPair( + self, shapeName: str | None = None, tVal: float | None = None + ) -> tuple[ProgPair, int]: + """Create a shape and DO NOT add it to a progression + + Parameters + ---------- + shapeName : str or None + The name to give the shape + If None, give it a default value + tVal : float or None + The value to give the new ProgPair + if None, give it a "smart" default + + Returns + ------- + : ProgPair + The newly created ProgPair + : int + The insertion index for this ProgPair into this Progression + """ + from .shape import Shape + + if tVal is None: + tVal = self.guessNextTVal() + + if shapeName is None: + if self.controller is not None: + if abs(tVal) == 1.0: + shapeName = self.controller.name + else: + neg = "n" if tVal < 0.0 else "" + shapeName = f"{self.controller.name}_{neg}{int(abs(tVal) * 100)}" + + currentNames = [i.name for i in self.simplex.shapes] + shapeName = getNextName(shapeName, currentNames) + + idx = self.getInsertIndex(tVal) + shape = Shape(shapeName, self.simplex) + pp = ProgPair(self.simplex, shape, tVal) + return pp, idx + + def guessNextTVal(self) -> float: + """Given the current progression values, make an educated guess what's next. + + Returns + ------- + : float + The "smart" guess for the next tVal + """ + # The question remains if negative or + # intermediate values are more important + # I think intermediate + vals = [i.value for i in self.pairs] + mnv = min(vals) + mxv = max(vals) + if mnv == 0.0 and mxv == 1.0: + for c in [0.5, 0.25, 0.75, -1.0]: + if c not in vals: + return c + if mnv == -1.0 and mxv == 1.0: + for c in [0.5, -0.5, 0.25, -0.25, 0.75, -0.75]: + if c not in vals: + return c + return 1.0 + + @stackable + def deleteShape(self, shape: Shape) -> None: + """Delete a shape from the system and the DCC + + Parameters + ---------- + shape : Shape + The shape to delete + """ + ridx = None + for i, pp in enumerate(self.pairs): + if pp.shape == shape: + ridx = i + if ridx is None: + raise RuntimeError("Shape does not exist to remove") + + pp = self.pairs[ridx] + with self.removeItemManager(pp): + self.pairs.pop(ridx) + if not shape.isRest: + self.simplex.shapes.remove(shape) + self.DCC.deleteShape(shape) + + @stackable + def delete(self) -> None: + """Delete the Progression and all its Shapes""" + with self.removeItemManager(self): + for pp in self.pairs[:]: + if pp.shape.isRest: + continue + self.simplex.shapes.remove(pp.shape) + self.DCC.deleteShape(pp.shape) + + def getRange(self) -> tuple[float, float]: + """Get the range for this Progression + Returns + ------- + : float + The minimum value + : float + The maximum value + """ + vals = [i.value for i in self.pairs] + return min(vals), max(vals) + + def getExtremePairs(self) -> list[ProgPair]: + """Get the ProgPairs where the value is -1 or 1 + + Returns + ------- + : [ProgPair, ...] + ProgPairs whose values are -1 or 1 + """ + ret = [] + for pp in self.pairs: + if abs(pp.value) != 1.0: + continue + ret.append(pp) + return ret + + def treeChild(self, row: int) -> TreeItem: + return self.pairs[row] + + def treeRow(self) -> int: + from .combo import Combo + from .traversal import Traversal + + if isinstance(self.controller, Traversal): + # Show the progression after the mult and prog + return 2 + elif isinstance(self.controller, Combo): + # Show the progression after the comboPairs + return len(self.controller.pairs) + return 0 + + def treeParent(self) -> TreeItem | None: + return self.controller + + def treeChildCount(self) -> int: + return len(self.pairs) + + def treeData(self, column: int) -> Any | None: + if column == 0: + return "SHAPES" + return None diff --git a/src/python/simplexui/items/shape.py b/src/python/simplexui/items/shape.py index 421f4473..d19fcc46 100644 --- a/src/python/simplexui/items/shape.py +++ b/src/python/simplexui/items/shape.py @@ -1,443 +1,384 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - - -from ..interface import DCC, undoContext - -# pylint:disable=missing-docstring,unused-argument,no-self-use -from Qt.QtGui import QColor -from .accessor import SimplexAccessor -from .stack import stackable - - -class Shape(SimplexAccessor): - """A representation of a single blendshape - - For every Shape object in a system, there will be one blendshape. - The Simplex solver takes an ordered list of Slider values, and outputs - an ordered list of shape values. - - Shapes hold references to their DCC objects, and can also hold - the vertex positions in certain cases - - Parameters - ---------- - name : str - The name for the new Shape - simplex : Simplex - The Simplex system - create : bool - Whether to create the DCC Shape, or look for it already in-scene - color : QColor - The color of this item in the Ui - - Returns - ------- - - """ - - classDepth = 10 - - def __init__(self, name, simplex, create=True, color=None): - super(Shape, self).__init__(simplex) - color = QColor(128, 128, 128) if color is None else color - with self.stack.store(self): - self._thing = None - self._verts = None - self._thingRepr = None - self._name = name - self._buildIdx = None - simplex.shapes.append(self) - self.isRest = False - self.expanded = {} - self.color = color - self.progPairs = [] - - newThing = self.DCC.getShapeThing(self._name) - if newThing is None: - if create: - self.thing = self.DCC.createShape(self) - else: - raise RuntimeError( - "Unable to find existing shape: {0}".format(self.name) - ) - else: - self.thing = newThing - - @classmethod - def createShape(cls, name, simplex, slider=None): - """Convenience method for creating a new shape - This will create all required parent objects to have a new shape - - Parameters - ---------- - name : str - The name for the new Shape - simplex : Simplex - The Simplex system - slider : Slider or None - The slider to add this shape to. - If None, A new Slider will be created (Default value = None) - - Returns - ------- - : Shape - The new Shape - - """ - if simplex.restShape is None: - raise RuntimeError("Simplex system is missing rest shape") - - if slider is None: - # Implicitly creates a shape - from .slider import Slider - - slider = Slider.createSlider(name, simplex) - for p in slider.prog.pairs: - if p.shape.name == name: - return p.shape - raise RuntimeError("Problem creating shape with proper name") - else: - if slider.simplex != simplex: - raise RuntimeError("Slider does not belong to the provided Simplex") - tVal = slider.prog.guessNextTVal() - pp = slider.prog.createShape(name, tVal) - return pp.shape - - @classmethod - def buildRest(cls, simplex): - """Create/find the system's rest shape - - Parameters - ---------- - simplex : Simplex - The Simplex system - - Returns - ------- - : Shape - The system's rest Shape - - """ - rest = cls(simplex.getRestName(), simplex, create=True) - rest.isRest = True - return rest - - @property - def name(self): - """Get the Shape's name""" - return self._name - - @name.setter - @stackable - def name(self, value): - """Set the Shape's name - - Parameters - ---------- - value : - - - Returns - ------- - - """ - if value == self._name: - return - self.DCC.renameShape(self, value) - self._name = value - for model in self.models: - model.itemDataChanged(self) - - def strippedName(self): - """Get the name of this shape with any progressive numbers stripped from the end""" - sp = self.name.split("_") - if self.isNumberField(sp[-1]): - sp = sp[:-1] - return "_".join(sp) - - def _buildLinkedRename(self, newName, maxDepth, currentLinks): - """ - - Parameters - ---------- - newName : - - maxDepth : - - currentLinks : - - - Returns - ------- - - """ - # Now that all the bookkeeping has been handled by the main method - # I can handle recursing for the object specific stuff here - shape = None # TEMP - - from .combo import Combo - from .slider import Slider - from .traversal import Traversal - - for pp in self.progPairs: - currentLinks = pp.prog.siblingRename(shape, newName, currentLinks) - - ctrl = pp.prog.controller - if isinstance(ctrl, Slider): - nn = None - currentLinks = ctrl.buildLinkedRename( - nn, maxDepth=maxDepth - 1, currentLinks=currentLinks - ) - elif isinstance(ctrl, Combo): - nn = None - currentLinks = ctrl.buildLinkedRename( - nn, maxDepth=maxDepth - 1, currentLinks=currentLinks - ) - elif isinstance(ctrl, Traversal): - nn = None - currentLinks = ctrl.buildLinkedRename( - nn, maxDepth=maxDepth - 1, currentLinks=currentLinks - ) - - return currentLinks - - # First, check for a slider rename, - # if so, recurse into that slider - # Check for combo renames (because combos use the shape names) - - # if isinstance(item, Shape): - # Check if the parent is a slider - # Check if the slider needs renamed - # Check if the item's siblings need renamed - # Check if there are any combos that depend on this shape name - # If so, rename *both* the combo and its linked children - # Check if the parent is a combo - # Check if the combo needs renamed - # If so, check if the item's siblings need renamed too - # Check if the parent is a traversal - # Check if the traversal needs renamed - # If so, check if the item's siblings need renamed too - # elif isinstance(item, Slider): - # Check if the name change is linked to any of my shapes - # Go through the shape linked rename for one of those instead, maybe? - # There are possible ambiguities if you do a *full* slider rename - # with both positive and negatively named shapes. - # Otherwise - # Check for linked combos, and rename down that branch - # Check for linked traversals, and rename down that branch - # elif isinstance(item, Combo): - # pass - # elif isinstance(item, Traversal): - # pass - - @property - def thing(self): - """Get the stored reference to the DCC object""" - # if this is a deepcopied object, then self._thing will - # be None. Rebuild the thing connection by its representation - if self._thing is None and self._thingRepr: - self._thing = DCC.loadPersistentShape(self._thingRepr) - return self._thing - - @thing.setter - def thing(self, value): - """Set the stored reference to the DCC object - - Parameters - ---------- - value : - - - Returns - ------- - - """ - self._thing = value - self._thingRepr = self.DCC.getPersistentShape(value) - - @classmethod - def loadV2(cls, simplex, data, create): - """Load the data from a version2 formatted json dictionary - - Parameters - ---------- - simplex : Simplex - The Simplex system that's being built - data : dict - The chunk of the json dict used to build this object - create : bool - Whether to create the DCC Shape, or look for it already in-scene - - Returns - ------- - : Shape - The specified Shape - - """ - return cls(data["name"], simplex, create, QColor(*data.get("color", (0, 0, 0)))) - - def buildDefinition(self, simpDict, legacy): - """Output a dictionary definition of this object - - Parameters - ---------- - simpDict : dict - The dictionary that is being built - legacy : bool - Whether to write out the legacy definition, or the newer one - - Returns - ------- - - """ - if self._buildIdx is None: - self._buildIdx = len(simpDict["shapes"]) - if legacy: - simpDict.setdefault("shapes", []).append(self.name) - else: - x = { - "name": self.name, - "color": self.color.getRgb()[:3], - } - simpDict.setdefault("shapes", []).append(x) - return self._buildIdx - - def clearBuildIndex(self): - """Clear the build index of this object - - The buildIndex is stored when building a definition dictionary - that keeps track of its index for later referencing - - Parameters - ---------- - - Returns - ------- - - """ - self._buildIdx = None - - def zeroShape(self): - """Set the shape to be equal to the rest shape""" - self.DCC.zeroShape(self) - - @staticmethod - def zeroShapes(shapes): - """Set the shapes to be equal to the rest shape - - Parameters - ---------- - shapes : [Shape - Shapes to be zeroed - - Returns - ------- - - """ - for shape in shapes: - if not shape.isRest: - shape.zeroShape() - - def connectShape(self, mesh=None, live=False, delete=False): - """Force a shape to match a mesh - The "connect shape" button is: mesh=None, delete=True - The "match shape" button is: mesh=someMesh, delete=False - There is a possibility of a "make live" button: live=True, delete=False - - Parameters - ---------- - mesh : object or None - The DCC Mesh object. If None, it's searched for by name in scene (Default value = None) - live : bool - Whether or not to create a live connection in the DCC. Defaults False - delete : bool - Whether to delete the DCC Mesh after its connection. Defaults False - - Returns - ------- - - """ - self.DCC.connectShape(self, mesh, live, delete) - - @staticmethod - def connectShapes(shapes, meshes, live=False, delete=False): - """Connect multiple meshes to multiple Shapes - - Parameters - ---------- - shapes : [Shape - The shapes to connect to - meshes : [object - The DCC Meshes - live : bool - Whether or not to create a live connection in the DCC. Defaults False - delete : bool - Whether to delete the DCC Mesh after its connection. Defaults False - - Returns - ------- - - """ - with undoContext(): - for shape, mesh in zip(shapes, meshes): - shape.connectShape(mesh, live, delete) - - @staticmethod - def isNumberField(val): - """A utility function to check if a field is numeric - Also, this allows for the "n" prefix for negative numbers because - many DCC's don't allow "-" in an object name - - Parameters - ---------- - val : str - The string to check - - Returns - ------- - : bool - Whether the field is numeric - - """ - if not val: - return False - if val[0].lower() == "n": - val = val[1:] - return val.isdigit() - - @property - def verts(self): - """Get the stored vertices""" - if self._verts is None: - self._verts = self.DCC.getShapeVertices(self) - return self._verts - - @verts.setter - def verts(self, value): - """Set the stored vertices - - Parameters - ---------- - value : - - - Returns - ------- - - """ - self._verts = value +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from numpy import typing as npt + +from ..interface import DCC, undoContext +from .accessor import SimplexAccessor +from .stack import stackable + +if TYPE_CHECKING: + from .progression import ProgPair + from .simplex import DCCObject, Simplex + from .slider import Slider + + +class Shape(SimplexAccessor): + """A representation of a single blendshape + + For every Shape object in a system, there will be one blendshape. + The Simplex solver takes an ordered list of Slider values, and outputs + an ordered list of shape values. + + Shapes hold references to their DCC objects, and can also hold + the vertex positions in certain cases + + Parameters + ---------- + name : str + The name for the new Shape + simplex : Simplex + The Simplex system + create : bool + Whether to create the DCC Shape, or look for it already in-scene + color : QColor + The color of this item in the Ui + """ + + classDepth: int = 10 + + def __init__(self, name: str, simplex: Simplex, create: bool = True) -> None: + super().__init__(simplex) + with self.stack.store(self): + self._thing: DCCObject | None = None + self._verts: npt.NDArray | None = None + self._thingRepr: str | None = None + self._name: str = name + self._buildIdx: int | None = None + simplex.shapes.append(self) + self.isRest: bool = False + self.progPairs: list[ProgPair] = [] + + newThing = self.DCC.getShapeThing(self._name) + if newThing is None: + if create: + self.thing = self.DCC.createShape(self) + else: + raise RuntimeError(f"Unable to find existing shape: {self.name}") + else: + self.thing = newThing + + @classmethod + def createShape( + cls, name: str, simplex: Simplex, slider: Slider | None = None + ) -> Shape: + """Convenience method for creating a new shape + This will create all required parent objects to have a new shape + + Parameters + ---------- + name : str + The name for the new Shape + simplex : Simplex + The Simplex system + slider : Slider or None + The slider to add this shape to. + If None, A new Slider will be created (Default value = None) + + Returns + ------- + : Shape + The new Shape + """ + if simplex.restShape is None: + raise RuntimeError("Simplex system is missing rest shape") + + if slider is None: + # Implicitly creates a shape + from .slider import Slider + + slider = Slider.createSlider(name, simplex) + for p in slider.prog.pairs: + if p.shape.name == name: + return p.shape + raise RuntimeError("Problem creating shape with proper name") + else: + if slider.simplex != simplex: + raise RuntimeError("Slider does not belong to the provided Simplex") + tVal = slider.prog.guessNextTVal() + pp = slider.prog.createShape(name, tVal) + return pp.shape + + @classmethod + def buildRest(cls, simplex: Simplex) -> Shape: + """Create/find the system's rest shape + + Parameters + ---------- + simplex : Simplex + The Simplex system + + Returns + ------- + : Shape + The system's rest Shape + """ + rest = cls(simplex.getRestName(), simplex, create=True) + rest.isRest = True + return rest + + @property + def name(self) -> str: + """Get the Shape's name""" + return self._name + + @name.setter + @stackable + def name(self, value: str) -> None: + """Set the Shape's name""" + if value == self._name: + return + self.DCC.renameShape(self, value) + self._name = value + + def strippedName(self) -> str: + """Get the name of this shape with any progressive numbers stripped from the end""" + sp = self.name.split("_") + if self.isNumberField(sp[-1]): + sp = sp[:-1] + return "_".join(sp) + + def _buildLinkedRename( + self, + newName: str, + maxDepth: int, + currentLinks: dict[type, dict[str, tuple[SimplexAccessor, int]]], + ): + # Now that all the bookkeeping has been handled by the main method + # I can handle recursing for the object specific stuff here + + shape = self # TEMP for typechecking. This is very wrong + + from .combo import Combo + from .slider import Slider + from .traversal import Traversal + + for pp in self.progPairs: + if pp.prog is None: + continue + + currentLinks = pp.prog.siblingRename(shape, newName, currentLinks) + + ctrl = pp.prog.controller + if isinstance(ctrl, Slider): + nn = "" + currentLinks = ctrl.buildLinkedRename( + nn, maxDepth=maxDepth - 1, currentLinks=currentLinks + ) + elif isinstance(ctrl, Combo): + nn = "" + currentLinks = ctrl.buildLinkedRename( + nn, maxDepth=maxDepth - 1, currentLinks=currentLinks + ) + elif isinstance(ctrl, Traversal): + nn = "" + currentLinks = ctrl.buildLinkedRename( + nn, maxDepth=maxDepth - 1, currentLinks=currentLinks + ) + + return currentLinks + """ + # First, check for a slider rename, + # if so, recurse into that slider + # Check for combo renames (because combos use the shape names) + + # if isinstance(item, Shape): + # Check if the parent is a slider + # Check if the slider needs renamed + # Check if the item's siblings need renamed + # Check if there are any combos that depend on this shape name + # If so, rename *both* the combo and its linked children + # Check if the parent is a combo + # Check if the combo needs renamed + # If so, check if the item's siblings need renamed too + # Check if the parent is a traversal + # Check if the traversal needs renamed + # If so, check if the item's siblings need renamed too + # elif isinstance(item, Slider): + # Check if the name change is linked to any of my shapes + # Go through the shape linked rename for one of those instead, maybe? + # There are possible ambiguities if you do a *full* slider rename + # with both positive and negatively named shapes. + # Otherwise + # Check for linked combos, and rename down that branch + # Check for linked traversals, and rename down that branch + # elif isinstance(item, Combo): + # pass + # elif isinstance(item, Traversal): + # pass + """ + + @property + def thing(self) -> DCCObject: + """Get the stored reference to the DCC object""" + # if this is a deepcopied object, then self._thing will + # be None. Rebuild the thing connection by its representation + if self._thing is None and self._thingRepr: + self._thing = DCC.loadPersistentShape(self._thingRepr) + return self._thing + + @thing.setter + def thing(self, value: DCCObject) -> None: + """Set the stored reference to the DCC object""" + self._thing = value + self._thingRepr = self.DCC.getPersistentShape(value) + + @classmethod + def loadV2(cls, simplex: Simplex, data: dict[str, Any], create: bool) -> Shape: + """Load the data from a version2 formatted json dictionary + + Parameters + ---------- + simplex : Simplex + The Simplex system that's being built + data : dict + The chunk of the json dict used to build this object + create : bool + Whether to create the DCC Shape, or look for it already in-scene + + Returns + ------- + : Shape + The specified Shape + """ + return cls(data["name"], simplex, create) + + def buildDefinition(self, simpDict: dict[str, Any], legacy: bool) -> int: + """Output a dictionary definition of this object + + Parameters + ---------- + simpDict : dict + The dictionary that is being built + legacy : bool + Whether to write out the legacy definition, or the newer one + """ + if self._buildIdx is None: + self._buildIdx = len(simpDict["shapes"]) + if legacy: + simpDict.setdefault("shapes", []).append(self.name) + else: + x = { + "name": self.name, + } + simpDict.setdefault("shapes", []).append(x) + return self._buildIdx + + def clearBuildIndex(self) -> None: + """Clear the build index of this object + + The buildIndex is stored when building a definition dictionary + that keeps track of its index for later referencing + """ + self._buildIdx = None + + def zeroShape(self) -> None: + """Set the shape to be equal to the rest shape""" + self.DCC.zeroShape(self) + + @staticmethod + def zeroShapes(shapes) -> None: + """Set the shapes to be equal to the rest shape + + Parameters + ---------- + shapes : [Shape + Shapes to be zeroed + """ + for shape in shapes: + if not shape.isRest: + shape.zeroShape() + + def connectShape( + self, mesh: DCCObject | None = None, live: bool = False, delete: bool = False + ) -> None: + """Force a shape to match a mesh + The "connect shape" button is: mesh=None, delete=True + The "match shape" button is: mesh=someMesh, delete=False + There is a possibility of a "make live" button: live=True, delete=False + + Parameters + ---------- + mesh : object or None + The DCC Mesh object. If None, it's searched for by name in scene (Default value = None) + live : bool + Whether or not to create a live connection in the DCC. Defaults False + delete : bool + Whether to delete the DCC Mesh after its connection. Defaults False + """ + self.DCC.connectShape(self, mesh, live, delete) + + @staticmethod + def connectShapes( + shapes: list[Shape], + meshes: list[DCCObject], + live: bool = False, + delete: bool = False, + ) -> None: + """Connect multiple meshes to multiple Shapes + + Parameters + ---------- + shapes : [Shape + The shapes to connect to + meshes : [object + The DCC Meshes + live : bool + Whether or not to create a live connection in the DCC. Defaults False + delete : bool + Whether to delete the DCC Mesh after its connection. Defaults False + """ + with undoContext(): + for shape, mesh in zip(shapes, meshes): + shape.connectShape(mesh, live, delete) + + @staticmethod + def isNumberField(val: str) -> bool: + """A utility function to check if a field is numeric + Also, this allows for the "n" prefix for negative numbers because + many DCC's don't allow "-" in an object name + + Parameters + ---------- + val : str + The string to check + + Returns + ------- + : bool + Whether the field is numeric + """ + if not val: + return False + if val[0].lower() == "n": + val = val[1:] + return val.isdigit() + + @property + def verts(self) -> npt.NDArray | None: + """Get the stored vertices""" + if self._verts is None: + self._verts = self.DCC.getShapeVertices(self) + return self._verts + + @verts.setter + def verts(self, value: npt.NDArray) -> None: + """Set the stored vertices""" + self._verts = value diff --git a/src/python/simplexui/items/simplex.py b/src/python/simplexui/items/simplex.py index 0b3878db..5e51951d 100644 --- a/src/python/simplexui/items/simplex.py +++ b/src/python/simplexui/items/simplex.py @@ -1,1764 +1,1734 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# pylint:disable=missing-docstring,unused-argument,no-self-use -import copy -import itertools -import json - -try: - import numpy as np -except ImportError: - np = None - -from ..commands.alembicCommon import ( - buildAlembicArchiveData, - getPointCount, - getSmpxArchiveData, - readFalloffData, -) -from ..interface import DCC, undoContext -from ..interface.dummyInterface import DCC as DummyDCC -from Qt.QtGui import QColor -from Qt.QtWidgets import QApplication -from ..utils import nested -from .combo import Combo, ComboPair -from .falloff import Falloff -from .group import Group -from .progression import ProgPair, Progression -from .shape import Shape -from .slider import Slider -from .stack import Stack, stackable -from .traversal import Traversal, TravPair - - -class Simplex(object): - """The main Top-level abstract object that controls an entire setup - - Simplex objects contain and manage the entire hierarchy. They have methods - to import from and export to disk. Simplex objects also mange the connections - to the DCC and the UI, setting up connections with the undo stack, the dispatcher, - and all of the ui TreeModels. - - Finally Simplex systems handle splitting, which will be covered more in depth - in the documentation for the split method. - - """ - - classDepth = 0 - - def __init__( - self, name="", models=None, falloffModels=None, forceDummy=False, sliderMul=1.0 - ): - """Constructor - - Parameters - ---------- - name : str, optional - The name of the new system. Defaults to "" - models : [QAbstractItemModel, ....], optional - The ui models that read this system. Defaults to [] - falloffModels : [QAbstractItemModel, ....], optional - The ui models for managing Falloffs. - Defaults to [] - forceDummy : bool, optional - When loading, don't make a connection to the actual DCC. Instead use the - "dummy" DCC. Defaults False - sliderMul : float, optional - A multiplier for the range of sliders. Simplex will only define values - between -1 and 1. This multiplier will let the attribute range in the DCC be larger so - animators can push the extremes - - Returns - ------- - """ - self._name = name # The name of the system - self.sliders = [] # List of contained sliders - self.combos = [] # List of contained combos - self.traversals = [] # list of contained traversals - self.sliderGroups = [] # List of groups containing sliders - self.comboGroups = [] # List of groups containing combos - self.traversalGroups = [] # List of groups containing traversals - self.falloffs = [] # List of contained falloff objects - self.shapes = [] # List of contained shape objects - self.models = models or [] # connected Qt Item Models - self.falloffModels = falloffModels or [] # connected Qt Falloff Models - self.restShape = None # Name of the rest shape - self.clusterName = "Shape" # Name of the cluster (XSI use only) - self.expanded = {} # Am I expanded by model - self.comboExpanded = False # Am I expanded in the combo tree - self.sliderExpanded = False # Am I expanded in the slider tree - self.sliderMul = sliderMul - self.DCC = DummyDCC(self) if forceDummy else DCC(self) # Interface to the DCC - self.stack = Stack() # Reference to the Undo stack - self._extras = {} # Any extra key data to store in the output json - self._legacy = False # whether to write the legacy types - - def __deepcopy__(self, memo): - """Gotta be really picky about what gets deepcopied. - Especially since I pretty much abuse the deepcopy mechanism to do splitting - Deep-copied systems have no reference to a UI, or a DCC - """ - cls = self.__class__ - result = cls.__new__(cls) - memo[id(self)] = result - for k, v in self.__dict__.items(): - if k == "models": - # do not make a copy of the connected models - # a deepcopied simplex won't be connected to a UI - setattr(result, k, []) - elif k == "falloffModels": - # do not make a copy of the connected models - # a deepcopied simplex won't be connected to a UI - setattr(result, k, []) - elif k == "stack": - # Make a disabled stack for new simplex - s = Stack() - s.enabled = False - setattr(result, k, s) - elif k == "DCC": - # do not connect the deepcopied simplex to the DCC - # we will want to change it without affecting the current scene - # Requires the name be copied already - setattr(result, "_name", copy.deepcopy(self._name, memo)) # noqa: B010 - if v.program == "dummy": - # If it's already a dummy, go ahead and just deepcopy - setattr(result, k, copy.deepcopy(v, memo)) - else: - setattr(result, k, DummyDCC(result)) - elif k == "expanded": - # do not make a copy of the expansion - # because it's keyed off the un-copied models - setattr(result, k, {}) - else: - setattr(result, k, copy.deepcopy(v, memo)) - return result - - def _initValues(self): - """Re-initialize the variables to that of an empy system""" - self._name = "" # The name of the system - self.sliders = [] # List of contained sliders - self.combos = [] # List of contained combos - self.sliderGroups = [] # List of groups containing sliders - self.comboGroups = [] # List of groups containing combos - self.traversalGroups = [] # List of groups containing combos - self.falloffs = [] # List of contained falloff objects - self.shapes = [] # List of contained shape objects - self.restShape = None # Name of the rest shape - self.clusterName = "Shape" # Name of the cluster (XSI use only) - self.expanded = {} # Am I expanded? (Keep around for consistent interface) - self.color = QColor(128, 128, 128) - self.comboExpanded = False # Am I expanded in the combo tree - self.sliderExpanded = False # Am I expanded in the slider tree - - # Alternate Constructors - @classmethod - def buildBaseObject(cls, smpxPath, name=None, forceDummy=False): - """Build the rest object from a .smpx file - - Parameters - ---------- - smpxPath : str - The path to the .smpx file - name : str, optional - The Name of the object to create. Defaults to the name of the simplex system - - Returns - ------- - : object - A reference to the DCC mesh - - """ - iarch, abcMesh, jsString = getSmpxArchiveData(smpxPath) - try: - if name is None: - js = json.loads(jsString) - name = js["systemName"] - if forceDummy: - return DummyDCC.buildRestAbc(abcMesh, name) - else: - return DCC.buildRestAbc(abcMesh, name) - finally: - del iarch - - @classmethod - def buildEmptySystem(cls, thing, name, sliderMul=1.0, forceDummy=False): - """Create a new, empty system on a given mesh - - Parameters - ---------- - thing : object - The DCC mesh to build the system on - name : str - The name of the new Simplex system - sliderMul : float, optional - A multiplier for the range of sliders. Simplex will only define values - between -1 and 1. This multiplier will let the attribute range in the DCC be larger so - animators can push the extremes (Default value = 1.0) - forceDummy : bool, optional - When loading, don't make a connection to the actual DCC. Instead use the - "dummy" DCC. Defaults False - - Returns - ------- - : Simplex - A newly created Simplex system - - """ - self = cls(name, forceDummy=forceDummy, sliderMul=sliderMul) - self.DCC.loadNodes(self, thing, create=True) - self.restShape = Shape.buildRest(self) - return self - - @classmethod - def buildSystemFromJsonString( - cls, jsString, thing=None, name=None, forceDummy=False, sliderMul=1.0, pBar=None - ): - """Build a system from a json encoded string - - Parameters - ---------- - jsString : str - The json encoded simplex definition - thing : object - The DCC mesh to build the system on (Default value = None) - name : str - The name of the new Simplex system (Default value = None) - forceDummy : bool - When loading, don't make a connection to the actual DCC. Instead use the - "dummy" DCC. Defaults False - sliderMul : float - A multiplier for the range of sliders. Simplex will only define values - between -1 and 1. This multiplier will let the attribute range in the DCC be larger so - animators can push the extremes (Default value = 1.0) - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - : Simplex - A newly created Simplex system - - """ - js = json.loads(jsString) - if name is None: - name = js["systemName"] - return cls.buildSystemFromDict( - js, thing, name=name, forceDummy=forceDummy, sliderMul=sliderMul, pBar=pBar - ) - - @classmethod - def buildSystemFromJson( - cls, jsPath, thing=None, name=None, forceDummy=False, sliderMul=1.0, pBar=None - ): - """Build a system from .json file - - Parameters - ---------- - jsPath : str - The .json file to load - thing : object - The DCC mesh to build the system on (Default value = None) - name : str - The name of the new Simplex system (Default value = None) - forceDummy : bool - When loading, don't make a connection to the actual DCC. Instead use the - "dummy" DCC. Defaults False - sliderMul : float - A multiplier for the range of sliders. Simplex will only define values - between -1 and 1. This multiplier will let the attribute range in the DCC be larger so - animators can push the extremes (Default value = 1.0) - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - : Simplex - A newly created Simplex system - - """ - with open(jsPath, "r") as f: - jsString = f.read() - return cls.buildSystemFromJsonString( - jsString, - thing, - name=name, - forceDummy=forceDummy, - sliderMul=sliderMul, - pBar=pBar, - ) - - @classmethod - def buildSystemFromSmpx( - cls, smpxPath, thing=None, name=None, forceDummy=False, sliderMul=1.0, pBar=None - ): - """Build a system from a .smpx file - SMPX files are (under-the-hood) alembic caches with each shape delta stored as a frame of animation, - and the json string stored in a property on the mesh. - - Parameters - ---------- - smxpPath : str - The .smpx file to load - thing : object - The DCC mesh to build the system on (Default value = None) - name : str - The name of the new Simplex system (Default value = None) - forceDummy : bool - When loading, don't make a connection to the actual DCC. Instead use the - "dummy" DCC. Defaults False - sliderMul : float - A multiplier for the range of sliders. Simplex will only define values - between -1 and 1. This multiplier will let the attribute range in the DCC be larger so - animators can push the extremes (Default value = 1.0) - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - : Simplex - A newly created Simplex system - - """ - if thing is None: - thing = cls.buildBaseObject(smpxPath, forceDummy=forceDummy) - iarch, abcMesh, jsString = getSmpxArchiveData(smpxPath) - js = json.loads(jsString) - - if not forceDummy: - smpxCount = getPointCount(abcMesh) - dccCount = DCC.vertCount(thing) - if smpxCount != dccCount: - raise RuntimeError( - "Point Count Mismatch. Smpx File:{0} DCC:{1}".format( - smpxCount, dccCount - ) - ) - - del iarch, abcMesh # release the files - if name is None: - name = js["systemName"] - self = cls.buildSystemFromDict( - js, thing, name=name, forceDummy=forceDummy, sliderMul=sliderMul, pBar=pBar - ) - self.loadSmpxShapes(smpxPath, pBar=pBar) - self.loadSmpxFalloffs(smpxPath, pBar=pBar) - return self - - @classmethod - def buildSystemFromFile( - cls, path, thing=None, name=None, forceDummy=False, sliderMul=1.0, pBar=None - ): - """Build a system from a file - - Parameters - ---------- - path : str - The file to load. Either .json or .smpx - thing : object - The DCC mesh to build the system on (Default value = None) - name : str - The name of the new Simplex system (Default value = None) - forceDummy : bool - When loading, don't make a connection to the actual DCC. Instead use the - "dummy" DCC. Defaults False - sliderMul : float - A multiplier for the range of sliders. Simplex will only define values - between -1 and 1. This multiplier will let the attribute range in the DCC be larger so - animators can push the extremes (Default value = 1.0) - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - : Simplex - A newly created Simplex system - - """ - if path.endswith(".json"): - return cls.buildSystemFromJson( - path, - thing=thing, - name=name, - forceDummy=forceDummy, - sliderMul=sliderMul, - pBar=pBar, - ) - elif path.endswith(".smpx"): - return cls.buildSystemFromSmpx( - path, - thing=thing, - name=name, - forceDummy=forceDummy, - sliderMul=sliderMul, - pBar=pBar, - ) - else: - raise ValueError( - "The filepath provided is not a .json or .smpx: {0}".format(path) - ) - - @classmethod - def buildSystemFromMesh( - cls, thing, name, forceDummy=False, sliderMul=1.0, pBar=None - ): - """Build a system from the data already built in the DCC - - Parameters - ---------- - thing : object - The DCC mesh to load the system from - name : str - The name of the new Simplex system - forceDummy : bool - When loading, don't make a connection to the actual DCC. Instead use the - "dummy" DCC. Defaults False - sliderMul : float - A multiplier for the range of sliders. Simplex will only define values - between -1 and 1. This multiplier will let the attribute range in the DCC be larger so - animators can push the extremes (Default value = 1.0) - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - : Simplex - A newly created Simplex system - - """ - jsDict = json.loads(DCC.getSimplexStringOnThing(thing, name)) - return cls.buildSystemFromDict( - jsDict, - thing, - name=name, - create=False, - forceDummy=forceDummy, - sliderMul=sliderMul, - pBar=pBar, - ) - - @classmethod - def buildSystemFromDict( - cls, - jsDict, - thing, - name=None, - create=True, - forceDummy=False, - sliderMul=1.0, - pBar=None, - ): - """Utility for building a cleared system from a dictionary - - Parameters - ---------- - jsDict : dict - The definition dictonary (parsed from a json string) - thing : object - The DCC mesh to load the system on - name : str, optional - The name of the new Simplex system. If None, default to the system name - create : bool, optional - Create any missing blendshapes as the system is loaded. If False, error on missing. - Defaults to True - forceDummy : bool, optional - When loading, don't make a connection to the actual DCC. Instead use the - "dummy" DCC. Defaults to False - sliderMul : float, optional - A multiplier for the range of sliders. Simplex will only define values - between -1 and 1. This multiplier will let the attribute range in the DCC be larger so - animators can push the extremes (Default value = 1.0) - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - : Simplex - A newly created Simplex system - - """ - if name is None: - name = jsDict["systemName"] - self = cls(name, forceDummy=forceDummy, sliderMul=sliderMul) - self.DCC.loadNodes(self, thing, create=create) - self.loadDefinition(jsDict, create=create, pBar=pBar) - return self - - def loadSmpxShapes(self, smpxPath, pBar=None): - """Load the Shapes from a .smpx file onto an already loaded system - This is the "We got updated shapes from the modelers" method - - Parameters - ---------- - smpxPath : str - The path to the .smpx file - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - - """ - iarch, abcMesh, jsString = getSmpxArchiveData(smpxPath) - js = json.loads(jsString) - - try: - self.DCC.loadAbc(abcMesh, js, pBar=pBar) - finally: - del abcMesh, iarch - - def loadSmpxPoses(self, smpxPath, pBar=None): - """Load the Poses from a .smpx file onto an already loaded system - This is the "Update the joints and skin" method - - Parameters - ---------- - smpxPath : str - The path to the .smpx file - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - - """ - iarch, abcMesh, jsString = getSmpxArchiveData(smpxPath) - js = json.loads(jsString) - - try: - self.DCC.loadAbcPoses(abcMesh, js, pBar=pBar) - finally: - del abcMesh, iarch - - def loadSmpxFalloffs(self, abcPath, pBar=None): - """Load the relevant data from a simplex alembic - - Parameters - ---------- - abcPath : str - Path to the .smpx file - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - - """ - foDict = readFalloffData(abcPath) - - for fo in self.falloffs: - foData = foDict.get(fo.name, None) - if foData is not None: - fo.weights = foData - - # Properties - @property - def name(self): - """Get the system name""" - return self._name - - @name.setter - @stackable - def name(self, value): - """Set the system name""" - if value == self._name: - return - - self._name = value - self.DCC.renameSystem(value) # ??? probably needs work - if self.restShape is not None: - self.restShape.name = self.getRestName() - - for model in self.models: - model.itemDataChanged(self) - - @property - def progs(self): - """Get all Progressions defined in the system""" - out = [] - for slider in self.sliders: - out.append(slider.prog) - for combo in self.combos: - out.append(combo.prog) - for trav in self.traversals: - out.append(trav.prog) - return out - - @property - def groups(self): - """Get all Groups defined in the system""" - return self.sliderGroups + self.comboGroups + self.traversalGroups - - def treeChild(self, row): - """ """ - return self.groups[row] - - def treeRow(self): - """ """ - return 0 - - def treeParent(self): - """ """ - return None - - def treeChildCount(self): - """ """ - return len(self.groups) - - def treeData(self, column): - """ """ - if column == 0: - return self.name - return None - - def treeChecked(self): - """ """ - return None - - def icon(self): - return None - - # HELPER - def comboExists(self, sliders, values): - """Check if a combo exists with these specific sliders and values - Because combo names aren't necessarily always in the same order - - Parameters - ---------- - sliders : [Slider, ....] - The sliders to check - values : [float, ....] - The values to check - - Returns - ------- - : Combo or None - The Combo with those sliders and values, or None if none found - - """ - checkSet = {(s.name, v) for s, v in zip(sliders, values)} - for cmb in self.combos: - cmbSet = {(p.slider.name, p.value) for p in cmb.pairs} - if checkSet == cmbSet: - return cmb - return None - - # DESTRUCTOR - def deleteSystem(self): - """Delete an existing system from the DCC""" - # Store the models as temp so the model doesn't go crazy with the signals - models, self.models = self.models, None - mgrs = [model.resetModelManager() for model in models] - with nested(*mgrs): - self.DCC.deleteSystem() - self._initValues() - self.DCC = DCC(self) - self.models = models - - def getComboUpstreams(self, combo): - """Get a list of only combos that are upstream to the given combo - In this case, "upstream" means that when the given combo is active, - then any returned combos are also active. - - I am currently ignoring floating combos in this function - The upstream sliders are available trivially through combo.pairs - - Parameters - ---------- - combo : Combo - The given combo - - Returns - ------- - : [Combo, ....] - The list of upstream combos - - """ - pairDict = {p.slider: p.value for p in combo.pairs} - - upstreams = [] - for c in self.combos: - if c.isFloating(): - continue - - if len(c.pairs) >= len(pairDict): - continue - - if not all(p.slider in pairDict for p in c.pairs): - continue - - fail = False - for p in c.pairs: - if p.slider not in pairDict: - fail = True - break - if pairDict[p.slider] * p.value < 0.0: - # opposite Signs - fail = True - break - - if not fail: - upstreams.append(c) - - return upstreams - - def getDownstreamTraversals(self, slider): - """Get a list of any traversals that depend on the given slider - - Parameters - ---------- - slider : Slider - The system slider to check - - Returns - ------- - : [Traversal, ....] - The list of dependent Traversals - - """ - downstream = [] - for t in self.traversals: - for pair in t.startPoint.pairs + t.endPoint.pairs: - if slider == pair.slider: - downstream.append(t) - break - downstream = list(set(downstream)) - return downstream - - def getDownstreamCombos(self, slider): - """Get a list of any Combos that depend on the given slider - - Parameters - ---------- - slider : Slider - The Slider item to check - - Returns - ------- - : [Combo, ....] - The list of dependent Combos - - """ - downstream = [] - if not isinstance(slider, Slider): - return downstream - for c in self.combos: - for pair in c.pairs: - if pair.slider == slider: - downstream.append(c) - break - downstream = list(set(downstream)) - return downstream - - def deleteDownstream(self, item): - """Delete all items from the system that depend on the given item - - Parameters - ---------- - item : object - The system item to check - - Returns - ------- - - """ - todel = [] - todel.extend(self.getDownstreamCombos(item)) - todel.extend(self.getDownstreamTraversals(item)) - for c in todel: - c.delete() - - # USER METHODS - def setLegacy(self, legacy): - """Set whether to use the legacy .json format - - Parameters - ---------- - legacy : bool - Whether to use the legacy .json format - - Returns - ------- - - """ - self._legacy = legacy - - def getFloatingShapes(self): - """Find Combos with values other than -1 and 1 - - Parameters - ---------- - - Returns - ------- - : [Combo, ....] - Combos that don't have fully extreme activations - - """ - floaters = [c for c in self.combos if c.isFloating()] - floatShapes = [] - for f in floaters: - floatShapes.extend(f.prog.getShapes()) - return floatShapes - - def buildDefinition(self): - """Create a simplex definition dictionary - Loop through all the objects managed by this simplex system, and build a dictionary that defines it - - Parameters - ---------- - - Returns - ------- - : dict - The simplex definition dictionary - - """ - things = [ - self.shapes, - self.sliders, - self.combos, - self.traversals, - self.groups, - self.falloffs, - ] - for thing in things: - for i in thing: - i.clearBuildIndex() - - # Make sure we start with the extras in - # case we're overwriting with new data - d = copy.deepcopy(self._extras) - - # Then set all the top-level system keys - d["encodingVersion"] = 1 if self._legacy else 3 - d["systemName"] = self.name - d["clusterName"] = self.clusterName - d.setdefault("falloffs", []) - d.setdefault("combos", []) - d.setdefault("shapes", []) - d.setdefault("sliders", []) - d.setdefault("groups", []) - d.setdefault("progressions", []) - d.setdefault("traversals", []) - - # rest shape should *ALWAYS* be index 0 - for shape in self.shapes: - shape.buildDefinition(d, self._legacy) - - for group in self.groups: - group.buildDefinition(d, self._legacy) - - for falloff in self.falloffs: - falloff.buildDefinition(d, self._legacy) - - for slider in self.sliders: - slider.buildDefinition(d, self._legacy) - - for combo in self.combos: - combo.buildDefinition(d, self._legacy) - - for trav in self.traversals: - trav.buildDefinition(d, self._legacy) - - return d - - @stackable - def loadDefinition(self, simpDict, create=True, pBar=None): - """Build the structure of objects in this system - based on a provided dictionary - - Parameters - ---------- - simpDict : dict - The dictionary to load - create : bool, optional - Create any missing blendshapes as the system is loaded. If False, error on missing. - Defaults to True - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - - """ - - self.name = simpDict["systemName"] - self.clusterName = simpDict["clusterName"] # for XSI - if simpDict["encodingVersion"] == 1: - self.loadV1(simpDict, create=create, pBar=pBar) - elif simpDict["encodingVersion"] == 2: - self.loadV2(simpDict, create=create, pBar=pBar) - elif simpDict["encodingVersion"] == 3: - self.loadV3(simpDict, create=create, pBar=pBar) - self.storeExtras(simpDict) - - def _incPBar(self, pBar, txt, inc=1): - """Increment the progress bar and return False if the user cancelled - - Parameters - ---------- - pBar : - - txt : - - inc : - (Default value = 1) - - Returns - ------- - - """ - if pBar is not None: - pBar.setValue(pBar.value() + inc) - pBar.setLabelText("Building:\n" + txt) - QApplication.processEvents() - return not pBar.wasCanceled() - return True - - def loadV3(self, simpDict, create=True, pBar=None): - """Load the version 3 simplex definition - V3 is just the same as V2, except for an update Traversal definition - - Parameters - ---------- - simpDict : dict - The simplex definition dictionary - create : bool, optional - Create any missing blendshapes as the system is loaded. If False, error on missing. - Defaults to True - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - - """ - preRet = self.DCC.preLoad(self, simpDict, create=create, pBar=pBar) - try: - fos = simpDict.get("falloffs", []) - gs = simpDict.get("groups", []) - for f in fos: - Falloff.loadV2(self, f) - if gs: - for g in gs: - Group.loadV2(self, g) - else: - Group("Group_0", self, Slider) - Group("Group_1", self, Combo) - Group("Group_2", self, Traversal) - - if pBar is not None: - maxLen = max(len(i["name"]) for i in simpDict["shapes"]) - pBar.setLabelText("_" * maxLen) - pBar.setValue(0) - pBar.setMaximum(len(simpDict["shapes"]) + 1) - self.shapes = [] - for s in simpDict["shapes"]: - if not self._incPBar(pBar, s["name"]): - return - Shape.loadV2(self, s, create) - - self.restShape = self.shapes[0] - self.restShape.isRest = True - - progs = [Progression.loadV2(self, p) for p in simpDict["progressions"]] - - for s in simpDict["sliders"]: - Slider.loadV2(self, progs, s, create) - for c in simpDict["combos"]: - Combo.loadV2(self, progs, c) - for t in simpDict["traversals"]: - Traversal.loadV3(self, progs, t) - - for x in itertools.chain(self.sliders, self.combos, self.traversals): - x.prog.name = x.name - finally: - self.DCC.postLoad(self, preRet) - - def loadV2(self, simpDict, create=True, pBar=None): - """Load the version 2 simplex definition - - Parameters - ---------- - simpDict : dict - The simplex definition dictionary - create : bool, optional - Create any missing blendshapes as the system is loaded. If False, error on missing. - Defaults to True - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - - """ - preRet = self.DCC.preLoad(self, simpDict, create=create, pBar=pBar) - try: - fos = simpDict.get("falloffs", []) - gs = simpDict.get("groups", []) - for f in fos: - Falloff.loadV2(self, f) - if gs: - for g in gs: - Group.loadV2(self, g) - else: - Group("Group_0", self, Slider) - Group("Group_1", self, Combo) - Group("Group_2", self, Traversal) - - if pBar is not None: - maxLen = max(len(i["name"]) for i in simpDict["shapes"]) - pBar.setLabelText("_" * maxLen) - pBar.setValue(0) - pBar.setMaximum(len(simpDict["shapes"]) + 1) - self.shapes = [] - for s in simpDict["shapes"]: - if not self._incPBar(pBar, s["name"]): - return - Shape.loadV2(self, s, create) - - self.restShape = self.shapes[0] - self.restShape.isRest = True - - progs = [Progression.loadV2(self, p) for p in simpDict["progressions"]] - - for s in simpDict["sliders"]: - Slider.loadV2(self, progs, s, create) - for c in simpDict["combos"]: - Combo.loadV2(self, progs, c) - for t in simpDict["traversals"]: - Traversal.loadV2(self, progs, t) - - for x in itertools.chain(self.sliders, self.combos, self.traversals): - x.prog.name = x.name - finally: - self.DCC.postLoad(self, preRet) - - def loadV1(self, simpDict, create=True, pBar=None): - """Load the version 1 simplex definition - - Parameters - ---------- - simpDict : dict - The simplex definition dictionary - create : bool, optional - Create any missing blendshapes as the system is loaded. If False, error on missing. - Defaults to True - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - - """ - preRet = self.DCC.preLoad(self, simpDict, create=create, pBar=pBar) - try: - self.falloffs = [Falloff(f[0], self, *f[1:]) for f in simpDict["falloffs"]] - groupNames = simpDict["groups"] - - if pBar is not None: - maxLen = max(list(map(len, simpDict["shapes"]))) - pBar.setLabelText("_" * maxLen) - pBar.setValue(0) - pBar.setMaximum(len(simpDict["shapes"]) + 1) - - shapes = [] - for s in simpDict["shapes"]: - if not self._incPBar(pBar, s): - return - shapes.append(Shape(s, self)) - - self.restShape = shapes[0] - self.restShape.isRest = True - - progs = [] - for p in simpDict["progressions"]: - progShapes = [shapes[i] for i in p[1]] - progFalloffs = [self.falloffs[i] for i in p[4]] - progPairs = [ProgPair(self, s, pv) for s, pv in zip(progShapes, p[2])] - progs.append(Progression(p[0], self, progPairs, p[3], progFalloffs)) - - self.sliders = [] - self.sliderGroups = [] - createdSlidergroups = {} - for s in simpDict["sliders"]: - sliderProg = progs[s[1]] - - gn = groupNames[s[2]] - if gn in createdSlidergroups: - sliderGroup = createdSlidergroups[gn] - else: - sliderGroup = Group(gn, self, Slider) - createdSlidergroups[gn] = sliderGroup - - Slider(s[0], self, sliderProg, sliderGroup) - - self.combos = [] - self.comboGroups = [] - createdComboGroups = {} - for c in simpDict["combos"]: - prog = progs[c[1]] - sliderIdxs, sliderVals = list(zip(*c[2])) - sliders = [self.sliders[i] for i in sliderIdxs] - pairs = list(map(ComboPair, sliders, sliderVals)) - if len(c) >= 4: - gn = groupNames[c[3]] - else: - gn = "DEPTH_0" - - if gn in createdComboGroups: - comboGroup = createdComboGroups[gn] - else: - comboGroup = Group(gn, self, Combo) - createdComboGroups[gn] = comboGroup - - cmb = Combo(c[0], self, pairs, prog, comboGroup, None) - cmb.simplex = self - - self.traversals = [] - self.traversalGroups = [] - createdTraversalGroups = {} - if "traversals" in simpDict: - for t in simpDict["traversals"]: - name = t["name"] - prog = progs[t["prog"]] - - pcIdx = t["progressControl"] - pcSearch = ( - self.sliders - if t["progressType"].lower() == "slider" - else self.combos - ) - pc = pcSearch[pcIdx] - pFlip = t["progressFlip"] - pp = TravPair(pc, -1 if pFlip else 1, "progress") - - mcIdx = t["multiplierControl"] - mcSearch = ( - self.sliders - if t["multiplierType"].lower() == "slider" - else self.combos - ) - mc = mcSearch[mcIdx] - mFlip = t["multiplierFlip"] - mm = TravPair(mc, -1 if mFlip else 1, "multiplier") - - gn = groupNames[t.get("group", 2)] - if gn in createdTraversalGroups: - travGroup = createdTraversalGroups[gn] - else: - travGroup = Group(gn, self, Traversal) - createdTraversalGroups[gn] = travGroup - - color = QColor(*t.get("color", (0, 0, 0))) - - trav = Traversal(name, self, mm, pp, prog, travGroup, color) - trav.simplex = self - - for x in itertools.chain(self.sliders, self.combos, self.traversals): - x.prog.name = x.name - finally: - self.DCC.postLoad(self, preRet) - - def storeExtras(self, simpDict): - """Store any unknown keys when dumping, just in case they're important elsewhere - - Parameters - ---------- - simpDict : dict - The simplex definition dictionary - - Returns - ------- - - """ - sd = copy.deepcopy(simpDict) - knownTopLevel = [ - "encodingVersion", - "systemName", - "clusterName", - "falloffs", - "combos", - "shapes", - "sliders", - "groups", - "traversals", - "progressions", - ] - - for ktn in knownTopLevel: - if ktn in sd: - del sd[ktn] - self._extras = sd - - def loadJSON(self, jsString): - """Convenience method to load a JSON string definition - - Parameters - ---------- - jsString : str - The json formatted definition string - - Returns - ------- - - """ - self.loadDefinition(json.loads(jsString)) - - def getRestName(self): - """Get the default rest shape name - - Parameters - ---------- - - Returns - ------- - : str - The default rest shape name - - """ - return "Rest_{0}".format(self.name) - - def dump(self): - """Dump the definition dictionary to a json string - - Parameters - ---------- - - Returns - ------- - : str - The json formatted definition string - - """ - return json.dumps(self.buildDefinition()) - - def exportAbc(self, path, pBar=None): - """Export the current mesh to a .smpx formatted file - - Parameters - ---------- - path : str - The path to export to - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - - """ - defDict = self.buildDefinition() - jsString = json.dumps(defDict) - - arch, abcMesh = buildAlembicArchiveData(path, self.name, jsString, True) - try: - self.DCC.exportAbc( - self.DCC.mesh, - abcMesh, - defDict, - world=False, - ensureCorrect=True, - pBar=pBar, - ) - finally: - del arch, abcMesh - - def exportOther(self, path, dccMesh, world=False, ensureCorrect=False, pBar=None): - """Export shapes from a mesh that isn't part of the current system - - The export process for shapes differs from DCC to DCC. - In Maya, every blendshape is activated, one by one and the point posisitions - are read from the target mesh. This allows exportOther to work - - In XSI, the blendshape properties are read directly, so there's no chance - to process the new shapes (which means this won't work in XSI) - - Parameters - ---------- - path : str - The output path for the .smpx file - dccMesh : object - The system-external DCC mesh to export - world : bool, optional - Whether to do the export in worldspace. Defaults to False - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - """ - defDict = self.buildDefinition() - jsString = json.dumps(defDict) - - arch, abcMesh = buildAlembicArchiveData(path, self.name, jsString, True) - try: - self.DCC.exportOtherAbc(dccMesh, abcMesh, defDict, world=world, pBar=pBar) - finally: - del arch, abcMesh - - def setSlidersWeights(self, sliders, weights): - """Set the weights of multiple sliders as one method - - Parameters - ---------- - sliders : [Slider, ....] - The list of Sliders to set weights for - weights : [float, ....] - The weights to set - - Returns - ------- - - """ - with undoContext(self.DCC): - for slider, weight in zip(sliders, weights): - slider.value = weight - self.DCC.setSlidersWeights(sliders, weights) - for model in self.models: - for slider in sliders: - model.itemDataChanged(slider) - - def extractRestShape(self, offset=0): - """Extract the rest shape to a mesh in the DCC - - Parameters - ---------- - offset : float - The offset value given to the extracted mesh (Default value = 0) - - Returns - ------- - - """ - if self.restShape is None: - return None - return self.DCC.extractShape(self.restShape, live=False, offset=offset) - - def buildRestShape(self): - """Build and store a rest shape for this system""" - self.restShape = Shape.buildRest(self) - return self.restShape - - def buildInputVectors( - self, - keepSliders=None, - ignoreSliders=None, - depthCutoff=None, - ignoreFloaters=False, - ignoreTraversals=False, - extremes=False, - ): - """This is kind of a specialized function. Often, I have to sum combo deltas - into the full sculpted shape. But to do that, I have to build the inputs to the - solver that enable each shape, and I need a name for each shape. - This function gives me both. - - Parameters - ---------- - keepSliders : set([str, ....]), optional - The returned sliders will have names that are part of this list - ignoreSliders : set([str, ....]), optional - A set of Slider names to ignore as part of this process - depthCutoff : int, optional - The maximum number of Sliders allowed per Combo - (Defaults to None which allows all Combos) - ignoreFloaters : bool, optional - Ignore Combos with values other than -1 and 1 - extremes : bool, optional - Only build activations for sliders and combos at -1 and 1 - - Returns - ------- - : [str, ...] - The names of the shapes that are activated in each list - : [[float, ...], ...] - A list of activation values for the entire system - - """ - # InputVector comes from the c++ std::vector - # Get all endpoint shapes from the progressions - shapeNames = [] - inVecs = [] - keyIdxs = [] - ignoreSliders = ignoreSliders or set() - ignoreSliders = set(ignoreSliders) - indexByShape = {shape: idx for idx, shape in enumerate(self.shapes)} - - for slIdx, slider in enumerate(self.sliders): - if slider.name in ignoreSliders: - continue - if keepSliders is not None and slider.name not in keepSliders: - continue - - pairs = [p for p in slider.prog.pairs if not p.shape.isRest] - if extremes: - pairs = [p for p in pairs if abs(p.value) == 1.0] - - for pp in pairs: - inVec = [0.0] * len(self.sliders) - inVec[slIdx] = pp.value - shapeNames.append(pp.shape.name) - inVecs.append(inVec) - keyIdxs.append(indexByShape[pp.shape]) - - for combo in self.combos: - if ignoreFloaters and combo.isFloating(): - continue - if depthCutoff is not None and len(combo.pairs) > depthCutoff: - continue - if ignoreSliders & {i.name for i in combo.getSliders()}: - # if the combo's sliders are in ignoreSliders - continue - - pairs = [p for p in combo.prog.pairs if not p.shape.isRest] - if extremes: - pairs = [p for p in pairs if abs(p.value) == 1.0] - - iv = combo.getInputVector() - for pp in pairs: - inVecs.append([x * pp.value for x in iv]) - shapeNames.append(pp.shape.name) - keyIdxs.append(indexByShape[pp.shape]) - - for trav in self.traversals: - # Extremes doesn't make sense at all for traversals - if ignoreTraversals: - continue - pairs = [p for p in trav.prog.pairs if not p.shape.isRest] - if extremes: - pairs = [p for p in pairs if abs(p.value) == 1.0] - - for pp in pairs: - inVecs.append(trav.getInputVector(pp.value)) - shapeNames.append(pp.shape.name) - keyIdxs.append(indexByShape[pp.shape]) - - return shapeNames, inVecs, keyIdxs - - def evaluateInputs(self, inVecs): - """Get the shape activation vectors that are paired with the given input vectors - It will probably be useful to pass the returned inVecs from `buildInputVectors` - - This will use the compiled python solver, and may not be available - - Parameters - ---------- - inVecs : [[SliderVal, ...], ...] - A list of lists of sliderValues. Each sub-list has to have the same number of - items as there are sliders in the current simplex system - - Returns - ------- - : [[ShapeVal, ...], ...] - A list of lists of shapeValues resulting from the given inVecs. This returns - the activations of all the shape values. Each sub-list will have the same - number of items as there are shapes in the current system. - """ - from pysimplex import PySimplex - - solver = PySimplex(self.dump()) - return [solver.solve(iv) for iv in inVecs] - - def controllersByDepth(self): - """Get the shapes ordered by the depth of their controllers - in the simplex hierarchy - This is often useful when doing vertex position computations - - Returns - ------- - : [Shape, ...] - A list of all shapes in the depth order - """ - ctrlOrder = self.sliders[:] - combosByDepth = {} - for c in self.combos: - combosByDepth.setdefault(len(c.pairs), []).append(c) - - for depth in sorted(combosByDepth.keys()): - combos = combosByDepth[depth] - regular, floating = [], [] - for c in combos: - lst = floating if c.isFloating() else regular - lst.append(c) - ctrlOrder.extend(regular + floating) - - travByDepth = {} - for t in self.traversals: - travByDepth.setdefault(len(t.startPoint.pairs), []).append(t) - - for depth in sorted(travByDepth.keys()): - ctrlOrder.extend(travByDepth[depth]) - - return ctrlOrder - - # SPLIT CODE - def buildSplitterList(self, foList): - """The way deepcopy works is that every object visited is added to the 'memo' dictionary, - keyed by its id(). This way, you don't have to re-copy an object if you've already seen - it. This means that if I make a memo that already contains objects that I don't want copied, - then I should just be able to use deepcopy, and that will handle keeping references to the - un-copied objects. - - For example: If X references A, and I want to split X into L and R, then I would still want - L to reference A *and* R to reference A. This process just auto-handles that - I think that's is kinda neat. - - Parameters - ---------- - foList : [Falloff, ....] - A list of Falloff objects *that all share the same split axis* - - Returns - ------- - : [object, ....] - A list of objects to be split - : dict - A dict of {object: Falloff} saying what falloff should be used to split each objct - : dict - The memo to start the deepcopy with - - """ - # Add all items to the memo. - memo = {} - memo[id(self)] = self - stack = Stack() - stack.enabled = False - memo[id(self.stack)] = stack - memo[id(self.DCC)] = self.DCC - - memList = [ - self.groups, - self.sliders, - self.combos, - self.traversals, - self.falloffs, - self.shapes, - self.progs, - ] - - for lst in memList: - for item in lst: - memo[id(item)] = item - # splitApplied needs to be part of the item so it persists - # through the copy. Otherwise I'd have to keep track of it - item._splitApplied = set() - - # Using the memo from here would mean that nothing got copied - # because all items are in it - - toSplit = [] # A list of objects to be split along the shared foList axis - - # A dictionary of {object: set(falloff)}. When recursing down the hierarchy, - # some objects may be split by many falloffs. Keep track of that in this dict - splitBySet = {} - - for prog in self.progs: - # I take pains to this with lists (rather than sets) so it keeps order - sect = [i for i in foList if i in prog.falloffs] - if sect: - # Because I can only split an item once on an axis - # Assume that the foList is in priority order - # ... So I can just grab the first (highest priority) - # falloff affecting this item - splitFalloff = sect[0] - ctrl = prog.controller - - toSplit.append(prog) - toSplit.append(ctrl) - splitBySet.setdefault(prog, set()).add(splitFalloff) - splitBySet.setdefault(ctrl, set()).add(splitFalloff) - for pair in prog.pairs: - toSplit.append(pair.shape) - splitBySet.setdefault(pair.shape, set()).add(splitFalloff) - - # Also add the downstream combos, and their progs - dss = [] - if isinstance(ctrl, Slider): - dss.extend(self.getDownstreamCombos(ctrl)) - dss.extend(self.getDownstreamTraversals(ctrl)) - - for ds in dss: - toSplit.append(ds) - toSplit.append(ds.prog) - splitBySet.setdefault(ds, set()).add(splitFalloff) - splitBySet.setdefault(ds.prog, set()).add(splitFalloff) - for pair in ds.prog.pairs: - toSplit.append(pair.shape) - splitBySet.setdefault(pair.shape, set()).add(splitFalloff) - - # Dict of {item : falloff} - splitBy = {} - for item, foSet in splitBySet.items(): - # Because I can only split an item once on an axis - # Assume that the foList is in priority order - # So get the min-indexed falloff in the set - idx = min([foList.index(f) for f in foSet]) - splitBy[item] = foList[idx] - - toSplit = set(toSplit) - toSplit.discard(self.restShape) - # Get the items to split that haven't already had a split applied along this axis - toSplit = [i for i in toSplit if foList[0].axis not in i._splitApplied] - # If I can't apply a sided name ot this item, then it can't be split - toSplit = [i for i in toSplit if foList[0].canRename(i)] - - # Remove the splitter items from the memo, ensuring they actually get copied - for sp in toSplit: - try: - del memo[id(sp)] - except KeyError: - print("SP", sp, sp.name) - raise - sp._splitApplied.add(foList[0].axis.lower()) - - return toSplit, splitBy, memo - - def split(self, pBar=None): - """Return a split deepcopy of the system. - The new system will be a dummy system containing all the shapes as numpy arrays which can be - exported to a .smpx file - - Parameters - ---------- - pBar : QProgressDialog, optional - If provided, display progress in this dialog - - Returns - ------- - : Simplex : - A newly split system - - """ - if np is None: - raise RuntimeError("Numpy is not available, and splitting requires it") - - if pBar is not None: - pBar.setValue(0) - pBar.setLabelText("Building Split System") - - # Very first thing: Ensure that every object with a falloff is fully splittable - # Meaning that splittable progs only contain splittable shapes. And splittable progs - # are only controlled by splittable controllers - for fo in self.falloffs: - controllers = self.sliders + self.combos + self.traversals - for ctrl in controllers: - prog = ctrl.prog - - pSplit = fo.canRename(prog) - cSplit = fo.canRename(ctrl) - sSplit = [s for s in prog.getShapes() if not s.isRest] - sSplit = [fo.canRename(shape) for shape in sSplit] - sSplitSame = all(i == sSplit[0] for i in sSplit) - sSplit = sSplit[0] - if not sSplitSame: - shapes = [i.name for i in prog.getShapes()] - msg = "Bad shapes: {0}".format(", ".join(shapes)) - raise ValueError( - "Mix of splittable and un-splittable shapes in a progression\n" - + msg - ) - - if pSplit != sSplit: - msg = "Bad Prog: {0}".format(prog.name) - raise ValueError("A progression is not fully splittable\n" + msg) - - if pSplit != cSplit: - msg = "Bad prog: {0}\nBad Controller:{1}".format( - prog.name, ctrl.name - ) - raise ValueError("A controller is not fully splittable\n" + msg) - - # Create the initial deepcopy - splitSmpx = copy.deepcopy(self) - splitSmpx.DCC.dummyLoad(self.DCC, pBar=pBar) - - # Sort the falloffs by which axis the split on - foByAxis = {} - for fo in splitSmpx.falloffs: - foByAxis.setdefault(fo.axis.lower(), []).append(fo) - - for axis, foList in foByAxis.items(): - if pBar is not None: - pBar.setLabelText("Splitting On {0} axis".format(axis)) - QApplication.processEvents() - else: - print("Splitting On {0} axis".format(axis)) - - # Get the items to split, and the memo that ensures *only* those items will be copied - toSplit, splitBy, memo = splitSmpx.buildSplitterList(foList) - - # DeepCopy the items twice. Once for each side of the split - lSideSplitList = copy.deepcopy(toSplit, memo=copy.copy(memo)) - rSideSplitList = copy.deepcopy(toSplit, memo=copy.copy(memo)) - - if pBar is not None: - pBar.setMaximum(len(toSplit)) - QApplication.processEvents() - - # Loop through the copied items and make the replacements - for i, (oldItem, lItem, rItem) in enumerate( - zip(toSplit, lSideSplitList, rSideSplitList) - ): - if pBar is not None: - pBar.setValue(i + 1) - QApplication.processEvents() - - # Get thefalloff that will split oldItem into lItem and rItem - fo = splitBy[oldItem] - - # Rename the newly split items - fo.splitRename(lItem, 0) - fo.splitRename(rItem, 1) - - # Apply the falloff weights to any shapes - if isinstance(oldItem, Shape): - fo.applyFalloff(lItem, 0) - fo.applyFalloff(rItem, 1) - - # Maybe for all this, get the index of the oldItem in the group - # and insert rather than append?? - - # Remove the oldItem from any groups and add the newItems - if hasattr(oldItem, "group"): - oldItem.group.items.remove(oldItem) - oldItem.group = None - lItem.group.items.append(lItem) - rItem.group.items.append(rItem) - - # Remove the oldItem from the simplex storage, and add the newItems - if isinstance(oldItem, Slider): - splitSmpx.sliders.remove(oldItem) - splitSmpx.sliders.append(lItem) - splitSmpx.sliders.append(rItem) - elif isinstance(oldItem, Combo): - splitSmpx.combos.remove(oldItem) - splitSmpx.combos.append(lItem) - splitSmpx.combos.append(rItem) - elif isinstance(oldItem, Traversal): - splitSmpx.traversals.remove(oldItem) - splitSmpx.traversals.append(lItem) - splitSmpx.traversals.append(rItem) - elif isinstance(oldItem, Shape): - splitSmpx.shapes.remove(oldItem) - # Part of deepCopy ensures the new system uses the DummyDCC - # So this just removes the shape verts from the DummyDCC dictionary - # and doesn't actually delete the shape from anywhere important - splitSmpx.DCC.deleteShape(oldItem) - splitSmpx.shapes.append(lItem) - splitSmpx.shapes.append(rItem) - - splitSmpx.DCC.pushAllShapeVertices(splitSmpx.shapes) - return splitSmpx +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +import copy +import itertools +import json +from typing import TYPE_CHECKING, Any, Union + +import numpy as np +from Qt.QtGui import QColor +from Qt.QtWidgets import QApplication + +from ..commands.alembicCommon import ( + buildAlembicArchiveData, + getPointCount, + getSmpxArchiveData, + readFalloffData, +) +from ..interface import DCC, undoContext +from ..interface.dummyInterface import DCC as DummyDCC +from .combo import Combo, ComboPair +from .falloff import Falloff, SplitDefinition +from .group import Group +from .progression import ProgPair, Progression +from .shape import Shape +from .slider import Slider +from .stack import Stack, stackable +from .traversal import Traversal, TravPair +from .treeItem import TreeRootItem + +if TYPE_CHECKING: + from Qt.QtWidgets import QProgressDialog + + +DCCObject = Any +Controllers = Union[Slider, Combo, Traversal] +Splittable = Union[Shape, Progression, Slider, Combo, Traversal] + + +class Simplex(TreeRootItem): + """The main Top-level abstract object that controls an entire setup + + Simplex objects contain and manage the entire hierarchy. They have methods + to import from and export to disk. Simplex objects also mange the connections + to the DCC and the UI, setting up connections with the undo stack, and the dispatcher + + Finally Simplex systems handle splitting, which will be covered more in depth + in the documentation for the split method. + """ + + classDepth = 0 + + def __init__(self, name: str = "", forceDummy=False, sliderMul=1.0) -> None: + """Constructor + + Parameters + ---------- + name : str, optional + The name of the new system. Defaults to "" + forceDummy : bool, optional + When loading, don't make a connection to the actual DCC. Instead use the + "dummy" DCC. Defaults False + sliderMul : float, optional + A multiplier for the range of sliders. Simplex will only define values + between -1 and 1. This multiplier will let the attribute range in the DCC be larger so + animators can push the extremes + """ + super().__init__() + self.sliderMul: float = 1.0 + self._name: str = name # The name of the system + self.sliders: list[Slider] = [] # List of contained sliders + self.combos: list[Combo] = [] # List of contained combos + self.traversals: list[Traversal] = [] # list of contained traversals + self.sliderGroups: list[Group] = [] # List of groups containing sliders + self.comboGroups: list[Group] = [] # List of groups containing combos + self.traversalGroups: list[Group] = [] # List of groups containing traversals + self.falloffs: list[Falloff] = [] # List of contained falloff objects + self.shapes: list[Shape] = [] # List of contained shape objects + self.restShape: Shape | None = None # Quick access to the rest shape + self.clusterName: str = "Shape" # Name of the cluster (XSI use only) + self.DCC = DummyDCC(self) if forceDummy else DCC(self) # Interface to the DCC + self.stack: Stack = Stack() # Reference to the Undo stack + self._extras: dict[str, Any] = {} # extra key data to store in the output json + self._legacy: bool = False # whether to write the legacy types + self.sdef: SplitDefinition = SplitDefinition() # Read by falloffs for splitting + + @property + def simplex(self) -> Simplex: + """A uniform accessor so that we can always get the root from any object""" + return self + + def __deepcopy__(self, memo: dict[int, Any]) -> Simplex: + """Gotta be really picky about what gets deepcopied. + Especially since I pretty much abuse the deepcopy mechanism to do splitting + Deep-copied systems have no reference to a UI, or a DCC + """ + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + for k, v in self.__dict__.items(): + if k == "stack": + # Make a disabled stack for new simplex + s = Stack() + s.enabled = False + setattr(result, k, s) + elif k == "DCC": + # do not connect the deepcopied simplex to the DCC + # we will want to change it without affecting the current scene + # Requires the name be copied already + setattr(result, "_name", copy.deepcopy(self._name, memo)) # noqa: B010 + if v.program == "dummy": + # If it's already a dummy, go ahead and just deepcopy + setattr(result, k, copy.deepcopy(v, memo)) + else: + setattr(result, k, DummyDCC(result)) + elif k == '_observerServers': + # do not make a copy of the observers + # because they contain a bunch of Qt stuff + setattr(result, k, []) + elif k == "expanded": + # do not make a copy of the expansion + # because it's keyed off the un-copied models + setattr(result, k, {}) + else: + setattr(result, k, copy.deepcopy(v, memo)) + return result + + def _initValues(self) -> None: + """Re-initialize the variables to that of an empy system""" + self.sliderMul = 1.0 + self._name = "" # The name of the system + self.sliders = [] # List of contained sliders + self.combos = [] # List of contained combos + self.traversals = [] # list of contained traversals + self.sliderGroups = [] # List of groups containing sliders + self.comboGroups = [] # List of groups containing combos + self.traversalGroups = [] # List of groups containing combos + self.falloffs = [] # List of contained falloff objects + self.shapes = [] # List of contained shape objects + self.restShape = None # Quick access to the rest shape + self.clusterName = "Shape" # Name of the cluster (XSI use only) + + # Alternate Constructors + @classmethod + def buildBaseObject( + cls, smpxPath: str, name: str | None = None, forceDummy: bool = False + ): + """Build the rest object from a .smpx file + + Parameters + ---------- + smpxPath : str + The path to the .smpx file + name : str, optional + The Name of the object to create. Defaults to the name of the simplex system + + Returns + ------- + : object + A reference to the DCC mesh + """ + iarch, abcMesh, jsString = getSmpxArchiveData(smpxPath) + try: + if name is None: + js = json.loads(jsString) + name = js["systemName"] + if forceDummy: + return DummyDCC.buildRestAbc(abcMesh, name) + else: + return DCC.buildRestAbc(abcMesh, name) + finally: + del iarch + + @classmethod + def buildEmptySystem( + cls, + thing: DCCObject, + name: str, + sliderMul: float = 1.0, + forceDummy: bool = False, + ): + """Create a new, empty system on a given mesh + + Parameters + ---------- + thing : object + The DCC mesh to build the system on + name : str + The name of the new Simplex system + sliderMul : float, optional + A multiplier for the range of sliders. Simplex will only define values + between -1 and 1. This multiplier will let the attribute range in the DCC be larger so + animators can push the extremes (Default value = 1.0) + forceDummy : bool, optional + When loading, don't make a connection to the actual DCC. Instead use the + "dummy" DCC. Defaults False + + Returns + ------- + : Simplex + A newly created Simplex system + """ + self = cls(name, forceDummy=forceDummy, sliderMul=sliderMul) + self.DCC.loadNodes(self, thing, create=True) + self.restShape = Shape.buildRest(self) + return self + + @classmethod + def buildSystemFromJsonString( + cls, + jsString: str, + thing: DCCObject | None = None, + name: str | None = None, + forceDummy: bool = False, + sliderMul: float = 1.0, + pBar: QProgressDialog | None = None, + ) -> Simplex: + """Build a system from a json encoded string + + Parameters + ---------- + jsString : str + The json encoded simplex definition + thing : object + The DCC mesh to build the system on (Default value = None) + name : str + The name of the new Simplex system (Default value = None) + forceDummy : bool + When loading, don't make a connection to the actual DCC. Instead use the + "dummy" DCC. Defaults False + sliderMul : float + A multiplier for the range of sliders. Simplex will only define values + between -1 and 1. This multiplier will let the attribute range in the DCC be larger so + animators can push the extremes (Default value = 1.0) + pBar : QProgressDialog, optional + If provided, display progress in this dialog + + Returns + ------- + : Simplex + A newly created Simplex system + """ + js = json.loads(jsString) + if name is None: + name = js["systemName"] + return cls.buildSystemFromDict( + js, thing, name=name, forceDummy=forceDummy, sliderMul=sliderMul, pBar=pBar + ) + + @classmethod + def buildSystemFromJson( + cls, + jsPath: str, + thing: DCCObject | None = None, + name: str | None = None, + forceDummy: bool = False, + sliderMul: float = 1.0, + pBar: QProgressDialog | None = None, + ) -> Simplex: + """Build a system from .json file + + Parameters + ---------- + jsPath : str + The .json file to load + thing : object + The DCC mesh to build the system on (Default value = None) + name : str + The name of the new Simplex system (Default value = None) + forceDummy : bool + When loading, don't make a connection to the actual DCC. Instead use the + "dummy" DCC. Defaults False + sliderMul : float + A multiplier for the range of sliders. Simplex will only define values + between -1 and 1. This multiplier will let the attribute range in the DCC be larger so + animators can push the extremes (Default value = 1.0) + pBar : QProgressDialog, optional + If provided, display progress in this dialog + + Returns + ------- + : Simplex + A newly created Simplex system + """ + with open(jsPath, "r") as f: + jsString = f.read() + return cls.buildSystemFromJsonString( + jsString, + thing, + name=name, + forceDummy=forceDummy, + sliderMul=sliderMul, + pBar=pBar, + ) + + @classmethod + def buildSystemFromSmpx( + cls, + smpxPath: str, + thing: DCCObject | None = None, + name: str | None = None, + forceDummy: bool = False, + sliderMul: float = 1.0, + pBar: QProgressDialog | None = None, + ) -> Simplex: + """Build a system from a .smpx file + SMPX files are (under-the-hood) alembic caches with each shape delta stored as a frame of animation, + and the json string stored in a property on the mesh. + + Parameters + ---------- + smxpPath : str + The .smpx file to load + thing : object + The DCC mesh to build the system on (Default value = None) + name : str + The name of the new Simplex system (Default value = None) + forceDummy : bool + When loading, don't make a connection to the actual DCC. Instead use the + "dummy" DCC. Defaults False + sliderMul : float + A multiplier for the range of sliders. Simplex will only define values + between -1 and 1. This multiplier will let the attribute range in the DCC be larger so + animators can push the extremes (Default value = 1.0) + pBar : QProgressDialog, optional + If provided, display progress in this dialog + + Returns + ------- + : Simplex + A newly created Simplex system + """ + if thing is None: + thing = cls.buildBaseObject(smpxPath, forceDummy=forceDummy) + iarch, abcMesh, jsString = getSmpxArchiveData(smpxPath) + js = json.loads(jsString) + + if not forceDummy: + smpxCount = getPointCount(abcMesh) + dccCount = DCC.vertCount(thing) + if smpxCount != dccCount: + raise RuntimeError( + f"Point Count Mismatch. Smpx File:{smpxCount} DCC:{dccCount}" + ) + + del iarch, abcMesh # release the files + if name is None: + name = js["systemName"] + self = cls.buildSystemFromDict( + js, thing, name=name, forceDummy=forceDummy, sliderMul=sliderMul, pBar=pBar + ) + self.loadSmpxShapes(smpxPath, pBar=pBar) + self.loadSmpxFalloffs(smpxPath, pBar=pBar) + return self + + @classmethod + def buildSystemFromFile( + cls, + path: str, + thing: DCCObject | None = None, + name: str | None = None, + forceDummy: bool = False, + sliderMul: float = 1.0, + pBar: QProgressDialog | None = None, + ) -> Simplex: + """Build a system from a file + + Parameters + ---------- + path : str + The file to load. Either .json or .smpx + thing : object + The DCC mesh to build the system on (Default value = None) + name : str + The name of the new Simplex system (Default value = None) + forceDummy : bool + When loading, don't make a connection to the actual DCC. Instead use the + "dummy" DCC. Defaults False + sliderMul : float + A multiplier for the range of sliders. Simplex will only define values + between -1 and 1. This multiplier will let the attribute range in the DCC be larger so + animators can push the extremes (Default value = 1.0) + pBar : QProgressDialog, optional + If provided, display progress in this dialog + + Returns + ------- + : Simplex + A newly created Simplex system + """ + if path.endswith(".json"): + return cls.buildSystemFromJson( + path, + thing=thing, + name=name, + forceDummy=forceDummy, + sliderMul=sliderMul, + pBar=pBar, + ) + elif path.endswith(".smpx"): + return cls.buildSystemFromSmpx( + path, + thing=thing, + name=name, + forceDummy=forceDummy, + sliderMul=sliderMul, + pBar=pBar, + ) + else: + raise ValueError(f"The filepath provided is not a .json or .smpx: {path}") + + @classmethod + def buildSystemFromMesh( + cls, + thing: DCCObject, + name: str, + forceDummy: bool = False, + sliderMul: float = 1.0, + pBar: QProgressDialog | None = None, + ) -> Simplex: + """Build a system from the data already built in the DCC + + Parameters + ---------- + thing : object + The DCC mesh to load the system from + name : str + The name of the new Simplex system + forceDummy : bool + When loading, don't make a connection to the actual DCC. Instead use the + "dummy" DCC. Defaults False + sliderMul : float + A multiplier for the range of sliders. Simplex will only define values + between -1 and 1. This multiplier will let the attribute range in the DCC be larger so + animators can push the extremes (Default value = 1.0) + pBar : QProgressDialog, optional + If provided, display progress in this dialog + + Returns + ------- + : Simplex + A newly created Simplex system + """ + jsDict = json.loads(DCC.getSimplexStringOnThing(thing, name)) + return cls.buildSystemFromDict( + jsDict, + thing, + name=name, + create=False, + forceDummy=forceDummy, + sliderMul=sliderMul, + pBar=pBar, + ) + + @classmethod + def buildSystemFromDict( + cls, + jsDict: dict[str, Any], + thing: DCCObject, + name: str | None = None, + create: bool = True, + forceDummy: bool = False, + sliderMul: float = 1.0, + pBar: QProgressDialog | None = None, + ) -> Simplex: + """Utility for building a cleared system from a dictionary + + Parameters + ---------- + jsDict : dict + The definition dictonary (parsed from a json string) + thing : object + The DCC mesh to load the system on + name : str, optional + The name of the new Simplex system. If None, default to the system name + create : bool, optional + Create any missing blendshapes as the system is loaded. If False, error on missing. + Defaults to True + forceDummy : bool, optional + When loading, don't make a connection to the actual DCC. Instead use the + "dummy" DCC. Defaults to False + sliderMul : float, optional + A multiplier for the range of sliders. Simplex will only define values + between -1 and 1. This multiplier will let the attribute range in the DCC be larger so + animators can push the extremes (Default value = 1.0) + pBar : QProgressDialog, optional + If provided, display progress in this dialog + + Returns + ------- + : Simplex + A newly created Simplex system + """ + if name is None: + name = jsDict["systemName"] + self = cls(name, forceDummy=forceDummy, sliderMul=sliderMul) + self.DCC.loadNodes(self, thing, create=create) + self.loadDefinition(jsDict, create=create, pBar=pBar) + return self + + def loadSmpxShapes( + self, smpxPath: str, pBar: QProgressDialog | None = None + ) -> None: + """Load the Shapes from a .smpx file onto an already loaded system + This is the "We got updated shapes from the modelers" method + + Parameters + ---------- + smpxPath : str + The path to the .smpx file + pBar : QProgressDialog, optional + If provided, display progress in this dialog + """ + iarch, abcMesh, jsString = getSmpxArchiveData(smpxPath) + js = json.loads(jsString) + + try: + self.DCC.loadAbc(abcMesh, js, pBar=pBar) + finally: + del abcMesh, iarch + + def loadSmpxPoses(self, smpxPath: str, pBar: QProgressDialog | None = None) -> None: + """Load the Poses from a .smpx file onto an already loaded system + This is the "Update the joints and skin" method + + Parameters + ---------- + smpxPath : str + The path to the .smpx file + pBar : QProgressDialog, optional + If provided, display progress in this dialog + """ + iarch, abcMesh, jsString = getSmpxArchiveData(smpxPath) + js = json.loads(jsString) + + try: + self.DCC.loadAbcPoses(abcMesh, js, pBar=pBar) + finally: + del abcMesh, iarch + + def loadSmpxFalloffs( + self, abcPath: str, pBar: QProgressDialog | None = None + ) -> None: + """Load the relevant data from a simplex alembic + + Parameters + ---------- + abcPath : str + Path to the .smpx file + pBar : QProgressDialog, optional + If provided, display progress in this dialog + """ + foDict = readFalloffData(abcPath) + + for fo in self.falloffs: + foData = foDict.get(fo.name, None) + if foData is not None: + fo.weights = foData + + # Properties + @property + def name(self) -> str: + """Get the system name""" + return self._name + + @name.setter + @stackable + def name(self, value: str) -> None: + """Set the system name""" + if value == self._name: + return + + self._name = value + self.DCC.renameSystem(value) # ??? probably needs work + if self.restShape is not None: + self.restShape.name = self.getRestName() + + @property + def progs(self) -> list[Progression]: + """Get all Progressions defined in the system""" + out = [] + for slider in self.sliders: + out.append(slider.prog) + for combo in self.combos: + out.append(combo.prog) + for trav in self.traversals: + out.append(trav.prog) + return out + + @property + def groups(self) -> list[Group]: + """Get all Groups defined in the system""" + return self.sliderGroups + self.comboGroups + self.traversalGroups + + # HELPER + def comboExists(self, sliders: list[Slider], values: list[float]) -> Combo | None: + """Check if a combo exists with these specific sliders and values + Because combo names aren't necessarily always in the same order + + Parameters + ---------- + sliders : [Slider, ....] + The sliders to check + values : [float, ....] + The values to check + + Returns + ------- + : Combo or None + The Combo with those sliders and values, or None if none found + """ + checkSet = {(s.name, v) for s, v in zip(sliders, values)} + for cmb in self.combos: + cmbSet = {(p.slider.name, p.value) for p in cmb.pairs} + if checkSet == cmbSet: + return cmb + return None + + # DESTRUCTOR + def deleteSystem(self) -> None: + """Delete an existing system from the DCC""" + # Store the models as temp so the model doesn't go crazy with the signals + with self.resetManager(): + self.DCC.deleteSystem() + self._initValues() + self.DCC = DCC(self) + + def getComboUpstreams(self, combo: Combo) -> list[Combo]: + """Get a list of only combos that are upstream to the given combo + In this case, "upstream" means that when the given combo is active, + then any returned combos are also active. + + I am currently ignoring floating combos in this function + The upstream sliders are available trivially through combo.pairs + + Parameters + ---------- + combo : Combo + The given combo + + Returns + ------- + : [Combo, ....] + The list of upstream combos + """ + pairDict = {p.slider: p.value for p in combo.pairs} + + upstreams = [] + for c in self.combos: + if c.isFloating(): + continue + + if len(c.pairs) >= len(pairDict): + continue + + if not all(p.slider in pairDict for p in c.pairs): + continue + + fail = False + for p in c.pairs: + if p.slider not in pairDict: + fail = True + break + if pairDict[p.slider] * p.value < 0.0: + # opposite Signs + fail = True + break + + if not fail: + upstreams.append(c) + + return upstreams + + def getDownstreamTraversals(self, slider: Slider) -> list[Traversal]: + """Get a list of any traversals that depend on the given slider + + Parameters + ---------- + slider : Slider + The system slider to check + + Returns + ------- + : [Traversal, ....] + The list of dependent Traversals + """ + downstream = [] + if not isinstance(slider, Slider): + return downstream + for t in self.traversals: + for pair in t.startPoint.pairs + t.endPoint.pairs: + if slider == pair.slider: + downstream.append(t) + break + downstream = list(set(downstream)) + return downstream + + def getDownstreamCombos(self, slider: Slider) -> list[Combo]: + """Get a list of any Combos that depend on the given slider + + Parameters + ---------- + slider : Slider + The Slider item to check + + Returns + ------- + : [Combo, ....] + The list of dependent Combos + """ + downstream = [] + if not isinstance(slider, Slider): + return downstream + for c in self.combos: + for pair in c.pairs: + if pair.slider == slider: + downstream.append(c) + break + downstream = list(set(downstream)) + return downstream + + def deleteDownstream(self, item: Slider) -> None: + """Delete all items from the system that depend on the given item + + Parameters + ---------- + item : object + The system item to check + """ + todel = [] + todel.extend(self.getDownstreamCombos(item)) + todel.extend(self.getDownstreamTraversals(item)) + for c in todel: + c.delete() + + # USER METHODS + def setLegacy(self, legacy: bool) -> None: + """Set whether to use the legacy .json format + + Parameters + ---------- + legacy : bool + Whether to use the legacy .json format + """ + self._legacy = legacy + + def getFloatingShapes(self) -> list[Combo]: + """Find Combos with values other than -1 and 1 + + Returns + ------- + : [Combo, ....] + Combos that don't have fully extreme activations + """ + floaters = [c for c in self.combos if c.isFloating()] + floatShapes = [] + for f in floaters: + floatShapes.extend(f.prog.getShapes()) + return floatShapes + + def buildDefinition(self) -> dict[str, Any]: + """Create a simplex definition dictionary + Loop through all the objects managed by this simplex system, and build a dictionary that defines it + + Returns + ------- + : dict + The simplex definition dictionary + """ + things = [ + self.shapes, + self.sliders, + self.combos, + self.traversals, + self.groups, + self.falloffs, + ] + for thing in things: + for i in thing: + i.clearBuildIndex() + + # Make sure we start with the extras in + # case we're overwriting with new data + d = copy.deepcopy(self._extras) + + # Then set all the top-level system keys + d["encodingVersion"] = 1 if self._legacy else 3 + d["systemName"] = self.name + d["clusterName"] = self.clusterName + d.setdefault("falloffs", []) + d.setdefault("combos", []) + d.setdefault("shapes", []) + d.setdefault("sliders", []) + d.setdefault("groups", []) + d.setdefault("progressions", []) + d.setdefault("traversals", []) + + # rest shape should *ALWAYS* be index 0 + for shape in self.shapes: + shape.buildDefinition(d, self._legacy) + + for group in self.groups: + group.buildDefinition(d, self._legacy) + + for falloff in self.falloffs: + falloff.buildDefinition(d, self._legacy) + + for slider in self.sliders: + slider.buildDefinition(d, self._legacy) + + for combo in self.combos: + combo.buildDefinition(d, self._legacy) + + for trav in self.traversals: + trav.buildDefinition(d, self._legacy) + + return d + + @stackable + def loadDefinition( + self, + simpDict: dict[str, Any], + create: bool = True, + pBar: QProgressDialog | None = None, + ) -> None: + """Build the structure of objects in this system + based on a provided dictionary + + Parameters + ---------- + simpDict : dict + The dictionary to load + create : bool, optional + Create any missing blendshapes as the system is loaded. If False, error on missing. + Defaults to True + pBar : QProgressDialog, optional + If provided, display progress in this dialog + """ + + self.name = simpDict["systemName"] + self.clusterName = simpDict["clusterName"] # for XSI + if simpDict["encodingVersion"] == 1: + self.loadV1(simpDict, create=create, pBar=pBar) + elif simpDict["encodingVersion"] == 2: + self.loadV2(simpDict, create=create, pBar=pBar) + elif simpDict["encodingVersion"] == 3: + self.loadV3(simpDict, create=create, pBar=pBar) + self.storeExtras(simpDict) + + def _incPBar(self, pBar: QProgressDialog, txt: str, inc: int = 1) -> bool: + """Increment the progress bar and return False if the user cancelled + + Parameters + ---------- + pBar : + + txt : + + inc : + (Default value = 1) + """ + if pBar is not None: + pBar.setValue(pBar.value() + inc) + pBar.setLabelText("Building:\n" + txt) + QApplication.processEvents() + return not pBar.wasCanceled() + return True + + def loadV3( + self, + simpDict: dict[str, Any], + create: bool = True, + pBar: QProgressDialog | None = None, + ) -> None: + """Load the version 3 simplex definition + V3 is just the same as V2, except for an update Traversal definition + + Parameters + ---------- + simpDict : dict + The simplex definition dictionary + create : bool, optional + Create any missing blendshapes as the system is loaded. If False, error on missing. + Defaults to True + pBar : QProgressDialog, optional + If provided, display progress in this dialog + """ + preRet = self.DCC.preLoad(self, simpDict, create=create, pBar=pBar) + try: + fos = simpDict.get("falloffs", []) + gs = simpDict.get("groups", []) + for f in fos: + Falloff.loadV2(self, f) + if gs: + for g in gs: + Group.loadV2(self, g) + else: + Group("Group_0", self, Slider) + Group("Group_1", self, Combo) + Group("Group_2", self, Traversal) + + if pBar is not None: + maxLen = max(len(i["name"]) for i in simpDict["shapes"]) + pBar.setLabelText("_" * maxLen) + pBar.setValue(0) + pBar.setMaximum(len(simpDict["shapes"]) + 1) + self.shapes = [] + for s in simpDict["shapes"]: + if not self._incPBar(pBar, s["name"]): + return + Shape.loadV2(self, s, create) + + self.restShape = self.shapes[0] + self.restShape.isRest = True + + progs = [Progression.loadV2(self, p) for p in simpDict["progressions"]] + + for s in simpDict["sliders"]: + Slider.loadV2(self, progs, s, create) + for c in simpDict["combos"]: + Combo.loadV2(self, progs, c) + for t in simpDict["traversals"]: + Traversal.loadV3(self, progs, t) + + for x in itertools.chain(self.sliders, self.combos, self.traversals): + x.prog.name = x.name + finally: + self.DCC.postLoad(self, preRet) + + def loadV2( + self, + simpDict: dict[str, Any], + create: bool = True, + pBar: QProgressDialog | None = None, + ) -> None: + """Load the version 2 simplex definition + + Parameters + ---------- + simpDict : dict + The simplex definition dictionary + create : bool, optional + Create any missing blendshapes as the system is loaded. If False, error on missing. + Defaults to True + pBar : QProgressDialog, optional + If provided, display progress in this dialog + """ + preRet = self.DCC.preLoad(self, simpDict, create=create, pBar=pBar) + try: + fos = simpDict.get("falloffs", []) + gs = simpDict.get("groups", []) + for f in fos: + Falloff.loadV2(self, f) + if gs: + for g in gs: + Group.loadV2(self, g) + else: + Group("Group_0", self, Slider) + Group("Group_1", self, Combo) + Group("Group_2", self, Traversal) + + if pBar is not None: + maxLen = max(len(i["name"]) for i in simpDict["shapes"]) + pBar.setLabelText("_" * maxLen) + pBar.setValue(0) + pBar.setMaximum(len(simpDict["shapes"]) + 1) + self.shapes = [] + for s in simpDict["shapes"]: + if not self._incPBar(pBar, s["name"]): + return + Shape.loadV2(self, s, create) + + self.restShape = self.shapes[0] + self.restShape.isRest = True + + progs = [Progression.loadV2(self, p) for p in simpDict["progressions"]] + + for s in simpDict["sliders"]: + Slider.loadV2(self, progs, s, create) + for c in simpDict["combos"]: + Combo.loadV2(self, progs, c) + for t in simpDict["traversals"]: + Traversal.loadV2(self, progs, t) + + for x in itertools.chain(self.sliders, self.combos, self.traversals): + x.prog.name = x.name + finally: + self.DCC.postLoad(self, preRet) + + def loadV1( + self, + simpDict: dict[str, Any], + create: bool = True, + pBar: QProgressDialog | None = None, + ) -> None: + """Load the version 1 simplex definition + + Parameters + ---------- + simpDict : dict + The simplex definition dictionary + create : bool, optional + Create any missing blendshapes as the system is loaded. If False, error on missing. + Defaults to True + pBar : QProgressDialog, optional + If provided, display progress in this dialog + """ + preRet = self.DCC.preLoad(self, simpDict, create=create, pBar=pBar) + try: + self.falloffs = [Falloff(f[0], self, *f[1:]) for f in simpDict["falloffs"]] + groupNames = simpDict["groups"] + + if pBar is not None: + maxLen = max(list(map(len, simpDict["shapes"]))) + pBar.setLabelText("_" * maxLen) + pBar.setValue(0) + pBar.setMaximum(len(simpDict["shapes"]) + 1) + + shapes = [] + for s in simpDict["shapes"]: + if not self._incPBar(pBar, s): + return + shapes.append(Shape(s, self)) + + self.restShape = shapes[0] + self.restShape.isRest = True + + progs = [] + for p in simpDict["progressions"]: + progShapes = [shapes[i] for i in p[1]] + progFalloffs = [self.falloffs[i] for i in p[4]] + progPairs = [ProgPair(self, s, pv) for s, pv in zip(progShapes, p[2])] + progs.append(Progression(p[0], self, progPairs, p[3], progFalloffs)) + + self.sliders = [] + self.sliderGroups = [] + createdSlidergroups = {} + for s in simpDict["sliders"]: + sliderProg = progs[s[1]] + + gn = groupNames[s[2]] + if gn in createdSlidergroups: + sliderGroup = createdSlidergroups[gn] + else: + sliderGroup = Group(gn, self, Slider) + createdSlidergroups[gn] = sliderGroup + + Slider(s[0], self, sliderProg, sliderGroup) + + self.combos = [] + self.comboGroups = [] + createdComboGroups = {} + for c in simpDict["combos"]: + prog = progs[c[1]] + sliderIdxs, sliderVals = list(zip(*c[2])) + sliders = [self.sliders[i] for i in sliderIdxs] + pairs = list(map(ComboPair, sliders, sliderVals)) + if len(c) >= 4: + gn = groupNames[c[3]] + else: + gn = "DEPTH_0" + + if gn in createdComboGroups: + comboGroup = createdComboGroups[gn] + else: + comboGroup = Group(gn, self, Combo) + createdComboGroups[gn] = comboGroup + + cmb = Combo(c[0], self, pairs, prog, comboGroup, None) + cmb.simplex = self + + self.traversals = [] + self.traversalGroups = [] + createdTraversalGroups = {} + if "traversals" in simpDict: + for t in simpDict["traversals"]: + name = t["name"] + prog = progs[t["prog"]] + + pcIdx = t["progressControl"] + pcSearch = ( + self.sliders + if t["progressType"].lower() == "slider" + else self.combos + ) + pc = pcSearch[pcIdx] + pFlip = t["progressFlip"] + pp = TravPair(pc, -1 if pFlip else 1, "progress") + + mcIdx = t["multiplierControl"] + mcSearch = ( + self.sliders + if t["multiplierType"].lower() == "slider" + else self.combos + ) + mc = mcSearch[mcIdx] + mFlip = t["multiplierFlip"] + mm = TravPair(mc, -1 if mFlip else 1, "multiplier") + + gn = groupNames[t.get("group", 2)] + if gn in createdTraversalGroups: + travGroup = createdTraversalGroups[gn] + else: + travGroup = Group(gn, self, Traversal) + createdTraversalGroups[gn] = travGroup + + color = QColor(*t.get("color", (0, 0, 0))) + + trav = Traversal(name, self, mm, pp, prog, travGroup, color) + trav.simplex = self + + for x in itertools.chain(self.sliders, self.combos, self.traversals): + x.prog.name = x.name + finally: + self.DCC.postLoad(self, preRet) + + def storeExtras(self, simpDict: dict[str, Any]) -> None: + """Store any unknown keys when dumping, just in case they're important elsewhere + + Parameters + ---------- + simpDict : dict + The simplex definition dictionary + """ + sd = copy.deepcopy(simpDict) + knownTopLevel = [ + "encodingVersion", + "systemName", + "clusterName", + "falloffs", + "combos", + "shapes", + "sliders", + "groups", + "traversals", + "progressions", + ] + + for ktn in knownTopLevel: + if ktn in sd: + del sd[ktn] + self._extras = sd + + def loadJSON(self, jsString: str) -> None: + """Convenience method to load a JSON string definition + + Parameters + ---------- + jsString : str + The json formatted definition string + """ + self.loadDefinition(json.loads(jsString)) + + def getRestName(self) -> str: + """Get the default rest shape name + + Returns + ------- + : str + The default rest shape name + """ + return f"Rest_{self.name}" + + def dump(self) -> str: + """Dump the definition dictionary to a json string + + Returns + ------- + : str + The json formatted definition string + """ + return json.dumps(self.buildDefinition()) + + def exportAbc(self, path: str, pBar: QProgressDialog | None = None) -> None: + """Export the current mesh to a .smpx formatted file + + Parameters + ---------- + path : str + The path to export to + pBar : QProgressDialog, optional + If provided, display progress in this dialog + """ + defDict = self.buildDefinition() + jsString = json.dumps(defDict) + + arch, abcMesh = buildAlembicArchiveData(path, self.name, jsString, True) + try: + self.DCC.exportAbc( + self.DCC.mesh, + abcMesh, + defDict, + world=False, + ensureCorrect=True, + pBar=pBar, + ) + finally: + del arch, abcMesh + + def exportOther( + self, + path: str, + dccMesh: DCCObject, + world: bool = False, + ensureCorrect: bool = False, + pBar: QProgressDialog | None = None, + ) -> None: + """Export shapes from a mesh that isn't part of the current system + + The export process for shapes differs from DCC to DCC. + In Maya, every blendshape is activated, one by one and the point posisitions + are read from the target mesh. This allows exportOther to work + + In XSI, the blendshape properties are read directly, so there's no chance + to process the new shapes (which means this won't work in XSI) + + Parameters + ---------- + path : str + The output path for the .smpx file + dccMesh : object + The system-external DCC mesh to export + world : bool, optional + Whether to do the export in worldspace. Defaults to False + pBar : QProgressDialog, optional + If provided, display progress in this dialog + """ + defDict = self.buildDefinition() + jsString = json.dumps(defDict) + + arch, abcMesh = buildAlembicArchiveData(path, self.name, jsString, True) + try: + self.DCC.exportOtherAbc(dccMesh, abcMesh, defDict, world=world, pBar=pBar) + finally: + del arch, abcMesh + + def setSlidersWeights(self, sliders: list[Slider], weights: list[float]) -> None: + """Set the weights of multiple sliders as one method + + Parameters + ---------- + sliders : [Slider, ....] + The list of Sliders to set weights for + weights : [float, ....] + The weights to set + """ + with undoContext(self.DCC): + for slider, weight in zip(sliders, weights): + slider.value = weight + self.DCC.setSlidersWeights(sliders, weights) + + def extractRestShape(self, offset: int = 0) -> DCCObject | None: + """Extract the rest shape to a mesh in the DCC + + Parameters + ---------- + offset : float + The offset value given to the extracted mesh (Default value = 0) + """ + if self.restShape is None: + return None + return self.DCC.extractShape(self.restShape, live=False, offset=offset) + + def buildRestShape(self) -> Shape: + """Build and store a rest shape for this system""" + self.restShape = Shape.buildRest(self) + return self.restShape + + def buildInputVectors( + self, + keepSliders: set[str] | None = None, + ignoreSliders: set[str] | None = None, + depthCutoff: int | None = None, + ignoreFloaters: bool = False, + ignoreTraversals: bool = False, + extremes: bool = False, + ) -> tuple[list[str], list[list[float]]]: + """This is kind of a specialized function. Often, I have to sum combo deltas + into the full sculpted shape. But to do that, I have to build the inputs to the + solver that enable each shape, and I need a name for each shape. + This function gives me both. + + Parameters + ---------- + keepSliders : set([str, ....]), optional + The returned sliders will have names that are part of this list + ignoreSliders : set([str, ....]), optional + A set of Slider names to ignore as part of this process + depthCutoff : int, optional + The maximum number of Sliders allowed per Combo + (Defaults to None which allows all Combos) + ignoreFloaters : bool, optional + Ignore Combos with values other than -1 and 1 + extremes : bool, optional + Only build activations for sliders and combos at -1 and 1 + + Returns + ------- + : [str, ...] + The names of the shapes that are activated in each list + : [[float, ...], ...] + A list of activation values for the entire system + """ + # InputVector comes from the c++ std::vector + # Get all endpoint shapes from the progressions + shapeNames = [] + inVecs = [] + keyIdxs = [] + ignoreSliders = ignoreSliders or set() + ignoreSliders = set(ignoreSliders) + indexByShape = {shape: idx for idx, shape in enumerate(self.shapes)} + + for slIdx, slider in enumerate(self.sliders): + if slider.name in ignoreSliders: + continue + if keepSliders is not None and slider.name not in keepSliders: + continue + + pairs = [p for p in slider.prog.pairs if not p.shape.isRest] + if extremes: + pairs = [p for p in pairs if abs(p.value) == 1.0] + + for pp in pairs: + inVec = [0.0] * len(self.sliders) + inVec[slIdx] = pp.value + shapeNames.append(pp.shape.name) + inVecs.append(inVec) + keyIdxs.append(indexByShape[pp.shape]) + + for combo in self.combos: + if ignoreFloaters and combo.isFloating(): + continue + if depthCutoff is not None and len(combo.pairs) > depthCutoff: + continue + if ignoreSliders & {i.name for i in combo.getSliders()}: + # if the combo's sliders are in ignoreSliders + continue + + pairs = [p for p in combo.prog.pairs if not p.shape.isRest] + if extremes: + pairs = [p for p in pairs if abs(p.value) == 1.0] + + iv = combo.getInputVector() + for pp in pairs: + inVecs.append([x * pp.value for x in iv]) + shapeNames.append(pp.shape.name) + keyIdxs.append(indexByShape[pp.shape]) + + for trav in self.traversals: + # Extremes doesn't make sense at all for traversals + if ignoreTraversals: + continue + pairs = [p for p in trav.prog.pairs if not p.shape.isRest] + if extremes: + pairs = [p for p in pairs if abs(p.value) == 1.0] + + for pp in pairs: + inVecs.append(trav.getInputVector(pp.value)) + shapeNames.append(pp.shape.name) + keyIdxs.append(indexByShape[pp.shape]) + + return shapeNames, inVecs, keyIdxs + + def evaluateInputs(self, inVecs: list[list[float]]) -> list[list[float]]: + """Get the shape activation vectors that are paired with the given input vectors + It will probably be useful to pass the returned inVecs from `buildInputVectors` + + This will use the compiled python solver, and may not be available + + Parameters + ---------- + inVecs : [[SliderVal, ...], ...] + A list of lists of sliderValues. Each sub-list has to have the same number of + items as there are sliders in the current simplex system + + Returns + ------- + : [[ShapeVal, ...], ...] + A list of lists of shapeValues resulting from the given inVecs. This returns + the activations of all the shape values. Each sub-list will have the same + number of items as there are shapes in the current system. + """ + from pysimplex import PySimplex + + solver = PySimplex(self.dump()) + return [solver.solve(iv) for iv in inVecs] + + def controllersByDepth(self) -> list[Controllers]: + """Get the shapes ordered by the depth of their controllers + in the simplex hierarchy + This is often useful when doing vertex position computations + + Returns + ------- + : [Shape, ...] + A list of all shapes in the depth order + """ + ctrlOrder = self.sliders[:] + combosByDepth = {} + for c in self.combos: + combosByDepth.setdefault(len(c.pairs), []).append(c) + + for depth in sorted(combosByDepth.keys()): + combos = combosByDepth[depth] + regular, floating = [], [] + for c in combos: + lst = floating if c.isFloating() else regular + lst.append(c) + ctrlOrder.extend(regular + floating) + + travByDepth = {} + for t in self.traversals: + travByDepth.setdefault(len(t.startPoint.pairs), []).append(t) + + for depth in sorted(travByDepth.keys()): + ctrlOrder.extend(travByDepth[depth]) + + return ctrlOrder + + # SPLIT CODE + def buildSplitterList( + self, foList: list[Falloff] + ) -> tuple[list[Splittable], dict[Splittable, Falloff], dict]: + """The way deepcopy works is that every object visited is added to the 'memo' dictionary, + keyed by its id(). This way, you don't have to re-copy an object if you've already seen + it. This means that if I make a memo that already contains objects that I don't want copied, + then I should just be able to use deepcopy, and that will handle keeping references to the + un-copied objects. + + For example: If X references A, and I want to split X into L and R, then I would still want + L to reference A *and* R to reference A. This process just auto-handles that + I think that's is kinda neat. + + Parameters + ---------- + foList : [Falloff, ....] + A list of Falloff objects *that all share the same split axis* + + Returns + ------- + : [object, ....] + A list of objects to be split + : dict + A dict of {object: Falloff} saying what falloff should be used to split each objct + : dict + The memo to start the deepcopy with + """ + # Add all items to the memo. + memo = {} + memo[id(self)] = self + stack = Stack() + stack.enabled = False + memo[id(self.stack)] = stack + memo[id(self.DCC)] = self.DCC + + memList = [ + self.groups, + self.sliders, + self.combos, + self.traversals, + self.falloffs, + self.shapes, + self.progs, + ] + + for lst in memList: + for item in lst: + memo[id(item)] = item + # splitApplied needs to be part of the item so it persists + # through the copy. Otherwise I'd have to keep track of it + item._splitApplied = set() + + # Using the memo from here would mean that nothing got copied + # because all items are in it + + toSplit = [] # A list of objects to be split along the shared foList axis + + # A dictionary of {object: set(falloff)}. When recursing down the hierarchy, + # some objects may be split by many falloffs. Keep track of that in this dict + splitBySet = {} + + for prog in self.progs: + # I take pains to this with lists (rather than sets) so it keeps order + sect = [i for i in foList if i in prog.falloffs] + if sect: + # Because I can only split an item once on an axis + # Assume that the foList is in priority order + # ... So I can just grab the first (highest priority) + # falloff affecting this item + splitFalloff = sect[0] + ctrl = prog.controller + + toSplit.append(prog) + toSplit.append(ctrl) + splitBySet.setdefault(prog, set()).add(splitFalloff) + splitBySet.setdefault(ctrl, set()).add(splitFalloff) + for pair in prog.pairs: + toSplit.append(pair.shape) + splitBySet.setdefault(pair.shape, set()).add(splitFalloff) + + # Also add the downstream combos, and their progs + dss = [] + if isinstance(ctrl, Slider): + dss.extend(self.getDownstreamCombos(ctrl)) + dss.extend(self.getDownstreamTraversals(ctrl)) + + for ds in dss: + toSplit.append(ds) + toSplit.append(ds.prog) + splitBySet.setdefault(ds, set()).add(splitFalloff) + splitBySet.setdefault(ds.prog, set()).add(splitFalloff) + for pair in ds.prog.pairs: + toSplit.append(pair.shape) + splitBySet.setdefault(pair.shape, set()).add(splitFalloff) + + # Dict of {item : falloff} + splitBy = {} + for item, foSet in splitBySet.items(): + # Because I can only split an item once on an axis + # Assume that the foList is in priority order + # So get the min-indexed falloff in the set + idx = min([foList.index(f) for f in foSet]) + splitBy[item] = foList[idx] + + toSplit = set(toSplit) + toSplit.discard(self.restShape) + # Get the items to split that haven't already had a split applied along this axis + toSplit = [i for i in toSplit if foList[0].axis not in i._splitApplied] + # If I can't apply a sided name ot this item, then it can't be split + toSplit = [i for i in toSplit if foList[0].canRename(i)] + + # Remove the splitter items from the memo, ensuring they actually get copied + for sp in toSplit: + try: + del memo[id(sp)] + except KeyError: + print("SP", sp, sp.name) + raise + sp._splitApplied.add(foList[0].axis.lower()) + + return toSplit, splitBy, memo + + def split( + self, + sdef: SplitDefinition | None = None, + pBar: QProgressDialog | None = None, + ) -> Simplex: + """Return a split deepcopy of the system. + The new system will be a dummy system containing all the shapes as numpy arrays which can be + exported to a .smpx file + + Parameters + ---------- + sdef : SplitDefinition, optional + If provided, use this split definition while splitting + pBar : QProgressDialog, optional + If provided, display progress in this dialog + + Returns + ------- + : Simplex : + A newly split system + """ + if np is None: + raise RuntimeError("Numpy is not available, and splitting requires it") + + if pBar is not None: + pBar.setValue(0) + pBar.setLabelText("Building Split System") + + oldsdef = self.sdef + newsdef = self.sdef if sdef is None else sdef + + try: + self.sdef = newsdef + + # Ensure that every object with a falloff is fully splittable + # Meaning that splittable progs only contain splittable shapes. And splittable progs + # are only controlled by splittable controllers + for fo in self.falloffs: + controllers = self.sliders + self.combos + self.traversals + for ctrl in controllers: + prog = ctrl.prog + + pSplit = fo.canRename(prog) + cSplit = fo.canRename(ctrl) + sSplit = [s for s in prog.getShapes() if not s.isRest] + sSplit = [fo.canRename(shape) for shape in sSplit] + sSplitSame = all(i == sSplit[0] for i in sSplit) + sSplit = sSplit[0] + if not sSplitSame: + shapes = [i.name for i in prog.getShapes()] + msg = "Bad shapes: {}".format(", ".join(shapes)) + raise ValueError( + "Mix of splittable and un-splittable shapes in a progression\n" + + msg + ) + + if pSplit != sSplit: + msg = f"Bad Prog: {prog.name}" + raise ValueError( + "A progression is not fully splittable\n" + msg + ) + + if pSplit != cSplit: + msg = f"Bad prog: {prog.name}\nBad Controller:{ctrl.name}" + raise ValueError("A controller is not fully splittable\n" + msg) + + # Create the initial deepcopy + splitSmpx = copy.deepcopy(self) + splitSmpx.DCC.dummyLoad(self.DCC, pBar=pBar) + + # Sort the falloffs by which axis the split on + foByAxis = {} + for fo in splitSmpx.falloffs: + foByAxis.setdefault(fo.axis.lower(), []).append(fo) + + for axis, foList in foByAxis.items(): + if pBar is not None: + pBar.setLabelText(f"Splitting On {axis} axis") + QApplication.processEvents() + else: + print(f"Splitting On {axis} axis") + + # Get the items to split, and the memo that ensures *only* those + # items will be copied when we deepcopy + toSplit, splitBy, memo = splitSmpx.buildSplitterList(foList) + + # DeepCopy the items twice. Once for each side of the split + lSideSplitList = copy.deepcopy(toSplit, memo=copy.copy(memo)) + rSideSplitList = copy.deepcopy(toSplit, memo=copy.copy(memo)) + + if pBar is not None: + pBar.setMaximum(len(toSplit)) + QApplication.processEvents() + + # Loop through the copied items and make the replacements + for i, (oldItem, lItem, rItem) in enumerate( + zip(toSplit, lSideSplitList, rSideSplitList) + ): + if pBar is not None: + pBar.setValue(i + 1) + QApplication.processEvents() + + # Get thefalloff that will split oldItem into lItem and rItem + fo = splitBy[oldItem] + + # Rename the newly split items + fo.splitRename(lItem, 0) + fo.splitRename(rItem, 1) + + # Apply the falloff weights to any shapes + if isinstance(oldItem, Shape): + assert isinstance(lItem, Shape) + assert isinstance(rItem, Shape) + fo.applyFalloff(lItem, 0) + fo.applyFalloff(rItem, 1) + + # Maybe for all this, get the index of the oldItem in the group + # and insert rather than append?? + + # Remove the oldItem from any groups and add the newItems + if isinstance(oldItem, (Slider, Combo, Traversal)): + assert isinstance(lItem, (Slider, Combo, Traversal)) + assert isinstance(rItem, (Slider, Combo, Traversal)) + oldItem.group.items.remove(oldItem) + oldItem.group = None # type: ignore + lItem.group.items.append(lItem) + rItem.group.items.append(rItem) + + # Remove the oldItem from the simplex storage, and add the newItems + if isinstance(oldItem, Slider): + assert isinstance(lItem, Slider) + assert isinstance(rItem, Slider) + splitSmpx.sliders.remove(oldItem) + splitSmpx.sliders.append(lItem) + splitSmpx.sliders.append(rItem) + elif isinstance(oldItem, Combo): + assert isinstance(lItem, Combo) + assert isinstance(rItem, Combo) + splitSmpx.combos.remove(oldItem) + splitSmpx.combos.append(lItem) + splitSmpx.combos.append(rItem) + elif isinstance(oldItem, Traversal): + assert isinstance(lItem, Traversal) + assert isinstance(rItem, Traversal) + splitSmpx.traversals.remove(oldItem) + splitSmpx.traversals.append(lItem) + splitSmpx.traversals.append(rItem) + elif isinstance(oldItem, Shape): + assert isinstance(lItem, Shape) + assert isinstance(rItem, Shape) + splitSmpx.shapes.remove(oldItem) + # Part of deepCopy ensures the new system uses the DummyDCC + # So this just removes the shape verts from the DummyDCC dictionary + # and doesn't actually delete the shape from anywhere important + splitSmpx.DCC.deleteShape(oldItem) + splitSmpx.shapes.append(lItem) + splitSmpx.shapes.append(rItem) + + splitSmpx.DCC.pushAllShapeVertices(splitSmpx.shapes) + return splitSmpx + finally: + self.sdef = oldsdef + + # TREE CODE + def columnCount(self) -> int: + return 3 + + def treeChild(self, row: int) -> Group: + return self.groups[row] + + def treeChildCount(self) -> int: + return len(self.groups) + + def treeData(self, column: int) -> str | None: + if column == 0: + return self.name + return None diff --git a/src/python/simplexui/items/slider.py b/src/python/simplexui/items/slider.py index f49bf626..2eb53e45 100644 --- a/src/python/simplexui/items/slider.py +++ b/src/python/simplexui/items/slider.py @@ -1,738 +1,632 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# pylint:disable=missing-docstring,unused-argument,no-self-use -import itertools - -from ..interface import undoContext -from Qt.QtGui import QColor -from ..utils import caseSplit, getNextName, makeUnique, nested, singleShot -from .accessor import SimplexAccessor -from .group import Group -from .progression import ProgPair, Progression -from .shape import Shape -from .stack import stackable - - -class Slider(SimplexAccessor): - """A user-input to the simplex system that directly controls a Progression - - Parameters - ---------- - name : str - The name of this Slider - simplex : Simplex - The parent Simplex system - prog : Progression - The Progression that this Slider controls - group : Group - The Group to create this Slider in - color : QColor - The color of this item in the UI - create : bool - Whether to create a DCC Shape, or look for it already in-scene - - Returns - ------- - - """ - - classDepth = 7 - - def __init__(self, name, simplex, prog, group, color=None, create=True): - if group.groupType is not type(self): - raise ValueError("Cannot add this slider to a combo group") - - super(Slider, self).__init__(simplex) - color = QColor(128, 128, 128) if color is None else color - with self.stack.store(self): - self._name = name - self._thing = None - self._thingRepr = None - self.prog = prog - self.split = False - self.prog.controller = self - self._buildIdx = None - self._value = 0.0 - self.expanded = {} - self.color = color - self._enabled = True - - mn, mx = self.prog.getRange() - self.minValue = mn - self.maxValue = mx - - mgrs = [model.insertItemManager(group) for model in self.models] - with nested(*mgrs): - self.group = group - self.group.items.append(self) - - self.simplex.sliders.append(self) - - newThing = self.DCC.getSliderThing(self._name) - if newThing is None: - if create: - self.thing = simplex.DCC.createSlider(self) - else: - raise RuntimeError( - "Unable to find existing shape: {0}".format(self.name) - ) - else: - self.thing = newThing - - @property - def enabled(self): - """Get whether this Slider is evaluated in the solver""" - return self._enabled - - @enabled.setter - @stackable - def enabled(self, value): - """Get whether this Slider is evaluated in the solver - - Parameters - ---------- - value : - - - Returns - ------- - - """ - self._enabled = value - for model in self.models: - model.itemDataChanged(self) - - @classmethod - def createSlider(cls, name, simplex, group=None, shape=None, tVal=1.0): - """Create a new slider with a name in a group. - Possibly create a single default shape for this slider - - Parameters - ---------- - name : str - The name of this Slider - simplex : Simplex - The parent Simplex system - group : Group or None - The group to add the Slider to. - If None, create a default group. Defaults to None - shape : Shape or None - What shape to add to the new Slider's Progression at the given tVal - if None, create a default shape. Defaults to None - tVal : float - The value for the new shape in the Slider's Progression. Defaults to 1.0 - - Returns - ------- - Slider - The newly created Slider - - """ - if simplex.restShape is None: - raise RuntimeError("Simplex system is missing rest shape") - - if group is None: - if simplex.sliderGroups: - group = simplex.sliderGroups[0] - else: - group = Group("{0}_GROUP".format(name), simplex, Slider) - - currentNames = [s.name for s in simplex.sliders] - name = getNextName(name, currentNames) - - prog = Progression(name, simplex) - if shape is None: - prog.createShape(name, tVal) - else: - prog.pairs.append(ProgPair(simplex, shape, tVal)) - - sli = cls(name, simplex, prog, group) - return sli - - @classmethod - def createMultiSlider(cls, name, simplex, shapes, tVals, group=None): - """Create a new slider with a name (possibly in a custom group) with the - provided shapes and t-values. - - Parameters - ---------- - name : str - The name of this Slider - simplex : Simplex - The parent Simplex system - shape : list of Shape - What shape s to add to the new Slider's Progression at the given tVals - tVal : list of float - The values for the new shapes in the Slider's Progression - group : Group or None - The group to add the Slider to. - If None, create a default group. Defaults to None - Returns - ------- - Slider - The newly created Slider - - """ - if simplex.restShape is None: - raise RuntimeError("Simplex system is missing rest shape") - - if group is None: - if simplex.sliderGroups: - group = simplex.sliderGroups[0] - else: - group = Group("{0}_GROUP".format(name), simplex, Slider) - - currentNames = [s.name for s in simplex.sliders] - name = getNextName(name, currentNames) - - prog = Progression(name, simplex) - for shape, tVal in zip(shapes, tVals): - prog.pairs.append(ProgPair(simplex, shape, tVal)) - - sli = cls(name, simplex, prog, group) - return sli - - @property - def name(self): - """Get the name of a Slider""" - return self._name - - @name.setter - @stackable - def name(self, value): - """Set the name of a Slider - - Parameters - ---------- - value : - - - Returns - ------- - - """ - self._name = value - self.prog.name = value - self.DCC.renameSlider(self, value) - # TODO Also rename the combos - for model in self.models: - model.itemDataChanged(self) - - def treeChild(self, row): - """ - - Parameters - ---------- - row : - - - Returns - ------- - - """ - return self.prog.pairs[row] - - def treeRow(self): - """ """ - return self.group.items.index(self) - - def treeParent(self): - """ """ - return self.group - - def treeChildCount(self): - """ """ - return len(self.prog.pairs) - - def treeData(self, column): - """ - - Parameters - ---------- - column : - - - Returns - ------- - - """ - if column == 0: - return self.name - if column == 1: - return self.value - return None - - def treeChecked(self): - """ """ - return self.enabled - - def nameLinks(self): - """ - - Parameters - ---------- - - Returns - ------- - : type - Name Linking is currenly in-development - - """ - # split by underscore - sp = self._name.split("_") - sliderPoss = [] - for orig in sp: - s = caseSplit(orig) - if len(s) > 1: - s = orig + s - sliderPoss.append(s) - - # remove numbered chunks from the end - shapeNames = [] - shapes = [p.shape for p in self.prog.pairs] - for s in shapes: - x = s.name.rsplit("_", 1) - if len(x) == 2: - base, sfx = x - if sfx[0].lower() == "n": - sfx = sfx[1:] - x = base if sfx.isdigit() else s.name - shapeNames.append(x) - - out = [False] * len(shapeNames) - for poss in itertools.product(*sliderPoss): - check = "".join(poss) - for i, s in enumerate(shapeNames): - if check == s: - out[i] = True - if all(out): - break - return out - - @classmethod - def buildSliderName(cls, pairs): - """Figure out then name for a slider with given shapes - - This will mostly be used to figure out what the new name - for a slider will be if its shapes are renamed - - Parameters - ---------- - pairs : [(str - A list of (name, value) pairs - - Returns - ------- - : str - A newly created name - - """ - # In this case, pairs is *not* a list of ProgPairs - # but a list of (name, value) tuples - - # First, I'm just going to ignore anything with values that aren't 1.0 - # This simplifies the logic greatly - extPairs = [p for p in pairs if abs(p[1]) == 1.0] - if len(extPairs) == 1: - return extPairs[0] - if extPairs[0] == 1: - extPairs = extPairs.reversed() - - names = [] - for ep in extPairs: - sp = ep[0].split("_") - if Shape.isNumberField(sp[-1]): - sp = sp[:-1] - names.append(sp) - - return "_".join(["".join(makeUnique(n)) for n in names]) - - @property - def thing(self): - """Get the stored reference to the DCC attribute""" - # if this is a deepcopied object, then self._thing will - # be None. Rebuild the thing connection by its representation - if self._thing is None and self._thingRepr: - self._thing = self.DCC.loadPersistentSlider(self._thingRepr) - return self._thing - - @thing.setter - def thing(self, value): - """Set the stored reference to the DCC attribute - - Parameters - ---------- - value : - - - Returns - ------- - - """ - self._thing = value - self._thingRepr = self.DCC.getPersistentSlider(value) - - @property - def value(self): - """Get the current value for this Slider""" - return self._value - - @value.setter - def value(self, val): - """Set the current value for this Slider - - Parameters - ---------- - val : - - - Returns - ------- - - """ - self._value = val - for model in self.models: - model.itemDataChanged(self) - self._setAllSliders(self) - - @singleShot() - def _setAllSliders(self, sliders): - """ - - Parameters - ---------- - sliders : - - - Returns - ------- - - """ - with undoContext(self.DCC): - for slider in sliders: - self.DCC.setSliderWeight(slider, slider.value) - - def updateValue(self): - """ """ - pass - - @classmethod - def loadV2(cls, simplex, progs, data, create): - """Load the data from a version2 formatted json dictionary - - Parameters - ---------- - simplex : Simplex - The Simplex system that's being built - progs : [Progression - The progressions that have already been built - data : dict - The chunk of the json dict used to build this object - create : bool - Whether to create the DCC Shape, or look for it already in-scene - - Returns - ------- - : Slider - The specified Slider - - """ - name = data["name"] - prog = progs[data["prog"]] - group = simplex.groups[data.get("group", 0)] - color = QColor(*data.get("color", (128, 128, 128))) - return cls(name, simplex, prog, group, create=create, color=color) - - def buildDefinition(self, simpDict, legacy): - """Output a dictionary definition of this object - - Parameters - ---------- - simpDict : dict - The dictionary that is being built - legacy : bool - Whether to write out the legacy definition, or the newer one - - Returns - ------- - - """ - if self._buildIdx is None: - self._buildIdx = len(simpDict["sliders"]) - if legacy: - gIdx = self.group.buildDefinition(simpDict, legacy) - pIdx = self.prog.buildDefinition(simpDict, legacy) - simpDict.setdefault("sliders", []).append([self.name, pIdx, gIdx]) - else: - x = { - "name": self.name, - "prog": self.prog.buildDefinition(simpDict, legacy), - "group": self.group.buildDefinition(simpDict, legacy), - "color": self.color.getRgb()[:3], - "enabled": self._enabled, - } - simpDict.setdefault("sliders", []).append(x) - return self._buildIdx - - def clearBuildIndex(self): - """Clear the build index of this object - - The buildIndex is stored when building a definition dictionary - that keeps track of its index for later referencing - - Parameters - ---------- - - Returns - ------- - - """ - self._buildIdx = None - self.prog.clearBuildIndex() - self.group.clearBuildIndex() - - def setRange(self): - """Set the range of this Slider based on the progPair values""" - values = [i.value for i in self.prog.pairs] - self.minValue = min(values) - self.maxValue = max(values) - self.DCC.setSliderRange(self) - - @stackable - def delete(self): - """Delete a slider, any shapes it contains, and all downstream Combos and Traversals""" - self.simplex.deleteDownstream(self) - mgrs = [model.removeItemManager(self) for model in self.models] - with nested(*mgrs): - g = self.group - g.items.remove(self) - self.group = None - self.simplex.sliders.remove(self) - - pairs = self.prog.pairs[:] # gotta make a copy - for pp in pairs: - if not pp.shape.isRest: - self.simplex.shapes.remove(pp.shape) - self.DCC.deleteShape(pp.shape) - - self.DCC.deleteSlider(self) - - @stackable - def setInterpolation(self, interp): - """Set the interpolation of a single Slider - - Parameters - ---------- - interp : str - The interpolation for this Slider's Progression - - Returns - ------- - - """ - self.prog.interp = interp - - @stackable - def setInterps(self, sliders, interp): - """Set the interpolation of multiple Sliders - - Parameters - ---------- - sliders : [Slider - List of sliders to set interpolations on - interp : str - The interpolation to set on the list of Sliders - - Returns - ------- - - """ - # This uses an instantiated slider to set the values - # of multiple sliders. This is so we don't update the - # DCC over and over again - if not sliders: - return - with undoContext(self.DCC): - for slider in sliders: - slider.prog.interp = interp - - @stackable - def createShape(self, shapeName=None, tVal=None): - """Create a shape and add it to a progression - - Parameters - ---------- - shapeName : str or None - The new Shape name in this Slider's Progression. - If None, give it a default name. Defaults to None - tVal : float or None - The value to give the shape in this Slider's Progression. - If None, give it a "smart" default. Defaults to None - - Returns - ------- - : ProgPair - The newly created Shape in a ProgPair already added to the Progression - - """ - pp, idx = self.prog.newProgPair(shapeName, tVal) - mgrs = [model.insertItemManager(self, idx) for model in self.models] - with nested(*mgrs): - pp.prog = self.prog - self.prog.pairs.insert(idx, pp) - self.updateRange() - return pp - - def extractProgressive(self, live=True, offset=10.0, separation=5.0): - """Extract all of the Shapes in this Slider's Progression - - Parameters - ---------- - live : bool - Whether to maintain a live connection in the DCC for the extracted meshes - Defaults to True - offset : float - The offset value for the first extracted mesh in the DCC (Default value = 10.0) - separation : float - The offset to add between any two extracted meshes (Default value = 5.0) - - Returns - ------- - - """ - with undoContext(self.DCC): - pos, neg = [], [] - for pp in sorted(self.prog.pairs): - if pp.value < 0.0: - neg.append((pp.value, pp.shape, offset)) - offset += separation - elif pp.value > 0.0: - pos.append((pp.value, pp.shape, offset)) - offset += separation - # skip the rest value at == 0.0 - neg = list(reversed(neg)) - - for prog in [pos, neg]: - if not prog: - continue - xtVal, shape, shift = prog[-1] - ext, deltaShape = self.DCC.extractWithDeltaShape(shape, live, shift) - for value, shape, shift in prog[:-1]: - self.DCC.extractWithDeltaConnection( - shape, deltaShape, value / xtVal, live, shift - ) - - def extractShape(self, shape, live=True, offset=10.0): - """Extract a Shape that is controlled by a Slider to a DCC mesh - - This is on Slider (vs being on Shape) because live connections are handled - differently based on the different Progression controllers - - Parameters - ---------- - shape : Shape - The shape to extract - offset : float - The offset value for the first extracted mesh in the DCC (Default value = 10.0) - separation : float - The offset to add between any two extracted meshes - live : - (Default value = True) - - Returns - ------- - - """ - return self.DCC.extractShape(shape, live, offset) - - def connectShape(self, shape, mesh=None, live=False, delete=False): - """Connect a Shape that is controlled by a Slider to a DCC mesh - - This is on Slider (vs being on Shape) because live connections are handled - differently based on the different Progression controllers - - Parameters - ---------- - shape : Shape - The shape to connect - mesh : object or None - The DCC mesh to connect to the shape. - If None, search the DCC by name. Defaults to None - live : bool - Whether to maintain a live connection in the DCC for the extracted meshes - Defaults to True - delete : bool - Whether to delete the DCC mesh after it was connected (Default value = False) - - Returns - ------- - - """ - self.DCC.connectShape(shape, mesh, live, delete) - - def updateRange(self): - """Update the range in the DCC for this Slider""" - self.DCC.updateSlidersRange([self]) - - @stackable - def setGroup(self, grp): - """Set the Group for this Slider - - Parameters - ---------- - grp : Group - The Group to put this Slider under - - Returns - ------- - - """ - if grp.groupType is None: - grp.groupType = type(self) - - if not isinstance(self, grp.groupType): - raise ValueError( - "All items in this group must be of type: {}".format(grp.groupType) - ) - - mgrs = [model.moveItemManager(self, grp) for model in self.models] - with nested(*mgrs): - if self.group: - self.group.items.remove(self) - grp.items.append(self) - self.group = grp - - def getInputVectors(self): - """Get the ordered values for input to the solver that activate this Slider - Multiple outputs are possible if the slider has -1 to 1 range - - Parameters - ---------- - - Returns - ------- - : [[float, ....], ....] - The ordered slider values - - """ - inVecs = [] - for pp in self.prog.getExtremePairs(): - inVec = [0.0] * len(self.simplex.sliders) - inVec[self.simplex.sliders.index(self)] = pp.value - inVecs.append(inVec) - return inVecs +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +import itertools +from typing import TYPE_CHECKING, Any + +from ..interface import undoContext +from ..utils import caseSplit, getNextName, makeUnique, singleShot +from .accessor import SimplexTreeAccessor +from .dragItem import Draggable +from .group import Group +from .progression import ProgPair, Progression +from .shape import Shape +from .stack import stackable +from .treeItem import TreeItem + +if TYPE_CHECKING: + from .simplex import DCCObject, Simplex + + +class Slider(SimplexTreeAccessor, Draggable): + """A user-input to the simplex system that directly controls a Progression + + Parameters + ---------- + name : str + The name of this Slider + simplex : Simplex + The parent Simplex system + prog : Progression + The Progression that this Slider controls + group : Group + The Group to create this Slider in + color : QColor + The color of this item in the UI + create : bool + Whether to create a DCC Shape, or look for it already in-scene + """ + + classDepth: int = 7 + + def __init__( + self, + name: str, + simplex: Simplex, + prog: Progression, + group: Group, + create: bool = True, + ) -> None: + if group.groupType is not type(self): + raise ValueError("Cannot add this slider to a combo group") + + super().__init__(simplex) + + with self.stack.store(self): + self._name: str = name + self._thing: DCCObject | None = None + self._thingRepr: str | None = None + self.prog: Progression = prog + self.prog.controller = self + self.split: bool = False + self._buildIdx: int | None = None + self._value: float = 0.0 + self._enabled: bool = True + + mn, mx = self.prog.getRange() + self.minValue: float = mn + self.maxValue: float = mx + self.simplex.sliders.append(self) + + with self.insertItemManager(group): + self.group: Group = group + self.group.items.append(self) + + newThing = self.DCC.getSliderThing(self._name) + if newThing is None: + if create: + self.thing = simplex.DCC.createSlider(self) + else: + raise RuntimeError(f"Unable to find existing shape: {self.name}") + else: + self.thing = newThing + + @property + def enabled(self) -> bool: + """Get whether this Slider is evaluated in the solver""" + return self._enabled + + @enabled.setter + @stackable + def enabled(self, value: bool) -> None: + """Get whether this Slider is evaluated in the solver""" + self._enabled = value + + @classmethod + def createSlider( + cls, + name: str, + simplex: Simplex, + group: Group | None = None, + shape: Shape | None = None, + tVal: float = 1.0, + ) -> Slider: + """Create a new slider with a name in a group. + Possibly create a single default shape for this slider + + Parameters + ---------- + name : str + The name of this Slider + simplex : Simplex + The parent Simplex system + group : Group or None + The group to add the Slider to. + If None, create a default group. Defaults to None + shape : Shape or None + What shape to add to the new Slider's Progression at the given tVal + if None, create a default shape. Defaults to None + tVal : float + The value for the new shape in the Slider's Progression. Defaults to 1.0 + + Returns + ------- + Slider + The newly created Slider + """ + if simplex.restShape is None: + raise RuntimeError("Simplex system is missing rest shape") + + if group is None: + if simplex.sliderGroups: + group = simplex.sliderGroups[0] + else: + group = Group(f"{name}_GROUP", simplex, Slider) + + currentNames = [s.name for s in simplex.sliders] + name = getNextName(name, currentNames) + + prog = Progression(name, simplex) + if shape is None: + prog.createShape(name, tVal) + else: + prog.pairs.append(ProgPair(simplex, shape, tVal)) + + sli = cls(name, simplex, prog, group) + return sli + + @classmethod + def createMultiSlider( + cls, + name: str, + simplex: Simplex, + shapes: list[Shape], + tVals: list[float], + group: Group | None = None, + ) -> Slider: + """Create a new slider with a name (possibly in a custom group) with the + provided shapes and t-values. + + Parameters + ---------- + name : str + The name of this Slider + simplex : Simplex + The parent Simplex system + shape : list of Shape + What shape s to add to the new Slider's Progression at the given tVals + tVal : list of float + The values for the new shapes in the Slider's Progression + group : Group or None + The group to add the Slider to. + If None, create a default group. Defaults to None + Returns + ------- + Slider + The newly created Slider + """ + if simplex.restShape is None: + raise RuntimeError("Simplex system is missing rest shape") + + if group is None: + if simplex.sliderGroups: + group = simplex.sliderGroups[0] + else: + group = Group(f"{name}_GROUP", simplex, Slider) + + currentNames = [s.name for s in simplex.sliders] + name = getNextName(name, currentNames) + + prog = Progression(name, simplex) + for shape, tVal in zip(shapes, tVals): + prog.pairs.append(ProgPair(simplex, shape, tVal)) + + sli = cls(name, simplex, prog, group) + return sli + + @property + def name(self) -> str: + """Get the name of a Slider""" + return self._name + + @name.setter + @stackable + def name(self, value: str) -> None: + """Set the name of a Slider""" + self._name = value + self.prog.name = value + self.DCC.renameSlider(self, value) + # TODO Also rename the combos + + def nameLinks(self) -> list[bool]: + sp = self._name.split("_") + sliderPoss = [] + for orig in sp: + s = caseSplit(orig) + if len(s) > 1: + s = [orig] + s + sliderPoss.append(s) + + # remove numbered chunks from the end + shapeNames = [] + shapes = [p.shape for p in self.prog.pairs] + for s in shapes: + x = s.name.rsplit("_", 1) + if len(x) == 2: + base, sfx = x + if sfx[0].lower() == "n": + sfx = sfx[1:] + x = base if sfx.isdigit() else s.name + shapeNames.append(x) + + out = [False] * len(shapeNames) + for poss in itertools.product(*sliderPoss): + check = "".join(poss) + for i, s in enumerate(shapeNames): + if check == s: + out[i] = True + if all(out): + break + return out + + @classmethod + def buildSliderName(cls, pairs: list[tuple[str, float]]) -> str: + """Figure out then name for a slider with given shapes + + This will mostly be used to figure out what the new name + for a slider will be if its shapes are renamed + + Parameters + ---------- + pairs : [(str, float)] + A list of (name, value) pairs + + Returns + ------- + : str + A newly created name + """ + # In this case, pairs is *not* a list of ProgPairs + # but a list of (name, value) tuples + + # First, I'm just going to ignore anything with values that aren't 1.0 + # This simplifies the logic greatly + extPairs = [p for p in pairs if abs(p[1]) == 1.0] + if len(extPairs) == 1: + return extPairs[0][0] + if extPairs[0] == 1: + extPairs = extPairs[::-1] + + names = [] + for ep in extPairs: + sp = ep[0].split("_") + if Shape.isNumberField(sp[-1]): + sp = sp[:-1] + names.append(sp) + + return "_".join(["".join(makeUnique(n)) for n in names]) + + @property + def thing(self) -> DCCObject: + """Get the stored reference to the DCC attribute""" + # if this is a deepcopied object, then self._thing will + # be None. Rebuild the thing connection by its representation + if self._thing is None and self._thingRepr: + self._thing = self.DCC.loadPersistentSlider(self._thingRepr) + return self._thing + + @thing.setter + def thing(self, value: DCCObject) -> None: + self._thing = value + self._thingRepr = self.DCC.getPersistentSlider(value) + + @property + def value(self) -> float: + """Get the current value for this Slider""" + return self._value + + @value.setter + def value(self, val: float) -> None: + """Set the current value for this Slider""" + self._value = val + # singleShot consolidates all + self._setAllSliders(self) # type: ignore + + @singleShot() + def _setAllSliders(self, *sliders: Slider) -> None: + with undoContext(self.DCC): + for slider in sliders: + self.DCC.setSliderWeight(slider, slider.value) + + def updateValue(self) -> None: + pass + + @classmethod + def loadV2( + cls, + simplex: Simplex, + progs: list[Progression], + data: dict[str, Any], + create: bool, + ) -> Slider: + """Load the data from a version2 formatted json dictionary + + Parameters + ---------- + simplex : Simplex + The Simplex system that's being built + progs : [Progression + The progressions that have already been built + data : dict + The chunk of the json dict used to build this object + create : bool + Whether to create the DCC Shape, or look for it already in-scene + + Returns + ------- + : Slider + The specified Slider + """ + name = data["name"] + prog = progs[data["prog"]] + group = simplex.groups[data.get("group", 0)] + return cls(name, simplex, prog, group, create=create) + + def buildDefinition(self, simpDict: dict[str, Any], legacy: bool) -> int: + """Output a dictionary definition of this object + + Parameters + ---------- + simpDict : dict + The dictionary that is being built + legacy : bool + Whether to write out the legacy definition, or the newer one + + Returns + ------- + : int + The build index + """ + if self._buildIdx is None: + self._buildIdx = len(simpDict["sliders"]) + if legacy: + gIdx = self.group.buildDefinition(simpDict, legacy) + pIdx = self.prog.buildDefinition(simpDict, legacy) + simpDict.setdefault("sliders", []).append([self.name, pIdx, gIdx]) + else: + x = { + "name": self.name, + "prog": self.prog.buildDefinition(simpDict, legacy), + "group": self.group.buildDefinition(simpDict, legacy), + "enabled": self._enabled, + } + simpDict.setdefault("sliders", []).append(x) + return self._buildIdx + + def clearBuildIndex(self) -> None: + """Clear the build index of this object + + The buildIndex is stored when building a definition dictionary + that keeps track of its index for later referencing + """ + self._buildIdx = None + self.prog.clearBuildIndex() + self.group.clearBuildIndex() + + def setRange(self) -> None: + """Set the range of this Slider based on the progPair values""" + values = [i.value for i in self.prog.pairs] + self.minValue = min(values) + self.maxValue = max(values) + self.DCC.setSliderRange(self) + + @stackable + def delete(self) -> None: + """Delete a slider, any shapes it contains, and all downstream Combos and Traversals""" + self.simplex.deleteDownstream(self) + with self.removeItemManager(self): + g = self.group + g.items.remove(self) + self.group = None # type: ignore + self.simplex.sliders.remove(self) + + pairs = self.prog.pairs[:] # gotta make a copy + for pp in pairs: + if not pp.shape.isRest: + self.simplex.shapes.remove(pp.shape) + self.DCC.deleteShape(pp.shape) + + self.DCC.deleteSlider(self) + + @stackable + def setInterpolation(self, interp: str) -> None: + """Set the interpolation of a single Slider + + Parameters + ---------- + interp : str + The interpolation for this Slider's Progression + """ + self.prog.interp = interp + + @stackable + def setInterps(self, sliders: list[Slider], interp: str) -> None: + """Set the interpolation of multiple Sliders + + Parameters + ---------- + sliders : [Slider + List of sliders to set interpolations on + interp : str + The interpolation to set on the list of Sliders + """ + # This uses an instantiated slider to set the values + # of multiple sliders. This is so we don't update the + # DCC over and over again + if not sliders: + return + with undoContext(self.DCC): + for slider in sliders: + slider.prog.interp = interp + + @stackable + def createShape( + self, shapeName: str | None = None, tVal: float | None = None + ) -> ProgPair: + """Create a shape and add it to a progression + + Parameters + ---------- + shapeName : str or None + The new Shape name in this Slider's Progression. + If None, give it a default name. Defaults to None + tVal : float or None + The value to give the shape in this Slider's Progression. + If None, give it a "smart" default. Defaults to None + + Returns + ------- + : ProgPair + The newly created Shape in a ProgPair already added to the Progression + """ + pp, idx = self.prog.newProgPair(shapeName, tVal) + with self.insertItemManager(self, row=idx): + pp.prog = self.prog + self.prog.pairs.insert(idx, pp) + self.updateRange() + return pp + + def extractProgressive( + self, live: bool = True, offset: float = 10.0, separation: float = 5.0 + ) -> None: + """Extract all of the Shapes in this Slider's Progression + + Parameters + ---------- + live : bool + Whether to maintain a live connection in the DCC for the extracted meshes + Defaults to True + offset : float + The offset value for the first extracted mesh in the DCC (Default value = 10.0) + separation : float + The offset to add between any two extracted meshes (Default value = 5.0) + """ + with undoContext(self.DCC): + pos, neg = [], [] + for pp in sorted(self.prog.pairs): + if pp.value < 0.0: + neg.append((pp.value, pp.shape, offset)) + offset += separation + elif pp.value > 0.0: + pos.append((pp.value, pp.shape, offset)) + offset += separation + # skip the rest value at == 0.0 + neg = list(reversed(neg)) + + for prog in [pos, neg]: + if not prog: + continue + xtVal, shape, shift = prog[-1] + ret = self.DCC.extractWithDeltaShape(shape, live, shift) + if ret is not None: + ext, deltaShape = ret + for value, shape, shift in prog[:-1]: + self.DCC.extractWithDeltaConnection( + shape, deltaShape, value / xtVal, live, shift + ) + + def extractShape( + self, shape: Shape, live: bool = True, offset: float = 10.0 + ) -> DCCObject: + """Extract a Shape that is controlled by a Slider to a DCC mesh + + This is on Slider (vs being on Shape) because live connections are handled + differently based on the different Progression controllers + + Parameters + ---------- + shape : Shape + The shape to extract + live : + (Default value = True) + offset : float + The offset value for the first extracted mesh in the DCC (Default value = 10.0) + """ + return self.DCC.extractShape(shape, live, offset) + + def connectShape( + self, + shape: Shape, + mesh: DCCObject | None = None, + live: bool = False, + delete: bool = False, + ) -> None: + """Connect a Shape that is controlled by a Slider to a DCC mesh + + This is on Slider (vs being on Shape) because live connections are handled + differently based on the different Progression controllers + + Parameters + ---------- + shape : Shape + The shape to connect + mesh : object or None + The DCC mesh to connect to the shape. + If None, search the DCC by name. Defaults to None + live : bool + Whether to maintain a live connection in the DCC for the extracted meshes + Defaults to True + delete : bool + Whether to delete the DCC mesh after it was connected (Default value = False) + """ + self.DCC.connectShape(shape, mesh, live, delete) + + def updateRange(self) -> None: + """Update the range in the DCC for this Slider""" + self.DCC.updateSlidersRange([self]) + + @stackable + def setGroup(self, grp: Group) -> None: + """Set the Group for this Slider + + Parameters + ---------- + grp : Group + The Group to put this Slider under + """ + if grp.groupType is None: + grp.groupType = type(self) + + if not isinstance(self, grp.groupType): + raise ValueError( + f"All items in this group must be of type: {grp.groupType}" + ) + + with self.moveItemManager(self, grp): + if self.group: + self.group.items.remove(self) + grp.items.append(self) + self.group = grp + + def getInputVectors(self) -> list[list[float]]: + """Get the ordered values for input to the solver that activate this Slider + Multiple outputs are possible if the slider has -1 to 1 range + + Returns + ------- + : [[float, ....], ....] + The ordered slider values + """ + inVecs = [] + for pp in self.prog.getExtremePairs(): + inVec = [0.0] * len(self.simplex.sliders) + inVec[self.simplex.sliders.index(self)] = pp.value + inVecs.append(inVec) + return inVecs + + def treeChild(self, row: int) -> TreeItem: + return self.prog.pairs[row] + + def treeRow(self) -> int: + return self.group.items.index(self) + + def treeParent(self) -> TreeItem: + return self.group + + def treeChildCount(self) -> int: + return len(self.prog.pairs) + + def treeData(self, column: int) -> Any | None: + if column == 0: + return self.name + if column == 1: + return self.value + return None + + def treeChecked(self) -> bool: + return self.enabled diff --git a/src/python/simplexui/items/stack.py b/src/python/simplexui/items/stack.py index 75d6d25c..02742bc7 100644 --- a/src/python/simplexui/items/stack.py +++ b/src/python/simplexui/items/stack.py @@ -1,154 +1,147 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# pylint:disable=missing-docstring,unused-argument,no-self-use -import copy -from collections import OrderedDict -from contextlib import contextmanager -from functools import wraps - -from ..interface import undoContext - - -# UNDO STACK SETUP -class Stack(object): - """Integrate simplex into the DCC undo stack""" - - def __init__(self): - self._stack = OrderedDict() - self.depth = 0 - self.currentRevision = 0 - self.enabled = True - - def __setitem__(self, key, value): - gt = [] - # when setting a new key, remove all keys from - # the previous branch - for k in reversed(self._stack): # pylint: disable=bad-reversed-sequence - if k > key: - gt.append(k) - else: - # yay ordered dict - break - for k in gt: - del self._stack[k] - # traceback.print_stack() - self._stack[key] = value - - def getRevision(self, revision): - """Every time a change is made to the simplex definition, - the revision counter is updated, and the revision/definition - pair is put on the undo stack - - Parameters - ---------- - revision : int - The revision number to get - - Returns - ------- - : Simplex or None - The stored Simplex system for the given revision - or None if nothing found - - """ - # This method will ***ONLY*** be called by the undo callback - # Seriously, don't call this yourself - if revision != self.currentRevision: - if revision in self._stack: - data = self._stack[revision] - self.currentRevision = revision - return data - return None - - def purge(self): - """Clear the undo stack. This should be done on new-file""" - self._stack = OrderedDict() - self.depth = 0 - self.currentRevision = 0 - - @contextmanager - def store(self, wrapObj): - """A context manager That will store changes to a Simplex system - Nested calls to this manager will only store the first one - - Parameters - ---------- - wrapObj : object - A system object that has a reference to the Simplex - - Returns - ------- - - """ - from .simplex import Simplex - - if self.enabled: - with undoContext(wrapObj.DCC): - self.depth += 1 - try: - yield - finally: - self.depth -= 1 - - if self.depth == 0: - # Only store the top Level of the stack - srevision = wrapObj.DCC.incrementRevision() - if not isinstance(wrapObj, Simplex): - wrapObj = wrapObj.simplex - self[srevision] = copy.deepcopy(wrapObj) - else: - yield - - -def stackable(method): - """A Decorator to make a method auto update the stack - This decorator can only be used on methods of an object - that has its .simplex value set with a stack. If you need - to wrap an init method, use the stack.store contextmanager - - Parameters - ---------- - method : - - - Returns - ------- - - """ - - @wraps(method) - def stacked(self, *data, **kwdata): - """Decorator closure that handles the stack - - Parameters - ---------- - *data : - - **kwdata : - - - Returns - ------- - - """ - ret = None - with self.stack.store(self): - ret = method(self, *data, **kwdata) - return ret - - return stacked +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +import copy +from collections import OrderedDict +from contextlib import contextmanager +from functools import wraps +from typing import TYPE_CHECKING, Any + +from ..interface import undoContext + +if TYPE_CHECKING: + from .simplex import Simplex + + +# UNDO STACK SETUP +class Stack: + """Integrate simplex into the DCC undo stack""" + + def __init__(self) -> None: + # Technically not needed, but I depend on the ordering behavior + self._stack: OrderedDict[int, Any] = OrderedDict() + self.depth: int = 0 + self.currentRevision: int = 0 + self.enabled: bool = True + + def push(self, key: int, value: Any) -> None: + """Push the memento value onto the stack for the given revision key""" + gt = [] + # when setting a new key, remove all keys from + # the previous branch + for k in reversed(self._stack): + if k > key: + gt.append(k) + else: + # yay ordered dict + break + for k in gt: + del self._stack[k] + self._stack[key] = value + + def getRevision(self, revision: int) -> Simplex | None: + """Every time a change is made to the simplex definition, + the revision counter is updated, and the revision/definition + pair is put on the undo stack + + Parameters + ---------- + revision : int + The revision number to get + + Returns + ------- + : Simplex or None + The stored Simplex system for the given revision + or None if nothing found + """ + # This method will ***ONLY*** be called by the undo callback + # Seriously, don't call this yourself + if revision != self.currentRevision: + if revision in self._stack: + data = self._stack[revision] + self.currentRevision = revision + return data + return None + + def purge(self) -> None: + """Clear the undo stack. This should be done on new-file""" + self._stack = OrderedDict() + self.depth = 0 + self.currentRevision = 0 + + @contextmanager + def store(self, wrapObj: Any): + """A context manager That will store changes to a Simplex system + Nested calls to this manager will only store the first one + + Parameters + ---------- + wrapObj : object + A system object that has a reference to the Simplex + """ + if self.enabled: + with undoContext(wrapObj.DCC): + self.depth += 1 + try: + yield + finally: + self.depth -= 1 + + if self.depth == 0: + # Only store the top Level of the stack + srevision = wrapObj.DCC.incrementRevision() + + """ + print("COPYING", wrapObj.simplex) + for k in dir(wrapObj.simplex): + print(k, type(getattr(wrapObj.simplex, k))) + print("------------DONE") + """ + memo = {} + try: + self.push(srevision, copy.deepcopy(wrapObj.simplex, memo=memo)) + except Exception: + print("MEMO!", memo) + raise + else: + yield + + +class Memo(dict): + def __setitem__(self, key, value) -> None: + print("SETTING", key, value) + super().__setitem__(key, value) + + +def stackable(method): + """A Decorator to make a method auto update the stack + This decorator can only be used on methods of an object + that has its .simplex value set with a stack. If you need + to wrap an init method, use the stack.store contextmanager + """ + + @wraps(method) + def stacked(self, *data, **kwdata): + """Decorator closure that handles the stack""" + ret = None + with self.stack.store(self): + ret = method(self, *data, **kwdata) + return ret + + return stacked diff --git a/src/python/simplexui/items/traversal.py b/src/python/simplexui/items/traversal.py index 40a56069..34409570 100644 --- a/src/python/simplexui/items/traversal.py +++ b/src/python/simplexui/items/traversal.py @@ -1,966 +1,780 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -# pylint:disable=missing-docstring,unused-argument,no-self-use -from Qt.QtGui import QColor -from ..utils import nested -from .accessor import SimplexAccessor -from .combo import Combo -from .group import Group -from .progression import Progression -from .slider import Slider -from .stack import stackable - - -class TravPair(SimplexAccessor): - """ """ - - classDepth = 4 - - def __init__(self, slider, value): - simplex = slider.simplex - super(TravPair, self).__init__(simplex) - self.slider = slider - self._value = float(value) - self.minValue = -1.0 - self.maxValue = 1.0 - self._tickDelta = 0.0 - self.travPoint = None - self.expanded = {} - - def valueTick(self, ticks, mul): - """ """ - self._tickDelta += self.dragStep * ticks * mul - if (self._tickDelta + self.value) <= self.slider.minValue: - self._tickDelta = self.slider.minValue - self.value - if self.value != self.slider.minValue: - self.value = self.slider.minValue - self._tickDelta = 0.0 - elif (self._tickDelta + self.value) >= self.slider.maxValue: - self._tickDelta = self.slider.maxValue - self.value - if self.value != self.slider.maxValue: - self._tickDelta = 0.0 - self.value = self.slider.maxValue - elif abs(self._tickDelta + self.value) <= 1.0e-5: - if self.value != 0.0: - self._tickDelta = 0.0 - self.value = 0.0 - - @property - def models(self): - """ """ - return self.simplex.models - - @property - def name(self): - """ """ - return self.slider.name - - @property - def value(self): - """ """ - return self._value - - @value.setter - @stackable - def value(self, val): - """ """ - self._value = val - for model in self.models: - model.itemDataChanged(self) - - def buildDefinition(self, simpDict, legacy): - """ """ - sIdx = self.slider.buildDefinition(simpDict, legacy) - return sIdx, self.value - - def treeRow(self): - """ """ - return self.travPoint.pairs.index(self) - - def treeParent(self): - """ """ - return self.travPoint - - def treeData(self, column): - """ """ - if column == 0: - return self.name - if column == 1: - return self.value - return None - - @stackable - def remove(self): - """ """ - mgrs = [model.removeItemManager(self) for model in self.models] - with nested(*mgrs): - self.travPoint.pairs.remove(self) - self.travPoint = None - - @stackable - def delete(self): - """ """ - self.travPoint.traversal.removePairs([self]) - - @staticmethod - def removeAll(pairs): - """ """ - travs = list({p.travPoint.traversal for p in pairs}) - for trav in travs: - trav.removePairs(pairs) - - -class TravPoint(SimplexAccessor): - """ """ - - classDepth = 3 - - def __init__(self, pairs, row): - if not pairs: - raise ValueError("Pairs must be provided for a TravPoint") - simplex = pairs[0].slider.simplex - super(TravPoint, self).__init__(simplex) - - self.pairs = pairs - for pair in pairs: - pair.travPoint = self - self.row = row - self.traversal = None - self.expanded = {} - - def sliders(self): - """ """ - return [i.slider for i in self.pairs] - - @staticmethod - def _wideCeiling(val, eps=0.001): - """ """ - if val > eps: - return 1.0 - elif val < -eps: - return -1.0 - return 0.0 - - @stackable - def addPair(self, pair): - """ - - Parameters - ---------- - pair : - - - Returns - ------- - - """ - mgrs = [model.insertItemManager(self) for model in self.models] - with nested(*mgrs): - self.pairs.append(pair) - pair.travPoint = self - - def removePair(self, pair): - """ - - Parameters - ---------- - pair : - - - Returns - ------- - - """ - pair.remove() - - def addSlider(self, slider, val=None): - """ - - Parameters - ---------- - slider : - - val : - (Default value = None) - - Returns - ------- - - """ - val = val if val is not None else slider.value - val = self._wideCeiling(val) - sliders = self.sliders() - try: - idx = sliders.index(slider) - except ValueError: - self.addPair(TravPair(slider, val)) - else: - self.pairs[idx].value = val - - def addItem(self, item): - """ - - Parameters - ---------- - item : - - - Returns - ------- - - """ - if isinstance(item, Slider): - self.addSlider(item) - elif isinstance(item, Combo): - for cp in item.pairs: - self.addSlider(cp.slider, cp.value) - - @property - def name(self): - """ """ - return "START" if self.row == 0 else "END" - - def treeData(self, column): - """ - - Parameters - ---------- - column : - - - Returns - ------- - - """ - if column == 0: - return self.name - return None - - def treeChild(self, row): - """ - - Parameters - ---------- - row : - - - Returns - ------- - - """ - return self.pairs[row] - - def treeRow(self): - """ """ - return self.row - - def treeParent(self): - """ """ - return self.traversal - - def treeChildCount(self): - """ """ - return len(self.pairs) - - def buildDefinition(self, simpDict, legacy): - """ - - Parameters - ---------- - simpDict : - - legacy : - - - Returns - ------- - - """ - return [p.buildDefinition(simpDict, legacy) for p in self.pairs] - - def getInputVector(self): - """get the input to the solver that would fully activate this point of the traversal - - parameters - ---------- - - returns - ------- - : [float, ...] - the ordered slider values - - """ - invec = [0.0] * len(self.simplex.sliders) - for cp in self.pairs: - invec[self.simplex.sliders.index(cp.slider)] = cp.value - return invec - - -class Traversal(SimplexAccessor): - """Traversals control a Progression based on any 2 points in the Solver space. - - Traversals only make sense with intermediate shapes in the progression of the sliders - that control it. - - Traversals should never have a shape at 100%. That shape should be handled by a Combo - - First: A "point in solver space" just means a list of slider/value pairs. - The Slider/Value pairs that make a up a Combo are just a "Point in solver space" as well. - So technically Combos could be thought of as a special-case of Traversals. Combos control - a progression between the "Rest Point" where all sliders are at 0, and the Combo point - - Outside of the context of Traversals, I just call solver space points "Combos", because - I don't need to be crazy specific like I do here. - - The initial use-case for Traversals was dealing with eye combo shapes with incremental - Progressions. The eyeLookDown and the eyeClosed shapes both pull the upper lid down a great - deal, and the eyeClosed was a 4-shape progression. So, when transitioning from eyeLookDown - to eyeLookDown+eyeClosed, the deltas for all the progressive shapes were being triggered as - the combo was coming on, causing major wobbles in the eyelid. So we needed shapes that - countered those incrementals, but *only* on the transition from eyeLookDown to - eyeLookDown+eyeClosed (NOT on the transition from eyeClosed to eyeLookDown+eyeClosed) - - Early setups used floating Combos, but those have linearinterpolation, and I wanted a - cleaner solution. That solution is the Traversal - - Parameters - ---------- - name : str - The name of this Combo - simplex : Simplex - The parent Simplex system - startPoint : TravPoint - A set of Slider/Value pairs where the Traversal solves to 0 - endPoint : TravPoint - A set of Slider/Value pairs where the Traversal solves to 1 - prog : Progression - The Progression that this Combo controls - group : Group - The Group to create this combo in - color : QColor - The color of this item in the UI - - Returns - ------- - - """ - - classDepth = 2 - - def __init__( - self, - name, - simplex, - startPoint, - endPoint, - prog, - group, - color=None, - ): - super(Traversal, self).__init__(simplex) - color = QColor(128, 128, 128) if color is None else color - with self.stack.store(self): - if group.groupType is not type(self): - raise ValueError( - "Cannot add this Traversal to a group of a different type" - ) - self._name = name - self.startPoint = startPoint - self.endPoint = endPoint - self.prog = prog - self._buildIdx = None - self.expanded = {} - self._enabled = True - self.color = color - - mgrs = [model.insertItemManager(group) for model in self.models] - with nested(*mgrs): - self.group = group - self.startPoint.traversal = self - self.endPoint.traversal = self - self.prog.controller = self - self.group.items.append(self) - self.simplex.traversals.append(self) - - @classmethod - def createTraversal(cls, name, simplex, startPairs, endPairs, group=None, count=4): - """Create a Traversal between two lists of pairs - - Parameters - ---------- - name : str - The name of this Combo - simplex : Simplex - The parent Simplex system - startPairs : [(Slider - A list of Slider/Value pairs to make the startPoint - endPairs : [(Slider - A list of Slider/Value pairs to make the endPoint - group : Group - The Group to create this combo in (Default value = None) - count : int - The number of incrementals to create (including the 100%) (Default value = 4) - - Returns - ------- - - """ - if simplex.restShape is None: - raise RuntimeError("Simplex system is missing rest shape") - - if group is None: - gname = "TRAVERSALS" - matches = [i for i in simplex.traversalGroups if i.name == gname] - if matches: - group = matches[0] - else: - group = Group(gname, simplex, Traversal) - - startPairs = [TravPair(p[0], p[1]) for p in startPairs] - endPairs = [TravPair(p[0], p[1]) for p in endPairs] - - startPoint = TravPoint(startPairs, 0) - endPoint = TravPoint(endPairs, 1) - - prog = Progression(name, simplex) - trav = cls(name, simplex, startPoint, endPoint, prog, group) - - for c in reversed(list(range(count))): - val = (100 * (c + 1)) // count - pp = prog.createShape("{0}_{1}".format(name, val), val / 100.0) - simplex.DCC.zeroShape(pp.shape) - return trav - - @property - def enabled(self): - """Get whether this Traversal is evaluated in the solver""" - return self._enabled - - @enabled.setter - @stackable - def enabled(self, value): - """Set whether this Traversal is evaluated in the solver - - Parameters - ---------- - value : - - - Returns - ------- - - """ - self._enabled = value - for model in self.models: - model.itemDataChanged(self) - - @property - def name(self): - """Get the name of a Traversal""" - return self._name - - @name.setter - @stackable - def name(self, value): - """Set the name of a Traversal - - Parameters - ---------- - value : - - - Returns - ------- - - """ - self._name = value - self.prog.name = value - # self.DCC.renameTraversal(self, value) - # for model in self.models: - # model.itemDataChanged(self) - - def treeChild(self, row): - """ - - Parameters - ---------- - row : - - - Returns - ------- - - """ - if row == 0: - return self.startPoint - elif row == 1: - return self.endPoint - elif row == 2: - return self.prog - return None - - def treeRow(self): - """ """ - return self.group.items.index(self) - - def treeParent(self): - """ """ - return self.group - - def treeChildCount(self): - """ """ - return 3 - - def treeChecked(self): - """ """ - return self.enabled - - def allSliders(self): - """Get the list of all Sliders that control this Traversal - - Parameters - ---------- - - Returns - ------- - : [Slider, ...] - The list of all Sliders that control this Traversal - - """ - startSliders = [p.slider for p in self.startPoint.pairs] - endSliders = [ - p.slider for p in self.endPoint.pairs if p.slider not in startSliders - ] - return startSliders + endSliders - - def dynamicSliders(self): - """Get a list of sliders that have different values at the start and end""" - return [sli for sli, rng in self.ranges() if rng[0] != rng[1]] - - def staticSliders(self): - """Get a list of sliders that have the same values at the start and end""" - return [sli for sli, rng in self.ranges() if rng[0] == rng[1]] - - def ranges(self): - """Get the range per Slider for this Traversal - - Parameters - ---------- - - Returns - ------- - : type - (dict): A {Slider: range} dict - - """ - startDict = {p.slider: p.value for p in self.startPoint.pairs} - endDict = {p.slider: p.value for p in self.endPoint.pairs} - allSliders = startDict.keys() | endDict.keys() - - rangeDict = {} - for sli in allSliders: - rangeDict[sli] = (startDict.get(sli, 0.0), endDict.get(sli, 0.0)) - return rangeDict - - @staticmethod - def buildTraversalName(ranges): - """Given the range dict (like from Traversal.ranges()) come up with a name - - Parameters - ---------- - ranges : dict - A {Slider: range} dict - - Returns - ------- - : str - The suggested Traversal name - - """ - static, dynamic = [], [] - for sli, rng in ranges.items(): - if rng[0] == rng[1]: - static.append(sli) - else: - dynamic.append(sli) - - parts = [] - for grp in static, dynamic: - for slider in sorted(grp, key=lambda x: x.name): - prefix = None - start, end = ranges[slider] - if start == end: - # prefix = 'St' # St for Static - if start == 0: - continue - shp = slider.prog.getShapeAtValue(start) - if shp is None: - continue - name = shp.strippedName() - else: - prefix = "Dy" # Dy for Dynamic - if start == 0: - shp = slider.prog.getShapeAtValue(end) - if shp is None: - continue - name = shp.strippedName() - elif end == 0: - shp = slider.prog.getShapeAtValue(start) - if shp is None: - continue - name = shp.strippedName() - else: - name = slider.name - - if prefix is not None: - parts.append(prefix) - parts.append(name) - - return "Tv_" + "_".join(parts) - - def controllerNameLinks(self): - """ """ - surr = "_{0}_".format(self.name) - return ["_{0}_".format(sli) in surr for sli in self.allSliders()] - - def nameLinks(self): - """ - - Parameters - ---------- - - Returns - ------- - : type - progression depends on this traversal's name - - """ - # In this case, these names will *NOT* have the possibility of - # a pos/neg name. Only the traversal name, and possibly a percentage - shapeNames = [] - shapes = [i.shape for i in self.prog.pairs] - for s in shapes: - x = s.name.rsplit("_", 1) - if len(x) == 2: - base, sfx = x - x = base if sfx.isdigit() else s.name - shapeNames.append(x) - return [i == self.name for i in shapeNames] - - @stackable - def createShape(self, shapeName=None, tVal=None): - """Create a shape and add it to a progression - - Parameters - ---------- - shapeName : str or None - The name of the shape to create. - If None, give it a default name - tVal : float or None - The progression value to set for the new Shape. - If None, it gets a "smart" default value - - Returns - ------- - - """ - pp, idx = self.prog.newProgPair(shapeName, tVal) - mgrs = [model.insertItemManager(self.prog, idx) for model in self.models] - with nested(*mgrs): - pp.prog = self.prog - self.prog.pairs.insert(idx, pp) - return pp - - @classmethod - def loadV2(cls, simplex, progs, data): - """Load the data from a version2 formatted json dictionary - - Parameters - ---------- - simplex : Simplex - The Simplex system that's being built - progs : [Progression - The progressions that have already been built - data : dict - The chunk of the json dict used to build this object - - Returns - ------- - : Traversal - The specified Traversal - - """ - name = data["name"] - prog = progs[data["prog"]] - group = simplex.groups[data.get("group", 2)] - color = QColor(*data.get("color", (0, 0, 0))) - - rangeDict = {} # slider: [startVal, endVal] - - pFlip = -1.0 if data["progressFlip"] else 1.0 - pcIdx = data["progressControl"] - if data["progressType"].lower() == "slider": - sli = simplex.sliders[pcIdx] - rangeDict[sli] = (0.0, pFlip) - else: - cmb = simplex.combos[pcIdx] - for cp in cmb.pairs: - rangeDict[cp.slider] = (0.0, cp.value) - - mFlip = -1.0 if data["multiplierFlip"] else 1.0 - mcIdx = data["multiplierControl"] - if data["multiplierType"].lower() == "slider": - sli = simplex.sliders[mcIdx] - rangeDict[sli] = (mFlip, mFlip) - else: - cmb = simplex.combos[mcIdx] - for cp in cmb.pairs: - rangeDict[cp.slider] = (cp.value, cp.value) - - ssli = sorted((rangeDict.items()), key=lambda x: x[0].name) - startPairs, endPairs = [], [] - for slider, (startVal, endVal) in ssli: - startPairs.append(TravPair(slider, startVal)) - endPairs.append(TravPair(slider, endVal)) - - startPoint = TravPoint(startPairs, 0) - endPoint = TravPoint(endPairs, 1) - - return cls(name, simplex, startPoint, endPoint, prog, group, color) - - @classmethod - def loadV3(cls, simplex, progs, data): - """Load the data from a version3 formatted json dictionary - - Parameters - ---------- - simplex : Simplex - The Simplex system that's being built - progs : [Progression - The progressions that have already been built - data : dict - The chunk of the json dict used to build this object - - Returns - ------- - : Traversal - The specified Traversal - - """ - name = data["name"] - prog = progs[data["prog"]] - group = simplex.groups[data.get("group", 2)] - color = QColor(*data.get("color", (0, 0, 0))) - - startDict = dict(data["start"]) - endDict = dict(data["end"]) - sliIdxs = sorted(startDict.keys() | endDict.keys()) - startPairs, endPairs = [], [] - for idx in sliIdxs: - startPairs.append(TravPair(simplex.sliders[idx], startDict.get(idx, 0.0))) - endPairs.append(TravPair(simplex.sliders[idx], endDict.get(idx, 0.0))) - startPoint = TravPoint(startPairs, 0) - endPoint = TravPoint(endPairs, 1) - - return cls(name, simplex, startPoint, endPoint, prog, group, color) - - def buildDefinition(self, simpDict, legacy): - """Output a dictionary definition of this object - - Parameters - ---------- - simpDict : dict - The dictionary that is being built - legacy : bool - Whether to write out the legacy definition, or the newer one - This is ignored for Traversals. There is no legacy definition - - Returns - ------- - - """ - if self._buildIdx is None: - self._buildIdx = len(simpDict["traversals"]) - x = { - "name": self.name, - "prog": self.prog.buildDefinition(simpDict, legacy), - "start": self.startPoint.buildDefinition(simpDict, legacy), - "end": self.endPoint.buildDefinition(simpDict, legacy), - "group": self.group.buildDefinition(simpDict, legacy), - "color": self.color.getRgb()[:3], - "enabled": self._enabled, - } - simpDict.setdefault("traversals", []).append(x) - return self._buildIdx - - def clearBuildIndex(self): - """Clear the build index of this object - - The buildIndex is stored when building a definition dictionary - that keeps track of its index for later referencing - - Parameters - ---------- - - Returns - ------- - - """ - self._buildIdx = None - self.prog.clearBuildIndex() - self.group.clearBuildIndex() - - @stackable - def delete(self): - """Delete a traversal and any shapes it contains""" - mgrs = [model.removeItemManager(self) for model in self.models] - with nested(*mgrs): - g = self.group - if self not in g.items: - return # Can happen when deleting multiple groups - g.items.remove(self) - self.group = None - self.simplex.traversals.remove(self) - - pairs = self.prog.pairs[:] # gotta make a copy - for pp in pairs: - if not pp.shape.isRest: - self.simplex.shapes.remove(pp.shape) - self.DCC.deleteShape(pp.shape) - - def extractShape(self, shape, live=True, offset=10.0): - """Extract a shape from a Traversal progression - - Parameters - ---------- - shape : - - live : - (Default value = True) - offset : - (Default value = 10.0) - - Returns - ------- - - """ - return self.DCC.extractTraversalShape(self, shape, live, offset) - - def addSlider(self, slider): - """Add a slider to both the startPoint and endPoint of this Traversal - - Parameters - ---------- - slider : Slider - The slider to add - - Returns - ------- - - """ - self.startPoint.addSlider(slider, val=0.0) - self.endPoint.addSlider(slider) - - def removePairs(self, pairs): - """Remove the given pairs from both the startPoint and endPoint of this Traversal - - Parameters - ---------- - pairs : [TravPair - The pairs to remove - - Returns - ------- - - """ - # Get only the pairs that are a part of this traversal - sPairs = [i for i in self.startPoint.pairs if i in pairs] - ePairs = [i for i in self.endPoint.pairs if i in pairs] - pairs = sPairs + ePairs - - # Get all the pairs that use the selected sliders - sliders = {p.slider for p in pairs} - sPairs = [i for i in self.startPoint.pairs if i.slider in sliders] - ePairs = [i for i in self.endPoint.pairs if i.slider in sliders] - - # do the removal - for pair in sPairs: - pair.remove() - - for pair in ePairs: - pair.remove() - - @staticmethod - def traversalAlreadyExists(simplex, sliders, ranges): - """In a given simplex syste, check if a traversal exists - with the given sliders and ranges - """ - chk = dict(zip(sliders, ranges)) - for trav in simplex.traversals: - if chk == trav.ranges(): - return trav - return None - - @staticmethod - def getCount(sliders, ranges): - """Get the count of shapes to create for a traversal with the given - sliders and ranges. It's the max number of shapes on a given side of 0 - """ - counts = [] - for sli, rng in zip(sliders, ranges): - if rng[0] == rng[1]: - continue - vals = sli.prog.getValues() - if max(rng) == 0: - count = len([v for v in vals if v < 0]) - else: - count = len([v for v in vals if v > 0]) - counts.append(count) - if not counts: - return 0 - return max(counts) - - def getInputVector(self, value): - """Get the input to the Solver that would set this traversal to - the given value - - Parameters - ---------- - value : float - The value to set the traversal to - - Returns - ------- - : [float, ...] - The ordered slider values - - """ - indexBySlider = {slider: idx for idx, slider in enumerate(self.simplex.sliders)} - - fullStart = [0.0] * len(self.simplex.sliders) - for pair in self.startPoint.pairs: - fullStart[indexBySlider[pair.slider]] = pair.value - - fullEnd = [0.0] * len(self.simplex.sliders) - for pair in self.endPoint.pairs: - fullEnd[indexBySlider[pair.slider]] = pair.value - - def _lerp(s, e, v): - return s * (1 - v) + e * v - - return [_lerp(fs, fe, value) for fs, fe in zip(fullStart, fullEnd)] +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING, Any + +from .accessor import SimplexTreeAccessor +from .combo import Combo +from .dragItem import Draggable +from .group import Group +from .progression import Progression +from .slider import Slider +from .stack import stackable +from .treeItem import TreeItem + +if TYPE_CHECKING: + from .progression import ProgPair + from .simplex import DCCObject, Simplex + + +class TravSide(Enum): + Start = "START" + End = "END" + + +class TravPair(SimplexTreeAccessor, Draggable): + classDepth: int = 4 + + def __init__(self, slider: Slider, value: float) -> None: + simplex = slider.simplex + super().__init__(simplex) + self.slider: Slider = slider + self._value: float = float(value) + self.minValue: float = -1.0 + self.maxValue: float = 1.0 + self._tickDelta: float = 0.0 + self.travPoint: TravPoint | None = None + + @property + def name(self) -> str: + return self.slider.name + + @property + def value(self) -> float: + return self._value + + @value.setter + @stackable + def value(self, val: float) -> None: + self._value = val + + def buildDefinition( + self, simpDict: dict[str, Any], legacy: bool + ) -> tuple[int, float]: + sIdx = self.slider.buildDefinition(simpDict, legacy) + return sIdx, self.value + + @stackable + def remove(self) -> None: + with self.removeItemManager(self): + if self.travPoint is not None: + self.travPoint.pairs.remove(self) + self.travPoint = None + + @stackable + def delete(self) -> None: + if self.travPoint is not None: + if self.travPoint.traversal is not None: + self.travPoint.traversal.removePairs([self]) + + @staticmethod + def removeAll(pairs: list[TravPair]) -> None: + points = [i.travPoint for i in pairs if i.travPoint is not None] + travs = list({pp.traversal for pp in points}) + for trav in travs: + if trav is not None: + trav.removePairs(pairs) + + def treeRow(self) -> int: + if self.travPoint is None: + raise ValueError( + "Somehow you're trying to show a TravPair with no TravPoint" + ) + return self.travPoint.pairs.index(self) + + def treeParent(self) -> TreeItem: + if self.travPoint is None: + raise ValueError("") + return self.travPoint + + def treeData(self, column: int) -> Any | None: + if column == 0: + return self.name + if column == 1: + return self.value + return None + + +class TravPoint(SimplexTreeAccessor): + classDepth: int = 3 + + def __init__(self, pairs: list[TravPair], side: TravSide) -> None: + if not pairs: + raise ValueError("Pairs must be provided for a TravPoint") + simplex = pairs[0].slider.simplex + super().__init__(simplex) + + self.pairs = pairs + for pair in pairs: + pair.travPoint = self + self.side: TravSide = side + self.traversal: Traversal | None = None + + def sliders(self) -> list[Slider]: + return [i.slider for i in self.pairs] + + @staticmethod + def _wideCeiling(val: float, eps: float = 0.001) -> float: + if val > eps: + return 1.0 + elif val < -eps: + return -1.0 + return 0.0 + + @stackable + def addPair(self, pair: TravPair) -> None: + with self.insertItemManager(self): + self.pairs.append(pair) + pair.travPoint = self + + def removePair(self, pair: TravPair) -> None: + pair.remove() + + def addSlider(self, slider: Slider, val: float | None = None) -> None: + val = val if val is not None else slider.value + val = self._wideCeiling(val) + sliders = self.sliders() + try: + idx = sliders.index(slider) + except ValueError: + self.addPair(TravPair(slider, val)) + else: + self.pairs[idx].value = val + + def addItem(self, item: Slider | Combo) -> None: + if isinstance(item, Slider): + self.addSlider(item) + elif isinstance(item, Combo): + for cp in item.pairs: + self.addSlider(cp.slider, cp.value) + + @property + def name(self) -> str: + return self.side.value + + def buildDefinition( + self, simpDict: dict[str, Any], legacy: bool + ) -> list[tuple[int, float]]: + return [p.buildDefinition(simpDict, legacy) for p in self.pairs] + + def getInputVector(self) -> list[float]: + """Get the input to the solver that would fully activate this point of the traversal + + returns + ------- + : [float, ...] + the ordered slider values + """ + invec = [0.0] * len(self.simplex.sliders) + for cp in self.pairs: + invec[self.simplex.sliders.index(cp.slider)] = cp.value + return invec + + def treeData(self, column: int) -> Any | None: + if column == 0: + return self.name + return None + + def treeChild(self, row: int) -> TreeItem: + return self.pairs[row] + + def treeRow(self) -> int: + if self.side == TravSide.Start: + return 0 + return 1 + + def treeParent(self) -> TreeItem | None: + return self.traversal + + def treeChildCount(self) -> int: + return len(self.pairs) + + +class Traversal(SimplexTreeAccessor): + """Traversals control a Progression based on any 2 points in the Solver space. + + Traversals only make sense with intermediate shapes in the progression of the sliders + that control it. + + Traversals should never have a shape at 100%. That shape should be handled by a Combo + + First: A "point in solver space" just means a list of slider/value pairs. + The Slider/Value pairs that make a up a Combo are just a "Point in solver space" as well. + So technically Combos could be thought of as a special-case of Traversals. Combos control + a progression between the "Rest Point" where all sliders are at 0, and the Combo point + + Outside of the context of Traversals, I just call solver space points "Combos", because + I don't need to be crazy specific like I do here. + + The initial use-case for Traversals was dealing with eye combo shapes with incremental + Progressions. The eyeLookDown and the eyeClosed shapes both pull the upper lid down a great + deal, and the eyeClosed was a 4-shape progression. So, when transitioning from eyeLookDown + to eyeLookDown+eyeClosed, the deltas for all the progressive shapes were being triggered as + the combo was coming on, causing major wobbles in the eyelid. So we needed shapes that + countered those incrementals, but *only* on the transition from eyeLookDown to + eyeLookDown+eyeClosed (NOT on the transition from eyeClosed to eyeLookDown+eyeClosed) + + Early setups used floating Combos, but those have linearinterpolation, and I wanted a + cleaner solution. That solution is the Traversal + + Parameters + ---------- + name : str + The name of this Combo + simplex : Simplex + The parent Simplex system + startPoint : TravPoint + A set of Slider/Value pairs where the Traversal solves to 0 + endPoint : TravPoint + A set of Slider/Value pairs where the Traversal solves to 1 + prog : Progression + The Progression that this Combo controls + group : Group + The Group to create this combo in + color : QColor + The color of this item in the UI + """ + + classDepth: int = 2 + + def __init__( + self, + name: str, + simplex: Simplex, + startPoint: TravPoint, + endPoint: TravPoint, + prog: Progression, + group: Group, + ) -> None: + super().__init__(simplex) + with self.stack.store(self): + if group.groupType is not type(self): + raise ValueError( + "Cannot add this Traversal to a group of a different type" + ) + self._name: str = name + self.startPoint: TravPoint = startPoint + self.endPoint: TravPoint = endPoint + self.prog: Progression = prog + self.prog.controller = self + self._buildIdx: int | None = None + self._enabled: bool = True + self.startPoint.traversal = self + self.endPoint.traversal = self + self.simplex.traversals.append(self) + + with self.insertItemManager(group): + self.group: Group = group + self.group.items.append(self) + + @classmethod + def createTraversal( + cls, + name: str, + simplex: Simplex, + startPairs: list[tuple[Slider, float]], + endPairs: list[tuple[Slider, float]], + group: Group | None = None, + count: int = 4, + ) -> Traversal: + """Create a Traversal between two lists of pairs + + Parameters + ---------- + name : str + The name of this Combo + simplex : Simplex + The parent Simplex system + startPairs : [(Slider + A list of Slider/Value pairs to make the startPoint + endPairs : [(Slider + A list of Slider/Value pairs to make the endPoint + group : Group + The Group to create this combo in (Default value = None) + count : int + The number of incrementals to create (including the 100%) (Default value = 4) + """ + if simplex.restShape is None: + raise RuntimeError("Simplex system is missing rest shape") + + if group is None: + gname = "TRAVERSALS" + matches = [i for i in simplex.traversalGroups if i.name == gname] + if matches: + group = matches[0] + else: + group = Group(gname, simplex, Traversal) + + startTPairs = [TravPair(p[0], p[1]) for p in startPairs] + endTPairs = [TravPair(p[0], p[1]) for p in endPairs] + + startPoint = TravPoint(startTPairs, TravSide.Start) + endPoint = TravPoint(endTPairs, TravSide.End) + + prog = Progression(name, simplex) + trav = cls(name, simplex, startPoint, endPoint, prog, group) + + for c in reversed(list(range(count))): + val = (100 * (c + 1)) // count + pp = prog.createShape(f"{name}_{val}", val / 100.0) + simplex.DCC.zeroShape(pp.shape) + return trav + + @property + def enabled(self) -> bool: + """Get whether this Traversal is evaluated in the solver""" + return self._enabled + + @enabled.setter + @stackable + def enabled(self, value: bool) -> None: + """Set whether this Traversal is evaluated in the solver""" + self._enabled = value + + @property + def name(self) -> str: + """Get the name of a Traversal""" + return self._name + + @name.setter + @stackable + def name(self, value: str) -> None: + """Set the name of a Traversal""" + self._name = value + self.prog.name = value + # self.DCC.renameTraversal(self, value) + + def allSliders(self) -> list[Slider]: + """Get the list of all Sliders that control this Traversal + Returns + ------- + : [Slider, ...] + The list of all Sliders that control this Traversal + """ + startSliders = [p.slider for p in self.startPoint.pairs] + endSliders = [ + p.slider for p in self.endPoint.pairs if p.slider not in startSliders + ] + return startSliders + endSliders + + def dynamicSliders(self) -> list[Slider]: + """Get a list of sliders that have different values at the start and end""" + return [sli for sli, rng in self.ranges().items() if rng[0] != rng[1]] + + def staticSliders(self) -> list[Slider]: + """Get a list of sliders that have the same values at the start and end""" + return [sli for sli, rng in self.ranges().items() if rng[0] == rng[1]] + + def ranges(self) -> dict[Slider, tuple[float, float]]: + """Get the range per Slider for this Traversal + + Returns + ------- + : type + (dict): A {Slider: range} dict + """ + startDict = {p.slider: p.value for p in self.startPoint.pairs} + endDict = {p.slider: p.value for p in self.endPoint.pairs} + allSliders = startDict.keys() | endDict.keys() + + rangeDict = {} + for sli in allSliders: + rangeDict[sli] = (startDict.get(sli, 0.0), endDict.get(sli, 0.0)) + return rangeDict + + @stackable + def setGroup(self, grp: Group) -> None: + """Set the Group for this Slider + + Parameters + ---------- + grp : Group + The Group to put this Slider under + """ + if grp.groupType is None: + grp.groupType = type(self) + + if not isinstance(self, grp.groupType): + raise ValueError( + f"All items in this group must be of type: {grp.groupType}" + ) + + if self.group: + self.group.items.remove(self) + grp.items.append(self) + self.group = grp + + @staticmethod + def buildTraversalName(ranges: dict[Slider, tuple[float, float]]) -> str: + """Given the range dict (like from Traversal.ranges()) come up with a name + + Parameters + ---------- + ranges : dict + A {Slider: range} dict + + Returns + ------- + : str + The suggested Traversal name + """ + static, dynamic = [], [] + for sli, rng in ranges.items(): + if rng[0] == rng[1]: + static.append(sli) + else: + dynamic.append(sli) + + parts = [] + for grp in static, dynamic: + for slider in sorted(grp, key=lambda x: x.name): + prefix = None + start, end = ranges[slider] + if start == end: + # prefix = 'St' # St for Static + if start == 0: + continue + shp = slider.prog.getShapeAtValue(start) + if shp is None: + continue + name = shp.strippedName() + else: + prefix = "Dy" # Dy for Dynamic + if start == 0: + shp = slider.prog.getShapeAtValue(end) + if shp is None: + continue + name = shp.strippedName() + elif end == 0: + shp = slider.prog.getShapeAtValue(start) + if shp is None: + continue + name = shp.strippedName() + else: + name = slider.name + + if prefix is not None: + parts.append(prefix) + parts.append(name) + + return "Tv_" + "_".join(parts) + + def controllerNameLinks(self) -> list[bool]: + surr = f"_{self.name}_" + return [f"_{sli}_" in surr for sli in self.allSliders()] + + def nameLinks(self) -> list[bool]: + # In this case, these names will *NOT* have the possibility of + # a pos/neg name. Only the traversal name, and possibly a percentage + shapeNames = [] + shapes = [i.shape for i in self.prog.pairs] + for s in shapes: + x = s.name.rsplit("_", 1) + if len(x) == 2: + base, sfx = x + x = base if sfx.isdigit() else s.name + shapeNames.append(x) + return [i == self.name for i in shapeNames] + + @stackable + def createShape( + self, shapeName: str | None = None, tVal: float | None = None + ) -> ProgPair: + """Create a shape and add it to a progression + + Parameters + ---------- + shapeName : str or None + The name of the shape to create. + If None, give it a default name + tVal : float or None + The progression value to set for the new Shape. + If None, it gets a "smart" default value + """ + pp, idx = self.prog.newProgPair(shapeName, tVal) + with self.insertItemManager(self.prog, row=idx): + pp.prog = self.prog + self.prog.pairs.insert(idx, pp) + return pp + + @classmethod + def loadV2( + cls, simplex: Simplex, progs: list[Progression], data: dict[str, Any] + ) -> Traversal: + """Load the data from a version2 formatted json dictionary + + Parameters + ---------- + simplex : Simplex + The Simplex system that's being built + progs : [Progression + The progressions that have already been built + data : dict + The chunk of the json dict used to build this object + + Returns + ------- + : Traversal + The specified Traversal + """ + name = data["name"] + prog = progs[data["prog"]] + group = simplex.groups[data.get("group", 2)] + + rangeDict = {} # slider: [startVal, endVal] + + pFlip = -1.0 if data["progressFlip"] else 1.0 + pcIdx = data["progressControl"] + if data["progressType"].lower() == "slider": + sli = simplex.sliders[pcIdx] + rangeDict[sli] = (0.0, pFlip) + else: + cmb = simplex.combos[pcIdx] + for cp in cmb.pairs: + rangeDict[cp.slider] = (0.0, cp.value) + + mFlip = -1.0 if data["multiplierFlip"] else 1.0 + mcIdx = data["multiplierControl"] + if data["multiplierType"].lower() == "slider": + sli = simplex.sliders[mcIdx] + rangeDict[sli] = (mFlip, mFlip) + else: + cmb = simplex.combos[mcIdx] + for cp in cmb.pairs: + rangeDict[cp.slider] = (cp.value, cp.value) + + ssli = sorted((rangeDict.items()), key=lambda x: x[0].name) + startPairs, endPairs = [], [] + for slider, (startVal, endVal) in ssli: + startPairs.append(TravPair(slider, startVal)) + endPairs.append(TravPair(slider, endVal)) + + startPoint = TravPoint(startPairs, TravSide.Start) + endPoint = TravPoint(endPairs, TravSide.End) + + return cls(name, simplex, startPoint, endPoint, prog, group) + + @classmethod + def loadV3( + cls, simplex: Simplex, progs: list[Progression], data: dict[str, Any] + ) -> Traversal: + """Load the data from a version3 formatted json dictionary + + Parameters + ---------- + simplex : Simplex + The Simplex system that's being built + progs : [Progression + The progressions that have already been built + data : dict + The chunk of the json dict used to build this object + + Returns + ------- + : Traversal + The specified Traversal + """ + name = data["name"] + prog = progs[data["prog"]] + group = simplex.groups[data.get("group", 2)] + + startDict = dict(data["start"]) + endDict = dict(data["end"]) + sliIdxs = sorted(startDict.keys() | endDict.keys()) + startPairs, endPairs = [], [] + for idx in sliIdxs: + startPairs.append(TravPair(simplex.sliders[idx], startDict.get(idx, 0.0))) + endPairs.append(TravPair(simplex.sliders[idx], endDict.get(idx, 0.0))) + startPoint = TravPoint(startPairs, TravSide.Start) + endPoint = TravPoint(endPairs, TravSide.End) + + return cls(name, simplex, startPoint, endPoint, prog, group) + + def buildDefinition(self, simpDict: dict[str, Any], legacy: bool) -> int: + """Output a dictionary definition of this object + + Parameters + ---------- + simpDict : dict + The dictionary that is being built + legacy : bool + Whether to write out the legacy definition, or the newer one + This is ignored for Traversals. There is no legacy definition + """ + if self._buildIdx is None: + self._buildIdx = len(simpDict["traversals"]) + x = { + "name": self.name, + "prog": self.prog.buildDefinition(simpDict, legacy), + "start": self.startPoint.buildDefinition(simpDict, legacy), + "end": self.endPoint.buildDefinition(simpDict, legacy), + "group": self.group.buildDefinition(simpDict, legacy), + "enabled": self._enabled, + } + simpDict.setdefault("traversals", []).append(x) + return self._buildIdx + + def clearBuildIndex(self) -> None: + """Clear the build index of this object + + The buildIndex is stored when building a definition dictionary + that keeps track of its index for later referencing + """ + self._buildIdx = None + self.prog.clearBuildIndex() + self.group.clearBuildIndex() + + @stackable + def delete(self) -> None: + """Delete a traversal and any shapes it contains""" + with self.removeItemManager(self): + g = self.group + if self not in g.items: + return # Can happen when deleting multiple groups + g.items.remove(self) + self.group = None # type: ignore + self.simplex.traversals.remove(self) + + pairs = self.prog.pairs[:] # gotta make a copy + for pp in pairs: + if not pp.shape.isRest: + self.simplex.shapes.remove(pp.shape) + self.DCC.deleteShape(pp.shape) + + def extractShape(self, shape, live: bool = True, offset: float = 10.0) -> DCCObject: + """Extract a shape from a Traversal progression""" + return self.DCC.extractTraversalShape(self, shape, live, offset) + + def addSlider(self, slider: Slider) -> None: + """Add a slider to both the startPoint and endPoint of this Traversal + + Parameters + ---------- + slider : Slider + The slider to add + """ + self.startPoint.addSlider(slider, val=0.0) + self.endPoint.addSlider(slider) + + def removePairs(self, pairs: list[TravPair]) -> None: + """Remove the given pairs from both the startPoint and endPoint of this Traversal + + Parameters + ---------- + pairs : [TravPair + The pairs to remove + """ + # Get only the pairs that are a part of this traversal + sPairs = [i for i in self.startPoint.pairs if i in pairs] + ePairs = [i for i in self.endPoint.pairs if i in pairs] + pairs = sPairs + ePairs + + # Get all the pairs that use the selected sliders + sliders = {p.slider for p in pairs} + sPairs = [i for i in self.startPoint.pairs if i.slider in sliders] + ePairs = [i for i in self.endPoint.pairs if i.slider in sliders] + + # do the removal + for pair in sPairs: + pair.remove() + + for pair in ePairs: + pair.remove() + + @staticmethod + def traversalAlreadyExists( + simplex: Simplex, sliders: list[Slider], ranges: list[tuple[float, float]] + ) -> Traversal | None: + """In a given simplex syste, check if a traversal exists + with the given sliders and ranges + """ + chk = dict(zip(sliders, ranges)) + for trav in simplex.traversals: + if chk == trav.ranges(): + return trav + return None + + @staticmethod + def getCount(sliders: list[Slider], ranges: list[tuple[float, float]]) -> int: + """Get the count of shapes to create for a traversal with the given + sliders and ranges. It's the max number of shapes on a given side of 0 + """ + counts = [] + for sli, rng in zip(sliders, ranges): + if rng[0] == rng[1]: + continue + vals = sli.prog.getValues() + if max(rng) == 0: + count = len([v for v in vals if v < 0]) + else: + count = len([v for v in vals if v > 0]) + counts.append(count) + if not counts: + return 0 + return max(counts) + + def getInputVector(self, value: float) -> list[float]: + """Get the input to the Solver that would set this traversal to + the given value + + Parameters + ---------- + value : float + The value to set the traversal to + + Returns + ------- + : [float, ...] + The ordered slider values + """ + indexBySlider = {slider: idx for idx, slider in enumerate(self.simplex.sliders)} + + fullStart = [0.0] * len(self.simplex.sliders) + for pair in self.startPoint.pairs: + fullStart[indexBySlider[pair.slider]] = pair.value + + fullEnd = [0.0] * len(self.simplex.sliders) + for pair in self.endPoint.pairs: + fullEnd[indexBySlider[pair.slider]] = pair.value + + def _lerp(s: float, e: float, v: float) -> float: + return s * (1 - v) + e * v + + return [_lerp(fs, fe, value) for fs, fe in zip(fullStart, fullEnd)] + + def treeChild(self, row: int) -> TreeItem: + if row == 0: + return self.startPoint + elif row == 1: + return self.endPoint + elif row == 2: + return self.prog + raise ValueError("Somehow have a Traversal item with more than 3 children") + + def treeRow(self) -> int: + return self.group.items.index(self) + + def treeParent(self) -> TreeItem: + return self.group + + def treeChildCount(self) -> int: + return 3 + + def treeChecked(self) -> bool: + return self.enabled diff --git a/src/python/simplexui/items/treeItem.py b/src/python/simplexui/items/treeItem.py new file mode 100644 index 00000000..e7c272b8 --- /dev/null +++ b/src/python/simplexui/items/treeItem.py @@ -0,0 +1,441 @@ +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +"""A Qt model that stays out of the way of the hierarchy you want to make +Your objects don't need to know anything about Qt. They just expose the few methods +that are required for the rest of the model to work. + +Why do this? +For some cases it makes sense to have a hierarchy built into your objects, but if you +don't want to tightly couple your hierarchy to Qt, you have limited choices on how to +proceed: + +One way would be to write a translation system that can map between your objects and the +Qt standard item hierarchy. This can be done in a non-intrusive way, but it requires +building and maintaining maps between the two separate hierarchies, or discovering the +relationship dynamically. It's totally doable, but the mapping feels fragile and it's +duplicating data (which I don't like) + +Another possible way is to create a custom model from your objects, which is extra work. +So I did that work and packaged it into this setup. + +Just inherit from TreeItem and override the methods that expose the tree structure. +Then you can attach the root of your hierarchy to the Adapter model, which translates +between your hierarchy and Qt. The TreeItem itself has nothing to do with QT. It +communicates to Qt via the OverserverServer. + +--- +I tried to plug as many leaks in this abstraction as I could, but there's still a couple +things that are required. You have to call self.notifyChanged() when a +value visible in the tree changes. And you have to use the managers when changing +the hierarchy. But I think that's it. + +This model doesn't handle the full "each cell can have its own separate table of +children" thing that the full QAbstractItemModel allows for. It's closer to the +QTreeWidget's model where each item is its own row. +""" + +from __future__ import annotations + +import enum +import uuid +from contextlib import ExitStack, contextmanager +from typing import Any, Callable, Generator, Iterator, Literal, overload + +from Qt.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt +from Qt.QtGui import QIcon +from Qt.QtWidgets import QTreeView + + +class CustomRoles(enum.IntEnum): + UID_ROLE = Qt.ItemDataRole.UserRole + 1 + + +SimpleGenerator = Generator[None, None, None] + + +class ObserverServer: # it's just fun to say! + def __init__(self, model: AdapterModel) -> None: + self.model: AdapterModel = model + + def valueObserver(self, item: TreeItem) -> None: + self.model.itemDataChanged(item) + + @contextmanager + def insertManager(self, parent: TreeItem, row=-1) -> SimpleGenerator: + parIdx = self.model.indexFromItem(parent) + if row == -1: + row = parent.getItemAppendRow() + self.model.beginInsertRows(parIdx, row, row) + try: + yield + finally: + self.model.endInsertRows() + + @contextmanager + def removeManager(self, item: TreeItem) -> SimpleGenerator: + idx = self.model.indexFromItem(item) + valid = idx.isValid() + if valid: + parIdx = idx.parent() + self.model.beginRemoveRows(parIdx, idx.row(), idx.row()) + try: + yield + finally: + if valid: + self.model.endRemoveRows() + + @contextmanager + def moveManager( + self, item: TreeItem, destPar: TreeItem, destRow: int = -1 + ) -> SimpleGenerator: + itemIdx = self.model.indexFromItem(item) + destParIdx = self.model.indexFromItem(destPar) + handled = False + if itemIdx.isValid() and destParIdx.isValid(): + handled = True + srcParIdx = itemIdx.parent() + row = itemIdx.row() + if destRow == -1: + destRow = destPar.getItemAppendRow() + self.model.beginMoveRows(srcParIdx, row, row, destParIdx, destRow) + try: + yield + finally: + if handled: + self.model.endMoveRows() + + @contextmanager + def resetManager(self) -> SimpleGenerator: + self.model.beginResetModel() + try: + yield + finally: + self.model.endResetModel() + + +class TreeItem: + classDepth = -1 + + def __init__(self, root: TreeRootItem) -> None: + self.root: TreeRootItem = root + self.uid: str = uuid.uuid4().hex # Unique identifier for tree expansion + + @property + def observers(self) -> list[ObserverServer]: + return self.root.observerServers() + + def notifyChanged(self) -> None: + """Let the observers know that this item has changed""" + for ob in self.observers: + if ob.valueObserver is not None: + ob.valueObserver(self) + + @contextmanager + def insertItemManager(self, item: TreeItem, row: int = -1): + with ExitStack() as stack: + for ob in self.observers: + stack.enter_context(ob.insertManager(item, row=row)) + yield + + @contextmanager + def removeItemManager(self, item: TreeItem): + with ExitStack() as stack: + for ob in self.observers: + stack.enter_context(ob.removeManager(item)) + yield + + @contextmanager + def moveItemManager(self, item: TreeItem, destPar: TreeItem, destRow: int = -1): + with ExitStack() as stack: + for ob in self.observers: + stack.enter_context(ob.moveManager(item, destPar, destRow=destRow)) + yield + + @contextmanager + def resetManager(self): + with ExitStack() as stack: + for ob in self.observers: + stack.enter_context(ob.resetManager()) + yield + + ################ + # Default implementations of the methods that are available to override + ################ + + def getItemAppendRow(self) -> int: + """Get the row to insert at to append a new item""" + return 0 + + def treeChild(self, row: int) -> TreeItem | None: + """Return the child tree item at the given row if it exists""" + return None + + def treeRow(self) -> int: + """Return the row of the current item in the tree""" + return 0 + + def treeParent(self) -> TreeItem | None: + """Return the parent of this TreeItem if it has one""" + return None + + def treeChildCount(self) -> int: + """Return the number of children this item has""" + return 0 + + def treeChecked(self) -> bool | None: + """Return whether this item is checked""" + return None + + def treeData(self, column: int) -> Any | None: + """Return the data for the given column""" + return None + + def icon(self) -> QIcon | None: + """Return the icon of this item, if it has one""" + return None + + +class TreeRootItem(TreeItem): + def __init__(self, observerServers: list[ObserverServer] | None = None) -> None: + super().__init__(self) + # The root just keeps track of everybody's observers + if observerServers is None: + observerServers = [] + self._observerServers: list[ObserverServer] = observerServers + + def addObserver(self, observerServer: ObserverServer) -> None: + self._observerServers.append(observerServer) + + def removeObserver(self, observerServer: ObserverServer) -> None: + self._observerServers.remove(observerServer) + + def observerServers(self) -> list[ObserverServer]: + return self._observerServers[:] # Return a copy + + # Override this + def columnCount(self) -> int: + return 1 + + +class AdapterModel(QAbstractItemModel): + """Model that adapts the values from the TreeItem to the AbstractItemModel interface""" + + def __init__( + self, rootItem: TreeRootItem | None = None, parent: QObject | None = None + ) -> None: + super().__init__(parent=parent) + self._rootItem: TreeRootItem | None = None + self.observer = ObserverServer(self) + if rootItem is not None: + self.setRootItem(rootItem) + + def setRootItem(self, rootItem: TreeRootItem) -> None: + self.beginResetModel() + try: + if self._rootItem is not None: + self._rootItem.removeObserver(self.observer) + self._rootItem = rootItem + self._rootItem.addObserver(self.observer) + finally: + self.endResetModel() + + def getItemRow(self, item: TreeItem | None) -> int | None: + if item is None: + return None + return item.treeRow() + + def indexFromItem(self, item: TreeItem, column: int = 0) -> QModelIndex: + row = self.getItemRow(item) + if row is None: + return QModelIndex() + return self.createIndex(row, column, item) + + def itemFromIndex(self, index: QModelIndex) -> TreeItem | None: + return index.internalPointer() + + def itemDataChanged(self, item: TreeItem) -> None: + idx = self.indexFromItem(item) + self.emitDataChanged(idx) + + def emitDataChanged(self, index: QModelIndex) -> None: + if index.isValid(): + self.dataChanged.emit(index, index, []) + + def getChildItem(self, parent: TreeItem | None, row: int) -> TreeItem | None: + if parent is None: + if row == 0: + return self._rootItem + else: + return None + return parent.treeChild(row) + + def getParentItem(self, item: TreeItem | None) -> TreeItem | None: + if item is None: + return None + return item.treeParent() + + def getItemRowCount(self, item: TreeItem | None) -> int: + if item is None: + # Null parent means return the only root item + ret = 1 + else: + ret = item.treeChildCount() + return ret + + def index(self, row: int, column: int, parIndex: QModelIndex) -> QModelIndex: + par = parIndex.internalPointer() + child = self.getChildItem(par, row) + if child is None: + return QModelIndex() + return self.createIndex(row, column, child) + + def parent(self, index: QModelIndex) -> QModelIndex: + if not index.isValid(): + return QModelIndex() + item = index.internalPointer() + if item is None: + return QModelIndex() + par = self.getParentItem(item) + if par is None: + return QModelIndex() + row = self.getItemRow(par) + if row is None: + return QModelIndex() + return self.createIndex(row, 0, par) + + def rowCount(self, parIndex: QModelIndex) -> int: + parent = parIndex.internalPointer() + ret = self.getItemRowCount(parent) + return ret + + def data(self, index: QModelIndex, role: int) -> Any: + if not index.isValid(): + return None + item = index.internalPointer() + return self.getItemData(item, index.column(), role) + + def columnCount(self, parIndex: QModelIndex) -> int: + if self._rootItem is None: + return 1 + return self._rootItem.columnCount() + + @overload + def getItemData(self, item: None, column: int, role: int) -> None: ... + + @overload + def getItemData( + self, + item: TreeItem, + column: int, + role: Literal[Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole], + ) -> str | None: ... + + @overload + def getItemData( + self, + item: TreeItem, + column: int, + role: Literal[Qt.ItemDataRole.CheckStateRole], + ) -> Qt.CheckState | None: ... + + @overload + def getItemData( + self, + item: TreeItem, + column: int, + role: Literal[Qt.ItemDataRole.DecorationRole], + ) -> QIcon | None: ... + + @overload + def getItemData( + self, + item: TreeItem, + column: int, + role: Literal[CustomRoles.UID_ROLE], + ) -> str | None: ... + + def getItemData(self, item: TreeItem | None, column: int, role: int) -> Any | None: + if item is None: + return None + + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): + return item.treeData(column) + + elif role == Qt.ItemDataRole.CheckStateRole: + chk = None + if column == 0: + chk = item.treeChecked() + if chk is not None: + chk = Qt.CheckState.Checked if chk else Qt.CheckState.Unchecked + return chk + elif role == Qt.ItemDataRole.DecorationRole: + if column == 0: + return item.icon() + elif role == CustomRoles.UID_ROLE: + return item.uid + return None + + def iterindices( + self, pred: Callable[[QModelIndex], bool] | None = None + ) -> Iterator[QModelIndex]: + """Iterate all indices of this model breadth-first""" + queue = [QModelIndex()] + while queue: + index = queue.pop() + if pred is None or pred(index): + yield index + for row in range(self.rowCount(index)): + queue.append(self.index(row, 0, index)) + + def iteritems( + self, pred: Callable[[TreeItem], bool] | None = None + ) -> Iterator[TreeItem | None]: + """Iterate all items of this model breadth-first""" + # The only way I could make the typechecker happy was by + # having 2 separate loops over self.iterindices + if pred is None: + for index in self.iterindices(pred=pred): + yield self.itemFromIndex(index) + return + + def itempred(x) -> bool: + item = self.itemFromIndex(x) + return False if item is None else pred(item) + + for index in self.iterindices(pred=itempred): + yield self.itemFromIndex(index) + + +def save_view_expansion_state(tree_view: QTreeView) -> set[str]: + expanded_uids = set() + model = tree_view.model() + assert isinstance(model, AdapterModel) + + for index in model.iterindices(pred=lambda x: tree_view.isExpanded(x)): + uid = model.data(index, CustomRoles.UID_ROLE) + if uid: + expanded_uids.add(uid) + return expanded_uids + + +def restore_view_expansion_state(tree_view: QTreeView, expanded_uids: set[str]) -> None: + model = tree_view.model() + assert isinstance(model, AdapterModel) + for index in model.iterindices( + pred=lambda x: model.data(x, CustomRoles.UID_ROLE) in expanded_uids + ): + tree_view.setExpanded(index, True) diff --git a/src/python/simplexui/menu/__init__.py b/src/python/simplexui/menu/__init__.py index 7df9552e..e471d01a 100644 --- a/src/python/simplexui/menu/__init__.py +++ b/src/python/simplexui/menu/__init__.py @@ -15,13 +15,17 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + import importlib import os import pkgutil import sys from Qt.QtWidgets import QMenu + from . import genericPlugins +from types import ModuleType CONTEXT = os.path.basename(sys.executable) if CONTEXT == "maya.exe": @@ -39,7 +43,7 @@ def _iter_namespace(ns_pkg): # Registration class -def loadPlugins(): +def loadPlugins() -> tuple[list[ModuleType], list[ModuleType]]: toolModules = [] contextModules = [] imod = sorted([i[1] for i in _iter_namespace(genericPlugins)]) @@ -62,7 +66,7 @@ def buildToolMenu(window, modules): return menu -def buildRightClickMenu(tree, indexes, modules): +def buildRightClickMenu(tree, indexes, modules) -> QMenu: menu = QMenu() for m in modules: m.registerContext(tree, indexes, menu) diff --git a/src/python/simplexui/menu/genericPlugins/_builtins.py b/src/python/simplexui/menu/genericPlugins/_builtins.py index 8584943c..2de80dc7 100644 --- a/src/python/simplexui/menu/genericPlugins/_builtins.py +++ b/src/python/simplexui/menu/genericPlugins/_builtins.py @@ -15,15 +15,17 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . -# pylint:disable=unused-variable +from __future__ import annotations + from functools import partial -from ...items import Combo, ComboPair, ProgPair, Progression, Slider from Qt.QtCore import Qt from Qt.QtWidgets import QCheckBox, QWidgetAction +from ...items import Combo, ComboPair, ProgPair, Progression, Slider + -def registerContext(tree, clickIdx, indexes, menu): +def registerContext(tree, clickIdx, indexes, menu) -> None: self = tree.window() if tree == self.uiComboTREE: registerComboTree(self, clickIdx, indexes, menu) @@ -31,7 +33,7 @@ def registerContext(tree, clickIdx, indexes, menu): registerSliderTree(self, clickIdx, indexes, menu) -def registerSliderTree(window, clickIdx, indexes, menu): +def registerSliderTree(window, clickIdx, indexes, menu) -> None: self = window # live = self.uiLiveShapeConnectionACT.isChecked() items = [i.model().itemFromIndex(i) for i in indexes] @@ -173,7 +175,7 @@ def registerSliderTree(window, clickIdx, indexes, menu): menu.addSeparator() -def registerComboTree(window, clickIdx, indexes, menu): +def registerComboTree(window, clickIdx, indexes, menu) -> None: self = window # live = self.uiLiveShapeConnectionACT.isChecked() items = [i.model().itemFromIndex(i) for i in indexes] diff --git a/src/python/simplexui/menu/genericPlugins/checkPossibleCombos.py b/src/python/simplexui/menu/genericPlugins/checkPossibleCombos.py index a890f550..d385cd13 100644 --- a/src/python/simplexui/menu/genericPlugins/checkPossibleCombos.py +++ b/src/python/simplexui/menu/genericPlugins/checkPossibleCombos.py @@ -15,14 +15,17 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + from functools import partial +from Qt.QtWidgets import QAction + from ...comboCheckDialog import ComboCheckDialog from ...items import Slider -from Qt.QtWidgets import QAction -def registerTool(window, menu): +def registerTool(window, menu) -> None: checkPossibleCombosACT = QAction("Check Possible Combos ...", window) menu.addAction(checkPossibleCombosACT) checkPossibleCombosACT.triggered.connect( @@ -30,7 +33,7 @@ def registerTool(window, menu): ) -def registerContext(tree, clickIdx, indexes, menu): +def registerContext(tree, clickIdx, indexes, menu) -> None: window = tree.window() checkPossibleCombosACT = QAction("Check Possible Combos ...", tree) menu.addAction(checkPossibleCombosACT) @@ -39,7 +42,7 @@ def registerContext(tree, clickIdx, indexes, menu): ) -def checkPossibleCombosInterface(window): +def checkPossibleCombosInterface(window) -> None: sliders = window.uiSliderTREE.getSelectedItems(typ=Slider) ccd = ComboCheckDialog(sliders, parent=window) ccd.show() diff --git a/src/python/simplexui/menu/genericPlugins/exportSplit.py b/src/python/simplexui/menu/genericPlugins/exportSplit.py index bd05a97d..8d44c6fd 100644 --- a/src/python/simplexui/menu/genericPlugins/exportSplit.py +++ b/src/python/simplexui/menu/genericPlugins/exportSplit.py @@ -15,25 +15,24 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + from functools import partial +import numpy as np from Qt import QtCompat -from Qt.QtWidgets import QAction, QMessageBox, QProgressDialog - -try: - import numpy as np -except ImportError: - np = None +from Qt.QtGui import QAction +from Qt.QtWidgets import QMessageBox, QProgressDialog -def registerTool(window, menu): +def registerTool(window, menu) -> None: if np is not None: exportSplitACT = QAction("Export Split", window) menu.addAction(exportSplitACT) exportSplitACT.triggered.connect(partial(exportSplitInterface, window)) -def exportSplitInterface(window): +def exportSplitInterface(window) -> None: if np is None: QMessageBox.warning( window, @@ -41,6 +40,15 @@ def exportSplitInterface(window): "Numpy is not available here, an it is required to split a system", ) return + + if window.simplex is None: + QMessageBox.warning( + window, + "Nothing Loaded", + "No simplex system is loaded", + ) + return + path, _filter = QtCompat.QFileDialog.getSaveFileName( window, "Export Split", "", "Simplex (*.smpx)" ) @@ -51,7 +59,7 @@ def exportSplitInterface(window): pBar = QProgressDialog("Exporting Split smpx File", "Cancel", 0, 100, window) pBar.show() try: - split = window.simplex.split(pBar) + split = window.simplex.split(pBar=pBar) split.exportAbc(path, pBar) except ValueError as e: QMessageBox.warning(window, "Unsplittable", str(e)) diff --git a/src/python/simplexui/menu/genericPlugins/showFalloffs.py b/src/python/simplexui/menu/genericPlugins/showFalloffs.py index 5b34366c..48df9644 100644 --- a/src/python/simplexui/menu/genericPlugins/showFalloffs.py +++ b/src/python/simplexui/menu/genericPlugins/showFalloffs.py @@ -15,10 +15,12 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + from Qt.QtWidgets import QAction -def registerTool(window, menu): +def registerTool(window, menu) -> None: editFalloffsACT = QAction("Edit Falloffs ...", window) menu.addAction(editFalloffsACT) editFalloffsACT.triggered.connect(window.showFalloffDialog) diff --git a/src/python/simplexui/menu/genericPlugins/showTraversals.py b/src/python/simplexui/menu/genericPlugins/showTraversals.py index 02f3045f..9e83e6fa 100644 --- a/src/python/simplexui/menu/genericPlugins/showTraversals.py +++ b/src/python/simplexui/menu/genericPlugins/showTraversals.py @@ -15,10 +15,12 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + from Qt.QtWidgets import QAction -def registerTool(window, menu): +def registerTool(window, menu) -> None: showTraversalsACT = QAction("Show Traversals ...", window) menu.addAction(showTraversalsACT) showTraversalsACT.triggered.connect(window.showTraversalDialog) diff --git a/src/python/simplexui/menu/genericPlugins/simplexUvTransfer.py b/src/python/simplexui/menu/genericPlugins/simplexUvTransfer.py index 7592d8ea..4e31f489 100644 --- a/src/python/simplexui/menu/genericPlugins/simplexUvTransfer.py +++ b/src/python/simplexui/menu/genericPlugins/simplexUvTransfer.py @@ -15,21 +15,20 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + import json +import numpy as np + from ...commands.alembicCommon import buildSmpx, readSmpx from ...commands.mesh import Mesh from ...commands.uvTransfer import applyTransfer, getVertCorrelation -try: - import numpy as np -except ImportError: - np = None - def simplexUvTransfer( - srcSmpxPath, tarPath, outPath, srcUvPath=None, tol=0.0001, pBar=None -): + srcSmpxPath, tarPath, outPath, srcUvPath=None, tol: float = 0.0001, pBar=None +) -> None: """Transfer a simplex system onto a mesh through UV space Parameters diff --git a/src/python/simplexui/menu/genericPlugins/unsubdivide.py b/src/python/simplexui/menu/genericPlugins/unsubdivide.py index 5cb77168..ad9102e0 100644 --- a/src/python/simplexui/menu/genericPlugins/unsubdivide.py +++ b/src/python/simplexui/menu/genericPlugins/unsubdivide.py @@ -15,29 +15,31 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + import os from functools import partial -from ...commands.unsubdivide import unsubdivideSimplex from Qt import QtCompat from Qt.QtWidgets import QAction, QMessageBox, QProgressDialog +from ...commands.unsubdivide import unsubdivideSimplex try: - from ..commands.numpytoimath import numpyToImath + import imathnumpy except ImportError: - numpyToImath = None + imathnumpy = None -def registerTool(window, menu): - if numpyToImath is not None: +def registerTool(window, menu) -> None: + if imathnumpy is not None: exportUnsubACT = QAction("Un Subdivide Smpx ...", window) menu.addAction(exportUnsubACT) exportUnsubACT.triggered.connect(partial(exportUnsubInterface, window)) -def exportUnsubInterface(window): - if numpyToImath is None: +def exportUnsubInterface(window) -> None: + if imathnumpy is None: QMessageBox.warning( window, "No ImathToNumpy", @@ -54,12 +56,12 @@ def exportUnsubInterface(window): outPath = path.replace(".smpx", "_UNSUB.smpx") if path == outPath: - QMessageBox.warning(window, "Unable to rename smpx file: {}".format(path)) + QMessageBox.warning(window, f"Unable to rename smpx file: {path}") return if os.path.isfile(outPath): btns = QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel - msg = "Unsub file already exists.\n{0}\nOverwrite?".format(outPath) + msg = f"Unsub file already exists.\n{outPath}\nOverwrite?" response = QMessageBox.question(window, "File already exists", msg, btns) if not response & QMessageBox.StandardButton.Ok: return diff --git a/src/python/simplexui/menu/mayaPlugins/exportOther.py b/src/python/simplexui/menu/mayaPlugins/exportOther.py index 502b98e1..704093f1 100644 --- a/src/python/simplexui/menu/mayaPlugins/exportOther.py +++ b/src/python/simplexui/menu/mayaPlugins/exportOther.py @@ -15,21 +15,31 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + from functools import partial import maya.cmds as cmds - from Qt import QtCompat -from Qt.QtWidgets import QAction, QProgressDialog +from Qt.QtGui import QAction +from Qt.QtWidgets import QMessageBox, QProgressDialog -def registerTool(window, menu): +def registerTool(window, menu) -> None: exportOtherACT = QAction("Export Other", window) menu.addAction(exportOtherACT) exportOtherACT.triggered.connect(partial(exportOtherInterface, window)) -def exportOtherInterface(window): +def exportOtherInterface(window) -> None: + if window.simplex is None: + QMessageBox.warning( + window, + "Nothing Loaded", + "No simplex system is loaded", + ) + return + sel = cmds.ls(sl=True) path, _filter = QtCompat.QFileDialog.getSaveFileName( window, "Export Other", "", "Simplex (*.smpx)" diff --git a/src/python/simplexui/menu/mayaPlugins/extractProgressives.py b/src/python/simplexui/menu/mayaPlugins/extractProgressives.py index e320ab89..d9e38243 100644 --- a/src/python/simplexui/menu/mayaPlugins/extractProgressives.py +++ b/src/python/simplexui/menu/mayaPlugins/extractProgressives.py @@ -15,14 +15,17 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + from functools import partial +from Qt.QtWidgets import QAction + from ...interfaceModel import coerceIndexToType from ...items import Slider -from Qt.QtWidgets import QAction -def registerTool(window, menu): +def registerTool(window, menu) -> None: extractProgressivesACT = QAction("Extract Progressive", window) menu.addAction(extractProgressivesACT) extractProgressivesACT.triggered.connect( @@ -30,7 +33,7 @@ def registerTool(window, menu): ) -def registerContext(tree, clickIdx, indexes, menu): +def registerContext(tree, clickIdx, indexes, menu) -> bool: window = tree.window() live = window.uiLiveShapeConnectionACT.isChecked() sliders = coerceIndexToType(indexes, Slider) @@ -48,14 +51,14 @@ def registerContext(tree, clickIdx, indexes, menu): return False -def extractProgressivesContext(indexes, live): +def extractProgressivesContext(indexes, live) -> None: sliders = [idx.model().itemFromIndex(idx) for idx in indexes] sliders = list(set(sliders)) for sli in sliders: sli.extractProgressive(live=live) -def extractProgressivesInterface(window): +def extractProgressivesInterface(window) -> None: live = window.uiLiveShapeConnectionACT.isChecked() indexes = window.uiSliderTREE.getSelectedIndexes() indexes = coerceIndexToType(indexes, Slider) diff --git a/src/python/simplexui/menu/mayaPlugins/freezeCombo.py b/src/python/simplexui/menu/mayaPlugins/freezeCombo.py index 90652d57..369db23d 100644 --- a/src/python/simplexui/menu/mayaPlugins/freezeCombo.py +++ b/src/python/simplexui/menu/mayaPlugins/freezeCombo.py @@ -1,245 +1,247 @@ -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -from functools import partial - -from maya import cmds - -from ...interface.mayaInterface import disconnected -from ...interfaceModel import coerceIndexToType -from ...items import Combo - - -# UI stuff -def registerContext(tree, clickIdx, indexes, menu): - # The basicBlendshape deformer is included in simplex_maya - if not cmds.pluginInfo("simplex_maya", query=True, loaded=True): - try: - cmds.loadPlugin("simplex_maya") - except RuntimeError: - return False - - comboIdxs = coerceIndexToType(indexes, Combo) - combos = [cidx.model().itemFromIndex(cidx) for cidx in comboIdxs] - - if combos: - freezeAct = menu.addAction("Freeze Combos") - unfreezeAct = menu.addAction("Un-Freeze Combos") - freezeAct.triggered.connect(partial(freezeCombosContext, combos, tree, True)) - unfreezeAct.triggered.connect(partial(freezeCombosContext, combos, tree, False)) - return True - return False - - -def freezeCombosContext(combos, tree, doFreeze): - if doFreeze: - for combo in combos: - if not combo.frozen: - freezeCombo(combo) - else: - for combo in combos: - if combo.frozen: - unfreezeCombo(combo) - - tree.update() - - -def freezeCombo(combo): - """Freeze a combo so you can change the upstream combos and shapes - without affecting the result that you sculpted for the given combo - - In practice, this snapshots the combo, then live-reads the upstream - shapes from the main blendshape and builds an up-to-date combo. The - difference between these two meshes is added back into the combo shape - """ - simplex = combo.simplex - - tweakShapeGroups = [] - fullGeos = [] - ppFilter = [] - freezeShapes = [] - - # disconnect the controller from the operator - with disconnected(simplex.DCC.op) as sliderCnx: - for _shapeIdx, pp in enumerate(combo.prog.pairs): - tVal = pp.value - freezeShape = pp.shape - if freezeShape.isRest: - continue - - freezeShapes.append(freezeShape) - # zero all the sliders - cnx = sliderCnx[simplex.DCC.op] - for a in cnx.values(): - cmds.setAttr(a, 0.0) - - # set the combo values - for pair in combo.pairs: - cmds.setAttr(cnx[pair.slider.thing], pair.value * tVal) - - tweakPairs = [] - for shape in simplex.shapes[1:]: # skip the restShape - shapeVal = cmds.getAttr(shape.thing) - if abs(shapeVal) > 0.0001: - tweakPairs.append((shape, shapeVal)) - - # Extract this fully-on shape - fullGeo = cmds.duplicate( - simplex.DCC.mesh, name="{0}_Freeze".format(freezeShape.name) - )[0] - fullGeos.append(fullGeo) - - # Clean any orig shapes for now - interObjs = cmds.ls( - cmds.listRelatives(fullGeo, shapes=True), intermediateObjects=True - ) - cmds.delete(interObjs) - - tweakShapeGroups.append(tweakPairs) - ppFilter.append(pp) - - simplex.DCC.primeShapes(combo) - - shapePlugFmt = ( - ".inputTarget[{meshIdx}].inputTargetGroup[{shapeIdx}].inputTargetItem[6000]" - ) - endPlugs = [ - ".inputRelativePointsTarget", - ".inputRelativeComponentsTarget", - ".inputPointsTarget", - ".inputComponentsTarget", - "", - ] - shapeNode = simplex.DCC.shapeNode - helpers = [] - for geo, tweakPairs, pp, freezeShape in zip( - fullGeos, tweakShapeGroups, ppFilter, freezeShapes - ): - # build the basicBS node - bbs = cmds.deformer(geo, type="basicBlendShape")[0] - helpers.append(bbs) - idx = 0 - - for shape, val in tweakPairs: - if shape == freezeShape: - continue - # connect the output shape.thing to the basicBS - - # Create an empty shape. Do it like this to get the automated renaming stuff - gDup = cmds.duplicate(geo, name="{0}_DeltaCnx".format(shape.name))[0] - # The 4th value must be 1.0 so the blendshape auto-names - cmds.blendShape(bbs, edit=True, target=(geo, idx, gDup, 1.0)) - cmds.blendShape(bbs, edit=True, weight=(idx, -val)) - cmds.delete(gDup) - - # Connect the shape plugs - inPlug = bbs + shapePlugFmt - inPlug = inPlug.format(meshIdx=0, shapeIdx=idx) - - shapeIdx = simplex.DCC.getShapeIndex(shape) - outPlug = shapeNode + shapePlugFmt - outPlug = outPlug.format(meshIdx=0, shapeIdx=shapeIdx) - - # Must connect the individual child plugs rather than the top - # because otherwise the input geometry plug overrides these deltas - for ep in endPlugs: - cmds.connectAttr(outPlug + ep, inPlug + ep) - - idx += 1 - - # Connect the basicBS back into freezeShape - freezeShapeIdx = simplex.DCC.getShapeIndex(pp.shape) - freezeShapeTarget = shapeNode + shapePlugFmt + ".inputGeomTarget" - freezeShapeTarget = freezeShapeTarget.format(meshIdx=0, shapeIdx=freezeShapeIdx) - cmds.connectAttr(geo + ".outMesh", freezeShapeTarget) - - gShapes = cmds.listRelatives(geo, shapes=True) - if True: - # Hide the frozen shapenode under the ctrl as an intermediate shape - for gs in gShapes: - cmds.setAttr(gs + ".intermediateObject", 1) - nn = cmds.parent(gs, simplex.DCC.ctrl, shape=True, relative=True) - helpers.extend(nn) - - # Get rid of the extra transform object - cmds.delete(geo) - else: - helpers.extend(cmds.listRelatives(geo, shapes=True)) - - # keep track of the shapes under the ctrl object - combo.freezeThing = helpers - - -def unfreezeCombo(combo): - if combo.freezeThing: - cmds.delete(combo.freezeThing) - combo.freezeThing = [] - - -def _getDeformerChain(chkObj): - # Get a deformer chain - memo = [] - while chkObj and chkObj not in memo: - memo.append(chkObj) - - typ = cmds.nodeType(chkObj) - if typ == "mesh": - cnx = cmds.listConnections(chkObj + ".inMesh") or [None] - chkObj = cnx[0] - elif typ == "groupParts": - cnx = cmds.listConnections( - chkObj + ".inputGeometry", destination=False, shapes=True - ) or [None] - chkObj = cnx[0] - else: - cnx = cmds.ls(chkObj, type="geometryFilter") or [None] - chkObj = cnx[0] - if chkObj: # we have a deformer - cnx = cmds.listConnections(chkObj + ".input[0].inputGeometry") or [None] - chkObj = cnx[0] - return memo - - -def checkFrozen(combo): - # If the blendshape shape has an incoming connection whose shape name - # ends with 'FreezeShape' and the shape's parent is the ctrl - # - simplex = combo.simplex - - ret = [] - shapes = combo.prog.getShapes() - shapes = [i for i in shapes if not i.isRest] - - shapePlugFmt = ( - ".inputTarget[{meshIdx}].inputTargetGroup[{shapeIdx}].inputTargetItem[6000]" - ) - - for shape in shapes: - shpIdx = simplex.DCC.getShapeIndex(shape) - shpPlug = ( - simplex.DCC.shapeNode - + shapePlugFmt.format(meshIdx=0, shapeIdx=shpIdx) - + ".inputGeomTarget" - ) - - cnx = cmds.listConnections(shpPlug, shapes=True, destination=False) or [] - for cc in cnx: - if not cc.endswith("FreezeShape"): - continue - par = cmds.listRelatives(cc, parent=True) - if par and par[0] == simplex.DCC.ctrl: - # Can't use list history to get the chain because it's a pseudo-cycle - ret.extend(_getDeformerChain(cc)) - return ret +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +from __future__ import annotations + +from functools import partial + +from maya import cmds + +from ...interface.mayaInterface import disconnected +from ...interfaceModel import coerceIndexToType +from ...items import Combo + + +# UI stuff +def registerContext(tree, clickIdx, indexes, menu) -> bool: + # The basicBlendshape deformer is included in simplex_maya + if not cmds.pluginInfo("simplex_maya", query=True, loaded=True): + try: + cmds.loadPlugin("simplex_maya") + except RuntimeError: + return False + + comboIdxs = coerceIndexToType(indexes, Combo) + combos = [cidx.model().itemFromIndex(cidx) for cidx in comboIdxs] + + if combos: + freezeAct = menu.addAction("Freeze Combos") + unfreezeAct = menu.addAction("Un-Freeze Combos") + freezeAct.triggered.connect(partial(freezeCombosContext, combos, tree, True)) + unfreezeAct.triggered.connect(partial(freezeCombosContext, combos, tree, False)) + return True + return False + + +def freezeCombosContext(combos, tree, doFreeze) -> None: + if doFreeze: + for combo in combos: + if not combo.frozen: + freezeCombo(combo) + else: + for combo in combos: + if combo.frozen: + unfreezeCombo(combo) + + tree.update() + + +def freezeCombo(combo) -> None: + """Freeze a combo so you can change the upstream combos and shapes + without affecting the result that you sculpted for the given combo + + In practice, this snapshots the combo, then live-reads the upstream + shapes from the main blendshape and builds an up-to-date combo. The + difference between these two meshes is added back into the combo shape + """ + simplex = combo.simplex + + tweakShapeGroups = [] + fullGeos = [] + ppFilter = [] + freezeShapes = [] + + # disconnect the controller from the operator + with disconnected(simplex.DCC.op) as sliderCnx: + for _shapeIdx, pp in enumerate(combo.prog.pairs): + tVal = pp.value + freezeShape = pp.shape + if freezeShape.isRest: + continue + + freezeShapes.append(freezeShape) + # zero all the sliders + cnx = sliderCnx[simplex.DCC.op] + for a in cnx.values(): + cmds.setAttr(a, 0.0) + + # set the combo values + for pair in combo.pairs: + cmds.setAttr(cnx[pair.slider.thing], pair.value * tVal) + + tweakPairs = [] + for shape in simplex.shapes[1:]: # skip the restShape + shapeVal = cmds.getAttr(shape.thing) + if abs(shapeVal) > 0.0001: + tweakPairs.append((shape, shapeVal)) + + # Extract this fully-on shape + fullGeo = cmds.duplicate( + simplex.DCC.mesh, name=f"{freezeShape.name}_Freeze" + )[0] + fullGeos.append(fullGeo) + + # Clean any orig shapes for now + interObjs = cmds.ls( + cmds.listRelatives(fullGeo, shapes=True), intermediateObjects=True + ) + cmds.delete(interObjs) + + tweakShapeGroups.append(tweakPairs) + ppFilter.append(pp) + + simplex.DCC.primeShapes(combo) + + shapePlugFmt = ( + ".inputTarget[{meshIdx}].inputTargetGroup[{shapeIdx}].inputTargetItem[6000]" + ) + endPlugs = [ + ".inputRelativePointsTarget", + ".inputRelativeComponentsTarget", + ".inputPointsTarget", + ".inputComponentsTarget", + "", + ] + shapeNode = simplex.DCC.shapeNode + helpers = [] + for geo, tweakPairs, pp, freezeShape in zip( + fullGeos, tweakShapeGroups, ppFilter, freezeShapes + ): + # build the basicBS node + bbs = cmds.deformer(geo, type="basicBlendShape")[0] + helpers.append(bbs) + idx = 0 + + for shape, val in tweakPairs: + if shape == freezeShape: + continue + # connect the output shape.thing to the basicBS + + # Create an empty shape. Do it like this to get the automated renaming stuff + gDup = cmds.duplicate(geo, name=f"{shape.name}_DeltaCnx")[0] + # The 4th value must be 1.0 so the blendshape auto-names + cmds.blendShape(bbs, edit=True, target=(geo, idx, gDup, 1.0)) + cmds.blendShape(bbs, edit=True, weight=(idx, -val)) + cmds.delete(gDup) + + # Connect the shape plugs + inPlug = bbs + shapePlugFmt + inPlug = inPlug.format(meshIdx=0, shapeIdx=idx) + + shapeIdx = simplex.DCC.getShapeIndex(shape) + outPlug = shapeNode + shapePlugFmt + outPlug = outPlug.format(meshIdx=0, shapeIdx=shapeIdx) + + # Must connect the individual child plugs rather than the top + # because otherwise the input geometry plug overrides these deltas + for ep in endPlugs: + cmds.connectAttr(outPlug + ep, inPlug + ep) + + idx += 1 + + # Connect the basicBS back into freezeShape + freezeShapeIdx = simplex.DCC.getShapeIndex(pp.shape) + freezeShapeTarget = shapeNode + shapePlugFmt + ".inputGeomTarget" + freezeShapeTarget = freezeShapeTarget.format(meshIdx=0, shapeIdx=freezeShapeIdx) + cmds.connectAttr(geo + ".outMesh", freezeShapeTarget) + + gShapes = cmds.listRelatives(geo, shapes=True) + if True: + # Hide the frozen shapenode under the ctrl as an intermediate shape + for gs in gShapes: + cmds.setAttr(gs + ".intermediateObject", 1) + nn = cmds.parent(gs, simplex.DCC.ctrl, shape=True, relative=True) + helpers.extend(nn) + + # Get rid of the extra transform object + cmds.delete(geo) + else: + helpers.extend(cmds.listRelatives(geo, shapes=True)) + + # keep track of the shapes under the ctrl object + combo.freezeThing = helpers + + +def unfreezeCombo(combo) -> None: + if combo.freezeThing: + cmds.delete(combo.freezeThing) + combo.freezeThing = [] + + +def _getDeformerChain(chkObj): + # Get a deformer chain + memo = [] + while chkObj and chkObj not in memo: + memo.append(chkObj) + + typ = cmds.nodeType(chkObj) + if typ == "mesh": + cnx = cmds.listConnections(chkObj + ".inMesh") or [None] + chkObj = cnx[0] + elif typ == "groupParts": + cnx = cmds.listConnections( + chkObj + ".inputGeometry", destination=False, shapes=True + ) or [None] + chkObj = cnx[0] + else: + cnx = cmds.ls(chkObj, type="geometryFilter") or [None] + chkObj = cnx[0] + if chkObj: # we have a deformer + cnx = cmds.listConnections(chkObj + ".input[0].inputGeometry") or [None] + chkObj = cnx[0] + return memo + + +def checkFrozen(combo): + # If the blendshape shape has an incoming connection whose shape name + # ends with 'FreezeShape' and the shape's parent is the ctrl + # + simplex = combo.simplex + + ret = [] + shapes = combo.prog.getShapes() + shapes = [i for i in shapes if not i.isRest] + + shapePlugFmt = ( + ".inputTarget[{meshIdx}].inputTargetGroup[{shapeIdx}].inputTargetItem[6000]" + ) + + for shape in shapes: + shpIdx = simplex.DCC.getShapeIndex(shape) + shpPlug = ( + simplex.DCC.shapeNode + + shapePlugFmt.format(meshIdx=0, shapeIdx=shpIdx) + + ".inputGeomTarget" + ) + + cnx = cmds.listConnections(shpPlug, shapes=True, destination=False) or [] + for cc in cnx: + if not cc.endswith("FreezeShape"): + continue + par = cmds.listRelatives(cc, parent=True) + if par and par[0] == simplex.DCC.ctrl: + # Can't use list history to get the chain because it's a pseudo-cycle + ret.extend(_getDeformerChain(cc)) + return ret diff --git a/src/python/simplexui/menu/mayaPlugins/generateShapeIncrementals.py b/src/python/simplexui/menu/mayaPlugins/generateShapeIncrementals.py index 47bfd7ab..beb29eed 100644 --- a/src/python/simplexui/menu/mayaPlugins/generateShapeIncrementals.py +++ b/src/python/simplexui/menu/mayaPlugins/generateShapeIncrementals.py @@ -15,16 +15,18 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + from functools import partial import maya.cmds as cmds +from Qt.QtWidgets import QInputDialog, QMessageBox from ...interfaceModel import coerceIndexToType from ...items import Combo, Slider -from Qt.QtWidgets import QInputDialog, QMessageBox -def registerContext(tree, clickIdx, indexes, menu): +def registerContext(tree, clickIdx, indexes, menu) -> bool: window = tree.window() sliders = coerceIndexToType(indexes, Slider) combos = coerceIndexToType(indexes, Combo) @@ -45,7 +47,7 @@ def registerContext(tree, clickIdx, indexes, menu): return True -def generateShapeIncrementalsContext(indexes, window): +def generateShapeIncrementalsContext(indexes, window) -> None: idx = indexes[0] # Only on the click index slider = idx.model().itemFromIndex(idx) if len(slider.prog.pairs) > 2: @@ -82,7 +84,7 @@ def generateShapeIncrementalsContext(indexes, window): percent = int(float(i) * 100 / increments) cmds.blendShape(bs, edit=True, weight=((0, val))) - nne = endObj.replace("_100_", "_{0}_".format(percent)) + nne = endObj.replace("_100_", f"_{percent}_") nn = nne.replace("_Extract", "") inc = cmds.duplicate(shapeDup, name=nne) incs.append((percent, nn, nne)) diff --git a/src/python/simplexui/menu/mayaPlugins/importObjs.py b/src/python/simplexui/menu/mayaPlugins/importObjs.py index 4600ae19..ab34fbca 100644 --- a/src/python/simplexui/menu/mayaPlugins/importObjs.py +++ b/src/python/simplexui/menu/mayaPlugins/importObjs.py @@ -1,26 +1,24 @@ +from __future__ import annotations + import os from functools import partial import maya.cmds as cmds - -from ...items import Combo, Slider, Traversal +import numpy as np +from Qt.QtGui import QAction from Qt.QtWidgets import ( - QAction, QApplication, QFileDialog, QMessageBox, QProgressDialog, ) -try: - import numpy as np -except ImportError: - np = None +from ...items import Combo, Slider, Traversal try: - from MeshCrawler.commands import setAllVerts - from MeshCrawler.mesh import Mesh - from MeshCrawler.meshcrawlerGen import autoCrawlMeshes + from tools.MeshCrawler.commands import setAllVerts + from tools.MeshCrawler.mesh import Mesh + from tools.MeshCrawler.meshcrawlerGen import autoCrawlMeshes except ImportError: autoCrawlMeshes = None @@ -36,10 +34,10 @@ def buildMesh(simplex, mesh): return Mesh(topo[0], tuple(faces)) -def importSimpleObjs(simplex, orders, pBar): +def importSimpleObjs(simplex, orders, pBar) -> None: for shapeName, ctrl, shape, path in orders: pBar.setValue(pBar.value() + 1) - pBar.setLabelText("Loading Obj :\n{0}".format(shapeName)) + pBar.setLabelText(f"Loading Obj :\n{shapeName}") QApplication.processEvents() if pBar.wasCanceled(): return @@ -58,7 +56,7 @@ def importSimpleObjs(simplex, orders, pBar): ) -def importReorderObjs(simplex, orders, pBar): +def importReorderObjs(simplex, orders, pBar) -> None: reoMesh = simplex.DCC.extractShape(simplex.restShape, live=False) orderMesh = buildMesh(simplex, reoMesh) @@ -66,7 +64,7 @@ def importReorderObjs(simplex, orders, pBar): for shapeName, ctrl, shape, path in orders: pBar.setValue(pBar.value() + 1) - pBar.setLabelText("Loading Obj :\n{0}".format(shapeName)) + pBar.setLabelText(f"Loading Obj :\n{shapeName}") QApplication.processEvents() if pBar.wasCanceled(): return @@ -94,7 +92,7 @@ def importReorderObjs(simplex, orders, pBar): cmds.delete(reoMesh) -def importObjList(simplex, paths, pBar, reorder=True): +def importObjList(simplex, paths: list[str], pBar, reorder=True) -> None: """Import all given .obj files Parameters @@ -159,17 +157,25 @@ def importObjList(simplex, paths, pBar, reorder=True): importReorderObjs(simplex, importOrder, pBar) else: importSimpleObjs(simplex, importOrder, pBar) - - pBar.close() + if pBar is not None: + pBar.close() -def registerTool(window, menu): +def registerTool(window, menu) -> None: importObjsACT = QAction("Import Obj Folder", window) menu.addAction(importObjsACT) importObjsACT.triggered.connect(partial(importObjsInterface, window)) -def importObjsInterface(window): +def importObjsInterface(window) -> None: + if window.simplex is None: + QMessageBox.warning( + window, + "Nothing Loaded", + "No simplex system is loaded", + ) + return + reorder = True if np is None or autoCrawlMeshes is None: reorder = False diff --git a/src/python/simplexui/menu/mayaPlugins/linearizeTraversal.py b/src/python/simplexui/menu/mayaPlugins/linearizeTraversal.py index dab8823e..e5083390 100644 --- a/src/python/simplexui/menu/mayaPlugins/linearizeTraversal.py +++ b/src/python/simplexui/menu/mayaPlugins/linearizeTraversal.py @@ -15,18 +15,30 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + from functools import partial + import maya.cmds as cmds -from Qt.QtWidgets import QAction +from Qt.QtGui import QAction +from Qt.QtWidgets import QMessageBox -def registerTool(window, menu): +def registerTool(window, menu) -> None: lineTravACT = QAction("Linearize Traversal", window) menu.addAction(lineTravACT) lineTravACT.triggered.connect(partial(lineTrav, window)) -def lineTrav(window): +def lineTrav(window) -> None: + if window.simplex is None: + QMessageBox.warning( + window, + "Nothing Loaded", + "No simplex system is loaded", + ) + return + simplex = window.simplex travDialog = window.travDialog sel = travDialog.uiTraversalTREE.getSelectedIndexes() diff --git a/src/python/simplexui/menu/mayaPlugins/makeShelfBtn.py b/src/python/simplexui/menu/mayaPlugins/makeShelfBtn.py index b682376f..2a320d75 100644 --- a/src/python/simplexui/menu/mayaPlugins/makeShelfBtn.py +++ b/src/python/simplexui/menu/mayaPlugins/makeShelfBtn.py @@ -15,15 +15,17 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + import os from Qt.QtWidgets import QAction dn = os.path.dirname -SHELF_DEV_BUTTON = """ +SHELF_DEV_BUTTON = f""" import os, sys -path = r'{0}' +path = r'{dn(dn(dn(dn(__file__))))}' path = os.path.normcase(os.path.normpath(path)) if sys.path[0] != path: sys.path.insert(0, path) @@ -51,15 +53,15 @@ simplexui.runSimplexUI() sys.path.pop(0) -""".format(dn(dn(dn(dn(__file__))))) +""" -def registerTool(window, menu): +def registerTool(window, menu) -> None: makeShelfBtnACT = QAction("Make Shelf Button", window) menu.addAction(makeShelfBtnACT) makeShelfBtnACT.triggered.connect(makeShelfButton) -def makeShelfButton(): +def makeShelfButton() -> None: pass # TODO: Actually, ya know, Add the button to the shelf diff --git a/src/python/simplexui/menu/mayaPlugins/relaxToSelection.py b/src/python/simplexui/menu/mayaPlugins/relaxToSelection.py index 16727300..8b012817 100644 --- a/src/python/simplexui/menu/mayaPlugins/relaxToSelection.py +++ b/src/python/simplexui/menu/mayaPlugins/relaxToSelection.py @@ -15,24 +15,25 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . -import maya.cmds as cmds +from __future__ import annotations +import maya.cmds as cmds from Qt.QtWidgets import QAction -def registerTool(window, menu): +def registerTool(window, menu) -> None: relaxToSelectionACT = QAction("Relax To Selection", window) menu.addAction(relaxToSelectionACT) relaxToSelectionACT.triggered.connect(relaxToSelectionInterface) -def relaxToSelectionInterface(): +def relaxToSelectionInterface() -> None: sel = cmds.ls(sl=True) if len(sel) >= 2: relaxToSelection(sel[0], sel[1]) -def relaxToSelection(source, target): +def relaxToSelection(source, target) -> None: """ Transfer high-frequency sculpts (like wrinkles) from one shape to another @@ -54,7 +55,7 @@ def relaxToSelection(source, target): maxValue=100, defaultValue=10, ) - smoothIter = "{0}.smooth_iter".format(deltaMushRelax) + smoothIter = f"{deltaMushRelax}.smooth_iter" cmds.setAttr(smoothIter, edit=True, keyable=True) blender = cmds.blendShape(targetDup, sourceDup) diff --git a/src/python/simplexui/menu/mayaPlugins/reloadDefinition.py b/src/python/simplexui/menu/mayaPlugins/reloadDefinition.py index 5172228b..73b87a2f 100644 --- a/src/python/simplexui/menu/mayaPlugins/reloadDefinition.py +++ b/src/python/simplexui/menu/mayaPlugins/reloadDefinition.py @@ -15,19 +15,30 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + from functools import partial -from Qt.QtWidgets import QAction + +from Qt.QtGui import QAction +from Qt.QtWidgets import QMessageBox -def registerTool(window, menu): +def registerTool(window, menu) -> None: reloadDefinitionACT = QAction("Reload Definition", window) menu.addAction(reloadDefinitionACT) reloadDefinitionACT.triggered.connect(partial(reloadDefinitionInterface, window)) -def reloadDefinitionInterface(window): +def reloadDefinitionInterface(window) -> None: + if window.simplex is None: + QMessageBox.warning( + window, + "Nothing Loaded", + "No simplex system is loaded", + ) + return reloadDefinition(window.simplex) -def reloadDefinition(simplex): +def reloadDefinition(simplex) -> None: simplex.DCC.setSimplexString(simplex.DCC.op, simplex.dump()) diff --git a/src/python/simplexui/menu/mayaPlugins/snapToNeutral.py b/src/python/simplexui/menu/mayaPlugins/snapToNeutral.py index 5ee142e6..7f80f39c 100644 --- a/src/python/simplexui/menu/mayaPlugins/snapToNeutral.py +++ b/src/python/simplexui/menu/mayaPlugins/snapToNeutral.py @@ -15,14 +15,15 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + from functools import partial import maya.cmds as cmds - from Qt.QtWidgets import QAction -def registerTool(window, menu): +def registerTool(window, menu) -> None: snapShapeToNeutralACT = QAction("Snap Shape To Neutral", window) menu.addAction(snapShapeToNeutralACT) snapShapeToNeutralACT.triggered.connect( @@ -30,7 +31,7 @@ def registerTool(window, menu): ) -def snapShapeToNeutralInterface(window): +def snapShapeToNeutralInterface(window) -> None: sel = cmds.ls(sl=True) if len(sel) >= 2: snapShapeToNeutral(sel[0], sel[1]) @@ -40,7 +41,7 @@ def snapShapeToNeutralInterface(window): cmds.delete(rest) -def snapShapeToNeutral(source, target): +def snapShapeToNeutral(source, target) -> None: """ Take a mesh, and find the closest location on the target head, and snap to that Then set up a blendShape so the artist can "paint" in the snapping behavior @@ -65,8 +66,6 @@ def snapShapeToNeutral(source, target): # But set the weights back to 0.0 for painting numVerts = cmds.polyEvaluate(source, vertex=1) - setter = "{0}.inputTarget[0].inputTargetGroup[0].targetWeights[0:{1}]".format( - bs, numVerts - 1 - ) + setter = f"{bs}.inputTarget[0].inputTargetGroup[0].targetWeights[0:{numVerts - 1}]" weights = [0.0] * numVerts cmds.setAttr(setter, *weights, size=numVerts) diff --git a/src/python/simplexui/menu/mayaPlugins/softSelectToCluster.py b/src/python/simplexui/menu/mayaPlugins/softSelectToCluster.py index c3ff2d2a..34d0fd59 100644 --- a/src/python/simplexui/menu/mayaPlugins/softSelectToCluster.py +++ b/src/python/simplexui/menu/mayaPlugins/softSelectToCluster.py @@ -15,23 +15,24 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + import maya.cmds as cmds import maya.OpenMaya as om - from Qt.QtWidgets import QAction -def registerTool(window, menu): +def registerTool(window, menu) -> None: softSelectToClusterACT = QAction("Soft Select To Cluster", window) menu.addAction(softSelectToClusterACT) softSelectToClusterACT.triggered.connect(softSelectToClusterInterface) -def softSelectToClusterInterface(): +def softSelectToClusterInterface() -> None: sel = cmds.ls(sl=True, objectsOnly=True) if sel: name = sel[0].split("|")[-1] - softSelectToCluster(sel[0], "{0}_Soft".format(name)) + softSelectToCluster(sel[0], f"{name}_Soft") def getSoftSelectionValues(myNode, returnSimpleIndices=True): @@ -167,7 +168,7 @@ def getSoftSelectionValues(myNode, returnSimpleIndices=True): return toReturn -def softSelectToCluster(tfm, name): +def softSelectToCluster(tfm, name: str) -> None: # Get the manipulator position for the selection cmds.setToolTo("Move") currentMoveMode = cmds.manipMoveContext("Move", query=True, mode=True) @@ -181,7 +182,7 @@ def softSelectToCluster(tfm, name): shapes = [k for k in softSelDict if k.startswith(tfm)] if not shapes: - print("No selection found on the given mesh: {0}".format(tfm)) + print(f"No selection found on the given mesh: {tfm}") return elementIndices, elementWeights = softSelDict[shapes[0]] @@ -191,7 +192,7 @@ def softSelectToCluster(tfm, name): # Build the Cluster and set the weights # Currently this part is polymesh specific clusterNode, clusterHandle = cmds.cluster(tfm, name=name) - attr = "{0}.weightList[0].weights[0:{1}]".format(clusterNode, vnum - 1) + attr = f"{clusterNode}.weightList[0].weights[0:{vnum - 1}]" cmds.setAttr(attr, *weights, size=vnum) # Reposition the cluster diff --git a/src/python/simplexui/menu/mayaPlugins/tweakMix.py b/src/python/simplexui/menu/mayaPlugins/tweakMix.py index 991a59fa..47eed133 100644 --- a/src/python/simplexui/menu/mayaPlugins/tweakMix.py +++ b/src/python/simplexui/menu/mayaPlugins/tweakMix.py @@ -15,23 +15,25 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + from functools import partial import maya.cmds as cmds +from Qt.QtWidgets import QAction from ...interface.mayaInterface import disconnected from ...interfaceModel import coerceIndexToType from ...items import Combo -from Qt.QtWidgets import QAction -def registerTool(window, menu): +def registerTool(window, menu) -> None: tweakMixACT = QAction("Tweak Mix", window) menu.addAction(tweakMixACT) tweakMixACT.triggered.connect(partial(tweakMixInterface, window)) -def tweakMixInterface(window): +def tweakMixInterface(window) -> None: if not window.simplex: return live = window.uiLiveShapeConnectionACT.isChecked() @@ -45,7 +47,7 @@ def tweakMixInterface(window): tweakMix(window.simplex, combos, live) -def registerContext(tree, clickIdx, indexes, menu): +def registerContext(tree, clickIdx, indexes, menu) -> bool: window = tree.window() live = window.uiLiveShapeConnectionACT.isChecked() indexes = coerceIndexToType(indexes, Combo) @@ -57,13 +59,13 @@ def registerContext(tree, clickIdx, indexes, menu): return False -def tweakMixContext(window, indexes, live): +def tweakMixContext(window, indexes, live) -> None: combos = [idx.model().itemFromIndex(idx) for idx in indexes] combos = list(set(combos)) tweakMix(window.simplex, combos, live) -def tweakMix(simplex, combos, live): +def tweakMix(simplex, combos, live) -> None: # first extract the rest shape non-live restGeo = simplex.extractRestShape() @@ -110,7 +112,7 @@ def tweakMix(simplex, combos, live): cmds.setAttr(tshape.thing, shapeVal) # print "setAttr", tshape.thing, shapeVal tweakMesh = cmds.duplicate( - simplex.DCC.mesh, name="{0}_Tweak".format(tshape.name) + simplex.DCC.mesh, name=f"{tshape.name}_Tweak" )[0] tweakMeshes.append(tweakMesh) cmds.setAttr(tshape.thing, 0.0) diff --git a/src/python/simplexui/menu/mayaPlugins/updateRestShape.py b/src/python/simplexui/menu/mayaPlugins/updateRestShape.py index 00ba7cd1..bc4ddb5b 100644 --- a/src/python/simplexui/menu/mayaPlugins/updateRestShape.py +++ b/src/python/simplexui/menu/mayaPlugins/updateRestShape.py @@ -15,21 +15,31 @@ # You should have received a copy of the GNU Lesser General Public License # along with Simplex. If not, see . +from __future__ import annotations + import textwrap from functools import partial import maya.cmds as cmds - -from Qt.QtWidgets import QAction, QMessageBox +from Qt.QtGui import QAction +from Qt.QtWidgets import QMessageBox -def registerTool(window, menu): +def registerTool(window, menu) -> None: updateRestShapeACT = QAction("Update Rest Shape", window) menu.addAction(updateRestShapeACT) updateRestShapeACT.triggered.connect(partial(updateRestShapeInterface, window)) -def updateRestShapeInterface(window): +def updateRestShapeInterface(window) -> None: + if window.simplex is None: + QMessageBox.warning( + window, + "Nothing Loaded", + "No simplex system is loaded", + ) + return + sel = cmds.ls(sl=True) if not sel: QMessageBox.warning(window, "Nothing Selected", "Nothing Selected") @@ -42,9 +52,7 @@ def updateRestShapeInterface(window): meshVerts = cmds.polyEvaluate(mesh, vertex=1) if selVerts != meshVerts: - msg = "Selected object {0} has {1} verts\nBase Object has {2} verts".format( - sel, selVerts, meshVerts - ) + msg = f"Selected object {sel} has {selVerts} verts\nBase Object has {meshVerts} verts" QMessageBox.warning(window, "Vert Mismatch", msg) return @@ -70,7 +78,7 @@ def updateRestShapeInterface(window): updateRestShape(mesh, sel, window=window) -def updateRestShape(mesh, newRest, window=None): +def updateRestShape(mesh, newRest, window=None) -> None: allShapes = cmds.listRelatives(mesh, children=1, shapes=1) or [] noInter = cmds.listRelatives(mesh, children=1, shapes=1, noIntermediate=1) or [] hist = cmds.listHistory(mesh) @@ -92,13 +100,13 @@ def updateRestShape(mesh, newRest, window=None): QMessageBox.warning( window, "Too Many Intermediates", - "Too Many intermediate meshes found: {0}".format(origs), + f"Too Many intermediate meshes found: {origs}", ) return orig = origs[0] - outMesh = "{0}.worldMesh[0]".format(newRest) - inMesh = "{0}.inMesh".format(orig) + outMesh = f"{newRest}.worldMesh[0]" + inMesh = f"{orig}.inMesh" cmds.connectAttr(outMesh, inMesh, force=1) cmds.refresh(force=1) diff --git a/src/python/simplexui/simplexDialog.py b/src/python/simplexui/simplexDialog.py index 0ea7c44e..a44a0b18 100644 --- a/src/python/simplexui/simplexDialog.py +++ b/src/python/simplexui/simplexDialog.py @@ -1,1454 +1,1547 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - - -# Ignore a bunch of linter warnings that show up because of my choice of abstraction -# pylint: disable=unused-argument,too-many-public-methods,relative-import -# pylint: disable=too-many-statements,no-self-use,missing-docstring -import json -import os -import re -import sys -import weakref -from contextlib import contextmanager - -from .comboCheckDialog import ComboCheckDialog -from .falloffDialog import FalloffDialog -from .interface import DCC -from .interfaceModel import ( - ComboFilterModel, - ComboModel, - SimplexModel, - SliderFilterModel, - SliderModel, - coerceIndexToChildType, - coerceIndexToParentType, - coerceIndexToRoots, -) -from .interfaceModelTrees import ComboTree, SliderTree -from .items import Combo, Group, ProgPair, Simplex, Slider, Stack -from .menu import buildToolMenu, loadPlugins - -# This module imports QT from PyQt4, PySide or PySide2 -# Depending on what's available -from Qt import QtCompat -from Qt.QtCore import Qt, Signal -from Qt.QtGui import QStandardItemModel -from Qt.QtWidgets import QApplication, QInputDialog, QMessageBox, QProgressDialog -from .traversalDialog import TraversalDialog -from .utils import getNextName, getUiFile, makeUnique, naturalSortKey, Prefs - -if os.environ.get("SIMPLEX_AT_BLUR") == "true": - # If we're at blur, use our main window subclass - from blurdev.gui import Window -else: - from Qt.QtWidgets import QMainWindow as Window - -NAME_CHECK = re.compile(r"[A-Za-z][\w.]*") - -# If the decorated method is a slot for some Qt Signal -# and the method signature is *NOT* the same as the -# signal signature, you must double decorate the method like: -# -# @Slot(**signature) -# @stackable -# def method(**signature) - - -@contextmanager -def signalsBlocked(item): - """Context manager to block the qt signals on an item""" - item.blockSignals(True) - try: - yield - finally: - item.blockSignals(False) - - -class SimplexDialog(Window): - """The main ui for simplex - - Parameters - ---------- - parent : QWidget - The parent for this window - dispatch : Dispatch - An object that translates DCC events into - Qt Signals that the Simplex Ui can understand - - """ - - simplexLoaded = Signal() - openedDialogs = [] - - def __init__(self, parent=None, dispatch=None): - super(SimplexDialog, self).__init__(parent) - - uiPath = getUiFile(__file__) - QtCompat.loadUi(uiPath, self) - - # Custom widgets aren't working properly, so I bring them in manually - self.uiSliderTREE = SliderTree(self.uiMainShapesGRP) - self.uiSliderTREE.setDragEnabled(False) - self.uiSliderTREE.setDragDropMode(SliderTree.NoDragDrop) - self.uiSliderTREE.setSelectionMode(SliderTree.ExtendedSelection) - self.uiSliderTREE.dragFilter.dragPressed.connect(self.dragStart) - self.uiSliderTREE.dragFilter.dragReleased.connect(self.dragStop) - - self.uiSliderLAY.addWidget(self.uiSliderTREE) - - self.uiComboTREE = ComboTree(self.uiComboShapesGRP) - self.uiComboTREE.setDragEnabled(False) - self.uiComboTREE.setDragDropMode(ComboTree.NoDragDrop) - self.uiComboTREE.setSelectionMode(ComboTree.ExtendedSelection) - self.uiComboTREE.dragFilter.dragPressed.connect(self.dragStart) - self.uiComboTREE.dragFilter.dragReleased.connect(self.dragStop) - self.uiComboLAY.addWidget(self.uiComboTREE) - - self._sliderMenu = None - self._comboMenu = None - self._currentObject = None - self._currentObjectName = None - - # Connect the combo boxes and spinners to the data model - # TODO: Figure out how to deal with the type/axis "enum" cboxes - # see: http://doc.qt.io/qt-5/qtwidgets-itemviews-combowidgetmapper-example.html - """ - self._falloffMapper = QDataWidgetMapper() - self.uiShapeFalloffCBOX.currentIndexChanged.connect(self._falloffMapper.setCurrentIndex) - """ - - # Make sure to connect the dispatcher to the undo control - # but only keep a weakref to it - self.dispatch = None - if dispatch is not None: - self.dispatch = weakref.ref(dispatch) - dispatch.undo.connect(self.handleUndo) - dispatch.redo.connect(self.handleUndo) - dispatch.beforeNew.connect(self.newScene) - dispatch.beforeOpen.connect(self.newScene) - - self.simplex = None - - self._itemMap = {} - self._sliderTreeMap = {} - self._comboTreeMap = {} - - self._sliderDrag = None - self._comboDrag = None - - self.uiSliderExitIsolateBTN.hide() - self.uiComboExitIsolateBTN.hide() - - self._makeConnections() - - self._toolPlugins, self._contextPlugins = loadPlugins() - buildToolMenu(self, self._toolPlugins) - self.uiSliderTREE.setPlugins(self._contextPlugins) - self.uiComboTREE.setPlugins(self._contextPlugins) - - if DCC.program == "dummy": - # self.getSelectedObject() - self.uiObjectGRP.setEnabled(False) - self.uiSystemGRP.setEnabled(False) - - self.uiClearSelectedObjectBTN.hide() - self.uiMainShapesGRP.setEnabled(False) - self.uiComboShapesGRP.setEnabled(False) - self.uiConnectionGroupWID.setEnabled(False) - self.loadSettings() - self._sliderMul = 2.0 if self.uiDoubleSliderRangeACT.isChecked() else 1.0 - - self.travDialog = TraversalDialog(self) - self.falloffDialog = FalloffDialog(self) - # self.showTraversalDialog() - type(self).openedDialogs.append(weakref.ref(self)) - - @classmethod - def lastOpenedDialog(cls): - """Returns the last currently opened dialog that still exists""" - for dlgRef in reversed(cls.openedDialogs): - dlg = dlgRef() - if dlg is not None: - return dlg - return None - - def showTraversalDialog(self): - """Display the traversal dialog""" - self.travDialog.show() - self.travDialog.setGeometry(30, 30, 400, 400) - - def showFalloffDialog(self): - """Display the Falloff Dialog""" - self.falloffDialog.show() - pp = self.falloffDialog.pos() - x, y = pp.x(), pp.y() - if x < 0 or y < 0: - self.falloffDialog.move(max(x, 0), max(y, 0)) - - def dragStart(self): - """Slot for handling the start of a MMB Drag event""" - if self.simplex is not None: - self.simplex.DCC.undoOpen() - - def dragStop(self): - """Slot for handling the end of a MMB Drag event""" - if self.simplex is not None: - self.simplex.DCC.undoClose() - - def storeSettings(self): - """Store the state of the UI for the next run""" - pref = Prefs() - pref.recordProperty("geometry", self.saveGeometry()) - pref.save() - - def loadSettings(self): - """Load the state of the UI from a previous run""" - pref = Prefs() - geo = pref.restoreProperty("geometry", None) - if geo is not None: - self.restoreGeometry(geo) - - def closeEvent(self, event): - """Handle the close event""" - self.storeSettings() - self.deleteLater() - - # Undo/Redo - def newScene(self): - """Call this before a new scene is created. Usually called from the stack""" - self.clearSelectedObject() - - def handleUndo(self): - """Call this after an undo/redo action. Usually called from the stack""" - rev = self.simplex.DCC.getRevision() - data = self.simplex.stack.getRevision(rev) - if data is not None: - self.setSystem(data) - self.uiSliderTREE.setItemExpansion() - self.uiComboTREE.setItemExpansion() - - def currentSystemChanged(self, idx): - """Slot called when the current system changes""" - if idx == -1: - self.setSystem(None) - return - name = str(self.uiCurrentSystemCBOX.currentText()) - if not name: - self.setSystem(None) - return - if self.simplex is not None: - if self.simplex.name == name: - return # Do nothing - - pBar = QProgressDialog("Loading from Mesh", "Cancel", 0, 100, self) - system = Simplex.buildSystemFromMesh( - self._currentObject, name, sliderMul=self._sliderMul, pBar=pBar - ) - self.setSystem(system) - pBar.close() - - def setSystem(self, system): - """Set the system on this UI - - Parameters - ---------- - system : Simplex - The Simplex system to load into this UI - """ - if system == self.simplex: - return - - if self.simplex is not None: - # disconnect the previous stuff - sliderSelModel = self.uiSliderTREE.selectionModel() - sliderSelModel.selectionChanged.disconnect(self.unifySliderSelection) - sliderSelModel.selectionChanged.disconnect(self.populateComboRequirements) - sliderSelModel.selectionChanged.disconnect(self.autoSetSliders) - - comboSelModel = self.uiComboTREE.selectionModel() - comboSelModel.selectionChanged.disconnect(self.unifyComboSelection) - comboSelModel.selectionChanged.disconnect(self.populateSliderRequirements) - comboSelModel.selectionChanged.disconnect(self.autoSetComboSliders) - - oldStack = self.simplex.stack - else: - oldStack = Stack() - - if system is None: - # self.toolActions.simplex = None - self.uiSliderTREE.setModel(QStandardItemModel()) - self.uiComboTREE.setModel(QStandardItemModel()) - self.simplex = system - self.uiMainShapesGRP.setEnabled(False) - self.uiComboShapesGRP.setEnabled(False) - self.uiConnectionGroupWID.setEnabled(False) - self.falloffDialog.loadSimplex() - self.simplexLoaded.emit() - return - - # set and connect the new stuff - self.simplex = system - self.simplex.models = [] - self.simplex.falloffModels = [] - self.simplex.stack = oldStack - - # self.toolActions.simplex = self.simplex - - simplexModel = SimplexModel(self.simplex, None) - - sliderModel = SliderModel(simplexModel, None) - sliderProxModel = SliderFilterModel(sliderModel) - self.uiSliderTREE.setModel(sliderProxModel) - sliderSelModel = self.uiSliderTREE.selectionModel() - sliderSelModel.selectionChanged.connect(self.unifySliderSelection) - sliderSelModel.selectionChanged.connect(self.populateComboRequirements) - sliderSelModel.selectionChanged.connect(self.autoSetSliders) - - comboModel = ComboModel(simplexModel, None) - comboProxModel = ComboFilterModel(comboModel) - self.uiComboTREE.setModel(comboProxModel) - comboSelModel = self.uiComboTREE.selectionModel() - comboSelModel.selectionChanged.connect(self.unifyComboSelection) - comboSelModel.selectionChanged.connect(self.populateSliderRequirements) - comboSelModel.selectionChanged.connect(self.autoSetComboSliders) - - self.falloffDialog.loadSimplex() - - # Make sure the UI is up and running - self.enableComboRequirements() - self.enableSliderRequirements() - self.uiMainShapesGRP.setEnabled(True) - self.uiComboShapesGRP.setEnabled(True) - self.uiConnectionGroupWID.setEnabled(True) - - self.setSimplexLegacy() - self.simplexLoaded.emit() - - # UI Setup - def _makeConnections(self): - """Make all the ui connections""" - # Setup Trees! - self.uiSliderTREE.setColumnWidth(1, 50) - self.uiSliderTREE.setColumnWidth(2, 20) - self.uiSliderFilterLINE.textChanged.connect(self.sliderStringFilter) - self.uiSliderFilterClearBTN.clicked.connect(self.uiSliderFilterLINE.clear) - self.uiSliderFilterClearBTN.clicked.connect(self.sliderStringFilter) - - self.uiComboTREE.setColumnWidth(1, 50) - self.uiComboTREE.setColumnWidth(2, 20) - self.uiComboFilterLINE.textChanged.connect(self.comboStringFilter) - self.uiComboFilterClearBTN.clicked.connect(self.uiComboFilterLINE.clear) - self.uiComboFilterClearBTN.clicked.connect(self.comboStringFilter) - - # combo dependency filter setup - self.uiComboDependGRP.toggled.connect(self.enableComboRequirements) - self.uiComboDependAllRDO.toggled.connect(self.enableComboRequirements) - self.uiComboDependAnyRDO.toggled.connect(self.enableComboRequirements) - self.uiComboDependOnlyRDO.toggled.connect(self.enableComboRequirements) - self.uiComboDependLockCHK.toggled.connect(self.setLockComboRequirement) - - # slider dependency filter setup - self.uiSliderDependGRP.toggled.connect(self.enableSliderRequirements) - self.uiSliderDependAllRDO.toggled.connect(self.enableSliderRequirements) - self.uiSliderDependAnyRDO.toggled.connect(self.enableSliderRequirements) - self.uiSliderDependLockCHK.toggled.connect(self.setLockSliderRequirements) - - # Bottom Left Corner Buttons - self.uiZeroAllBTN.clicked.connect(self.zeroAllSliders) - self.uiZeroSelectedBTN.clicked.connect(self.zeroSelectedSliders) - self.uiSelectCtrlBTN.clicked.connect(self.selectCtrl) - - # Top Left Corner Buttons - self.uiNewGroupBTN.clicked.connect(self.newSliderGroup) - self.uiNewSliderBTN.clicked.connect(self.newSlider) - self.uiNewShapeBTN.clicked.connect(self.newSliderShape) - self.uiSliderDeleteBTN.clicked.connect(self.sliderTreeDelete) - - # Top Right Corner Buttons - self.uiDeleteComboBTN.clicked.connect(self.comboTreeDelete) - self.uiNewComboActiveBTN.clicked.connect(self.newActiveCombo) - self.uiNewComboSelectBTN.clicked.connect(self.newSelectedCombo) - self.uiNewComboShapeBTN.clicked.connect(self.newComboShape) - self.uiNewComboGroupBTN.clicked.connect(self.newComboGroup) - - # Bottom right corner buttons - self.uiSetSliderValsBTN.clicked.connect(self.setSliderVals) - self.uiSelectSlidersBTN.clicked.connect(self.selectSliders) - - # System level - self.uiCurrentObjectTXT.editingFinished.connect(self.currentObjectChanged) - self.uiGetSelectedObjectBTN.clicked.connect(self.getSelectedObject) - self.uiClearSelectedObjectBTN.clicked.connect(self.clearSelectedObject) - - self.uiNewSystemBTN.clicked.connect(self.newSystem) - self.uiRenameSystemBTN.clicked.connect(self.renameSystem) - self.uiCurrentSystemCBOX.currentIndexChanged[int].connect( - self.currentSystemChanged - ) - - # Extraction/connection - self.uiShapeExtractBTN.clicked.connect(self.shapeExtract) - self.uiShapeConnectBTN.clicked.connect(self.shapeConnect) - self.uiShapeConnectSceneBTN.clicked.connect(self.shapeConnectScene) - - # File Menu - self.uiImportACT.triggered.connect(self.importSystemFromFile) - self.uiExportACT.triggered.connect(self.exportSystemTemplate) - - # Edit Menu - self.uiHideRedundantACT.toggled.connect(self.hideRedundant) - self.uiDoubleSliderRangeACT.toggled.connect(self.setSliderRange) - - # Isolation - self.uiSliderExitIsolateBTN.clicked.connect(self.sliderTreeExitIsolate) - self.uiComboExitIsolateBTN.clicked.connect(self.comboTreeExitIsolate) - - self.uiLegacyJsonACT.toggled.connect(self.setSimplexLegacy) - - # Helpers - def getSelectedItems(self, tree, typ=None): - """Convenience function to get the selected system items - - Parameters - ---------- - tree : QTreeView - The tree to get selected items from - typ : Type - The type of objects to return - - Returns - ------- - [object, ...] - A list of selected items - """ - sel = tree.selectedIndexes() - sel = [i for i in sel if i.column() == 0] - model = tree.model() - items = [model.itemFromIndex(i) for i in sel] - if typ is not None: - items = [i for i in items if isinstance(i, typ)] - return items - - def getCurrentObject(self): - """Convenience function to get the current object loaded into the UI""" - return self._currentObject - - # Setup Trees! - def sliderStringFilter(self): - """Set the filter for the slider tree""" - filterString = str(self.uiSliderFilterLINE.text()) - sliderModel = self.uiSliderTREE.model() - sliderModel.filterString = str(filterString) - sliderModel.invalidateFilter() - - def comboStringFilter(self): - """Set the filter for the combo tree""" - filterString = str(self.uiComboFilterLINE.text()) - comboModel = self.uiComboTREE.model() - comboModel.filterString = str(filterString) - comboModel.invalidateFilter() - - # selection setup - def unifySliderSelection(self): - """Clear the selection of the combo tree when - an item on the slider tree is selected - """ - mods = QApplication.keyboardModifiers() - if not mods & ( - Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier - ): - comboSelModel = self.uiComboTREE.selectionModel() - if not comboSelModel: - return - - with signalsBlocked(comboSelModel): - comboSelModel.clearSelection() - self.uiComboTREE.viewport().update() - - def unifyComboSelection(self): - """Clear the selection of the slider tree when - an item on the combo tree is selected - """ - mods = QApplication.keyboardModifiers() - if not mods & ( - Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier - ): - sliderSelModel = self.uiSliderTREE.selectionModel() - if not sliderSelModel: - return - with signalsBlocked(sliderSelModel): - sliderSelModel.clearSelection() - self.uiSliderTREE.viewport().update() - - # dependency setup - def setLockComboRequirement(self): - """Refresh the combo selection filter with the new filterLock state""" - comboModel = self.uiComboTREE.model() - if not comboModel: - return - self.populateComboRequirements() - - def populateComboRequirements(self): - """Let the combo tree know the requirements from the slider tree""" - items = self.uiSliderTREE.getSelectedItems(Slider) - comboModel = self.uiComboTREE.model() - locked = self.uiComboDependLockCHK.isChecked() - if not locked: - comboModel.requires = items - if ( - comboModel.filterRequiresAll - or comboModel.filterRequiresAny - or comboModel.filterRequiresOnly - ): - comboModel.invalidateFilter() - - def enableComboRequirements(self): - """Set the requirements for the combo filter model""" - comboModel = self.uiComboTREE.model() - if not comboModel: - return - depCheck = self.uiComboDependGRP.isChecked() - comboModel.filterRequiresAll = self.uiComboDependAllRDO.isChecked() and depCheck - comboModel.filterRequiresAny = self.uiComboDependAnyRDO.isChecked() and depCheck - comboModel.filterRequiresOnly = ( - self.uiComboDependOnlyRDO.isChecked() and depCheck - ) - comboModel.invalidateFilter() - - def populateSliderRequirements(self): - """Let the slider tree know the requirements from the combo tree""" - items = self.uiComboTREE.getSelectedItems(Combo) - sliderModel = self.uiSliderTREE.model() - locked = self.uiSliderDependLockCHK.isChecked() - if not locked: - sliderModel.requires = items - if sliderModel.filterRequiresAny or sliderModel.filterRequiresAll: - sliderModel.invalidateFilter() - - def setLockSliderRequirements(self): - """Refresh the combo selection filter with the new filterLock state""" - sliderModel = self.uiSliderTREE.model() - if not sliderModel: - return - self.populateSliderRequirements() - - def enableSliderRequirements(self): - sliderModel = self.uiSliderTREE.model() - if not sliderModel: - return - - depCheck = self.uiSliderDependGRP.isChecked() - sliderModel.filterRequiresAny = ( - self.uiSliderDependAnyRDO.isChecked() and depCheck - ) - sliderModel.filterRequiresAll = ( - self.uiSliderDependAllRDO.isChecked() and depCheck - ) - sliderModel.invalidateFilter() - - # Bottom Left Corner Buttons - def zeroAllSliders(self): - """Slot to Zero all Sliders in Slider UI panel""" - if self.simplex is None: - return - sliders = self.simplex.sliders - weights = [0.0] * len(sliders) - self.simplex.setSlidersWeights(sliders, weights) - self.uiSliderTREE.repaint() - - def zeroSelectedSliders(self): - """Slot to Zero the selected sliders in the UI panel""" - if self.simplex is None: - return - items = self.uiSliderTREE.getSelectedItems(Slider) - values = [0.0] * len(items) - self.simplex.setSlidersWeights(items, values) - self.uiSliderTREE.repaint() - - def selectCtrl(self): - """Select the Control object in the DCC""" - if self.simplex is None: - return - self.simplex.DCC.selectCtrl() - - def autoSetSliders(self): - """Automatically set any selected sliders to 1.0""" - if self.simplex is None: - return - if not self.uiAutoSetSlidersCHK.isChecked(): - return - sel = set(self.uiSliderTREE.getSelectedItems(Slider)) - sliders = self.simplex.sliders - - weights = [0.0] * len(sliders) - for i, slider in enumerate(sliders): - if slider in sel: - weights[i] = 1.0 - self.simplex.setSlidersWeights(sliders, weights) - self.uiSliderTREE.repaint() - - def _getAName(self, tpe, default=None, taken=(), uniqueAccept=False): - """uniqueAccept forces the user to provide and accept a unique name - If the user enters a non-unique name, the name is uniquified, and the - dialog is re-shown with the unique name as the default suggestion - """ - tpe = tpe.lower() - uTpe = tpe[0].upper() + tpe[1:] - - unique = False - default = getNextName(default, taken) - while not unique: - eMsg = "Enter a name for the new {0}".format(tpe) - newName, good = QInputDialog.getText( - self, "New {0}".format(uTpe), eMsg, text=default - ) - if not good: - return None - if len(newName) < 3: - message = "Please use names longer than 2 letters" - QMessageBox.warning(self, "Warning", message) - return None - if not NAME_CHECK.match(newName): - message = "{0} name can only contain letters and numbers, and cannot start with a number" - message = message.format(uTpe) - QMessageBox.warning(self, "Warning", message) - return None - - unqName = getNextName(newName, taken) - unique = (unqName == newName) or uniqueAccept - default = unqName - - return default - - # Top Left Corner Buttons - def newSliderGroup(self): - """Slot to Create a new slider group""" - if self.simplex is None: - return - - newName = self._getAName("group", default="Group") - if newName is None: - return - - Group.createGroup(str(newName), self.simplex, groupType=Slider) - # self.uiSliderTREE.model().invalidateFilter() - # self.uiComboTREE.model().invalidateFilter() - - def newSlider(self): - """Slot to create a new slider""" - if self.simplex is None: - return - - newName = self._getAName("slider") - if newName is None: - return - - idxs = self.uiSliderTREE.getSelectedIndexes() - groups = coerceIndexToParentType(idxs, Group) - group = groups[0].model().itemFromIndex(groups[0]) if groups else None - - Slider.createSlider(str(newName), self.simplex, group=group) - - def newSliderShape(self): - """Slot to create a new Shape in a Slider's progression""" - pars = self.uiSliderTREE.getSelectedItems(Slider) - if not pars: - return - parItem = pars[0] - parItem.createShape() - self.uiSliderTREE.model().invalidateFilter() - - def sliderTreeDelete(self): - """Delete some objects in the slider tree""" - idxs = self.uiSliderTREE.getSelectedIndexes() - roots = coerceIndexToRoots(idxs) - if not roots: - QMessageBox.warning(self, "Warning", "Nothing Selected in Slider tree") - return - roots = makeUnique([i.model().itemFromIndex(i) for i in roots]) - for r in roots: - if isinstance(r, Simplex): - QMessageBox.warning( - self, "Warning", "Cannot delete a simplex system this way" - ) - return - - for r in roots: - r.delete() - self.uiSliderTREE.model().invalidateFilter() - - # Top Right Corner Buttons - def comboTreeDelete(self): - """Delete some objects in the Combo tree""" - idxs = self.uiComboTREE.getSelectedIndexes() - roots = coerceIndexToRoots(idxs) - if not roots: - QMessageBox.warning(self, "Warning", "Nothing Selected in Combo tree") - return - roots = makeUnique([i.model().itemFromIndex(i) for i in roots]) - for r in roots: - if isinstance(r, Simplex): - QMessageBox.warning( - self, "Warning", "Cannot delete a simplex system this way" - ) - return - - for r in roots: - r.delete() - self.uiComboTREE.model().invalidateFilter() - - def _newCombo(self, sliders, values): - if len(sliders) < 2: - message = "A combo must use at least 2 sliders" - QMessageBox.warning(self, "Warning", message) - return - - ccd = ComboCheckDialog(sliders, values=values, mode="create", parent=self) - ccd.move(self.pos()) - ccd.exec_() - - def newActiveCombo(self): - """Create a combo based on the UI Sliders that are currently nonzero""" - if self.simplex is None: - return - sliders = [] - values = {} - for s in self.simplex.sliders: - if s.value != 0.0: - sliders.append(s) - values[s] = [s.value] - self._newCombo(sliders, values) - - def newSelectedCombo(self): - """Create a combo based on the currently selected UI sliders""" - if self.simplex is None: - return - sliders = self.uiSliderTREE.getSelectedItems(Slider) - values = {s: [1.0] for s in sliders} - self._newCombo(sliders, values) - - def newComboShape(self): - """Create a new shape in the Combo's progression""" - parIdxs = self.uiComboTREE.getSelectedIndexes() - pars = coerceIndexToParentType(parIdxs, Combo) - if not pars: - return - - parItem = pars[0].model().itemFromIndex(pars[0]) if pars else None - parItem.createShape() - self.uiComboTREE.model().invalidateFilter() - - def newComboGroup(self): - """Create a new group for organizing combos""" - if self.simplex is None: - return - - newName = self._getAName("group", default="Group") - if newName is None: - return - - Group.createGroup(str(newName), self.simplex, groupType=Combo) - # self.uiComboTREE.model().invalidateFilter() - # self.uiSliderTREE.model().invalidateFilter() - - # Bottom right corner buttons - def setSliderVals(self): - """Set all slider values to those stored in the currently selected Combos""" - if self.simplex is None: - return - combos = self.uiComboTREE.getSelectedItems(Combo) - # maybe coerce to type instead?? - self.zeroAllSliders() - values = [] - sliders = [] - for combo in combos: - for pair in combo.pairs: - if pair.slider in sliders: - continue - sliders.append(pair.slider) - values.append(pair.value) - self.simplex.setSlidersWeights(sliders, values) - self.uiSliderTREE.repaint() - - def selectSliders(self): - """Select the sliders that are contained in the currently selected Combos""" - combos = self.uiComboTREE.getSelectedItems(Combo) - sliders = [] - for combo in combos: - for pair in combo.pairs: - sliders.append(pair.slider) - self.uiSliderTREE.setItemSelection(sliders) - - def autoSetComboSliders(self): - """Automatically set the DCC Slider values to activate the currently selected Combos""" - if self.simplex is None: - return - if not self.uiAutoSetCombosCHK.isChecked(): - return - sel = set(self.uiComboTREE.getSelectedItems(Combo)) - sv = {} - for combo in self.simplex.combos: - isSel = combo in sel - for pair in combo.pairs: - curVal = sv.get(pair.slider, 0.0) - newVal = pair.value if isSel else 0.0 - if abs(newVal) >= abs(curVal): - sv[pair.slider] = newVal - - sliders, weights = list(zip(*list(sv.items()))) - self.simplex.setSlidersWeights(sliders, weights) - self.uiSliderTREE.repaint() - - # Extraction/connection - def shapeConnectScene(self): - """Connect any selected meshes into the system based on the name""" - if self.simplex is None: - return - # make a dict of name:object - sel = DCC.getSelectedObjects() - selDict = {} - for s in sel: - name = DCC.getObjectName(s) - if name.endswith("_Extract"): - nn = name.rsplit("_Extract", 1)[0] - selDict[nn] = s - - pairDict = {} - for p in self.simplex.progs: - for pp in p.pairs: - pairDict[pp.shape.name] = pp - - # get all common names - common = selDict.keys() & pairDict.keys() - - # get those items - pairs = [pairDict[i] for i in common] - - # Set up the progress bar - pBar = QProgressDialog("Connecting Shapes", "Cancel", 0, 100, self) - pBar.setMaximum(len(pairs)) - - # Do the extractions - for pair in pairs: - c = pair.prog.controller - c.connectShape(pair.shape, delete=True) - - # ProgressBar - pBar.setValue(pBar.value() + 1) - pBar.setLabelText("Connecting:\n{0}".format(pair.shape.name)) - QApplication.processEvents() - if pBar.wasCanceled(): - return - - pBar.close() - - def sliderShapeExtract(self): - """Create meshes that are possibly live-connected to the shapes""" - sliderIdxs = self.uiSliderTREE.getSelectedIndexes() - return self.shapeIndexExtract(sliderIdxs) - - def comboShapeExtract(self): - """Create meshes that are possibly live-connected to the shapes""" - comboIdxs = self.uiComboTREE.getSelectedIndexes() - return self.shapeIndexExtract(comboIdxs) - - def shapeExtract(self): - """Create meshes that are possibly live-connected to the shapes""" - sliderIdxs = self.uiSliderTREE.getSelectedIndexes() - comboIdxs = self.uiComboTREE.getSelectedIndexes() - return self.shapeIndexExtract(sliderIdxs + comboIdxs) - - def shapeExport(self): - """Export meshes""" - sliderIdxs = self.uiSliderTREE.getSelectedIndexes() - comboIdxs = self.uiComboTREE.getSelectedIndexes() - return self.shapeIndexExport(sliderIdxs + comboIdxs) - - def shapeIndexExport(self, indexes): - """Export meshes - - Parameters - ---------- - indexes : [QModelIndex, ...] - A list of indexes to extract - """ - # Importing this here to keep people from getting confused - # You should use self._fileDialog which does compatibility - # stuff for file saving. Since I'm looking for a folder here - # I don't need to do that - from Qt.QtWidgets import QFileDialog - - if self.simplex is None: - return - - pref = Prefs() - defaultPath = str( - pref.restoreProperty("systemExportFolder", os.path.join(os.path.expanduser("~"))) - ) - outfld = QFileDialog.getExistingDirectory( - self, "Pick Export Folder", defaultPath - ) - if not outfld: - return - - pref.recordProperty("systemExportFolder", outfld) - pref.save() - - pairs = coerceIndexToChildType(indexes, ProgPair) - pairs = [i.model().itemFromIndex(i) for i in pairs] - pairs = makeUnique([i for i in pairs if not i.shape.isRest]) - pairs.sort(key=lambda x: naturalSortKey(x.shape.name)) - - # Set up the progress bar - pBar = QProgressDialog("Exporting Shapes", "Cancel", 0, 100, self) - pBar.setMaximum(len(pairs)) - - extension = '.abc' - # Do the extractions - for pair in pairs: - c = pair.prog.controller - extracted = c.extractShape(pair.shape, live=False) - path = os.path.join(outfld, c.name + extension) - if os.path.exists(path): - os.remove(path) - - self.simplex.DCC.exportMesh(extracted, path) - self.simplex.DCC.deleteObj(extracted) - - # ProgressBar - pBar.setValue(pBar.value() + 1) - pBar.setLabelText("Extracting:\n{0}".format(pair.shape.name)) - QApplication.processEvents() - if pBar.wasCanceled(): - return - - pBar.close() - - def shapeIndexExtract(self, indexes, live=None): - """Create meshes that are possibly live-connected to the shapes - - Parameters - ---------- - indexes : [QModelIndex, ...] - A list of indexes to extract - live : bool or None - Whether the connection is live. If None, check the UI properties - """ - if live is None: - live = self.uiLiveShapeConnectionACT.isChecked() - - pairs = coerceIndexToChildType(indexes, ProgPair) - pairs = [i.model().itemFromIndex(i) for i in pairs] - pairs = makeUnique([i for i in pairs if not i.shape.isRest]) - pairs.sort(key=lambda x: naturalSortKey(x.shape.name)) - - # Set up the progress bar - pBar = QProgressDialog("Extracting Shapes", "Cancel", 0, 100, self) - pBar.setMaximum(len(pairs)) - - # Do the extractions - offset = 10 - extracted = [] - for pair in pairs: - c = pair.prog.controller - ext = c.extractShape(pair.shape, live=live, offset=offset) - extracted.append(ext) - offset += 5 - - # ProgressBar - pBar.setValue(pBar.value() + 1) - pBar.setLabelText("Extracting:\n{0}".format(pair.shape.name)) - QApplication.processEvents() - if pBar.wasCanceled(): - return extracted - - pBar.close() - return extracted - - def shapeConnect(self): - """Match any selected Shapes to DCC meshes based on their names, then delete the Meshes""" - sliderIdxs = self.uiSliderTREE.getSelectedIndexes() - comboIdxs = self.uiComboTREE.getSelectedIndexes() - self.shapeConnectIndexes(sliderIdxs + comboIdxs) - - def shapeConnectIndexes(self, indexes): - """Match the provided shapes to DCC meshes based on their names, then delete the Meshes - - Parameters - ---------- - indexes : list of QModelIndex - A list of selected model indexes - """ - pairs = coerceIndexToChildType(indexes, ProgPair) - pairs = [i.model().itemFromIndex(i) for i in pairs] - pairs = makeUnique([i for i in pairs if not i.shape.isRest]) - - # Set up the progress bar - pBar = QProgressDialog("Connecting Shapes", "Cancel", 0, 100, self) - pBar.setMaximum(len(pairs)) - - # Do the extractions - for pair in pairs: - c = pair.prog.controller - c.connectShape(pair.shape, delete=True) - - # ProgressBar - pBar.setValue(pBar.value() + 1) - pBar.setLabelText("Extracting:\n{0}".format(pair.shape.name)) - QApplication.processEvents() - if pBar.wasCanceled(): - return - - pBar.close() - - def shapeMatch(self): - """Match any selected Shapes to A selected DCC mesh""" - sliderIdxs = self.uiSliderTREE.getSelectedIndexes() - comboIdxs = self.uiComboTREE.getSelectedIndexes() - self.shapeMatchIndexes(sliderIdxs + comboIdxs) - - def shapeMatchIndexes(self, indexes): - """Match any provided shapes to A selected DCC mesh - - Parameters - ---------- - indexes : list of QModelIndex - A list of selected model indexes - """ - # make a dict of name:object - sel = DCC.getSelectedObjects() - if not sel: - return - mesh = sel[0] - - pairs = coerceIndexToChildType(indexes, ProgPair) - pairs = [i.model().itemFromIndex(i) for i in pairs] - pairs = makeUnique([i for i in pairs if not i.shape.isRest]) - - # Set up the progress bar - pBar = QProgressDialog("Matching Shapes", "Cancel", 0, 100, self) - pBar.setMaximum(len(pairs)) - - # Do the extractions - for pair in pairs: - c = pair.prog.controller - c.connectShape(pair.shape, mesh=mesh) - - # ProgressBar - pBar.setValue(pBar.value() + 1) - pBar.setLabelText("Matching:\n{0}".format(pair.shape.name)) - QApplication.processEvents() - if pBar.wasCanceled(): - return - - pBar.close() - - def shapeClear(self): - """Match all selected shapes to the rest""" - sliderIdxs = self.uiSliderTREE.getSelectedIndexes() - comboIdxs = self.uiComboTREE.getSelectedIndexes() - self.shapeClearIndexes(sliderIdxs + comboIdxs) - - def shapeClearIndexes(self, indexes): - """Match all provided shapes to the rest - - Parameters - ---------- - indexes : list of QModelIndex - A list of selected model indexes - """ - pairs = coerceIndexToChildType(indexes, ProgPair) - pairs = [i.model().itemFromIndex(i) for i in pairs] - pairs = makeUnique([i for i in pairs if not i.shape.isRest]) - - for pair in pairs: - pair.shape.zeroShape() - - # System level - def loadObject(self, thing): - """Load a DCC mesh into the UI - - Parameters - ---------- - thing : object - The DCC mesh to load into the UI - """ - if thing is None: - return - - self.uiClearSelectedObjectBTN.show() - self.uiCurrentSystemCBOX.clear() - objName = DCC.getObjectName(thing) - self._currentObject = thing - self._currentObjectName = objName - self.uiCurrentObjectTXT.setText(objName) - - ops = DCC.getSimplexOperatorsOnObject(self._currentObject) - - for op in ops: - js = DCC.getSimplexString(op) - if not js: - continue - d = json.loads(js) - name = d["systemName"] - self.uiCurrentSystemCBOX.addItem(name, (self._currentObject, name)) - - def currentObjectChanged(self): - """Slot called when the current DCC object is changed""" - name = str(self.uiCurrentObjectTXT.text()) - if self._currentObjectName == name: - return - if not name: - return - - newObject = DCC.getObjectByName(name) - if not newObject: - return - - self.loadObject(newObject) - - def getSelectedObject(self): - """Load the first selected DCC object into the UI""" - sel = DCC.getSelectedObjects() - if not sel: - return - newObj = sel[0] - if not newObj: - return - self.loadObject(newObj) - - def clearSelectedObject(self): - """Unload the current DCC object from the UI""" - self.uiClearSelectedObjectBTN.hide() - self.uiCurrentSystemCBOX.clear() - self._currentObject = None - self._currentObjectName = None - self.uiCurrentObjectTXT.setText("") - self.setSystem(None) - # Clear the current system - - def newSystem(self): - """Create a new system on the current DCC Object""" - if self._currentObject is None: - QMessageBox.warning(self, "Warning", "Must have a current object selection") - return - - newName = self._getAName("system") - if newName is None: - return - - newSystem = Simplex.buildEmptySystem( - self._currentObject, newName, sliderMul=self._sliderMul - ) - with signalsBlocked(self.uiCurrentSystemCBOX): - self.uiCurrentSystemCBOX.addItem(newName) - self.uiCurrentSystemCBOX.setCurrentIndex( - self.uiCurrentSystemCBOX.count() - 1 - ) - self.setSystem(newSystem) - - def renameSystem(self): - """Rename the current Simplex system""" - if self.simplex is None: - return - - sysNames = [ - str(self.uiCurrentSystemCBOX.itemText(i)) - for i in range(self.uiCurrentSystemCBOX.count()) - ] - newName = self._getAName("system", taken=sysNames) - if newName is None: - return - - self.simplex.name = newName - idx = self.uiCurrentSystemCBOX.currentIndex() - self.uiCurrentSystemCBOX.setItemText(idx, newName) - - self.currentSystemChanged(idx) - - def setSimplexLegacy(self): - """Slot to toggle the legacy behavior of the current Simplex system""" - if self.simplex is not None: - self.simplex.setLegacy(self.uiLegacyJsonACT.isChecked()) - self.simplex.DCC.incrementRevision() - - # File Menu - def importSystemFromFile(self): - """Open a File Dialog to load a simplex system from a file. - Systems can be in either .smpx, or .json formats - """ - if self._currentObject is None: - impTypes = ["smpx"] - else: - impTypes = ["smpx", "json"] - - pref = Prefs() - defaultPath = str( - pref.restoreProperty("systemImport", os.path.join(os.path.expanduser("~"))) - ) - path = self._fileDialog("Import Template", defaultPath, impTypes, save=False) - if not path: - return - pref.recordProperty("systemImport", os.path.dirname(path)) - pref.save() - - self.loadFile(path) - - def loadFile(self, path): - pBar = QProgressDialog("Loading Shapes", "Cancel", 0, 100, self) - pBar.show() - QApplication.processEvents() - - # TODO: Come up with a better list of possibilites for loading - # simplex files, and make the appropriate methods on the Simplex - if path.endswith(".smpx"): - newSystem = Simplex.buildSystemFromSmpx( - path, self._currentObject, sliderMul=self._sliderMul, pBar=pBar - ) - if newSystem is None: - QMessageBox.warning( - self, - "Point Count Mismatch", - "The .smpx file point count does not match the current object", - ) - pBar.close() - return - - elif path.endswith(".json"): - newSystem = Simplex.buildSystemFromJson( - path, self._currentObject, sliderMul=self._sliderMul, pBar=pBar - ) - else: - QMessageBox.warning(self, "Bad Filepath", "Path type not recognized") - return - - with signalsBlocked(self.uiCurrentSystemCBOX): - self.loadObject(newSystem.DCC.mesh) - idx = self.uiCurrentSystemCBOX.findText(self._currentObjectName) - if idx >= 0: - self.uiCurrentSystemCBOX.setCurrentIndex(idx) - else: - self.uiCurrentSystemCBOX.addItem(newSystem.name) - self.uiCurrentSystemCBOX.setCurrentIndex( - self.uiCurrentSystemCBOX.count() - 1 - ) - - self.setSystem(newSystem) - pBar.close() - self.simplex.DCC.checkForErrors(self) - - def _fileDialog(self, title, initPath, filters, save=True): - """Convenience function for displaying File Dialogs""" - filters = ["{0} (*.{0})".format(f) for f in filters] - if not save: - filters += ["All files (*.*)"] - filters = ";;".join(filters) - - if save: - path, _ = QtCompat.QFileDialog.getSaveFileName( - self, title, initPath, filters - ) - else: - path, _ = QtCompat.QFileDialog.getOpenFileName( - self, title, initPath, filters - ) - - if not path: - return "" - - if not save and not os.path.exists(path): - return "" - - return path - - def exportSystemTemplate(self): - """Open a file dialog and export a system to the chosen path""" - if self._currentObject is None: - QMessageBox.warning(self, "Warning", "Must have a current object selection") - return - - pref = Prefs() - defaultPath = str( - pref.restoreProperty("systemExport", os.path.join(os.path.expanduser("~"))) - ) - path = self._fileDialog( - "Export Template", defaultPath, ["smpx", "json"], save=True - ) - if not path: - return - pref.recordProperty("systemExport", os.path.dirname(path)) - pref.save() - - if self.simplex is None: - QMessageBox.warning(self, "Warning", "No simplex loaded") - return - - if path.endswith(".smpx"): - pBar = QProgressDialog("Exporting smpx File", "Cancel", 0, 100, self) - pBar.show() - self.simplex.exportAbc(path, pBar) - pBar.close() - elif path.endswith(".json"): - dump = self.simplex.dump() - with open(path, "w") as f: - f.write(dump) - - # Slider Settings - def setSelectedSliderGroups(self, group): - """Set the group for the selected Sliders - - Parameters - ---------- - group : Group - The group that will take the selected Sliders - """ - if not group: - return - sliders = self.uiSliderTREE.getSelectedItems(Slider) - group.take(sliders) - self.uiSliderTREE.viewport().update() - - def setSelectedSliderFalloff(self, falloff, state): - """Set the Falloffs for the selected Sliders - - Parameters - ---------- - falloff : Falloff - The falloff to set on the selected sliders - state : bool - Whether to add or remove this falloff from the selection - """ - if not falloff: - return - sliders = self.uiSliderTREE.getSelectedItems(Slider) - for s in sliders: - if state == Qt.CheckState.Checked: - s.prog.addFalloff(falloff) - else: - s.prog.removeFalloff(falloff) - self.uiSliderTREE.viewport().update() - - def setSelectedSliderInterp(self, interp): - """Set the interpolation for the selected sliders - - Parameters - ---------- - interp : str - The interpolation to set on the selection. - Could be 'linear', 'spline', or 'splitSpline' - """ - sliders = self.uiSliderTREE.getSelectedItems(Slider) - for s in sliders: - s.prog.interp = interp - - # Combo Settings - def setSelectedComboGroups(self, group): - """Set the group for the selected Combos - - Parameters - ---------- - group : Group - The group that will take the selected Combos - """ - if not group: - return - combos = self.uiComboTREE.getSelectedItems(Combo) - group.take(combos) - self.uiComboTREE.viewport().update() - - def setSelectedComboSolveType(self, stVal): - """Set the solve type for the selected combos - - Parameters - ---------- - stVal : str - The solve type to set for the combos. - See Combo.solveTypes for a list - """ - combos = self.uiComboTREE.getSelectedItems(Combo) - for c in combos: - c.solveType = stVal - - # Edit Menu - def hideRedundant(self): - """Hide redundant items from the Slider and Combo trees based on a user preference""" - check = self.uiHideRedundantACT.isChecked() - comboModel = self.uiComboTREE.model() - comboModel.filterShapes = check - comboModel.invalidateFilter() - sliderModel = self.uiSliderTREE.model() - sliderModel.doFilter = check - sliderModel.invalidateFilter() - - def setSliderRange(self): - """Double the range for the sliders *IN THE DCC ONLY* based on a user preference""" - self._sliderMul = 2.0 if self.uiDoubleSliderRangeACT.isChecked() else 1.0 - if self.simplex is None: - return - self.simplex.DCC.sliderMul = self._sliderMul - self.simplex.DCC.setSlidersRange(self.simplex.sliders) - - # Isolation - def isSliderIsolate(self): - """Check if the slider tree is currently isolated""" - model = self.uiSliderTREE.model() - if model: - return bool(model.isolateList) - return False - - def sliderIsolateSelected(self): - """Isolate the selected Sliders in the Slider Tree""" - self.uiSliderTREE.isolateSelected() - self.uiSliderExitIsolateBTN.show() - - def sliderTreeExitIsolate(self): - """Disable isolation mode in the Slider Tree""" - self.uiSliderTREE.exitIsolate() - self.uiSliderExitIsolateBTN.hide() - - def isComboIsolate(self): - """Check if the combo tree is currently isolated""" - model = self.uiComboTREE.model() - if model: - return bool(model.isolateList) - return False - - def comboIsolateSelected(self): - """Isolate the selected Combos in the Combo Tree""" - self.uiComboTREE.isolateSelected() - self.uiComboExitIsolateBTN.show() - - def comboTreeExitIsolate(self): - """Disable isolation mode in the Combo Tree""" - self.uiComboTREE.exitIsolate() - self.uiComboExitIsolateBTN.hide() - - -def _test(): - app = QApplication(sys.argv) - path = r"C:\Users\tfox\Documents\GitHub\Simplex\scripts\SimplexUI\build\HeadMaleStandard_High_Unsplit.smpx" - d = SimplexDialog() - newSystem = Simplex.buildSystemFromSmpx(path, d.getCurrentObject(), sliderMul=1.0) - d.setSystem(newSystem) - - d.show() - sys.exit(app.exec_()) - - -if __name__ == "__main__": - _test() +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . +from __future__ import annotations + +import json +import os +import re +import sys +import weakref +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from Qt import QtCompat +from Qt.QtCore import Qt, Signal +from Qt.QtGui import QAction, QStandardItemModel +from Qt.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QFrame, + QGroupBox, + QInputDialog, + QLabel, + QLineEdit, + QMessageBox, + QProgressDialog, + QPushButton, + QRadioButton, + QVBoxLayout, + QWidget, +) + +from .comboCheckDialog import ComboCheckDialog +from .falloffDialog import FalloffDialog +from .interface import DCC +from .interfaceModel import ( + ComboFilterModel, + ComboModel, + SimplexModel, + SliderFilterModel, + SliderModel, + coerceIndexToChildType, + coerceIndexToParentType, + coerceIndexToRoots, +) +from .interfaceModelTrees import ComboTree, SliderTree +from .items import Combo, Group, ProgPair, Simplex, Slider, Stack +from .menu import buildToolMenu, loadPlugins +from .traversalDialog import TraversalDialog +from .utils import Prefs, execwid, getNextName, getUiFile, makeUnique, naturalSortKey + +if os.environ.get("SIMPLEX_AT_BLUR") == "true": + # If we're at blur, use our main window subclass + from blurdev.gui import Window +else: + from Qt.QtWidgets import QMainWindow as Window + +if TYPE_CHECKING: + # don't worry about blur stuff when typechecking + from Qt.QtWidgets import QMainWindow as Window + +NAME_CHECK = re.compile(r"[A-Za-z][\w.]*") + +# If the decorated method is a slot for some Qt Signal +# and the method signature is *NOT* the same as the +# signal signature, you must double decorate the method like: +# +# @Slot(**signature) +# @stackable +# def method(**signature) + + +@contextmanager +def signalsBlocked(item: QObject): + """Context manager to block the qt signals on an item""" + item.blockSignals(True) + try: + yield + finally: + item.blockSignals(False) + + +class SimplexDialog(Window): + """The main ui for simplex + + Parameters + ---------- + parent : QWidget + The parent for this window + dispatch : Dispatch + An object that translates DCC events into + Qt Signals that the Simplex Ui can understand + + """ + + uiConvertCorrectiveACT: QAction + uiDoubleSliderRangeACT: QAction + uiExportACT: QAction + uiExportObjPSDACT: QAction + uiExtractOnCreateACT: QAction + uiExtractPosedACT: QAction + uiHideRedundantACT: QAction + uiImportACT: QAction + uiImportObjPSDACT: QAction + uiLegacyJsonACT: QAction + uiLiveShapeConnectionACT: QAction + uiLiveUpdateACT: QAction + uiSetWorkingDirectoryACT: QAction + uiSplitShapePSDACT: QAction + + uiComboLAY: QVBoxLayout + uiSliderLAY: QVBoxLayout + uiAutoSetCombosCHK: QCheckBox + uiAutoSetSlidersCHK: QCheckBox + uiComboDependLockCHK: QCheckBox + uiSliderDependLockCHK: QCheckBox + uiCurrentSystemCBOX: QComboBox + uiComboButtonFRM: QFrame + uiSliderButtonFRM: QFrame + uiComboDependGRP: QGroupBox + uiComboShapesGRP: QGroupBox + uiMainShapesGRP: QGroupBox + uiObjectGRP: QGroupBox + uiSliderDependGRP: QGroupBox + uiSystemGRP: QGroupBox + uiCurrentObjectLBL: QLabel + uiCurrentSystemLBL: QLabel + uiComboFilterLINE: QLineEdit + uiCurrentObjectTXT: QLineEdit + uiSliderFilterLINE: QLineEdit + uiClearSelectedObjectBTN: QPushButton + uiComboExitIsolateBTN: QPushButton + uiComboFilterClearBTN: QPushButton + uiDeleteComboBTN: QPushButton + uiDeleteSystemBTN: QPushButton + uiGetSelectedObjectBTN: QPushButton + uiNewComboActiveBTN: QPushButton + uiNewComboGroupBTN: QPushButton + uiNewComboSelectBTN: QPushButton + uiNewComboShapeBTN: QPushButton + uiNewGroupBTN: QPushButton + uiNewShapeBTN: QPushButton + uiNewSliderBTN: QPushButton + uiNewSystemBTN: QPushButton + uiRenameSystemBTN: QPushButton + uiSelectCtrlBTN: QPushButton + uiSelectSlidersBTN: QPushButton + uiSetSliderValsBTN: QPushButton + uiShapeConnectBTN: QPushButton + uiShapeConnectSceneBTN: QPushButton + uiShapeExtractBTN: QPushButton + uiSliderDeleteBTN: QPushButton + uiSliderExitIsolateBTN: QPushButton + uiSliderFilterClearBTN: QPushButton + uiZeroAllBTN: QPushButton + uiZeroSelectedBTN: QPushButton + uiComboDependAllRDO: QRadioButton + uiComboDependAnyRDO: QRadioButton + uiComboDependOnlyRDO: QRadioButton + uiSliderDependAllRDO: QRadioButton + uiSliderDependAnyRDO: QRadioButton + uiConnectionGroupWID: QWidget + + simplexLoaded = Signal() + openedDialogs: list[weakref.ref[SimplexDialog]] = [] + + def __init__(self, parent=None, dispatch=None) -> None: + super().__init__(parent) + + uiPath = getUiFile(__file__) + QtCompat.loadUi(uiPath, self) + + # Custom widgets aren't working properly, so I bring them in manually + self.uiSliderTREE = SliderTree(self.uiMainShapesGRP) + self.uiSliderTREE.setDragEnabled(False) + self.uiSliderTREE.setDragDropMode(SliderTree.DragDropMode.NoDragDrop) + self.uiSliderTREE.setSelectionMode(SliderTree.SelectionMode.ExtendedSelection) + self.uiSliderTREE.dragFilter.dragPressed.connect(self.dragStart) + self.uiSliderTREE.dragFilter.dragReleased.connect(self.dragStop) + + self.uiSliderLAY.addWidget(self.uiSliderTREE) + + self.uiComboTREE = ComboTree(self.uiComboShapesGRP) + self.uiComboTREE.setDragEnabled(False) + self.uiComboTREE.setDragDropMode(ComboTree.DragDropMode.NoDragDrop) + self.uiComboTREE.setSelectionMode(ComboTree.SelectionMode.ExtendedSelection) + self.uiComboTREE.dragFilter.dragPressed.connect(self.dragStart) + self.uiComboTREE.dragFilter.dragReleased.connect(self.dragStop) + self.uiComboLAY.addWidget(self.uiComboTREE) + + self._sliderMenu = None + self._comboMenu = None + self._currentObject = None + self._currentObjectName = None + + # Connect the combo boxes and spinners to the data model + # TODO: Figure out how to deal with the type/axis "enum" cboxes + # see: http://doc.qt.io/qt-5/qtwidgets-itemviews-combowidgetmapper-example.html + """ + self._falloffMapper = QDataWidgetMapper() + self.uiShapeFalloffCBOX.currentIndexChanged.connect(self._falloffMapper.setCurrentIndex) + """ + + # Make sure to connect the dispatcher to the undo control + # but only keep a weakref to it + self.dispatch = None + if dispatch is not None: + self.dispatch = weakref.ref(dispatch) + dispatch.undo.connect(self.handleUndo) + dispatch.redo.connect(self.handleUndo) + dispatch.beforeNew.connect(self.newScene) + dispatch.beforeOpen.connect(self.newScene) + + self.simplex = None + + self._itemMap = {} + self._sliderTreeMap = {} + self._comboTreeMap = {} + + self._sliderDrag = None + self._comboDrag = None + + self.uiSliderExitIsolateBTN.hide() + self.uiComboExitIsolateBTN.hide() + + self._makeConnections() + + self._toolPlugins, self._contextPlugins = loadPlugins() + buildToolMenu(self, self._toolPlugins) + self.uiSliderTREE.setPlugins(self._contextPlugins) + self.uiComboTREE.setPlugins(self._contextPlugins) + + if DCC.program == "dummy": + # self.getSelectedObject() + self.uiObjectGRP.setEnabled(False) + self.uiSystemGRP.setEnabled(False) + + self.uiClearSelectedObjectBTN.hide() + self.uiMainShapesGRP.setEnabled(False) + self.uiComboShapesGRP.setEnabled(False) + self.uiConnectionGroupWID.setEnabled(False) + self.loadSettings() + self._sliderMul = 2.0 if self.uiDoubleSliderRangeACT.isChecked() else 1.0 + + self.travDialog = TraversalDialog(self) + self.falloffDialog = FalloffDialog(self) + # self.showTraversalDialog() + type(self).openedDialogs.append(weakref.ref(self)) + + @classmethod + def lastOpenedDialog(cls) -> SimplexDialog | None: + """Returns the last currently opened dialog that still exists""" + for dlgRef in reversed(cls.openedDialogs): + dlg = dlgRef() + if dlg is not None: + return dlg + return None + + def showTraversalDialog(self) -> None: + """Display the traversal dialog""" + if self.simplex is None: + return + + self.travDialog.show() + self.travDialog.setGeometry(30, 30, 400, 400) + + def showFalloffDialog(self) -> None: + """Display the Falloff Dialog""" + if self.simplex is None: + return + + self.falloffDialog.show() + pp = self.falloffDialog.pos() + x, y = pp.x(), pp.y() + if x < 0 or y < 0: + self.falloffDialog.move(max(x, 0), max(y, 0)) + + def dragStart(self) -> None: + """Slot for handling the start of a MMB Drag event""" + if self.simplex is not None: + self.simplex.DCC.undoOpen() + + def dragStop(self) -> None: + """Slot for handling the end of a MMB Drag event""" + if self.simplex is not None: + self.simplex.DCC.undoClose() + + def storeSettings(self) -> None: + """Store the state of the UI for the next run""" + pref = Prefs() + pref.recordProperty("geometry", self.saveGeometry()) + pref.save() + + def loadSettings(self) -> None: + """Load the state of the UI from a previous run""" + pref = Prefs() + geo = pref.restoreProperty("geometry", None) + if geo is not None: + self.restoreGeometry(geo) + + def closeEvent(self, event) -> None: + """Handle the close event""" + self.storeSettings() + self.deleteLater() + + # Undo/Redo + def newScene(self) -> None: + """Call this before a new scene is created. Usually called from the stack""" + self.clearSelectedObject() + + def handleUndo(self) -> None: + """Call this after an undo/redo action. Usually called from the stack""" + if self.simplex is None: + return + + rev = self.simplex.DCC.getRevision() + data = self.simplex.stack.getRevision(rev) + if data is not None: + self.setSystem(data) + self.uiSliderTREE.setItemExpansion() + self.uiComboTREE.setItemExpansion() + + def currentSystemChanged(self, idx: int) -> None: + """Slot called when the current system changes""" + if idx == -1: + self.setSystem(None) + return + name = str(self.uiCurrentSystemCBOX.currentText()) + if not name: + self.setSystem(None) + return + if self.simplex is not None: + if self.simplex.name == name: + return # Do nothing + + pBar = QProgressDialog("Loading from Mesh", "Cancel", 0, 100, self) + system = Simplex.buildSystemFromMesh( + self._currentObject, name, sliderMul=self._sliderMul, pBar=pBar + ) + self.setSystem(system) + pBar.close() + + def setSystem(self, system: Simplex | None) -> None: + """Set the system on this UI + + Parameters + ---------- + system : Simplex + The Simplex system to load into this UI + """ + if system == self.simplex: + return + + if self.simplex is not None: + # disconnect the previous stuff + sliderSelModel = self.uiSliderTREE.selectionModel() + sliderSelModel.selectionChanged.disconnect(self.unifySliderSelection) + sliderSelModel.selectionChanged.disconnect(self.populateComboRequirements) + sliderSelModel.selectionChanged.disconnect(self.autoSetSliders) + + comboSelModel = self.uiComboTREE.selectionModel() + comboSelModel.selectionChanged.disconnect(self.unifyComboSelection) + comboSelModel.selectionChanged.disconnect(self.populateSliderRequirements) + comboSelModel.selectionChanged.disconnect(self.autoSetComboSliders) + + oldStack = self.simplex.stack + else: + oldStack = Stack() + + if system is None: + # self.toolActions.simplex = None + self.uiSliderTREE.setModel(QStandardItemModel()) # type: ignore + self.uiComboTREE.setModel(QStandardItemModel()) # type: ignore + self.simplex = system + self.uiMainShapesGRP.setEnabled(False) + self.uiComboShapesGRP.setEnabled(False) + self.uiConnectionGroupWID.setEnabled(False) + self.falloffDialog.loadSimplex() + self.simplexLoaded.emit() + return + + # set and connect the new stuff + self.simplex = system + # self.simplex.models = [] + # self.simplex.falloffModels = [] + self.simplex.stack = oldStack + + # self.toolActions.simplex = self.simplex + + simplexModel = SimplexModel(self.simplex, None) + + sliderModel = SliderModel(simplexModel, None) + sliderProxModel = SliderFilterModel(sliderModel) + self.uiSliderTREE.setModel(sliderProxModel) + sliderSelModel = self.uiSliderTREE.selectionModel() + sliderSelModel.selectionChanged.connect(self.unifySliderSelection) + sliderSelModel.selectionChanged.connect(self.populateComboRequirements) + sliderSelModel.selectionChanged.connect(self.autoSetSliders) + + comboModel = ComboModel(simplexModel, None) + comboProxModel = ComboFilterModel(comboModel) + self.uiComboTREE.setModel(comboProxModel) + comboSelModel = self.uiComboTREE.selectionModel() + comboSelModel.selectionChanged.connect(self.unifyComboSelection) + comboSelModel.selectionChanged.connect(self.populateSliderRequirements) + comboSelModel.selectionChanged.connect(self.autoSetComboSliders) + + self.falloffDialog.loadSimplex() + + # Make sure the UI is up and running + self.enableComboRequirements() + self.enableSliderRequirements() + self.uiMainShapesGRP.setEnabled(True) + self.uiComboShapesGRP.setEnabled(True) + self.uiConnectionGroupWID.setEnabled(True) + + self.setSimplexLegacy() + self.simplexLoaded.emit() + + # UI Setup + def _makeConnections(self) -> None: + """Make all the ui connections""" + # Setup Trees! + self.uiSliderTREE.setColumnWidth(1, 50) + self.uiSliderTREE.setColumnWidth(2, 20) + self.uiSliderFilterLINE.textChanged.connect(self.sliderStringFilter) + self.uiSliderFilterClearBTN.clicked.connect(self.uiSliderFilterLINE.clear) + self.uiSliderFilterClearBTN.clicked.connect(self.sliderStringFilter) + + self.uiComboTREE.setColumnWidth(1, 50) + self.uiComboTREE.setColumnWidth(2, 20) + self.uiComboFilterLINE.textChanged.connect(self.comboStringFilter) + self.uiComboFilterClearBTN.clicked.connect(self.uiComboFilterLINE.clear) + self.uiComboFilterClearBTN.clicked.connect(self.comboStringFilter) + + # combo dependency filter setup + self.uiComboDependGRP.toggled.connect(self.enableComboRequirements) + self.uiComboDependAllRDO.toggled.connect(self.enableComboRequirements) + self.uiComboDependAnyRDO.toggled.connect(self.enableComboRequirements) + self.uiComboDependOnlyRDO.toggled.connect(self.enableComboRequirements) + self.uiComboDependLockCHK.toggled.connect(self.setLockComboRequirement) + + # slider dependency filter setup + self.uiSliderDependGRP.toggled.connect(self.enableSliderRequirements) + self.uiSliderDependAllRDO.toggled.connect(self.enableSliderRequirements) + self.uiSliderDependAnyRDO.toggled.connect(self.enableSliderRequirements) + self.uiSliderDependLockCHK.toggled.connect(self.setLockSliderRequirements) + + # Bottom Left Corner Buttons + self.uiZeroAllBTN.clicked.connect(self.zeroAllSliders) + self.uiZeroSelectedBTN.clicked.connect(self.zeroSelectedSliders) + self.uiSelectCtrlBTN.clicked.connect(self.selectCtrl) + + # Top Left Corner Buttons + self.uiNewGroupBTN.clicked.connect(self.newSliderGroup) + self.uiNewSliderBTN.clicked.connect(self.newSlider) + self.uiNewShapeBTN.clicked.connect(self.newSliderShape) + self.uiSliderDeleteBTN.clicked.connect(self.sliderTreeDelete) + + # Top Right Corner Buttons + self.uiDeleteComboBTN.clicked.connect(self.comboTreeDelete) + self.uiNewComboActiveBTN.clicked.connect(self.newActiveCombo) + self.uiNewComboSelectBTN.clicked.connect(self.newSelectedCombo) + self.uiNewComboShapeBTN.clicked.connect(self.newComboShape) + self.uiNewComboGroupBTN.clicked.connect(self.newComboGroup) + + # Bottom right corner buttons + self.uiSetSliderValsBTN.clicked.connect(self.setSliderVals) + self.uiSelectSlidersBTN.clicked.connect(self.selectSliders) + + # System level + self.uiCurrentObjectTXT.editingFinished.connect(self.currentObjectChanged) + self.uiGetSelectedObjectBTN.clicked.connect(self.getSelectedObject) + self.uiClearSelectedObjectBTN.clicked.connect(self.clearSelectedObject) + + self.uiNewSystemBTN.clicked.connect(self.newSystem) + self.uiRenameSystemBTN.clicked.connect(self.renameSystem) + self.uiCurrentSystemCBOX.currentIndexChanged[int].connect( + self.currentSystemChanged + ) + + # Extraction/connection + self.uiShapeExtractBTN.clicked.connect(self.shapeExtract) + self.uiShapeConnectBTN.clicked.connect(self.shapeConnect) + self.uiShapeConnectSceneBTN.clicked.connect(self.shapeConnectScene) + + # File Menu + self.uiImportACT.triggered.connect(self.importSystemFromFile) + self.uiExportACT.triggered.connect(self.exportSystemTemplate) + + # Edit Menu + self.uiHideRedundantACT.toggled.connect(self.hideRedundant) + self.uiDoubleSliderRangeACT.toggled.connect(self.setSliderRange) + + # Isolation + self.uiSliderExitIsolateBTN.clicked.connect(self.sliderTreeExitIsolate) + self.uiComboExitIsolateBTN.clicked.connect(self.comboTreeExitIsolate) + + self.uiLegacyJsonACT.toggled.connect(self.setSimplexLegacy) + + # Helpers + def getSelectedItems(self, tree, typ=None): + """Convenience function to get the selected system items + + Parameters + ---------- + tree : QTreeView + The tree to get selected items from + typ : Type + The type of objects to return + + Returns + ------- + [object, ...] + A list of selected items + """ + sel = tree.selectedIndexes() + sel = [i for i in sel if i.column() == 0] + model = tree.model() + items = [model.itemFromIndex(i) for i in sel] + if typ is not None: + items = [i for i in items if isinstance(i, typ)] + return items + + def getCurrentObject(self): + """Convenience function to get the current object loaded into the UI""" + return self._currentObject + + # Setup Trees! + def sliderStringFilter(self) -> None: + """Set the filter for the slider tree""" + filterString = str(self.uiSliderFilterLINE.text()) + sliderModel = self.uiSliderTREE.model() + sliderModel.filterString = str(filterString) + sliderModel.invalidateFilter() + + def comboStringFilter(self) -> None: + """Set the filter for the combo tree""" + filterString = str(self.uiComboFilterLINE.text()) + comboModel = self.uiComboTREE.model() + comboModel.filterString = str(filterString) + comboModel.invalidateFilter() + + # selection setup + def unifySliderSelection(self) -> None: + """Clear the selection of the combo tree when + an item on the slider tree is selected + """ + mods = QApplication.keyboardModifiers() + if not mods & ( + Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier + ): + comboSelModel = self.uiComboTREE.selectionModel() + if not comboSelModel: + return + + with signalsBlocked(comboSelModel): + comboSelModel.clearSelection() + self.uiComboTREE.viewport().update() + + def unifyComboSelection(self) -> None: + """Clear the selection of the slider tree when + an item on the combo tree is selected + """ + mods = QApplication.keyboardModifiers() + if not mods & ( + Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier + ): + sliderSelModel = self.uiSliderTREE.selectionModel() + if not sliderSelModel: + return + with signalsBlocked(sliderSelModel): + sliderSelModel.clearSelection() + self.uiSliderTREE.viewport().update() + + # dependency setup + def setLockComboRequirement(self) -> None: + """Refresh the combo selection filter with the new filterLock state""" + comboModel = self.uiComboTREE.model() + if not comboModel: + return + self.populateComboRequirements() + + def populateComboRequirements(self) -> None: + """Let the combo tree know the requirements from the slider tree""" + items = self.uiSliderTREE.getSelectedItems(Slider) + comboModel = self.uiComboTREE.model() + locked = self.uiComboDependLockCHK.isChecked() + if not locked: + comboModel.requires = items + if ( + comboModel.filterRequiresAll + or comboModel.filterRequiresAny + or comboModel.filterRequiresOnly + ): + comboModel.invalidateFilter() + + def enableComboRequirements(self) -> None: + """Set the requirements for the combo filter model""" + comboModel = self.uiComboTREE.model() + if not comboModel: + return + depCheck = self.uiComboDependGRP.isChecked() + comboModel.filterRequiresAll = self.uiComboDependAllRDO.isChecked() and depCheck + comboModel.filterRequiresAny = self.uiComboDependAnyRDO.isChecked() and depCheck + comboModel.filterRequiresOnly = ( + self.uiComboDependOnlyRDO.isChecked() and depCheck + ) + comboModel.invalidateFilter() + + def populateSliderRequirements(self) -> None: + """Let the slider tree know the requirements from the combo tree""" + items = self.uiComboTREE.getSelectedItems(Combo) + sliderModel = self.uiSliderTREE.model() + locked = self.uiSliderDependLockCHK.isChecked() + if not locked: + sliderModel.requires = items + if sliderModel.filterRequiresAny or sliderModel.filterRequiresAll: + sliderModel.invalidateFilter() + + def setLockSliderRequirements(self) -> None: + """Refresh the combo selection filter with the new filterLock state""" + sliderModel = self.uiSliderTREE.model() + if not sliderModel: + return + self.populateSliderRequirements() + + def enableSliderRequirements(self) -> None: + sliderModel = self.uiSliderTREE.model() + if not sliderModel: + return + + depCheck = self.uiSliderDependGRP.isChecked() + sliderModel.filterRequiresAny = ( + self.uiSliderDependAnyRDO.isChecked() and depCheck + ) + sliderModel.filterRequiresAll = ( + self.uiSliderDependAllRDO.isChecked() and depCheck + ) + sliderModel.invalidateFilter() + + # Bottom Left Corner Buttons + def zeroAllSliders(self) -> None: + """Slot to Zero all Sliders in Slider UI panel""" + if self.simplex is None: + return + sliders = self.simplex.sliders + weights = [0.0] * len(sliders) + self.simplex.setSlidersWeights(sliders, weights) + self.uiSliderTREE.repaint() + + def zeroSelectedSliders(self) -> None: + """Slot to Zero the selected sliders in the UI panel""" + if self.simplex is None: + return + items = self.uiSliderTREE.getSelectedItems(Slider) + values = [0.0] * len(items) + self.simplex.setSlidersWeights(items, values) + self.uiSliderTREE.repaint() + + def selectCtrl(self) -> None: + """Select the Control object in the DCC""" + if self.simplex is None: + return + self.simplex.DCC.selectCtrl() + + def autoSetSliders(self) -> None: + """Automatically set any selected sliders to 1.0""" + if self.simplex is None: + return + if not self.uiAutoSetSlidersCHK.isChecked(): + return + sel = set(self.uiSliderTREE.getSelectedItems(Slider)) + sliders = self.simplex.sliders + + weights = [0.0] * len(sliders) + for i, slider in enumerate(sliders): + if slider in sel: + weights[i] = 1.0 + self.simplex.setSlidersWeights(sliders, weights) + self.uiSliderTREE.repaint() + + def _getAName(self, tpe: str, default=None, taken=(), uniqueAccept=False) -> str | None: + """uniqueAccept forces the user to provide and accept a unique name + If the user enters a non-unique name, the name is uniquified, and the + dialog is re-shown with the unique name as the default suggestion + """ + tpe = tpe.lower() + uTpe = tpe[0].upper() + tpe[1:] + + unique = False + default = getNextName(default, taken) + while not unique: + eMsg = f"Enter a name for the new {tpe}" + newName, good = QInputDialog.getText( + self, f"New {uTpe}", eMsg, text=default + ) + if not good: + return None + if len(newName) < 3: + message = "Please use names longer than 2 letters" + QMessageBox.warning(self, "Warning", message) + return None + if not NAME_CHECK.match(newName): + message = "{0} name can only contain letters and numbers, and cannot start with a number" + message = message.format(uTpe) + QMessageBox.warning(self, "Warning", message) + return None + + unqName = getNextName(newName, taken) + unique = (unqName == newName) or uniqueAccept + default = unqName + + return default + + # Top Left Corner Buttons + def newSliderGroup(self) -> None: + """Slot to Create a new slider group""" + if self.simplex is None: + return + + newName = self._getAName("group", default="Group") + if newName is None: + return + + Group.createGroup(str(newName), self.simplex, groupType=Slider) + + def newSlider(self) -> None: + """Slot to create a new slider""" + if self.simplex is None: + return + + newName = self._getAName("slider") + if newName is None: + return + + idxs = self.uiSliderTREE.getSelectedIndexes() + groups = coerceIndexToParentType(idxs, Group) + group = groups[0].model().itemFromIndex(groups[0]) if groups else None + + Slider.createSlider(str(newName), self.simplex, group=group) + + def newSliderShape(self) -> None: + """Slot to create a new Shape in a Slider's progression""" + pars = self.uiSliderTREE.getSelectedItems(Slider) + if not pars: + return + parItem = pars[0] + parItem.createShape() + self.uiSliderTREE.model().invalidateFilter() + + def sliderTreeDelete(self) -> None: + """Delete some objects in the slider tree""" + idxs = self.uiSliderTREE.getSelectedIndexes() + roots = coerceIndexToRoots(idxs) + if not roots: + QMessageBox.warning(self, "Warning", "Nothing Selected in Slider tree") + return + roots = makeUnique([i.model().itemFromIndex(i) for i in roots]) + for r in roots: + if isinstance(r, Simplex): + QMessageBox.warning( + self, "Warning", "Cannot delete a simplex system this way" + ) + return + + for r in roots: + r.delete() + self.uiSliderTREE.model().invalidateFilter() + + # Top Right Corner Buttons + def comboTreeDelete(self) -> None: + """Delete some objects in the Combo tree""" + idxs = self.uiComboTREE.getSelectedIndexes() + roots = coerceIndexToRoots(idxs) + if not roots: + QMessageBox.warning(self, "Warning", "Nothing Selected in Combo tree") + return + roots = makeUnique([i.model().itemFromIndex(i) for i in roots]) + for r in roots: + if isinstance(r, Simplex): + QMessageBox.warning( + self, "Warning", "Cannot delete a simplex system this way" + ) + return + + for r in roots: + r.delete() + self.uiComboTREE.model().invalidateFilter() + + def _newCombo(self, sliders, values) -> None: + if len(sliders) < 2: + message = "A combo must use at least 2 sliders" + QMessageBox.warning(self, "Warning", message) + return + + ccd = ComboCheckDialog(sliders, values=values, mode="create", parent=self) + ccd.move(self.pos()) + execwid(ccd) + + def newActiveCombo(self) -> None: + """Create a combo based on the UI Sliders that are currently nonzero""" + if self.simplex is None: + return + sliders = [] + values = {} + for s in self.simplex.sliders: + if s.value != 0.0: + sliders.append(s) + values[s] = [s.value] + self._newCombo(sliders, values) + + def newSelectedCombo(self) -> None: + """Create a combo based on the currently selected UI sliders""" + if self.simplex is None: + return + sliders = self.uiSliderTREE.getSelectedItems(Slider) + values = {s: [1.0] for s in sliders} + self._newCombo(sliders, values) + + def newComboShape(self) -> None: + """Create a new shape in the Combo's progression""" + parIdxs = self.uiComboTREE.getSelectedIndexes() + pars = coerceIndexToParentType(parIdxs, Combo) + if not pars: + return + + parItem = pars[0].model().itemFromIndex(pars[0]) if pars else None + parItem.createShape() + self.uiComboTREE.model().invalidateFilter() + + def newComboGroup(self) -> None: + """Create a new group for organizing combos""" + if self.simplex is None: + return + + newName = self._getAName("group", default="Group") + if newName is None: + return + + Group.createGroup(str(newName), self.simplex, groupType=Combo) + + # Bottom right corner buttons + def setSliderVals(self) -> None: + """Set all slider values to those stored in the currently selected Combos""" + if self.simplex is None: + return + combos = self.uiComboTREE.getSelectedItems(Combo) + # maybe coerce to type instead?? + self.zeroAllSliders() + values = [] + sliders = [] + for combo in combos: + for pair in combo.pairs: + if pair.slider in sliders: + continue + sliders.append(pair.slider) + values.append(pair.value) + self.simplex.setSlidersWeights(sliders, values) + self.uiSliderTREE.repaint() + + def selectSliders(self) -> None: + """Select the sliders that are contained in the currently selected Combos""" + combos = self.uiComboTREE.getSelectedItems(Combo) + sliders = [] + for combo in combos: + for pair in combo.pairs: + sliders.append(pair.slider) + self.uiSliderTREE.setItemSelection(sliders) + + def autoSetComboSliders(self) -> None: + """Automatically set the DCC Slider values to activate the currently selected Combos""" + if self.simplex is None: + return + if not self.uiAutoSetCombosCHK.isChecked(): + return + sel = set(self.uiComboTREE.getSelectedItems(Combo)) + sv = {} + for combo in self.simplex.combos: + isSel = combo in sel + for pair in combo.pairs: + curVal = sv.get(pair.slider, 0.0) + newVal = pair.value if isSel else 0.0 + if abs(newVal) >= abs(curVal): + sv[pair.slider] = newVal + + sliders, weights = list(zip(*list(sv.items()))) + self.simplex.setSlidersWeights(sliders, weights) + self.uiSliderTREE.repaint() + + # Extraction/connection + def shapeConnectScene(self) -> None: + """Connect any selected meshes into the system based on the name""" + if self.simplex is None: + return + # make a dict of name:object + sel = DCC.getSelectedObjects() + selDict = {} + for s in sel: + name = DCC.getObjectName(s) + if name.endswith("_Extract"): + nn = name.rsplit("_Extract", 1)[0] + selDict[nn] = s + + pairDict = {} + for p in self.simplex.progs: + for pp in p.pairs: + pairDict[pp.shape.name] = pp + + # get all common names + common = selDict.keys() & pairDict.keys() + + # get those items + pairs = [pairDict[i] for i in common] + + # Set up the progress bar + pBar = QProgressDialog("Connecting Shapes", "Cancel", 0, 100, self) + pBar.setMaximum(len(pairs)) + + # Do the extractions + for pair in pairs: + c = pair.prog.controller + c.connectShape(pair.shape, delete=True) + + # ProgressBar + pBar.setValue(pBar.value() + 1) + pBar.setLabelText(f"Connecting:\n{pair.shape.name}") + QApplication.processEvents() + if pBar.wasCanceled(): + return + + pBar.close() + + def sliderShapeExtract(self): + """Create meshes that are possibly live-connected to the shapes""" + sliderIdxs = self.uiSliderTREE.getSelectedIndexes() + return self.shapeIndexExtract(sliderIdxs) + + def comboShapeExtract(self): + """Create meshes that are possibly live-connected to the shapes""" + comboIdxs = self.uiComboTREE.getSelectedIndexes() + return self.shapeIndexExtract(comboIdxs) + + def shapeExtract(self): + """Create meshes that are possibly live-connected to the shapes""" + sliderIdxs = self.uiSliderTREE.getSelectedIndexes() + comboIdxs = self.uiComboTREE.getSelectedIndexes() + return self.shapeIndexExtract(sliderIdxs + comboIdxs) + + def shapeExport(self) -> None: + """Export meshes""" + sliderIdxs = self.uiSliderTREE.getSelectedIndexes() + comboIdxs = self.uiComboTREE.getSelectedIndexes() + return self.shapeIndexExport(sliderIdxs + comboIdxs) + + def shapeIndexExport(self, indexes) -> None: + """Export meshes + + Parameters + ---------- + indexes : [QModelIndex, ...] + A list of indexes to extract + """ + # Importing this here to keep people from getting confused + # You should use self._fileDialog which does compatibility + # stuff for file saving. Since I'm looking for a folder here + # I don't need to do that + from Qt.QtWidgets import QFileDialog + + if self.simplex is None: + return + + pref = Prefs() + defaultPath = str( + pref.restoreProperty( + "systemExportFolder", os.path.join(os.path.expanduser("~")) + ) + ) + outfld = QFileDialog.getExistingDirectory( + self, "Pick Export Folder", defaultPath + ) + if not outfld: + return + + pref.recordProperty("systemExportFolder", outfld) + pref.save() + + pairs = coerceIndexToChildType(indexes, ProgPair) + pairs = [i.model().itemFromIndex(i) for i in pairs] + pairs = makeUnique([i for i in pairs if not i.shape.isRest]) + pairs.sort(key=lambda x: naturalSortKey(x.shape.name)) + + # Set up the progress bar + pBar = QProgressDialog("Exporting Shapes", "Cancel", 0, 100, self) + pBar.setMaximum(len(pairs)) + + extension = '.abc' + # Do the extractions + for pair in pairs: + c = pair.prog.controller + extracted = c.extractShape(pair.shape, live=False) + path = os.path.join(outfld, c.name + extension) + if os.path.exists(path): + os.remove(path) + + self.simplex.DCC.exportMesh(extracted, path) + self.simplex.DCC.deleteObj(extracted) + + # ProgressBar + pBar.setValue(pBar.value() + 1) + pBar.setLabelText(f"Extracting:\n{pair.shape.name}") + QApplication.processEvents() + if pBar.wasCanceled(): + return + + pBar.close() + + def shapeIndexExtract(self, indexes, live=None): + """Create meshes that are possibly live-connected to the shapes + + Parameters + ---------- + indexes : [QModelIndex, ...] + A list of indexes to extract + live : bool or None + Whether the connection is live. If None, check the UI properties + """ + if live is None: + live = self.uiLiveShapeConnectionACT.isChecked() + + pairs = coerceIndexToChildType(indexes, ProgPair) + pairs = [i.model().itemFromIndex(i) for i in pairs] + pairs = makeUnique([i for i in pairs if not i.shape.isRest]) + pairs.sort(key=lambda x: naturalSortKey(x.shape.name)) + + # Set up the progress bar + pBar = QProgressDialog("Extracting Shapes", "Cancel", 0, 100, self) + pBar.setMaximum(len(pairs)) + + # Do the extractions + offset = 10 + extracted = [] + for pair in pairs: + c = pair.prog.controller + ext = c.extractShape(pair.shape, live=live, offset=offset) + extracted.append(ext) + offset += 5 + + # ProgressBar + pBar.setValue(pBar.value() + 1) + pBar.setLabelText(f"Extracting:\n{pair.shape.name}") + QApplication.processEvents() + if pBar.wasCanceled(): + return extracted + + pBar.close() + return extracted + + def shapeConnect(self) -> None: + """Match any selected Shapes to DCC meshes based on their names, then delete the Meshes""" + sliderIdxs = self.uiSliderTREE.getSelectedIndexes() + comboIdxs = self.uiComboTREE.getSelectedIndexes() + self.shapeConnectIndexes(sliderIdxs + comboIdxs) + + def shapeConnectIndexes(self, indexes) -> None: + """Match the provided shapes to DCC meshes based on their names, then delete the Meshes + + Parameters + ---------- + indexes : list of QModelIndex + A list of selected model indexes + """ + pairs = coerceIndexToChildType(indexes, ProgPair) + pairs = [i.model().itemFromIndex(i) for i in pairs] + pairs = makeUnique([i for i in pairs if not i.shape.isRest]) + + # Set up the progress bar + pBar = QProgressDialog("Connecting Shapes", "Cancel", 0, 100, self) + pBar.setMaximum(len(pairs)) + + # Do the extractions + for pair in pairs: + c = pair.prog.controller + c.connectShape(pair.shape, delete=True) + + # ProgressBar + pBar.setValue(pBar.value() + 1) + pBar.setLabelText(f"Extracting:\n{pair.shape.name}") + QApplication.processEvents() + if pBar.wasCanceled(): + return + + pBar.close() + + def shapeMatch(self) -> None: + """Match any selected Shapes to A selected DCC mesh""" + sliderIdxs = self.uiSliderTREE.getSelectedIndexes() + comboIdxs = self.uiComboTREE.getSelectedIndexes() + self.shapeMatchIndexes(sliderIdxs + comboIdxs) + + def shapeMatchIndexes(self, indexes) -> None: + """Match any provided shapes to A selected DCC mesh + + Parameters + ---------- + indexes : list of QModelIndex + A list of selected model indexes + """ + # make a dict of name:object + sel = DCC.getSelectedObjects() + if not sel: + return + mesh = sel[0] + + pairs = coerceIndexToChildType(indexes, ProgPair) + pairs = [i.model().itemFromIndex(i) for i in pairs] + pairs = makeUnique([i for i in pairs if not i.shape.isRest]) + + # Set up the progress bar + pBar = QProgressDialog("Matching Shapes", "Cancel", 0, 100, self) + pBar.setMaximum(len(pairs)) + + # Do the extractions + for pair in pairs: + c = pair.prog.controller + c.connectShape(pair.shape, mesh=mesh) + + # ProgressBar + pBar.setValue(pBar.value() + 1) + pBar.setLabelText(f"Matching:\n{pair.shape.name}") + QApplication.processEvents() + if pBar.wasCanceled(): + return + + pBar.close() + + def shapeClear(self) -> None: + """Match all selected shapes to the rest""" + sliderIdxs = self.uiSliderTREE.getSelectedIndexes() + comboIdxs = self.uiComboTREE.getSelectedIndexes() + self.shapeClearIndexes(sliderIdxs + comboIdxs) + + def shapeClearIndexes(self, indexes) -> None: + """Match all provided shapes to the rest + + Parameters + ---------- + indexes : list of QModelIndex + A list of selected model indexes + """ + pairs = coerceIndexToChildType(indexes, ProgPair) + pairs = [i.model().itemFromIndex(i) for i in pairs] + pairs = makeUnique([i for i in pairs if not i.shape.isRest]) + + for pair in pairs: + pair.shape.zeroShape() + + # System level + def loadObject(self, thing) -> None: + """Load a DCC mesh into the UI + + Parameters + ---------- + thing : object + The DCC mesh to load into the UI + """ + if thing is None: + return + + self.uiClearSelectedObjectBTN.show() + self.uiCurrentSystemCBOX.clear() + objName = DCC.getObjectName(thing) + self._currentObject = thing + self._currentObjectName = objName + self.uiCurrentObjectTXT.setText(objName) + + ops = DCC.getSimplexOperatorsOnObject(self._currentObject) + + for op in ops: + js = DCC.getSimplexString(op) + if not js: + continue + d = json.loads(js) + name = d["systemName"] + self.uiCurrentSystemCBOX.addItem(name, (self._currentObject, name)) + + def currentObjectChanged(self) -> None: + """Slot called when the current DCC object is changed""" + name = str(self.uiCurrentObjectTXT.text()) + if self._currentObjectName == name: + return + if not name: + return + + newObject = DCC.getObjectByName(name) + if not newObject: + return + + self.loadObject(newObject) + + def getSelectedObject(self) -> None: + """Load the first selected DCC object into the UI""" + sel = DCC.getSelectedObjects() + if not sel: + return + newObj = sel[0] + if not newObj: + return + self.loadObject(newObj) + + def clearSelectedObject(self) -> None: + """Unload the current DCC object from the UI""" + self.uiClearSelectedObjectBTN.hide() + self.uiCurrentSystemCBOX.clear() + self._currentObject = None + self._currentObjectName = None + self.uiCurrentObjectTXT.setText("") + self.setSystem(None) + # Clear the current system + + def newSystem(self) -> None: + """Create a new system on the current DCC Object""" + if self._currentObject is None: + QMessageBox.warning(self, "Warning", "Must have a current object selection") + return + + newName = self._getAName("system") + if newName is None: + return + + newSystem = Simplex.buildEmptySystem( + self._currentObject, newName, sliderMul=self._sliderMul + ) + with signalsBlocked(self.uiCurrentSystemCBOX): + self.uiCurrentSystemCBOX.addItem(newName) + self.uiCurrentSystemCBOX.setCurrentIndex( + self.uiCurrentSystemCBOX.count() - 1 + ) + self.setSystem(newSystem) + + def renameSystem(self) -> None: + """Rename the current Simplex system""" + if self.simplex is None: + return + + sysNames = [ + str(self.uiCurrentSystemCBOX.itemText(i)) + for i in range(self.uiCurrentSystemCBOX.count()) + ] + newName = self._getAName("system", taken=sysNames) + if newName is None: + return + + self.simplex.name = newName + idx = self.uiCurrentSystemCBOX.currentIndex() + self.uiCurrentSystemCBOX.setItemText(idx, newName) + + self.currentSystemChanged(idx) + + def setSimplexLegacy(self) -> None: + """Slot to toggle the legacy behavior of the current Simplex system""" + if self.simplex is not None: + self.simplex.setLegacy(self.uiLegacyJsonACT.isChecked()) + self.simplex.DCC.incrementRevision() + + # File Menu + def importSystemFromFile(self) -> None: + """Open a File Dialog to load a simplex system from a file. + Systems can be in either .smpx, or .json formats + """ + if self._currentObject is None: + impTypes = ["smpx"] + else: + impTypes = ["smpx", "json"] + + pref = Prefs() + defaultPath = str( + pref.restoreProperty("systemImport", os.path.join(os.path.expanduser("~"))) + ) + path = self._fileDialog("Import Template", defaultPath, impTypes, save=False) + if not path: + return + pref.recordProperty("systemImport", os.path.dirname(path)) + pref.save() + + self.loadFile(path) + + def loadFile(self, path) -> None: + pBar = QProgressDialog("Loading Shapes", "Cancel", 0, 100, self) + pBar.show() + QApplication.processEvents() + + # TODO: Come up with a better list of possibilites for loading + # simplex files, and make the appropriate methods on the Simplex + if path.endswith(".smpx"): + newSystem = Simplex.buildSystemFromSmpx( + path, self._currentObject, sliderMul=self._sliderMul, pBar=pBar + ) + if newSystem is None: + QMessageBox.warning( + self, + "Point Count Mismatch", + "The .smpx file point count does not match the current object", + ) + pBar.close() + return + + elif path.endswith(".json"): + newSystem = Simplex.buildSystemFromJson( + path, self._currentObject, sliderMul=self._sliderMul, pBar=pBar + ) + else: + QMessageBox.warning(self, "Bad Filepath", "Path type not recognized") + return + + with signalsBlocked(self.uiCurrentSystemCBOX): + self.loadObject(newSystem.DCC.mesh) + idx = self.uiCurrentSystemCBOX.findText(self._currentObjectName) + if idx >= 0: + self.uiCurrentSystemCBOX.setCurrentIndex(idx) + else: + self.uiCurrentSystemCBOX.addItem(newSystem.name) + self.uiCurrentSystemCBOX.setCurrentIndex( + self.uiCurrentSystemCBOX.count() - 1 + ) + + self.setSystem(newSystem) + pBar.close() + self.simplex.DCC.checkForErrors(self) + + def _fileDialog(self, title: str, initPath: str, filters: list[str], save=True): + """Convenience function for displaying File Dialogs""" + filters = [f"{f} (*.{f})" for f in filters] + if not save: + filters += ["All files (*.*)"] + filters = ";;".join(filters) + + if save: + path, _ = QtCompat.QFileDialog.getSaveFileName( + self, title, initPath, filters + ) + else: + path, _ = QtCompat.QFileDialog.getOpenFileName( + self, title, initPath, filters + ) + + if not path: + return "" + + if not save and not os.path.exists(path): + return "" + + return path + + def exportSystemTemplate(self) -> None: + """Open a file dialog and export a system to the chosen path""" + if self._currentObject is None: + QMessageBox.warning(self, "Warning", "Must have a current object selection") + return + + pref = Prefs() + defaultPath = str( + pref.restoreProperty("systemExport", os.path.join(os.path.expanduser("~"))) + ) + path = self._fileDialog( + "Export Template", defaultPath, ["smpx", "json"], save=True + ) + if not path: + return + pref.recordProperty("systemExport", os.path.dirname(path)) + pref.save() + + if self.simplex is None: + QMessageBox.warning(self, "Warning", "No simplex loaded") + return + + if path.endswith(".smpx"): + pBar = QProgressDialog("Exporting smpx File", "Cancel", 0, 100, self) + pBar.show() + self.simplex.exportAbc(path, pBar) + pBar.close() + elif path.endswith(".json"): + dump = self.simplex.dump() + with open(path, "w") as f: + f.write(dump) + + # Slider Settings + def setSelectedSliderGroups(self, group) -> None: + """Set the group for the selected Sliders + + Parameters + ---------- + group : Group + The group that will take the selected Sliders + """ + if not group: + return + sliders = self.uiSliderTREE.getSelectedItems(Slider) + group.take(sliders) + self.uiSliderTREE.viewport().update() + + def setSelectedSliderFalloff(self, falloff, state) -> None: + """Set the Falloffs for the selected Sliders + + Parameters + ---------- + falloff : Falloff + The falloff to set on the selected sliders + state : bool + Whether to add or remove this falloff from the selection + """ + if not falloff: + return + sliders = self.uiSliderTREE.getSelectedItems(Slider) + for s in sliders: + if state == Qt.CheckState.Checked: + s.prog.addFalloff(falloff) + else: + s.prog.removeFalloff(falloff) + self.uiSliderTREE.viewport().update() + + def setSelectedSliderInterp(self, interp) -> None: + """Set the interpolation for the selected sliders + + Parameters + ---------- + interp : str + The interpolation to set on the selection. + Could be 'linear', 'spline', or 'splitSpline' + """ + sliders = self.uiSliderTREE.getSelectedItems(Slider) + for s in sliders: + s.prog.interp = interp + + # Combo Settings + def setSelectedComboGroups(self, group) -> None: + """Set the group for the selected Combos + + Parameters + ---------- + group : Group + The group that will take the selected Combos + """ + if not group: + return + combos = self.uiComboTREE.getSelectedItems(Combo) + group.take(combos) + self.uiComboTREE.viewport().update() + + def setSelectedComboSolveType(self, stVal) -> None: + """Set the solve type for the selected combos + + Parameters + ---------- + stVal : str + The solve type to set for the combos. + See Combo.solveTypes for a list + """ + combos = self.uiComboTREE.getSelectedItems(Combo) + for c in combos: + c.solveType = stVal + + # Edit Menu + def hideRedundant(self) -> None: + """Hide redundant items from the Slider and Combo trees based on a user preference""" + if self.simplex is None: + return + + check = self.uiHideRedundantACT.isChecked() + comboModel = self.uiComboTREE.model() + comboModel.filterShapes = check + comboModel.invalidateFilter() + sliderModel = self.uiSliderTREE.model() + sliderModel.doFilter = check + sliderModel.invalidateFilter() + + def setSliderRange(self) -> None: + """Double the range for the sliders *IN THE DCC ONLY* based on a user preference""" + self._sliderMul = 2.0 if self.uiDoubleSliderRangeACT.isChecked() else 1.0 + if self.simplex is None: + return + self.simplex.DCC.sliderMul = self._sliderMul + self.simplex.DCC.setSlidersRange(self.simplex.sliders) + + # Isolation + def isSliderIsolate(self) -> bool: + """Check if the slider tree is currently isolated""" + model = self.uiSliderTREE.model() + if model: + return bool(model.isolateList) + return False + + def sliderIsolateSelected(self) -> None: + """Isolate the selected Sliders in the Slider Tree""" + self.uiSliderTREE.isolateSelected() + self.uiSliderExitIsolateBTN.show() + + def sliderTreeExitIsolate(self) -> None: + """Disable isolation mode in the Slider Tree""" + self.uiSliderTREE.exitIsolate() + self.uiSliderExitIsolateBTN.hide() + + def isComboIsolate(self) -> bool: + """Check if the combo tree is currently isolated""" + model = self.uiComboTREE.model() + if model: + return bool(model.isolateList) + return False + + def comboIsolateSelected(self) -> None: + """Isolate the selected Combos in the Combo Tree""" + self.uiComboTREE.isolateSelected() + self.uiComboExitIsolateBTN.show() + + def comboTreeExitIsolate(self) -> None: + """Disable isolation mode in the Combo Tree""" + self.uiComboTREE.exitIsolate() + self.uiComboExitIsolateBTN.hide() + + +def _test(): + app = QApplication(sys.argv) + path = r"C:\Users\tfox\Documents\GitHub\Simplex\scripts\SimplexUI\build\HeadMaleStandard_High_Unsplit.smpx" + d = SimplexDialog() + newSystem = Simplex.buildSystemFromSmpx(path, d.getCurrentObject(), sliderMul=1.0) + d.setSystem(newSystem) + + d.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + _test() diff --git a/src/python/simplexui/travCheckDialog.py b/src/python/simplexui/travCheckDialog.py index 8168cb2a..52fd6b72 100644 --- a/src/python/simplexui/travCheckDialog.py +++ b/src/python/simplexui/travCheckDialog.py @@ -1,397 +1,419 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -from itertools import combinations, product - -from .dragFilter import DragFilter -from .items import Slider, Traversal -from Qt import QtCompat -from Qt.QtCore import Qt -from Qt.QtGui import QBrush, QColor -from Qt.QtWidgets import QDialog, QTreeWidgetItem -from .utils import getUiFile - - -class TooManyPossibilitiesError(Exception): - """Error raised when there are too many possibilities - Basically used as a stop-iteration - """ - - pass - - -def buildPossibleTraversals( - simplex, sliders, minDepth, maxDepth, lockDict=None, maxPoss=100 -): - """Build a list of possible traversals - - Parameters - ---------- - simplex : Simplex - The simplex system to check - sliders : Slider - The sliders to check - minDepth : int - The minimum number of sliders that will go into any traversals - maxDepth : int - The maximum number of sliders that will go into any traversals - lockDict : {Slider: ((float, ...), bool), ...} - An optional per-slider dict of possible values - maxPoss : float - The Maximum number of possibilities to return.(Default value = 100) - - Returns - ------- - : bool - True if the maximum number of possibilities was exceeded - : [([(Slider, (float, float)), ...], Traversal), ...] - Grouped slider/range pairs to existing (or None) Traversals - """ - allRanges = {} - allDyn = {} - sliderDict = {} - lockDict = lockDict or {} - - # Get the range values for each slider - for slider in sliders: - rng, dyn = lockDict.get(slider, (slider.prog.getRange(), True)) - rng = set(rng) - rng.discard(0) # ignore the zeros - allRanges[slider] = sorted(rng) - allDyn[slider] = dyn - sliderDict[slider.name] = slider - - poss = [] - tooMany = False - try: - for size in range(minDepth, maxDepth + 1): - for grp in combinations(sliders, size): - names = [i.name for i in grp] - ranges = [allRanges[s] for s in grp] - for vals in product(*ranges): - for dynIdx in range(len(grp)): - if not allDyn[grp[dynIdx]]: - continue - trng = list(zip(vals, vals)) - trng[dynIdx] = (0, trng[dynIdx][0]) - - count = Traversal.getCount(grp, trng) - if count == 0: - continue - - poss.append(frozenset(list(zip(names, trng)))) - if len(poss) > maxPoss: - raise TooManyPossibilitiesError("Don't melt your computer") - except TooManyPossibilitiesError: - tooMany = True - - # Build a dict of traversals that already exist - # but only if their sliders are in the list of sliders to check - onlys = {} - for trav in simplex.traversals: - sls = trav.allSliders() - if all(r in sliders for r in sls): - rngs = trav.ranges() - key = frozenset([(k.name, v) for k, v in rngs.items()]) - onlys[key] = trav - - toAdd = [] - for p in poss: - truePairs = [(sliderDict[n], r) for n, r in p] - toAdd.append((truePairs, onlys.get(p))) - return tooMany, toAdd - - -class TravCheckItem(QTreeWidgetItem): - def __init__(self, pairs, trav, *args, **kwargs): - super(TravCheckItem, self).__init__(*args, **kwargs) - self.pairs = pairs - self.trav = trav - - exists = False - grayBrush = QBrush(QColor(128, 128, 128)) - if self.trav is None: - ranges = dict(self.pairs) - newName = Traversal.buildTraversalName(ranges) - self.setText(0, newName) - else: - exists = True - self.setText(0, self.trav.name) - self.setForeground(0, grayBrush) - self.setForeground(1, grayBrush) - self.setForeground(2, grayBrush) - - # create the slider sub-rows - for slider, rng in pairs: - item = QTreeWidgetItem(self) - - item.setData(0, Qt.ItemDataRole.EditRole, slider.name) - item.setData(1, Qt.ItemDataRole.EditRole, rng[0]) - item.setData(2, Qt.ItemDataRole.EditRole, rng[1]) - if exists: - item.setForeground(0, grayBrush) - item.setForeground(1, grayBrush) - item.setForeground(2, grayBrush) - - self.setExpanded(True) - - -class TraversalCheckDialog(QDialog): - """Dialog for checking what possible traversals exist, and picking new traversals - In 'Create' mode, it provides a quick way of choosing the one specific traversal - that the user is looking for - - In 'Check' mode, it provides a convenient way to explore the possibilites - and create any missing traversals directly - - Parameters - ---------- - sliders : [Slider, ...] - A list of sliders to check - values : {Slider: (float, ...), ...} - A dictionary of values to use per slider - mode : str - The mode to display the dialog. Defaults to 'create' - parent : QObject - The Parent of the dialog. Must be a SimplexDialog - - Returns - ------- - """ - - def __init__( - self, - sliders, - values=None, - dynamics=None, - mode="create", - parent=None, - grandparent=None, - ): - super(TraversalCheckDialog, self).__init__(parent) - - uiPath = getUiFile(__file__) - QtCompat.loadUi(uiPath, self) - self.mode = mode.lower() - - # Store the Parent UI rather than relying on Qt's .parent() - # Could cause crashes otherwise - self.parUI = parent - self.gparUI = grandparent - self.maxPoss = 100 - self.colCheckRoles = [ - Qt.ItemDataRole.UserRole, - Qt.ItemDataRole.UserRole, - Qt.ItemDataRole.UserRole, - Qt.ItemDataRole.EditRole, - ] - - self.uiCreateSelectedBTN.clicked.connect(self.createMissing) - self.uiMinLimitSPIN.valueChanged.connect(self.populateWithoutUpdate) - self.uiMaxLimitSPIN.valueChanged.connect(self.populateWithoutUpdate) - self.uiCancelBTN.clicked.connect(self.close) - self.uiManualUpdateBTN.clicked.connect(self.populateWithUpdate) - self.uiEditTREE.itemChanged.connect(self.populateWithoutUpdate) - - self.dragFilter = DragFilter(self) - self.uiEditTREE.viewport().installEventFilter(self.dragFilter) - self.dragFilter.dragTick.connect(self.dragTick) - - self.gparUI.uiSliderTREE.selectionModel().selectionChanged.connect( - self.populateWithCheck - ) - - self.valueDict = values or {} - self.dynDict = dynamics or {} - self.setSliders(sliders) - if self.mode == "create": - self.uiAutoUpdateCHK.setCheckState(Qt.CheckState.Unchecked) - self.uiAutoUpdateCHK.hide() - self.uiManualUpdateBTN.hide() - - self.uiMaxLimitSPIN.setValue(max(len(sliders), 2)) - self.uiMinLimitSPIN.setValue(max(len(sliders) - 2, 2)) - - if sliders is None: - self.populateWithUpdate() - else: - self._populate() - - def dragTick(self, ticks, mul): - """Deal with the ticks coming from the drag handler - - Parameters - ---------- - ticks : int - The number of ticks since the last update - mul : float - The multiplier value from the drag handler - - Returns - ------- - - """ - items = self.uiEditTREE.selectedItems() - for item in items: - val = item.data(3, Qt.ItemDataRole.EditRole) - val += (0.05) * ticks * mul - if abs(val) < 1.0e-5: - val = 0.0 - val = max(min(val, 1.0), -1.0) - item.setData(3, Qt.ItemDataRole.EditRole, val) - self.uiEditTREE.viewport().update() - - def setSliders(self, val): - """Set the sliders displayed in this UI - - Parameters - ---------- - val : [Slider, ...] - The sliders to be displayed - - Returns - ------- - - """ - self.uiEditTREE.clear() - dvs = [None, -1.0, 1.0, 0.5] - val = val or [] - for slider in val: - item = QTreeWidgetItem(self.uiEditTREE, [slider.name]) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) - rangeVals = self.valueDict.get(slider, [-1.0, 1.0]) - - item.setData(0, Qt.ItemDataRole.UserRole, slider) - for col in range(1, 3): - val = dvs[col] - item.setData(col, self.colCheckRoles[col], val) - rng = slider.prog.getRange() - if val in rng: - chk = ( - Qt.CheckState.Checked - if val in rangeVals - else Qt.CheckState.Unchecked - ) - item.setCheckState(col, chk) - item.setCheckState(3, self.dynDict.get(slider, Qt.CheckState.Checked)) - - for col in reversed(list(range(4))): - self.uiEditTREE.resizeColumnToContents(col) - - def closeEvent(self, event): - """Override the Qt close event""" - self.gparUI.uiSliderTREE.selectionModel().selectionChanged.disconnect( - self.populateWithCheck - ) - super(TraversalCheckDialog, self).closeEvent(event) - - def populateWithUpdate(self): - """Populate the list from the main dialog selection""" - self.setSliders(self.gparUI.uiSliderTREE.getSelectedItems(typ=Slider)) - self._populate() - - def populateWithoutUpdate(self): - """Populate the list and but don't look at the main dialog""" - self._populate() - - def populateWithCheck(self): - """Populate the list from the main dialog selection, only if the AutoUpdate checkbox is checked""" - if self.uiAutoUpdateCHK.isChecked(): - self.setSliders(self.gparUI.uiSliderTREE.getSelectedItems(typ=Slider)) - self._populate() - - def _populate(self): - """Populate the list widgets in the UI""" - minDepth = self.uiMinLimitSPIN.value() - maxDepth = self.uiMaxLimitSPIN.value() - - root = self.uiEditTREE.invisibleRootItem() - lockDict = {} - sliderList = [] - - for row in range(root.childCount()): - item = root.child(row) - slider = item.data(0, Qt.ItemDataRole.UserRole) - if slider is not None: - sliderList.append(slider) - lv = [ - item.data(col, self.colCheckRoles[col]) - for col in range(1, 3) - if item.checkState(col) == Qt.CheckState.Checked - ] - dyn = item.checkState(3) == Qt.CheckState.Checked - lockDict[slider] = (lv, dyn) - - tooMany, toAdd = buildPossibleTraversals( - self.parUI.simplex, - sliderList, - minDepth, - maxDepth, - lockDict=lockDict, - maxPoss=self.maxPoss, - ) - - lbl = ( - "Too many possibilities. Limiting to {0}".format(self.maxPoss) - if tooMany - else "" - ) - self.uiWarningLBL.setText(lbl) - - self.uiTravCheckTREE.clear() - for pairs, trav in reversed(toAdd): - TravCheckItem(pairs, trav, self.uiTravCheckTREE) - - for i in reversed(list(range(self.uiTravCheckTREE.columnCount()))): - self.uiTravCheckTREE.resizeColumnToContents(i) - - if self.mode == "create": - if self.uiTravCheckTREE.topLevelItemCount() > 0: - self.uiTravCheckTREE.topLevelItem(0).setSelected(True) - - def createMissing(self): - """Create selected traversals if they don't already exist""" - simplex = self.parUI.simplex - created = [] - - tops = [] - for item in self.uiTravCheckTREE.selectedItems(): - par = item.parent() - if par is not None: - item = par - if item not in tops: - tops.append(item) - - for item in tops: - name = item.text(0) - sliders, ranges = list(zip(*item.pairs)) - # Double check that the user didn't create any extra sliders - if Traversal.traversalAlreadyExists(simplex, sliders, ranges) is None: - count = Traversal.getCount(sliders, ranges) - startPairs, endPairs = list( - zip(*[((s, a), (s, b)) for s, (a, b) in item.pairs]) - ) - t = Traversal.createTraversal( - name, simplex, startPairs, endPairs, count=count - ) - created.append(t) - - self.parUI.uiTraversalTREE.setItemSelection(created) - if self.mode == "create": - self.close() - else: - self.populateWithoutUpdate() +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +from __future__ import annotations + +from itertools import combinations, product + +from Qt import QtCompat +from Qt.QtCore import Qt +from Qt.QtGui import QBrush, QColor +from Qt.QtWidgets import ( + QCheckBox, + QDialog, + QGroupBox, + QLabel, + QPushButton, + QSpinBox, + QTreeWidget, + QTreeWidgetItem, +) + +from .dragFilter import DragFilter +from .items import Slider, Traversal +from .utils import getUiFile + + +class TooManyPossibilitiesError(Exception): + """Error raised when there are too many possibilities + Basically used as a stop-iteration + """ + + pass + + +def buildPossibleTraversals( + simplex, sliders, minDepth: int, maxDepth: int, lockDict=None, maxPoss=100 +): + """Build a list of possible traversals + + Parameters + ---------- + simplex : Simplex + The simplex system to check + sliders : Slider + The sliders to check + minDepth : int + The minimum number of sliders that will go into any traversals + maxDepth : int + The maximum number of sliders that will go into any traversals + lockDict : {Slider: ((float, ...), bool), ...} + An optional per-slider dict of possible values + maxPoss : float + The Maximum number of possibilities to return.(Default value = 100) + + Returns + ------- + : bool + True if the maximum number of possibilities was exceeded + : [([(Slider, (float, float)), ...], Traversal), ...] + Grouped slider/range pairs to existing (or None) Traversals + """ + allRanges = {} + allDyn = {} + sliderDict = {} + lockDict = lockDict or {} + + # Get the range values for each slider + for slider in sliders: + rng, dyn = lockDict.get(slider, (slider.prog.getRange(), True)) + rng = set(rng) + rng.discard(0) # ignore the zeros + allRanges[slider] = sorted(rng) + allDyn[slider] = dyn + sliderDict[slider.name] = slider + + poss = [] + tooMany = False + try: + for size in range(minDepth, maxDepth + 1): + for grp in combinations(sliders, size): + names = [i.name for i in grp] + ranges = [allRanges[s] for s in grp] + for vals in product(*ranges): + for dynIdx in range(len(grp)): + if not allDyn[grp[dynIdx]]: + continue + trng = list(zip(vals, vals)) + trng[dynIdx] = (0, trng[dynIdx][0]) + + count = Traversal.getCount(grp, trng) + if count == 0: + continue + + poss.append(frozenset(list(zip(names, trng)))) + if len(poss) > maxPoss: + raise TooManyPossibilitiesError("Don't melt your computer") + except TooManyPossibilitiesError: + tooMany = True + + # Build a dict of traversals that already exist + # but only if their sliders are in the list of sliders to check + onlys = {} + for trav in simplex.traversals: + sls = trav.allSliders() + if all(r in sliders for r in sls): + rngs = trav.ranges() + key = frozenset([(k.name, v) for k, v in rngs.items()]) + onlys[key] = trav + + toAdd = [] + for p in poss: + truePairs = [(sliderDict[n], r) for n, r in p] + toAdd.append((truePairs, onlys.get(p))) + return tooMany, toAdd + + +class TravCheckItem(QTreeWidgetItem): + def __init__(self, pairs, trav, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.pairs = pairs + self.trav = trav + + exists = False + grayBrush = QBrush(QColor(128, 128, 128)) + if self.trav is None: + ranges = dict(self.pairs) + newName = Traversal.buildTraversalName(ranges) + self.setText(0, newName) + else: + exists = True + self.setText(0, self.trav.name) + self.setForeground(0, grayBrush) + self.setForeground(1, grayBrush) + self.setForeground(2, grayBrush) + + # create the slider sub-rows + for slider, rng in pairs: + item = QTreeWidgetItem(self) + + item.setData(0, Qt.ItemDataRole.EditRole, slider.name) + item.setData(1, Qt.ItemDataRole.EditRole, rng[0]) + item.setData(2, Qt.ItemDataRole.EditRole, rng[1]) + if exists: + item.setForeground(0, grayBrush) + item.setForeground(1, grayBrush) + item.setForeground(2, grayBrush) + + self.setExpanded(True) + + +class TraversalCheckDialog(QDialog): + """Dialog for checking what possible traversals exist, and picking new traversals + In 'Create' mode, it provides a quick way of choosing the one specific traversal + that the user is looking for + + In 'Check' mode, it provides a convenient way to explore the possibilites + and create any missing traversals directly + + Parameters + ---------- + sliders : [Slider, ...] + A list of sliders to check + values : {Slider: (float, ...), ...} + A dictionary of values to use per slider + mode : str + The mode to display the dialog. Defaults to 'create' + parent : QObject + The Parent of the dialog. Must be a SimplexDialog + + Returns + ------- + """ + + uiLimitGRP: QGroupBox + uiMinLimitSPIN: QSpinBox + uiMaxLimitSPIN: QSpinBox + uiAutoUpdateCHK: QCheckBox + uiManualUpdateBTN: QPushButton + uiEditTREE: QTreeWidget + uiTravCheckTREE: QTreeWidget + uiWarningLBL: QLabel + uiCancelBTN: QPushButton + uiCreateSelectedBTN: QPushButton + + def __init__( + self, + sliders, + values=None, + dynamics=None, + mode: str = "create", + parent=None, + grandparent=None, + ) -> None: + # Store the Parent UI rather than relying on Qt's .parent() + # Could cause crashes otherwise + if parent is None or grandparent is None: + raise ValueError("The parent and grandparent must be provided") + + super().__init__(parent) + + uiPath = getUiFile(__file__) + QtCompat.loadUi(uiPath, self) + self.mode = mode.lower() + + self.parUI = parent + self.gparUI = grandparent + self.maxPoss = 100 + self.colCheckRoles = [ + Qt.ItemDataRole.UserRole, + Qt.ItemDataRole.UserRole, + Qt.ItemDataRole.UserRole, + Qt.ItemDataRole.EditRole, + ] + + self.uiCreateSelectedBTN.clicked.connect(self.createMissing) + self.uiMinLimitSPIN.valueChanged.connect(self.populateWithoutUpdate) + self.uiMaxLimitSPIN.valueChanged.connect(self.populateWithoutUpdate) + self.uiCancelBTN.clicked.connect(self.close) + self.uiManualUpdateBTN.clicked.connect(self.populateWithUpdate) + self.uiEditTREE.itemChanged.connect(self.populateWithoutUpdate) + + self.dragFilter = DragFilter(self) + self.uiEditTREE.viewport().installEventFilter(self.dragFilter) + self.dragFilter.dragTick.connect(self.dragTick) + + self.gparUI.uiSliderTREE.selectionModel().selectionChanged.connect( + self.populateWithCheck + ) + + self.valueDict = values or {} + self.dynDict = dynamics or {} + self.setSliders(sliders) + if self.mode == "create": + self.uiAutoUpdateCHK.setCheckState(Qt.CheckState.Unchecked) + self.uiAutoUpdateCHK.hide() + self.uiManualUpdateBTN.hide() + + self.uiMaxLimitSPIN.setValue(max(len(sliders), 2)) + self.uiMinLimitSPIN.setValue(max(len(sliders) - 2, 2)) + + if sliders is None: + self.populateWithUpdate() + else: + self._populate() + + def dragTick(self, ticks, mul) -> None: + """Deal with the ticks coming from the drag handler + + Parameters + ---------- + ticks : int + The number of ticks since the last update + mul : float + The multiplier value from the drag handler + + Returns + ------- + + """ + items = self.uiEditTREE.selectedItems() + for item in items: + val = item.data(3, Qt.ItemDataRole.EditRole) + val += (0.05) * ticks * mul + if abs(val) < 1.0e-5: + val = 0.0 + val = max(min(val, 1.0), -1.0) + item.setData(3, Qt.ItemDataRole.EditRole, val) + self.uiEditTREE.viewport().update() + + def setSliders(self, val) -> None: + """Set the sliders displayed in this UI + + Parameters + ---------- + val : [Slider, ...] + The sliders to be displayed + + Returns + ------- + + """ + self.uiEditTREE.clear() + dvs = [None, -1.0, 1.0, 0.5] + val = val or [] + for slider in val: + item = QTreeWidgetItem(self.uiEditTREE, [slider.name]) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) + rangeVals = self.valueDict.get(slider, [-1.0, 1.0]) + + item.setData(0, Qt.ItemDataRole.UserRole, slider) + for col in range(1, 3): + val = dvs[col] + item.setData(col, self.colCheckRoles[col], val) + rng = slider.prog.getRange() + if val in rng: + chk = ( + Qt.CheckState.Checked + if val in rangeVals + else Qt.CheckState.Unchecked + ) + item.setCheckState(col, chk) + item.setCheckState(3, self.dynDict.get(slider, Qt.CheckState.Checked)) + + for col in reversed(list(range(4))): + self.uiEditTREE.resizeColumnToContents(col) + + def closeEvent(self, event) -> None: + """Override the Qt close event""" + self.gparUI.uiSliderTREE.selectionModel().selectionChanged.disconnect( + self.populateWithCheck + ) + super().closeEvent(event) + + def populateWithUpdate(self) -> None: + """Populate the list from the main dialog selection""" + self.setSliders(self.gparUI.uiSliderTREE.getSelectedItems(typ=Slider)) + self._populate() + + def populateWithoutUpdate(self) -> None: + """Populate the list and but don't look at the main dialog""" + self._populate() + + def populateWithCheck(self) -> None: + """Populate the list from the main dialog selection, only if the AutoUpdate checkbox is checked""" + if self.uiAutoUpdateCHK.isChecked(): + self.setSliders(self.gparUI.uiSliderTREE.getSelectedItems(typ=Slider)) + self._populate() + + def _populate(self) -> None: + """Populate the list widgets in the UI""" + minDepth = self.uiMinLimitSPIN.value() + maxDepth = self.uiMaxLimitSPIN.value() + + root = self.uiEditTREE.invisibleRootItem() + lockDict = {} + sliderList = [] + + for row in range(root.childCount()): + item = root.child(row) + slider = item.data(0, Qt.ItemDataRole.UserRole) + if slider is not None: + sliderList.append(slider) + lv = [ + item.data(col, self.colCheckRoles[col]) + for col in range(1, 3) + if item.checkState(col) == Qt.CheckState.Checked + ] + dyn = item.checkState(3) == Qt.CheckState.Checked + lockDict[slider] = (lv, dyn) + + tooMany, toAdd = buildPossibleTraversals( + self.parUI.simplex, + sliderList, + minDepth, + maxDepth, + lockDict=lockDict, + maxPoss=self.maxPoss, + ) + + lbl = f"Too many possibilities. Limiting to {self.maxPoss}" if tooMany else "" + self.uiWarningLBL.setText(lbl) + + self.uiTravCheckTREE.clear() + for pairs, trav in reversed(toAdd): + TravCheckItem(pairs, trav, self.uiTravCheckTREE) + + for i in reversed(list(range(self.uiTravCheckTREE.columnCount()))): + self.uiTravCheckTREE.resizeColumnToContents(i) + + if self.mode == "create": + if self.uiTravCheckTREE.topLevelItemCount() > 0: + self.uiTravCheckTREE.topLevelItem(0).setSelected(True) + + def createMissing(self) -> None: + """Create selected traversals if they don't already exist""" + simplex = self.parUI.simplex + created = [] + + tops = [] + for item in self.uiTravCheckTREE.selectedItems(): + par = item.parent() + if par is not None: + item = par + if item not in tops: + tops.append(item) + + for item in tops: + name = item.text(0) + sliders, ranges = list(zip(*item.pairs)) + # Double check that the user didn't create any extra sliders + if Traversal.traversalAlreadyExists(simplex, sliders, ranges) is None: + count = Traversal.getCount(sliders, ranges) + startPairs, endPairs = list( + zip(*[((s, a), (s, b)) for s, (a, b) in item.pairs]) + ) + t = Traversal.createTraversal( + name, simplex, startPairs, endPairs, count=count + ) + created.append(t) + + self.parUI.uiTraversalTREE.setItemSelection(created) + if self.mode == "create": + self.close() + else: + self.populateWithoutUpdate() diff --git a/src/python/simplexui/traversalDialog.py b/src/python/simplexui/traversalDialog.py index 369038bd..360d906d 100644 --- a/src/python/simplexui/traversalDialog.py +++ b/src/python/simplexui/traversalDialog.py @@ -1,271 +1,299 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - - -# Ignore a bunch of linter warnings that show up because of my choice of abstraction -# pylint: disable=unused-argument,too-many-public-methods,relative-import -# pylint: disable=too-many-statements,no-self-use,missing-docstring -import re - -from .interface import DCC -from .interfaceModel import ( - TraversalFilterModel, - TraversalModel, - coerceIndexToRoots, - coerceIndexToType, -) -from .interfaceModelTrees import TraversalTree -from .items import Group, Simplex, Slider, Traversal, TravPair - -# This module imports QT from PyQt4, PySide or PySide2 -# Depending on what's available -from Qt import QtCompat -from Qt.QtGui import QStandardItemModel -from Qt.QtWidgets import ( - QApplication, - QDialog, - QInputDialog, - QMessageBox, - QProgressDialog, -) -from .travCheckDialog import TraversalCheckDialog -from .utils import getUiFile, makeUnique - -NAME_CHECK = re.compile(r"[A-Za-z][\w.]*") - - -class TraversalDialog(QDialog): - """The dialog for dealing with Traversals - - Parameters - ---------- - parent : SimplexDialog - The parent simplex dialog - """ - - def __init__(self, parent): - super(TraversalDialog, self).__init__(parent) - - uiPath = getUiFile(__file__) - QtCompat.loadUi(uiPath, self) - self.parUI = parent - - # Load the custom tree manually - self.uiTraversalTREE = TraversalTree(self) - self.uiTraversalTREE.setDragEnabled(False) - self.uiTraversalTREE.setDragDropMode(TraversalTree.NoDragDrop) - self.uiTraversalTREE.setSelectionMode(TraversalTree.ExtendedSelection) - self.uiTraversalTREE.dragFilter.dragPressed.connect(self.dragStart) - self.uiTraversalTREE.dragFilter.dragReleased.connect(self.dragStop) - - self.uiTraversalLAY.addWidget(self.uiTraversalTREE) - self.simplex = None - self.parUI.simplexLoaded.connect(self.loadSimplex) - - self.uiTravDeleteBTN.clicked.connect(self.deleteTrav) - self.uiTravNewBTN.clicked.connect(self.newTrav) - self.uiTravNewGroupBTN.clicked.connect(self.newGroup) - self.uiTravNewShapeBTN.clicked.connect(self.newShape) - self.uiTravAddSliderBTN.clicked.connect(self.addSlider) - self.uiShapeExtractBTN.clicked.connect(self.shapeExtract) - self.uiShapeConnectBTN.clicked.connect(self.shapeConnectFromSelection) - - self.parUI.uiHideRedundantACT.toggled.connect(self.hideRedundant) - - self.loadSimplex() - - def hideRedundant(self): - """Hide Redundant items in the ui based on the checkbox""" - check = self.uiHideRedundantACT.isChecked() - travModel = self.uiTraversalTREE.model() - travModel.doFilter = check - travModel.invalidateFilter() - - def dragStart(self): - """Slot for handling the start of a MMB Drag event""" - if self.simplex is not None: - self.simplex.DCC.undoOpen() - - def dragStop(self): - """Slot for handling the end of a MMB Drag event""" - if self.simplex is not None: - self.simplex.DCC.undoClose() - - def loadSimplex(self): - """Load the simplex system from the parent dialog""" - system = self.parUI.simplex - if system is None: - self.simplex = system - self.uiTraversalTREE.setModel(QStandardItemModel()) - - self.uiTravDeleteBTN.setEnabled(False) - self.uiTravNewBTN.setEnabled(False) - self.uiTravNewGroupBTN.setEnabled(False) - self.uiTravNewShapeBTN.setEnabled(False) - self.uiTravAddSliderBTN.setEnabled(False) - self.uiShapeExtractBTN.setEnabled(False) - self.uiShapeConnectBTN.setEnabled(False) - return - else: - self.uiTravDeleteBTN.setEnabled(True) - self.uiTravNewBTN.setEnabled(True) - self.uiTravNewGroupBTN.setEnabled(True) - self.uiTravNewShapeBTN.setEnabled(True) - self.uiTravAddSliderBTN.setEnabled(True) - self.uiShapeExtractBTN.setEnabled(True) - self.uiShapeConnectBTN.setEnabled(True) - - if system == self.simplex: - return - - self.simplex = system - - sliderProxyModel = self.parUI.uiSliderTREE.model() - if not sliderProxyModel: - self.uiTraversalTREE.setModel(None) - return - sliderModel = sliderProxyModel.sourceModel() - simplexModel = sliderModel.sourceModel() - - travModel = TraversalModel(simplexModel) - travProxModel = TraversalFilterModel(travModel) - self.uiTraversalTREE.setModel(travProxModel) - - def deleteTrav(self): - """Delete the selected traversals""" - idxs = self.uiTraversalTREE.getSelectedIndexes() - roots = coerceIndexToRoots(idxs) - if not roots: - QMessageBox.warning(self, "Warning", "Nothing Selected") - return - roots = makeUnique([i.model().itemFromIndex(i) for i in roots]) - for r in roots: - if isinstance(r, Simplex): - QMessageBox.warning( - self, "Warning", "Cannot delete a simplex system this way (for now)" - ) - return - - pairs = [r for r in roots if isinstance(r, TravPair)] - roots = [r for r in roots if not isinstance(r, TravPair)] - - for r in roots: - r.delete() - - TravPair.removeAll(pairs) - - self.uiTraversalTREE.model().invalidateFilter() - - def newGroup(self): - """Create a new group for organizing the traversals""" - if self.simplex is None: - return - newName, good = QInputDialog.getText( - self, "New Group", "Enter a name for the new group", text="Group" - ) - if not good: - return - if not NAME_CHECK.match(newName): - message = "Group name can only contain letters and numbers, and cannot start with a number" - QMessageBox.warning(self, "Warning", message) - return - - items = self.uiTraversalTREE.getSelectedItems(Slider) - Group.createGroup(str(newName), self.simplex, items) - - def newShape(self): - """Add a new shape to the traversal's Progression""" - pars = self.uiTraversalTREE.getSelectedIndexes() - if not pars: - return - travs = coerceIndexToType(pars, Traversal) - for travIdx in travs: - trav = travIdx.model().itemFromIndex(travIdx) - trav.prog.createShape() - - def shapeExtract(self): - """Extract a shape from the traversal's progression""" - indexes = self.uiTraversalTREE.getSelectedIndexes() - return self.parUI.shapeIndexExtract(indexes) - - def newTrav(self): - """Create a new traversal based on the selection in the main UI""" - sliders = self.parUI.uiSliderTREE.getSelectedItems(Slider) - if len(sliders) < 2: - message = "Must have at least 2 sliders selected" - QMessageBox.warning(self, "Warning", message) - return None - - tcd = TraversalCheckDialog( - sliders, mode="create", parent=self, grandparent=self.parUI - ) - tcd.move(self.pos()) - tcd.exec_() - - def addSlider(self): - """Add a slider to the traversal's definition""" - # add the slider to both the start and end - travs = self.uiTraversalTREE.getSelectedItems(Traversal) - sliders = self.parUI.uiSliderTREE.getSelectedItems(Slider) - if not travs: - return - if not sliders: - return - for slider in sliders: - travs[-1].addSlider(slider) - - def shapeConnectFromSelection(self): - """Connect a shape into the traversal based on the DCC scene selection""" - if self.simplex is None: - return - # make a dict of name:object - sel = DCC.getSelectedObjects() - selDict = {} - for s in sel: - name = DCC.getObjectName(s) - if name.endswith("_Extract"): - nn = name.rsplit("_Extract", 1)[0] - selDict[nn] = s - - pairDict = {} - for p in self.simplex.progs: - for pp in p.pairs: - pairDict[pp.shape.name] = pp - - # get all common names - common = selDict.keys() & pairDict.keys() - - # get those items - pairs = [pairDict[i] for i in common] - - # Set up the progress bar - pBar = QProgressDialog("Connecting Shapes", "Cancel", 0, 100, self) - pBar.setMaximum(len(pairs)) - - # Do the extractions - for pair in pairs: - c = pair.prog.controller - c.connectShape(pair.shape, delete=True) - - # ProgressBar - pBar.setValue(pBar.value() + 1) - pBar.setLabelText("Connecting:\n{0}".format(pair.shape.name)) - QApplication.processEvents() - if pBar.wasCanceled(): - return - - pBar.close() +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +from __future__ import annotations + +import re + +from Qt import QtCompat +from Qt.QtGui import QAction, QStandardItemModel +from Qt.QtWidgets import ( + QApplication, + QDialog, + QFrame, + QInputDialog, + QMessageBox, + QProgressDialog, + QPushButton, + QVBoxLayout, +) + +from .interface import DCC +from .interfaceModel import ( + TraversalFilterModel, + TraversalModel, + coerceIndexToRoots, + coerceIndexToType, +) +from .interfaceModelTrees import TraversalTree +from .items import Group, Simplex, Slider, Traversal, TravPair +from .travCheckDialog import TraversalCheckDialog +from .utils import execwid, getUiFile, makeUnique + +NAME_CHECK = re.compile(r"[A-Za-z][\w.]*") + + +class TraversalDialog(QDialog): + """The dialog for dealing with Traversals + + Parameters + ---------- + parent : SimplexDialog + The parent simplex dialog + """ + + uiTraversalLAY: QVBoxLayout + uiTravButtonFRM: QFrame + uiTravNewBTN: QPushButton + uiTravAddSliderBTN: QPushButton + uiTravNewGroupBTN: QPushButton + uiTravNewShapeBTN: QPushButton + uiTravDeleteBTN: QPushButton + uiShapeExtractBTN: QPushButton + uiShapeConnectBTN: QPushButton + + uiConvertCorrectiveACT: QAction + uiDoubleSliderRangeACT: QAction + uiExportACT: QAction + uiExportObjPSDACT: QAction + uiExtractOnCreateACT: QAction + uiExtractPosedACT: QAction + uiHideRedundantACT: QAction + uiImportACT: QAction + uiImportObjPSDACT: QAction + uiLegacyJsonACT: QAction + uiLiveShapeConnectionACT: QAction + uiLiveUpdateACT: QAction + uiSetWorkingDirectoryACT: QAction + uiSplitShapePSDACT: QAction + + def __init__(self, parent) -> None: + super().__init__(parent) + + uiPath = getUiFile(__file__) + QtCompat.loadUi(uiPath, self) + self.parUI = parent + + # Load the custom tree manually + self.uiTraversalTREE = TraversalTree(self) + self.uiTraversalTREE.setDragEnabled(False) + self.uiTraversalTREE.setDragDropMode(TraversalTree.DragDropMode.NoDragDrop) + self.uiTraversalTREE.setSelectionMode( + TraversalTree.SelectionMode.ExtendedSelection + ) + self.uiTraversalTREE.dragFilter.dragPressed.connect(self.dragStart) + self.uiTraversalTREE.dragFilter.dragReleased.connect(self.dragStop) + + self.uiTraversalLAY.addWidget(self.uiTraversalTREE) + self.simplex = None + self.parUI.simplexLoaded.connect(self.loadSimplex) + + self.uiTravDeleteBTN.clicked.connect(self.deleteTrav) + self.uiTravNewBTN.clicked.connect(self.newTrav) + self.uiTravNewGroupBTN.clicked.connect(self.newGroup) + self.uiTravNewShapeBTN.clicked.connect(self.newShape) + self.uiTravAddSliderBTN.clicked.connect(self.addSlider) + self.uiShapeExtractBTN.clicked.connect(self.shapeExtract) + self.uiShapeConnectBTN.clicked.connect(self.shapeConnectFromSelection) + + self.parUI.uiHideRedundantACT.toggled.connect(self.hideRedundant) + + self.loadSimplex() + + def hideRedundant(self) -> None: + """Hide Redundant items in the ui based on the checkbox""" + if self.simplex is None: + return + check = self.uiHideRedundantACT.isChecked() + travModel = self.uiTraversalTREE.model() + travModel.doFilter = check + travModel.invalidateFilter() + + def dragStart(self) -> None: + """Slot for handling the start of a MMB Drag event""" + if self.simplex is not None: + self.simplex.DCC.undoOpen() + + def dragStop(self) -> None: + """Slot for handling the end of a MMB Drag event""" + if self.simplex is not None: + self.simplex.DCC.undoClose() + + def loadSimplex(self) -> None: + """Load the simplex system from the parent dialog""" + system = self.parUI.simplex + if system is None: + self.simplex = system + self.uiTraversalTREE.setModel(QStandardItemModel()) + + self.uiTravDeleteBTN.setEnabled(False) + self.uiTravNewBTN.setEnabled(False) + self.uiTravNewGroupBTN.setEnabled(False) + self.uiTravNewShapeBTN.setEnabled(False) + self.uiTravAddSliderBTN.setEnabled(False) + self.uiShapeExtractBTN.setEnabled(False) + self.uiShapeConnectBTN.setEnabled(False) + return + else: + self.uiTravDeleteBTN.setEnabled(True) + self.uiTravNewBTN.setEnabled(True) + self.uiTravNewGroupBTN.setEnabled(True) + self.uiTravNewShapeBTN.setEnabled(True) + self.uiTravAddSliderBTN.setEnabled(True) + self.uiShapeExtractBTN.setEnabled(True) + self.uiShapeConnectBTN.setEnabled(True) + + if system == self.simplex: + return + + self.simplex = system + + sliderProxyModel = self.parUI.uiSliderTREE.model() + if not sliderProxyModel: + self.uiTraversalTREE.setModel(None) + return + sliderModel = sliderProxyModel.sourceModel() + simplexModel = sliderModel.sourceModel() + + travModel = TraversalModel(simplexModel) + travProxModel = TraversalFilterModel(travModel) + self.uiTraversalTREE.setModel(travProxModel) + + def deleteTrav(self) -> None: + """Delete the selected traversals""" + idxs = self.uiTraversalTREE.getSelectedIndexes() + roots = coerceIndexToRoots(idxs) + if not roots: + QMessageBox.warning(self, "Warning", "Nothing Selected") + return + roots = makeUnique([i.model().itemFromIndex(i) for i in roots]) + for r in roots: + if isinstance(r, Simplex): + QMessageBox.warning( + self, "Warning", "Cannot delete a simplex system this way (for now)" + ) + return + + pairs = [r for r in roots if isinstance(r, TravPair)] + roots = [r for r in roots if not isinstance(r, TravPair)] + + for r in roots: + r.delete() + + TravPair.removeAll(pairs) + + self.uiTraversalTREE.model().invalidateFilter() + + def newGroup(self) -> None: + """Create a new group for organizing the traversals""" + if self.simplex is None: + return + newName, good = QInputDialog.getText( + self, "New Group", "Enter a name for the new group", text="Group" + ) + if not good: + return + if not NAME_CHECK.match(newName): + message = "Group name can only contain letters and numbers, and cannot start with a number" + QMessageBox.warning(self, "Warning", message) + return + + items = self.uiTraversalTREE.getSelectedItems(Slider) + Group.createGroup(str(newName), self.simplex, items) + + def newShape(self) -> None: + """Add a new shape to the traversal's Progression""" + pars = self.uiTraversalTREE.getSelectedIndexes() + if not pars: + return + travs = coerceIndexToType(pars, Traversal) + for travIdx in travs: + trav = travIdx.model().itemFromIndex(travIdx) + trav.prog.createShape() + + def shapeExtract(self): + """Extract a shape from the traversal's progression""" + indexes = self.uiTraversalTREE.getSelectedIndexes() + return self.parUI.shapeIndexExtract(indexes) + + def newTrav(self) -> None: + """Create a new traversal based on the selection in the main UI""" + sliders = self.parUI.uiSliderTREE.getSelectedItems(Slider) + if len(sliders) < 2: + message = "Must have at least 2 sliders selected" + QMessageBox.warning(self, "Warning", message) + return None + + tcd = TraversalCheckDialog( + sliders, mode="create", parent=self, grandparent=self.parUI + ) + tcd.move(self.pos()) + execwid(tcd) + + def addSlider(self) -> None: + """Add a slider to the traversal's definition""" + # add the slider to both the start and end + travs = self.uiTraversalTREE.getSelectedItems(Traversal) + sliders = self.parUI.uiSliderTREE.getSelectedItems(Slider) + if not travs: + return + if not sliders: + return + for slider in sliders: + travs[-1].addSlider(slider) + + def shapeConnectFromSelection(self) -> None: + """Connect a shape into the traversal based on the DCC scene selection""" + if self.simplex is None: + return + # make a dict of name:object + sel = DCC.getSelectedObjects() + selDict = {} + for s in sel: + name = DCC.getObjectName(s) + if name.endswith("_Extract"): + nn = name.rsplit("_Extract", 1)[0] + selDict[nn] = s + + pairDict = {} + for p in self.simplex.progs: + for pp in p.pairs: + pairDict[pp.shape.name] = pp + + # get all common names + common = selDict.keys() & pairDict.keys() + + # get those items + pairs = [pairDict[i] for i in common] + + # Set up the progress bar + pBar = QProgressDialog("Connecting Shapes", "Cancel", 0, 100, self) + pBar.setMaximum(len(pairs)) + + # Do the extractions + for pair in pairs: + c = pair.prog.controller + c.connectShape(pair.shape, delete=True) + + # ProgressBar + pBar.setValue(pBar.value() + 1) + pBar.setLabelText(f"Connecting:\n{pair.shape.name}") + QApplication.processEvents() + if pBar.wasCanceled(): + return + + pBar.close() diff --git a/src/python/simplexui/utils.py b/src/python/simplexui/utils.py index 8cae2ab8..d79e1cc0 100755 --- a/src/python/simplexui/utils.py +++ b/src/python/simplexui/utils.py @@ -1,320 +1,307 @@ -# Copyright 2016, Blur Studio -# -# This file is part of Simplex. -# -# Simplex is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Simplex is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with Simplex. If not, see . - -"""Utility functions.""" - -import os -import re -import sys - -from contextlib import contextmanager, ExitStack - -from Qt.QtCore import QObject, QTimer, QSettings -from Qt.QtGui import QIcon - -AT_BLUR = os.environ.get("SIMPLEX_AT_BLUR") == "true" - - -def toPyObject(thing): - """Because we could still be in the sip api 1.0 - I have to check and convert all Qt returns to python objects - - Parameters - ---------- - thing : object - The object, possibly Qt type - - Returns - ------- - object - The python object - - """ - try: - return thing.toPyObject() - except Exception: - return thing - - -def getUiFile(fileVar, subFolder="ui", uiName=None): - """Get the path to the .ui file - - Parameters - ---------- - fileVar : str - The __file__ variable passed from the invocation - subFolder : str - The folder to look in for the ui files. Defaults to 'ui' - uiName : str or None - The name of the .ui file. Defaults to the basename of - fileVar with .ui instead of .py - - Returns - ------- - str - The path to the .ui file - - """ - uiFolder, filename = os.path.split(fileVar) - if uiName is None: - uiName = os.path.splitext(filename)[0] - if not subFolder: - raise ValueError("A subfolder must be provided") - uiFile = os.path.join(uiFolder, subFolder, uiName + ".ui") - return uiFile - - -def getNextName(name, currentNames): - """Get the next available number-incremented name - - Parameters - ---------- - name : str - The name I want to check - currentNames : list - The names that currently exist - - Returns - ------- - str - The next available number-incremented name - - """ - i = 0 - s = set(currentNames) - while True: - if not i: - nn = name - else: - nn = name + str(i) - if nn not in s: - return nn - i += 1 - return name - - -def clearPathSymbols(paths, keepers=None): - """Removes path symbols from the environment. - - This means I can unload my tools from the current process and re-import them - rather than dealing with the always finicky reload() - - We use directory paths rather than module names because it gives us more control - over what is unloaded - - Parameters - ---------- - paths : list - List of directory paths that will have their modules removed - keepers : list or None - List of module names that will not be removed (Default value = None) - """ - keepers = keepers or [] - paths = [os.path.normcase(os.path.normpath(p)) for p in paths] - - for key, value in sys.modules.items(): - protected = False - - # Used by multiprocessing library, don't remove this. - if key == "__parents_main__": - protected = True - - # Protect submodules of protected packages - if key in keepers: - protected = True - - ckey = key - while not protected and "." in ckey: - ckey = ckey.rsplit(".", 1)[0] - if ckey in keepers: - protected = True - - if protected: - continue - - try: - packPath = value.__file__ - except AttributeError: - continue - - packPath = os.path.normcase(os.path.normpath(packPath)) - - isEnvPackage = any(packPath.startswith(p) for p in paths) - if isEnvPackage: - sys.modules.pop(key) - - -def caseSplit(name): - """Split CamelCase and dromedaryCase words - Taken From https://stackoverflow.com/questions/29916065/how-to-do-camelcase-split-in-python - - Parameters - ---------- - name : str - The string to split - - Returns - ------- - list - The split string - """ - matches = re.finditer(".+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)", name) - return [m.group(0) for m in matches] - - -class singleShot(QObject): - """Decorator class used to implement a QTimer.singleShot(0, function) - - This is useful so your refresh function only gets called once even if - its connected to a signal that gets emitted several times at once. - - Note: - The values passed to the decorated method will be accumulated - and run all at once, then reset for the next go-round - - From the Qt Docs: - As a special case, a QTimer with a timeout of 0 will time out as - soon as all the events in the window system's event queue have - been processed. This can be used to do heavy work while providing - a snappy user interface - """ - - def __init__(self): - super(singleShot, self).__init__() - self._function = None - self._callScheduled = False - self._args = [] - self._inst = None - - def __call__(self, function): - self._function = function - - def newFunction(inst, *args): - """ - - Parameters - ---------- - inst : - - *args : - - - Returns - ------- - - """ - self._args.extend(args) - if not self._callScheduled: - self._inst = inst - self._callScheduled = True - QTimer.singleShot(0, self.callback) - - newFunction.__name__ = function.__name__ - newFunction.__doc__ = function.__doc__ - return newFunction - - def callback(self): - """Calls the decorated function and resets singleShot for the next group of calls""" - self._callScheduled = False - # self._args needs to be cleared before we call self._function - args = self._args - inst = self._inst - self._inst = None - self._args = [] - self._function(inst, args) - - -def makeUnique(seq): - """Make a sequence unique, keeping the first time each item is seen - - Parameters - ---------- - seq : list or tuple - A python sequence - - Returns - ------- - list - A list with unique items - """ - seen = set() - seen_add = seen.add # only resolve the method lookup once - return [x for x in seq if not (x in seen or seen_add(x))] - - -@contextmanager -def nested(*managers): - """Combine an arbitrary number of context managers into a single nested - context manager. - """ - with ExitStack() as stack: - yield [stack.enter_context(m) for m in managers] - - -def naturalSortKey(s, _nsre=re.compile("([0-9]+)")): - """Get a sort key that puts strings with numbers in numerical order - This is accomplished by splitting the string into groups of digits, and non-digits, - then converting the digit groups into integers. - - Parameters - ---------- - s : str - The string to get the key for - _nsre : - A hack argument to hold the compiled regex - - Returns - ------- - list - A list containing both strings and integers. - """ - return [int(text) if text.isdigit() else text.lower() for text in _nsre.split(s)] - - -def getIcon(iconName): - path = os.path.join(os.path.dirname(__file__), "img", iconName) - return QIcon(path) - - -class Prefs(object): - """A wrapper for reading/writing prefs both internal and external to blur""" - - def __init__(self): - if AT_BLUR: - import blurdev.prefs - - self._pref = blurdev.prefs.find("tools/simplex3") - else: - self._pref = QSettings("Blur", "Simplex3") - - def restoreProperty(self, prop, default=None): - if AT_BLUR: - return self._pref.restoreProperty(prop, default) - else: - return toPyObject(self._pref.value(prop, default)) - - def recordProperty(self, prop, val): - if AT_BLUR: - self._pref.recordProperty(prop, val) - else: - self._pref.setValue(prop, val) - - def save(self): - if AT_BLUR: - self._pref.save() - else: - self._pref.sync() +# Copyright 2016, Blur Studio +# +# This file is part of Simplex. +# +# Simplex is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Simplex is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Simplex. If not, see . + +"""Utility functions.""" + +from __future__ import annotations + +import os +import re +import sys +from typing import Any, Callable, Sequence, TypeVar, cast + +from Qt import IsPyQt6, IsPySide6 +from Qt.QtCore import QObject, QPoint, QSettings, QTimer +from Qt.QtGui import QIcon +from Qt.QtWidgets import QMenu + +AT_BLUR = os.environ.get("SIMPLEX_AT_BLUR") == "true" + + +def execmenu(act: QMenu, pos: QPoint) -> None: + if IsPySide6 or IsPyQt6: + act.exec(pos) + else: + act.exec_() + + +def execwid(wid) -> None: + if IsPySide6 or IsPyQt6: + wid.exec() + else: + wid.exec_() + + +def getUiFile(fileVar: str, subFolder: str = "ui", uiName: str | None = None) -> str: + """Get the path to the .ui file + + Parameters + ---------- + fileVar : str + The __file__ variable passed from the invocation + subFolder : str + The folder to look in for the ui files. Defaults to 'ui' + uiName : str or None + The name of the .ui file. Defaults to the basename of + fileVar with .ui instead of .py + + Returns + ------- + str + The path to the .ui file + """ + uiFolder, filename = os.path.split(fileVar) + if uiName is None: + uiName = os.path.splitext(filename)[0] + if not subFolder: + raise ValueError("A subfolder must be provided") + uiFile = os.path.join(uiFolder, subFolder, uiName + ".ui") + return uiFile + + +def getNextName(name: str, currentNames: Sequence[str]) -> str: + """Get the next available number-incremented name + + Parameters + ---------- + name : str + The name I want to check + currentNames : list + The names that currently exist + + Returns + ------- + str + The next available number-incremented name + """ + i = 0 + s = set(currentNames) + while True: + if not i: + nn = name + else: + nn = name + str(i) + if nn not in s: + return nn + i += 1 + return name + + +def clearPathSymbols(paths: list[str], keepers: list[str] | None = None) -> None: + """Removes path symbols from the environment. + + This means I can unload my tools from the current process and re-import them + rather than dealing with the always finicky reload() + + We use directory paths rather than module names because it gives us more control + over what is unloaded + + Parameters + ---------- + paths : list + List of directory paths that will have their modules removed + keepers : list or None + List of module names that will not be removed (Default value = None) + """ + keepers = keepers or [] + paths = [os.path.normcase(os.path.normpath(p)) for p in paths] + + for key, value in sys.modules.items(): + protected = False + + # Used by multiprocessing library, don't remove this. + if key == "__parents_main__": + protected = True + + # Protect submodules of protected packages + if key in keepers: + protected = True + + ckey = key + while not protected and "." in ckey: + ckey = ckey.rsplit(".", 1)[0] + if ckey in keepers: + protected = True + + if protected: + continue + + try: + packPath = value.__file__ + except AttributeError: + continue + if packPath is None: + continue + + packPath = os.path.normcase(os.path.normpath(packPath)) + + isEnvPackage = any(packPath.startswith(p) for p in paths) + if isEnvPackage: + sys.modules.pop(key) + + +def caseSplit(name: str) -> list[str]: + """Split CamelCase and dromedaryCase words + Taken From https://stackoverflow.com/questions/29916065/how-to-do-camelcase-split-in-python + + Parameters + ---------- + name : str + The string to split + + Returns + ------- + list + The split string + """ + matches = re.finditer(".+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)", name) + return [m.group(0) for m in matches] + + +F = TypeVar('F', bound=Callable) + + +class singleShot(QObject): + """Decorator class used to implement a QTimer.singleShot(0, function) + + This is useful so your refresh function only gets called once even if + its connected to a signal that gets emitted several times at once. + + Note: + The values passed to the decorated method will be accumulated + and run all at once, then reset for the next go-round + + From the Qt Docs: + As a special case, a QTimer with a timeout of 0 will time out as + soon as all the events in the window system's event queue have + been processed. This can be used to do heavy work while providing + a snappy user interface + """ + + def __init__(self) -> None: + super().__init__() + self._function: Callable | None = None + self._callScheduled: bool = False + self._args = [] + self._inst = None + + def __call__(self, function: F) -> F: + self._function = function + + def newFunction(inst, *args) -> None: + self._args.extend(args) + self._args = makeUnique(self._args) + if not self._callScheduled: + self._inst = inst + self._callScheduled = True + QTimer.singleShot(0, self.callback) + + newFunction.__name__ = function.__name__ + newFunction.__doc__ = function.__doc__ + + # 'cast' tricks the typechecker into preserving exact types, docstrings, + # and autocomplete features for the caller + return cast(F, newFunction) + + def callback(self) -> None: + """Calls the decorated function and resets singleShot for the next group of calls""" + self._callScheduled = False + # self._args needs to be cleared before we call self._function + args = self._args + inst = self._inst + self._inst = None + self._args = [] + if self._function is not None: + self._function(inst, *args) + + +T = TypeVar('T') + + +def makeUnique(seq: Sequence[T]) -> Sequence[T]: + """Make a sequence unique, keeping the first time each item is seen + + Parameters + ---------- + seq : list or tuple + A python sequence + + Returns + ------- + list + A list with unique items + """ + seen = set() + seen_add = seen.add # only resolve the method lookup once + return [x for x in seq if not (x in seen or seen_add(x))] + + +def naturalSortKey( + s: str, _nsre: re.Pattern[str] = re.compile("([0-9]+)") +) -> list[str | int]: + """Get a sort key that puts strings with numbers in numerical order + This is accomplished by splitting the string into groups of digits, and non-digits, + then converting the digit groups into integers. + + Parameters + ---------- + s : str + The string to get the key for + _nsre : + A hack argument to hold the compiled regex + + Returns + ------- + list + A list containing both strings and integers. + """ + return [int(text) if text.isdigit() else text.lower() for text in _nsre.split(s)] + + +def getIcon(iconName: str) -> QIcon: + path = os.path.join(os.path.dirname(__file__), "img", iconName) + return QIcon(path) + + +class Prefs: + """A wrapper for reading/writing prefs both internal and external to blur""" + + def __init__(self) -> None: + if AT_BLUR: + import blurdev.prefs + + self._pref = blurdev.prefs.find("tools/simplex3") + else: + self._pref = QSettings("Blur", "Simplex3") + + def restoreProperty(self, prop: str, default: str | None = None) -> Any: + if isinstance(self._pref, QSettings): + return self._pref.value(prop, default) + else: + return self._pref.restoreProperty(prop, default) + + def recordProperty(self, prop: str, val: Any) -> None: + if isinstance(self._pref, QSettings): + self._pref.setValue(prop, val) + else: + self._pref.recordProperty(prop, val) + + def save(self) -> None: + if isinstance(self._pref, QSettings): + self._pref.sync() + else: + self._pref.save() diff --git a/subprojects/maya/meson.build b/subprojects/maya/meson.build index 1d6dc7df..ac5035ba 100644 --- a/subprojects/maya/meson.build +++ b/subprojects/maya/meson.build @@ -1,12 +1,44 @@ -project('maya', 'cpp') +project('maya', 'cpp', meson_version : '>=1.7.0') + +fs = import('fs') +py = import('python').find_installation(pure: false) +cmplr = meson.get_compiler('cpp') + +# --- Zip Finder Helper Script --- +find_zip_script = ''' +import sys, glob, os +base = sys.argv[1] +pattern = sys.argv[2] + +bases = [b for b in base.split(';') if os.path.exists(b)] +if not bases: + print(f"a zip file matching '{pattern}'") + sys.exit(0) + +for base in bases: + matches = glob.glob(os.path.join(base, "**", pattern), recursive=True) + if matches: + print(matches[0]) + sys.exit(0) + +print(f"a zip file matching '{pattern}'") +''' maya_version = get_option('maya_version') maya_devkit_base = get_option('maya_devkit_base') -maya_link_qt = get_option('maya_link_qt') maya_qt_extra_includes = get_option('maya_qt_extra_includes') +maya_custom_install_path = get_option('maya_custom_install_path') -os_name = build_machine.system() +alembic_opt = get_option('maya_link_alembic') +imath_opt = get_option('maya_link_imath') +ufe_opt = get_option('maya_link_ufe') +qt_opt = get_option('maya_link_qt') +gl_opt = get_option('maya_link_opengl') +numpy_opt = get_option('maya_link_numpy') + +has_devkit = maya_devkit_base != '' +os_name = build_machine.system() maya_inc_suffix = 'include' maya_lib_suffix = 'lib' @@ -14,143 +46,300 @@ maya_compile_args = ['-DREQUIRE_IOSTREAM', '-D_BOOL'] maya_link_args = [] if os_name == 'windows' - maya_install_base = 'c:/Program Files/Autodesk' + maya_default_install_base = 'c:/Program Files/Autodesk' maya_plugin_ext = 'mll' maya_compile_args += ['-DNT_PLUGIN', '/Zc:__cplusplus'] - maya_link_args = [ - # Export the methods that maya requires - '/export:initializePlugin', - '/export:uninitializePlugin', - # Make sure to look for pdb file in the same directory as the .mll - '/PDBALTPATH:%_PDB%', - ] + maya_link_args = ['/export:initializePlugin', '/export:uninitializePlugin', '/PDBALTPATH:%_PDB%'] elif os_name == 'darwin' - maya_install_base = '/Applications/Autodesk' + maya_default_install_base = '/Applications/Autodesk' maya_plugin_ext = 'bundle' - if maya_devkit_base == '' + if not has_devkit maya_lib_suffix = 'Maya.app/Contents/MacOS' maya_bin_suffix = 'Maya.app/Contents/bin' endif - maya_compile_args += ['-DOSMac_'] - if meson.get_compiler('cpp').get_id() == 'clang' - maya_compile_args += ['--stdlib', 'libc++'] - maya_compile_args += ['-arch', 'x86_64'] + maya_compile_args += ['-DOSMac_', '-Wno-inconsistent-missing-override'] + if cmplr.get_id() == 'clang' + maya_compile_args += ['--stdlib', 'libc++', '-arch', 'x86_64'] maya_link_args += ['-arch', 'x86_64'] if maya_version.version_compare('>=2024') - # build both the arm and x86 plugins when compiling for mac maya_compile_args += ['-arch', 'arm64'] maya_link_args += ['-arch', 'arm64'] endif endif - - # ignore this warning that comes from maya's headers - maya_compile_args += ['-Wno-inconsistent-missing-override'] elif os_name == 'linux' - maya_install_base = '/usr/autodesk' + maya_default_install_base = '/usr/autodesk' maya_plugin_ext = 'so' maya_compile_args += ['-DLINUX', '-fPIC'] else error('Incompatible operating system') endif -maya_install_path = maya_install_base / ('Maya' + maya_version) -if maya_devkit_base != '' - message('Using Maya Devkit:', maya_devkit_base) - maya_install_path = maya_devkit_base +if maya_custom_install_path != '' + maya_install_path = maya_custom_install_path +else + maya_install_path = maya_default_install_base / ('Maya' + maya_version) endif -includes = [] -maya_inc_dir = maya_install_path / maya_inc_suffix -message('Searching Maya Include directory:', maya_inc_dir) -includes += include_directories(maya_inc_dir) +search_base = has_devkit ? maya_devkit_base : maya_install_path -maya_lib_dir = maya_install_path / maya_lib_suffix -message('Searching Maya lib directory:', maya_lib_dir) +# --- Define Ordered Base Search Paths --- +search_inc_dirs = [] +search_lib_dirs = [] +maya_bin_dir = '' +maya_py_ver = '' -maya_bin_dir = maya_install_path / 'bin' +has_install = maya_install_path != '' and fs.is_dir(maya_install_path) -# Get all the maya libraries -cmplr = meson.get_compiler('cpp') -maya_libs = [ - cmplr.find_library('Foundation', dirs : maya_lib_dir), - cmplr.find_library('OpenMaya', dirs : maya_lib_dir), - cmplr.find_library('OpenMayaAnim', dirs : maya_lib_dir), - cmplr.find_library('OpenMayaFX', dirs : maya_lib_dir), - cmplr.find_library('OpenMayaRender', dirs : maya_lib_dir), - cmplr.find_library('OpenMayaUI', dirs : maya_lib_dir), - cmplr.find_library('clew', dirs : maya_lib_dir), -] - -# Link to maya's qt libs if required -# Below, I expose the bin and include dirs so I can directly invoke the -# moc exe included with maya. Saves from having to make a maya qt module - -qt_moc_path = '' -if maya_link_qt - fs = import('fs') - - if maya_version.version_compare('>=2025') - if not fs.is_dir(maya_install_path / 'Qt' / 'include' / 'QtCore') - error( - 'Could not find Maya QT headers with `maya_link_qt` defined\n', - 'You probably need to unzip `devkitBase/Qt.zip`\n', - 'Checking in folder: ', maya_install_path, - ) - endif - maya_qt_lib_names = [f'Qt6Core', f'Qt6Gui', f'Qt6Widgets'] +if has_install + search_inc_dirs += maya_install_path / maya_inc_suffix + search_lib_dirs += maya_install_path / maya_lib_suffix + maya_bin_dir = maya_install_path / 'bin' + mayapy_prg = find_program('mayapy', dirs : maya_bin_dir, required : numpy_opt.enabled()) + pyverscript = 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' + maya_py_ver = run_command(mayapy_prg, '-c', pyverscript, check: false).stdout().strip() - qt_lib_dir = maya_lib_dir - qt_moc_path = maya_bin_dir / 'moc' - if maya_devkit_base != '' - qt_lib_dir = maya_devkit_base / 'Qt' / 'lib' + if mayapy_prg.found() + meson.override_find_program('maya:mayapy', mayapy_prg) + endif +endif - mocdirs = [ - maya_devkit_base / 'Qt' / 'bin', - maya_devkit_base / 'Qt' / 'libexec', - ] - moc_prg = find_program('moc', dirs : mocdirs, required : true) - qt_moc_path = moc_prg.full_path() +if has_devkit + search_inc_dirs += maya_devkit_base / maya_inc_suffix + search_lib_dirs += maya_devkit_base / maya_lib_suffix +endif - includes += include_directories(maya_devkit_base / 'Qt' / 'include') - endif - else - if not fs.is_dir(maya_inc_dir / 'QtCore') - error( - 'Could not find Maya QT headers with `maya_link_qt` defined\n', - 'You probably need to unzip `include/qt_*-include.zip`\n', - 'Checking in folder: ', maya_inc_dir, - ) - endif - qt_ver = 5 - maya_qt_lib_names = [f'Qt5Core', f'Qt5Gui', f'Qt5Widgets'] - qt_lib_dir = maya_lib_dir - qt_moc_path = maya_bin_dir / 'moc' - if maya_devkit_base != '' - qt_moc_path = maya_devkit_base / 'devkit' / 'bin' / 'moc' - endif - endif +if search_inc_dirs.length() == 0 + error('Could not find any maya install or devkit paths') +endif + +maya_inc_dir = search_inc_dirs[-1] +maya_lib_dir = search_lib_dirs[-1] - if maya_qt_extra_includes != '' - maya_qt_lib_names += maya_qt_extra_includes.split(';') +core_includes = [] +foreach inc_dir : search_inc_dirs + if fs.is_dir(inc_dir) + core_includes += include_directories(inc_dir) endif - foreach lib_name : maya_qt_lib_names - maya_libs += cmplr.find_library(lib_name, dirs : qt_lib_dir) - endforeach +endforeach -endif +# --- Retrieve Core Libraries --- +core_libs = [] +core_lib_names = ['Foundation', 'OpenMaya', 'OpenMayaAnim', 'OpenMayaFX', 'OpenMayaRender', 'OpenMayaUI', 'clew'] +foreach lib_name : core_lib_names + core_libs += cmplr.find_library(lib_name, dirs : search_lib_dirs) +endforeach -maya_dep = declare_dependency( - dependencies : maya_libs, - include_directories : includes, +# ============================================================================== +# DEPENDENCY 1: Core Maya (For Python Modules / Standalone Apps) +# ============================================================================== +maya_core_dep = declare_dependency( + dependencies : core_libs, + include_directories : core_includes, variables : { - 'name_suffix' : maya_plugin_ext, 'maya_version' : maya_version, 'maya_bin_dir': maya_bin_dir, 'maya_inc_dir': maya_inc_dir, - 'qt_moc_path': qt_moc_path, - }, + 'maya_install_path': maya_install_path, + 'maya_using_devkit': has_devkit.to_string(), + } +) +meson.override_dependency('maya-core', maya_core_dep) + +# ============================================================================== +# DEPENDENCY 2: Maya Plugin (For MLL/SO/Bundle compilation) +# ============================================================================== +maya_plugin_dep = declare_dependency( + dependencies: [maya_core_dep], compile_args : maya_compile_args, link_args : maya_link_args, + variables : { + 'name_suffix' : maya_plugin_ext, + } ) +meson.override_dependency('maya', maya_plugin_dep) +meson.override_dependency('maya-plugin', maya_plugin_dep) + +# ============================================================================== +# OPTIONAL GRANULAR DEPENDENCIES +# ============================================================================== +build_numpy = false +build_alembic = false +build_imath = false +build_ufe = false +build_qt = false +build_opengl = false + +# --- Numpy --- +if not numpy_opt.disabled() + if not has_install + error('The numpy install comes from the maya install. No install path found') + endif + build_numpy = true + np_include_script = 'import numpy;print(numpy.get_include())' + np_include_path = run_command(mayapy_prg, '-c', np_include_script, check: false).stdout().strip() + + maya_numpy_dep = declare_dependency( + include_directories: np_include_path, + variables : { + 'mayapy_version': maya_py_ver, + } + ) + meson.override_dependency('maya-numpy', maya_numpy_dep) +endif + +# --- Alembic & Imath --- +check_alembic = not alembic_opt.disabled() +check_imath = not imath_opt.disabled() -meson.override_dependency('maya', maya_dep) +if check_alembic or check_imath + if (alembic_opt.enabled() or imath_opt.enabled()) and not has_devkit + error('Alembic and Imath dependencies require maya_devkit_base') + endif + + if has_devkit + alembic_base = maya_devkit_base / 'devkit' / 'Alembic' + + if fs.is_dir(alembic_base / 'include') + alembic_inc = include_directories(alembic_base / 'include') + alembic_lib_dir = alembic_base / 'lib' + + if check_alembic + maya_alembic_dep = declare_dependency( + dependencies: [cmplr.find_library('Alembic', dirs : alembic_lib_dir)], + include_directories: alembic_inc + ) + meson.override_dependency('maya-alembic', maya_alembic_dep) + build_alembic = true + endif + + if check_imath + imath_lib = cmplr.find_library('Imath-3_2-maya', dirs : alembic_lib_dir, required: false) + if not imath_lib.found() + imath_lib = cmplr.find_library('Imath', dirs : alembic_lib_dir, required: false) + endif + imath_inc = include_directories(alembic_base / 'include' / 'Imath') + maya_imath_dep = declare_dependency(dependencies: [imath_lib], include_directories: imath_inc) + meson.override_dependency('maya-imath', maya_imath_dep) + build_imath = true + endif + + else + if alembic_opt.enabled() or imath_opt.enabled() + zip_hint = run_command(py, '-c', find_zip_script, search_base, '*Alembic*.zip', check: false).stdout().strip() + error('Could not find Alembic/Imath headers at: ' + alembic_base + '\nYou probably need to unzip: ' + zip_hint) + endif + endif + endif +endif + +# --- UFE --- +if not ufe_opt.disabled() + if ufe_opt.enabled() and not has_devkit + error('maya_link_ufe requires maya_devkit_base') + endif + + if has_devkit + ufe_base = maya_devkit_base / 'devkit' / 'ufe' + if fs.is_dir(ufe_base / 'include') + ufe_lib = cmplr.find_library('ufe_7', dirs : ufe_base / 'lib', required: false) + if not ufe_lib.found() + ufe_lib = cmplr.find_library('ufe', dirs : ufe_base / 'lib') + endif + maya_ufe_dep = declare_dependency( + dependencies: [ufe_lib], + include_directories: include_directories(ufe_base / 'include') + ) + meson.override_dependency('maya-ufe', maya_ufe_dep) + build_ufe = true + elif ufe_opt.enabled() + zip_hint = run_command(py, '-c', find_zip_script, search_base, '*ufe*.zip', check: false).stdout().strip() + error('Could not find UFE headers at: ' + ufe_base + '\nYou probably need to unzip: ' + zip_hint) + endif + endif +endif + +# --- Qt --- +if not qt_opt.disabled() + search_paths = [ + maya_inc_dir, maya_inc_dir / 'Qt', maya_inc_dir / 'Qt' / 'include', + maya_install_path, maya_install_path / 'Qt', maya_install_path / 'Qt' / 'include', + ] + qt_include_dir = '' + foreach search_path: search_paths + if fs.is_dir(search_path / 'QtCore') + qt_include_dir = search_path + break + endif + endforeach + + if qt_include_dir != '' + mocdirs = maya_bin_dir != '' ? [maya_bin_dir] : [] + mocdirs += [maya_install_path / 'Qt' / 'bin', maya_install_path / 'Qt' / 'libexec', maya_install_path / 'devkit' / 'bin'] + moc_prg = find_program('moc', dirs : mocdirs, required : qt_opt.enabled()) + + if moc_prg.found() + meson.override_find_program('maya:moc', moc_prg) + maya_qt_lib_names = maya_version.version_compare('>=2025') ? ['Qt6Core', 'Qt6Gui', 'Qt6Widgets'] : ['Qt5Core', 'Qt5Gui', 'Qt5Widgets'] + if maya_qt_extra_includes != '' + maya_qt_lib_names += maya_qt_extra_includes.split(';') + endif + + qt_libs = [] + foreach lib_name : maya_qt_lib_names + qt_libs += cmplr.find_library(lib_name, dirs : search_lib_dirs) + endforeach + + maya_qt_dep = declare_dependency( + dependencies: qt_libs, + include_directories: include_directories(qt_include_dir), + ) + meson.override_dependency('maya-qt', maya_qt_dep) + build_qt = true + endif + elif qt_opt.enabled() + baselist = ';'.join(search_paths) + zip_hint = run_command(py, '-c', find_zip_script, baselist, 'qt_*include.zip', check: false).stdout().strip() + error('Could not find Maya QT headers. Unzip: ' + zip_hint) + endif +endif + +# --- OpenGL --- +if not gl_opt.disabled() + gl_dep = dependency('gl', method : 'system', required: gl_opt.enabled()) + if gl_dep.found() + glu_dep = os_name == 'windows' ? cmplr.find_library('glu32', required : gl_opt.enabled()) : dependency('glu', required: gl_opt.enabled()) + if glu_dep.found() + maya_opengl_dep = declare_dependency(dependencies: [gl_dep, glu_dep]) + meson.override_dependency('maya-opengl', maya_opengl_dep) + build_opengl = true + endif + endif +endif + +# ============================================================================== +# SUMMARY +# ============================================================================== +summary( + { + 'Maya Version': maya_version, + 'Found Install Path': has_install, + 'Using Devkit': has_devkit, + }, + section: 'Maya Subproject Configuration', + bool_yn: true +) + +summary( + { + 'maya-core': true, + 'maya-plugin': true, + 'maya-qt': build_qt, + 'maya-numpy': build_numpy, + 'maya-opengl': build_opengl, + 'maya-alembic': build_alembic, + 'maya-imath': build_imath, + 'maya-ufe': build_ufe, + }, + section: 'Exported Dependencies', + bool_yn: true +) diff --git a/subprojects/maya/meson.options b/subprojects/maya/meson.options index c28e3585..01e057cf 100644 --- a/subprojects/maya/meson.options +++ b/subprojects/maya/meson.options @@ -9,22 +9,71 @@ option( option( 'maya_devkit_base', type : 'string', + value : '', description : 'Optional path to the maya devkit', yield : true, ) option( - 'maya_link_qt', - type : 'boolean', - description : 'Whether to link to the Qt libraries that maya provides in their devkit/install', - value: false, + 'maya_custom_install_path', + type : 'string', + value : '', + description : 'Path to a non-standard maya install', yield : true, ) option( 'maya_qt_extra_includes', type : 'string', + value : '', description : 'Any qt headers other than QtCore, QtGui, or QtWidgets that you need to include, separated by semicolons', - value: '', + yield : true, +) + +option( + 'maya_link_qt', + type : 'feature', + value : 'disabled', + description : 'Provide maya-qt dependency linking to Maya\'s Qt libraries', + yield : true, +) + +option( + 'maya_link_opengl', + type : 'feature', + value : 'auto', + description : 'Provide maya-opengl dependency linking to opengl and glu', + yield : true, +) + +option( + 'maya_link_numpy', + type : 'feature', + value : 'disabled', + description : 'Provide maya-numpy dependency (Requires maya_install_path to exist)', + yield : true, +) + +option( + 'maya_link_alembic', + type : 'feature', + value : 'disabled', + description : 'Provide maya-alembic dependency (Requires maya_devkit_base)', + yield : true, +) + +option( + 'maya_link_imath', + type : 'feature', + value : 'disabled', + description : 'Provide maya-imath dependency (Requires maya_devkit_base)', + yield : true, +) + +option( + 'maya_link_ufe', + type : 'feature', + value : 'disabled', + description : 'Provide maya-ufe dependency (Requires maya_devkit_base)', yield : true, ) diff --git a/typings/alembic/Abc.pyi b/typings/alembic/Abc.pyi new file mode 100644 index 00000000..3997efc0 --- /dev/null +++ b/typings/alembic/Abc.pyi @@ -0,0 +1,93 @@ +from typing import Any +from imath import Box3d, V3fArray, IntArray, V2fArray, UnsignedIntArray + +class IObject: + children: list['IObject'] + def getName(self) -> str: ... + def getParent(self) -> IObject: ... + def getMetaData(self) -> Any: ... + def getProperties(self) -> ICompoundProperty: ... + def getNumChildren(self) -> int: ... + def getChild(self, index: int) -> IObject: ... + +class OObject: + def __init__(self, parent: OObject, name: str, metaData: Any = None) -> None: ... + def getProperties(self) -> OCompoundProperty: ... + +class IArchive: + def __init__(self, path: str | list[str]) -> None: ... + def getTop(self) -> IObject: ... + def getNumTimeSamplings(self) -> int: ... + def getTimeSampling(self, idx: int) -> Any: ... + +class OArchive: + def __init__(self, path: str, ogawa: bool = ...) -> None: ... + def getTop(self) -> OObject: ... + def addTimeSampling(self, ts: Any) -> None: ... + +class OProperty: + def setTimeSampling(self, timeSampling: Any) -> None: ... + def setValue(self, value: Any) -> None: ... + +class IProperty: + def getName(self) -> str: ... + def getValue(self) -> Any: ... + def getDataType(self) -> Any: ... + def isCompound(self) -> bool: ... + def isArray(self) -> bool: ... + def getMetaData(self) -> Any: ... + def getTimeSampling(self) -> Any: ... + samples: list[Any] + +class ICompoundProperty(IProperty): + def valid(self) -> bool: ... + def getProperty(self, str) -> IProperty: ... + def getNumProperties(self) -> int: ... + +class OCompoundProperty(OProperty): + def __init__( + self, parent: OCompoundProperty, name: str, metaData: Any = None + ) -> None: ... + +class OStringProperty(OProperty): + def __init__(self, props: OCompoundProperty, key: str) -> None: ... + def setValue(self, value: str) -> None: ... + +class OScalarProperty(OProperty): + def __init__( + self, + parent: OCompoundProperty, + name: str, + dataType: Any = None, + metaData: Any = None, + ) -> None: ... + +class OArrayProperty(OProperty): + def __init__( + self, + parent: OCompoundProperty, + name: str, + dataType: Any = None, + metaData: Any = None, + ) -> None: ... + +class OBox3dProperty(OProperty): + def setValue(self, value: Box3d) -> None: ... + +class IP3fArrayProperty(IProperty): + samples: list[V3fArray] + +class IInt32ArrayProperty(IProperty): + samples: list[IntArray] + +class IV2fArrayProperty(IProperty): + def getValue(self) -> V2fArray: ... + +class IUInt32ArrayProperty(IProperty): + def getValue(self) -> UnsignedIntArray: ... + +class IV2fGeomParam: + def valid(self) -> bool: ... + def isIndexed(self) -> bool: ... + def getValueProperty(self) -> IV2fArrayProperty: ... + def getIndexProperty(self) -> IUInt32ArrayProperty: ... diff --git a/typings/alembic/AbcCoreAbstract.pyi b/typings/alembic/AbcCoreAbstract.pyi new file mode 100644 index 00000000..33971745 --- /dev/null +++ b/typings/alembic/AbcCoreAbstract.pyi @@ -0,0 +1 @@ +class MetaData: ... diff --git a/typings/alembic/AbcGeom.pyi b/typings/alembic/AbcGeom.pyi new file mode 100644 index 00000000..1bb096fb --- /dev/null +++ b/typings/alembic/AbcGeom.pyi @@ -0,0 +1,58 @@ +from typing import Any +from alembic.Abc import ( + IObject, + OObject, + OBox3dProperty, + IP3fArrayProperty, + IInt32ArrayProperty, + IV2fGeomParam, +) +from alembic.AbcCoreAbstract import MetaData + +class GeometryScope: + kFacevaryingScope: Any + +class ON3fGeomParamSample: + def __init__(self, array: Any, indexes_or_scope: Any, scope: Any = ...) -> None: ... + +class OV2fGeomParamSample: + def __init__(self, array: Any, indexes_or_scope: Any, scope: Any = ...) -> None: ... + +class OPolyMeshSchema: + def getChildBoundsProperty(self) -> OBox3dProperty: ... + def set(self, sample: OPolyMeshSchemaSample) -> None: ... + +class IPolyMeshSchema: + def getPositionsProperty(self) -> IP3fArrayProperty: ... + def getFaceIndicesProperty(self) -> IInt32ArrayProperty: ... + def getFaceCountsProperty(self) -> IInt32ArrayProperty: ... + def getUVsParam(self) -> IV2fGeomParam: ... + +class IPolyMesh(_IBase): + def getSchema(self) -> IPolyMeshSchema: ... + +class OPolyMeshSchemaSample: + def __init__( + self, points: Any, faceIndex: Any, faceCount: Any, **kwargs + ) -> None: ... + +class _IBase(IObject): + def __init__(self, parent: IObject, name: str) -> None: ... + @classmethod + def matches(cls, md: MetaData) -> bool: ... + def getSchema(self) -> Any: ... + +class ICamera(_IBase): ... +class ICurves(_IBase): ... +class ILight(_IBase): ... +class INuPatch(_IBase): ... +class IPoints(_IBase): ... +class ISubD(_IBase): ... +class IXform(_IBase): ... + +class _OBase(OObject): + def __init__(self, parent: OObject, name: str) -> None: ... + def getSchema(self) -> Any: ... + +class OPolyMesh(_OBase): ... +class OXform(_OBase): ... diff --git a/typings/alembic/__init__.pyi b/typings/alembic/__init__.pyi new file mode 100644 index 00000000..e69de29b diff --git a/typings/imath.pyi b/typings/imath.pyi new file mode 100644 index 00000000..c1904d1f --- /dev/null +++ b/typings/imath.pyi @@ -0,0 +1,29 @@ +from typing import Any, Union + +class V2f: + def __init__(self, x: float, y: float) -> None: ... + def setValue(self, x: float, y: float) -> None: ... + x: float + y: float + +class Box3d: ... + +class _BaseArray: + def __init__(self, length: int) -> None: ... + def __setitem__(self, index: Union[int, slice], value: Any) -> None: ... + def __getitem__(self, index: Union[int, slice]) -> Any: ... + def __len__(self) -> int: ... + def __iter__(self) -> Any: ... + +class FloatArray(_BaseArray): ... +class IntArray(_BaseArray): ... +class UnsignedIntArray(_BaseArray): ... + +class V2fArray(_BaseArray): + x: FloatArray + y: FloatArray + +class V3fArray(_BaseArray): + x: FloatArray + y: FloatArray + z: FloatArray