diff --git a/meshroom/core/__init__.py b/meshroom/core/__init__.py index 3bd30d5bbd..4c32381e9d 100644 --- a/meshroom/core/__init__.py +++ b/meshroom/core/__init__.py @@ -20,6 +20,7 @@ from meshroom.core.submitter import BaseSubmitter from . import desc +from .desc import builtins # Setup logging logging.basicConfig(format='[%(asctime)s][%(levelname)s] %(message)s', level=logging.INFO) @@ -337,6 +338,9 @@ def initNodes(): for f in nodesFolders: loadAllNodes(folder=f) + # Load all of the builtin Node Plugins + initBuiltinNodePlugins() + def initSubmitters(): meshroomFolder = os.path.dirname(os.path.dirname(__file__)) @@ -357,3 +361,8 @@ def initPipelines(): loadPipelineTemplates(f) else: logging.error("Pipeline templates folder '{}' does not exist.".format(f)) + +def initBuiltinNodePlugins(): + """ Registers the Builtin plugins for Meshroom. + """ + registerNodeType(builtins.Backdrop) diff --git a/meshroom/core/desc/builtins.py b/meshroom/core/desc/builtins.py new file mode 100644 index 0000000000..293e32bcc6 --- /dev/null +++ b/meshroom/core/desc/builtins.py @@ -0,0 +1,12 @@ +""" Defines the Built in Plugins. +""" +from .node import InputNode, AttributeFactory, Traits + + +class Backdrop(InputNode): + """ A Backdrop for other nodes. + """ + + internalInputs = AttributeFactory.getInternalParameters(Traits.RESIZABLE) + + category = "Utils" diff --git a/meshroom/core/desc/node.py b/meshroom/core/desc/node.py index b105d2f96a..a00fa9469b 100644 --- a/meshroom/core/desc/node.py +++ b/meshroom/core/desc/node.py @@ -1,34 +1,33 @@ +import enum import os import psutil import shlex +from typing import List from .computation import Level, StaticNodeSize -from .attribute import StringParam, ColorParam +from .attribute import Attribute, StringParam, ColorParam, IntParam, FloatParam from meshroom.core import cgroup -class Node(object): - """ +class Traits(enum.IntEnum): + """ Describes the characteristics of the Attribute Groups. """ - internalFolder = '{cache}/{nodeType}/{uid}/' - cpu = Level.NORMAL - gpu = Level.NONE - ram = Level.NORMAL - packageName = '' - packageVersion = '' - internalInputs = [ - StringParam( - name="invalidation", - label="Invalidation Message", - description="A message that will invalidate the node's output folder.\n" - "This is useful for development, we can invalidate the output of the node when we modify the code.\n" - "It is displayed in bold font in the invalidation/comment messages tooltip.", - value="", - semantic="multiline", - advanced=True, - uidIgnoreValue="", # If the invalidation string is empty, it does not participate to the node's UID - ), + + # Computable: Characterisics that holds invalidation attribute + COMPUTABLE = 1 + + # Incomputable: Characterisics that does not need processing + INCOMPUTABLE = 2 + + # Resizable: Characterisics that allows node's dimensions to be adjusted + RESIZABLE = 3 + + +class AttributeFactory: + + # Attribute Defines + BASIC = [ StringParam( name="comment", label="Comments", @@ -53,6 +52,72 @@ class Node(object): invalidate=False, ) ] + + INVALIDATION = [ + StringParam( + name="invalidation", + label="Invalidation Message", + description="A message that will invalidate the node's output folder.\n" + "This is useful for development, we can invalidate the output of the node when we modify the code.\n" + "It is displayed in bold font in the invalidation/comment messages tooltip.", + value="", + semantic="multiline", + advanced=True, + uidIgnoreValue="", # If the invalidation string is empty, it does not participate to the node's UID + ) + ] + + RESIZABLE = [ + IntParam( + name="fontSize", + label="Font Size", + description="The Font size for the User Comment.", + value=12, + range=(6, 100, 1), + ), + FloatParam( + name="nodeWidth", + label="Node Width", + description="The Node's Width.", + value=600, + range=None, + enabled=False # Hidden always + ), + FloatParam( + name="nodeHeight", + label="Node Height", + description="The Node's Height.", + value=400, + range=None, + enabled=False # Hidden always + ), + ] + + @classmethod + def getInternalParameters(cls, traits: Traits) -> List[Attribute]: + """ Returns an array of Attributes characterized by a given trait. + """ + paramMap = { + Traits.COMPUTABLE: cls.INVALIDATION + cls.BASIC, + Traits.INCOMPUTABLE: cls.BASIC, + Traits.RESIZABLE: cls.BASIC + cls.RESIZABLE + } + + return paramMap.get(traits) + + +class Node(object): + """ + """ + internalFolder = '{cache}/{nodeType}/{uid}/' + cpu = Level.NORMAL + gpu = Level.NONE + ram = Level.NORMAL + packageName = '' + packageVersion = '' + + internalInputs = AttributeFactory.getInternalParameters(Traits.COMPUTABLE) + inputs = [] outputs = [] size = StaticNodeSize(1) @@ -116,12 +181,18 @@ class InputNode(Node): """ Node that does not need to be processed, it is just a placeholder for inputs. """ + + internalInputs = AttributeFactory.getInternalParameters(Traits.INCOMPUTABLE) + def __init__(self): super(InputNode, self).__init__() def processChunk(self, chunk): pass + def stopProcess(self, chunk): + pass + class CommandLineNode(Node): """ diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 7808f1e235..7c020287de 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -16,7 +16,7 @@ from meshroom.core import Version from meshroom.core.attribute import Attribute, ListAttribute, GroupAttribute from meshroom.core.exception import GraphCompatibilityError, StopGraphVisit, StopBranchVisit -from meshroom.core.node import nodeFactory, Status, Node, CompatibilityNode +from meshroom.core.node import createNode, nodeFactory, Status, Node, CompatibilityNode # Replace default encoder to support Enums @@ -674,7 +674,7 @@ def pasteNodes(self, data, position): attributes.update(data[key].get("outputs", {})) attributes.update(data[key].get("internalInputs", {})) - node = Node(nodeType, position=position[positionCnt], **attributes) + node = createNode(nodeType, position=position[positionCnt], **attributes) self._addNode(node, key) nodes.append(node) @@ -768,7 +768,7 @@ def addNewNode(self, nodeType, name=None, position=None, **kwargs): if name and name in self._nodes.keys(): name = self._createUniqueNodeName(name) - n = self.addNode(Node(nodeType, position=position, **kwargs), uniqueName=name) + n = self.addNode(createNode(nodeType, position=position, **kwargs), uniqueName=name) n.updateInternals() return n diff --git a/meshroom/core/node.py b/meshroom/core/node.py index 1b8806e2e4..b3850fa393 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -14,7 +14,7 @@ import uuid from collections import namedtuple from enum import Enum -from typing import Callable, Optional +from typing import Callable, Optional, Type import meshroom from meshroom.common import Signal, Variant, Property, BaseObject, Slot, ListModel, DictModel @@ -517,6 +517,7 @@ def __init__(self, nodeType, position=None, parent=None, uid=None, **kwargs): self._locked = False self._duplicates = ListModel(parent=self) # list of nodes with the same uid self._hasDuplicates = False + self._childNodes = ListModel(parent=self) # List of nodes which are children of the given node self.globalStatusChanged.connect(self.updateDuplicatesStatusAndLocked) @@ -574,6 +575,42 @@ def getComment(self): return self.internalAttribute("comment").value return "" + def getFontSize(self): + """ Gets the Font Size of the node comment. + + Returns: + int: The font size from the node if it exists, else 12 as default. + """ + if self.hasInternalAttribute("fontSize"): + return self.internalAttribute("fontSize").value + + # Default to 12 + return 12 + + def getNodeWidth(self): + """ Gets the Width of the node from the internal attribute. + + Returns: + int: The Width from the node if the attribute exists, else 160 as default. + """ + if self.hasInternalAttribute("nodeWidth"): + return self.internalAttribute("nodeWidth").value + + # Default to 160 + return 160 + + def getNodeHeight(self): + """ Gets the Height of the node from the internal attribute. + + Returns: + int: The Height from the node if the attribute exists, else 120 as default. + """ + if self.hasInternalAttribute("nodeHeight"): + return self.internalAttribute("nodeHeight").value + + # Default to 120 + return 120 + @Slot(str, result=str) def nameToLabel(self, name): """ @@ -1200,6 +1237,11 @@ def _isCompatibilityNode(self): def _isInputNode(self): return isinstance(self.nodeDesc, desc.InputNode) + def _isBackdropNode(self) -> bool: + """ Returns True if the node is a Backdrop type. + """ + return False + @property def globalExecMode(self): return self._chunks.at(0).execModeName @@ -1318,6 +1360,16 @@ def updateDuplicates(self, nodesPerUid): self._hasDuplicates = bool(len(newList)) self.hasDuplicatesChanged.emit() + @Slot(list) + def updateChildren(self, nodes): + """ Update the list of nodes which are child to the current node (Case of Backdrop and Group). """ + self._childNodes.clear() + self._childNodes.setObjectList(nodes) + + def hasChildren(self) -> bool: + """ Returns True if the given node has child nodes. """ + return bool(self._childNodes) + def statusInThisSession(self): if not self._chunks: return False @@ -1389,6 +1441,13 @@ def has3DOutputAttribute(self): color = Property(str, getColor, notify=internalAttributesChanged) invalidation = Property(str, getInvalidationMessage, notify=internalAttributesChanged) comment = Property(str, getComment, notify=internalAttributesChanged) + fontSize = Property(int, getFontSize, notify=internalAttributesChanged) + + childNodes = Property(Variant, lambda self: self._childNodes, constant=True) + + # Node Dimensions + nodeWidth = Property(float, getNodeWidth, notify=internalAttributesChanged) + nodeHeight = Property(float, getNodeHeight, notify=internalAttributesChanged) internalFolderChanged = Signal() internalFolder = Property(str, internalFolder.fget, notify=internalFolderChanged) valuesFile = Property(str, valuesFile.fget, notify=internalFolderChanged) @@ -1408,6 +1467,7 @@ def has3DOutputAttribute(self): # isCompatibilityNode: need lambda to evaluate the virtual function isCompatibilityNode = Property(bool, lambda self: self._isCompatibilityNode(), constant=True) isInputNode = Property(bool, lambda self: self._isInputNode(), constant=True) + isBackdrop = Property(bool, lambda self: self._isBackdropNode(), constant=True) globalExecModeChanged = Signal() globalExecMode = Property(str, globalExecMode.fget, notify=globalExecModeChanged) @@ -1428,6 +1488,50 @@ def has3DOutputAttribute(self): has3DOutput = Property(bool, has3DOutputAttribute, notify=outputAttrEnabledChanged) +class Backdrop(BaseNode): + """ A Node serving as a Backdrop in the Graph. + """ + + def __init__(self, nodeType: str, position=None, parent=None, uid=None, **kwargs): + super().__init__(nodeType, position, parent=parent, uid=uid, **kwargs) + + if not self.nodeDesc: + raise UnknownNodeTypeError(nodeType) + + self.packageName = self.nodeDesc.packageName + self.packageVersion = self.nodeDesc.packageVersion + self._internalFolder = self.nodeDesc.internalFolder + + for attrDesc in self.nodeDesc.internalInputs: + self._internalAttributes.add(attributeFactory(attrDesc, kwargs.get(attrDesc.name, None), isOutput=False, + node=self)) + + def _isBackdropNode(self) -> bool: + """ Returns True. + """ + return True + + def toDict(self) -> dict: + """ Returns the serialised data for the Backdrop. + """ + internalInputs = {k: v.getExportValue() for k, v in self._internalAttributes.objects.items()} + + return { + 'nodeType': self.nodeType, + 'position': self._position, + 'parallelization': { + 'blockSize': 0, + 'size': 0, + 'split': 0 + }, + 'uid': self._uid, + 'internalFolder': self._internalFolder, + 'inputs': {}, + 'internalInputs': {k: v for k, v in internalInputs.items() if v is not None}, + 'outputs': {}, + } + + class Node(BaseNode): """ A standard Graph node based on a node type. @@ -1852,6 +1956,28 @@ def upgrade(self): issueDetails = Property(str, issueDetails.fget, constant=True) +def createNode(nodeType: str, position: Position=None, **kwargs) -> BaseNode: + """ Create a new Node instance based on the given node description. + Any other keyword argument will be used to initialize this node's attributes. + + Args: + nodeType: Node Plugin/Descriptor name. + position: Node Position. + parent (BaseObject): this Node's parent + **kwargs: attributes values + + Returns: + The created node. + """ + constructors = { + "Backdrop": Backdrop, + } + # Node constructor based on the nodeType + constructor = constructors.get(nodeType, Node) + + return constructor(nodeType, position=position, **kwargs) + + def nodeFactory(nodeDict, name=None, template=False, uidConflict=False): """ Create a node instance by deserializing the given node data. @@ -1945,7 +2071,7 @@ def nodeFactory(nodeDict, name=None, template=False, uidConflict=False): break if compatibilityIssue is None: - node = Node(nodeType, position, uid=uid, **inputs, **internalInputs, **outputs) + node = createNode(nodeType, position, uid=uid, **inputs, **internalInputs, **outputs) else: logging.debug("Compatibility issue detected for node '{}': {}".format(name, compatibilityIssue.name)) node = CompatibilityNode(nodeType, nodeDict, position, compatibilityIssue) diff --git a/meshroom/ui/commands.py b/meshroom/ui/commands.py index 47be0a3385..29d81d1f21 100755 --- a/meshroom/ui/commands.py +++ b/meshroom/ui/commands.py @@ -469,6 +469,25 @@ def undoImpl(self): self.graph.updateEnabled = self.previousState +class EnableUIGraphAnimationsCommand(EnableGraphUpdateCommand): + """ Command to enable/disable UI graph update. + Should not be used directly, use GroupedUIGraphModification context manager instead. + """ + def __init__(self, uigraph, enabled, disableAnimations, parent=None): + super(EnableUIGraphAnimationsCommand, self).__init__(uigraph.graph, enabled, parent) + self.uigraph = uigraph + self.disableAnimations = disableAnimations + self.previousAnimationState = self.uigraph.disableAnimations + + def redoImpl(self): + self.uigraph.disableAnimations = self.disableAnimations + return super().redoImpl() + + def undoImpl(self): + self.uigraph.disableAnimations = self.previousAnimationState + super().undoImpl() + + @contextmanager def GroupedGraphModification(graph, undoStack, title, disableUpdates=True): """ A context manager that creates a macro command disabling (if not already) graph update by default @@ -495,3 +514,33 @@ def GroupedGraphModification(graph, undoStack, title, disableUpdates=True): # Push a command restoring graph update state and end command macro undoStack.tryAndPush(EnableGraphUpdateCommand(graph, state)) undoStack.endMacro() + + +@contextmanager +def GroupedUIGraphModification(uigraph, undoStack, title, disableUpdates=True): + """ A context manager that creates a macro command disabling (if not already) graph update by default + and resetting its status after nested block execution. + + Args: + uigraph (Graph): the UI Graph that will be modified + undoStack (UndoStack): the UndoStack to work with + title (str): the title of the macro command + disableUpdates (bool): whether to disable graph updates + """ + # Store graph update state + state = uigraph.graph.updateEnabled + animationState = uigraph.disableAnimations + + # Create a new command macro and push a command that disable graph updates + undoStack.beginMacro(title) + if disableUpdates: + undoStack.tryAndPush(EnableUIGraphAnimationsCommand(uigraph, False, True)) + try: + yield # Execute nested block + except Exception: + raise + finally: + if disableUpdates: + # Push a command restoring graph update and animation states and end command macro + undoStack.tryAndPush(EnableUIGraphAnimationsCommand(uigraph, state, animationState)) + undoStack.endMacro() diff --git a/meshroom/ui/components/geom2D.py b/meshroom/ui/components/geom2D.py index 5cc972f1c2..c3e1602cf3 100644 --- a/meshroom/ui/components/geom2D.py +++ b/meshroom/ui/components/geom2D.py @@ -6,3 +6,16 @@ class Geom2D(QObject): def rectRectIntersect(self, rect1: QRectF, rect2: QRectF) -> bool: """Check if two rectangles intersect.""" return rect1.intersects(rect2) + + @Slot(QRectF, QRectF, result=bool) + def rectRectFullIntersect(self, rect1: QRectF, rect2: QRectF) -> bool: + """Check if two rectangles intersect fully. i.e. rect1 holds rect2 fully inside it.""" + intersected = rect1.intersected(rect2) + + # They don't intersect at all + if not intersected: + return False + + # Validate that intersected rect is same as rect2 + # If both are same, that implies it fully lies inside of rect1 + return intersected == rect2 diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 0cde6b2ddd..26dba46ef4 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -372,10 +372,24 @@ def __init__(self, undoStack, taskManager, parent=None): self._nodeSelection = QItemSelectionModel(self._graph.nodes, parent=self) self._hoveredNode = None + # UI based animations can be controlled by this flag + self._disableAnimations = False + self.submitLabel = "{projectName}" self.computeStatusChanged.connect(self.updateLockedUndoStack) self.filePollerRefreshChanged.connect(self._chunksMonitor.filePollerRefreshChanged) + @property + def disableAnimations(self) -> bool: + """ Returns whether the animations are currently disabled. """ + return self._disableAnimations + + @disableAnimations.setter + def disableAnimations(self, disable: bool): + """ Updates the Animation state for the UI graph. """ + self._disableAnimations = disable + self.attributeChanged.emit() + def setGraph(self, g): """ Set the internal graph. """ if self._graph: @@ -620,6 +634,18 @@ def groupedGraphModification(self, title, disableUpdates=True): """ return commands.GroupedGraphModification(self._graph, self._undoStack, title, disableUpdates) + def groupedUIGraphModification(self, title, disableUpdates=True): + """ Get a GroupedUIGraphModification for this Graph. + + Args: + title (str): the title of the macro command + disableUpdates (bool): whether to disable graph and ui updates + + Returns: + GroupedGraphModification: the instantiated context manager + """ + return commands.GroupedUIGraphModification(self, self._undoStack, title, disableUpdates) + @Slot(str) def beginModification(self, name): """ Begin a Graph modification. Calls to beginModification and endModification may be nested, but @@ -661,6 +687,40 @@ def moveNode(self, node: Node, position: Position): """ self.push(commands.MoveNodeCommand(self._graph, node, position)) + @Slot(Node, float, float) + def resizeNode(self, node, width, height): + """ Resizes the Node. + + Args: + node (Node): the node to move + width (float): Node width. + height (float): Node height. + position (QPoint): Node's position. + """ + self.resizeAndMoveNode(node, width, height) + + @Slot(Node, float, float, QPoint) + def resizeAndMoveNode(self, node, width, height, position=None): + """ Resizes the Node as well moves it in a single update. + + Args: + node (Node): the node to move + width (float): Node width. + height (float): Node height. + position (QPoint): Node's position. + """ + # Update the node size + with self.groupedUIGraphModification("Resize Node"): + if node.hasInternalAttribute("nodeWidth"): + self.setAttribute(node.internalAttribute("nodeWidth"), width) + if node.hasInternalAttribute("nodeHeight"): + self.setAttribute(node.internalAttribute("nodeHeight"), height) + + # If we have an offset, it means that the node was resized from the left side + # Update the node's actual position + if position: + self.moveNode(node, Position(position.x(), position.y())) + @Slot(QPoint) def moveSelectedNodesBy(self, offset: QPoint): """Move all the selected nodes by the given `offset`.""" @@ -670,6 +730,15 @@ def moveSelectedNodesBy(self, offset: QPoint): position = Position(node.x + offset.x(), node.y + offset.y()) self.moveNode(node, position) + @Slot(list, QPoint) + def moveNodesBy(self, nodes, offset: QPoint): + """Move all the selected nodes by the given `offset`.""" + + with self.groupedGraphModification("Move Nodes"): + for node in nodes: + position = Position(node.x + offset.x(), node.y + offset.y()) + self.moveNode(node, position) + @Slot() def removeSelectedNodes(self): """Remove selected nodes from the graph.""" @@ -1045,7 +1114,14 @@ def getSelectedNodesContent(self) -> str: """ if not self._nodeSelection.hasSelection(): return "" - serializedSelection = {node.name: node.toDict() for node in self.iterSelectedNodes()} + + serializedSelection = {} + for node in self.iterSelectedNodes(): + if node.hasChildren(): + for child in node.childNodes: + serializedSelection[child.name] = child.toDict() + serializedSelection[node.name] = node.toDict() + return json.dumps(serializedSelection, indent=4) @Slot(str, QPoint, bool, result=list) @@ -1192,5 +1268,9 @@ def pasteNodes(self, clipboardContent, position=None, centerPosition=False) -> l # Currently hovered node hoveredNode = makeProperty(QObject, "_hoveredNode", hoveredNodeChanged, resetOnDestroy=True) + # Currently resizing a node + attributeChanged = Signal() + animationsDisabled = Property(bool, disableAnimations.fget, notify=attributeChanged) + filePollerRefreshChanged = Signal(int) filePollerRefresh = Property(int, lambda self: self._chunksMonitor.filePollerRefresh, notify=filePollerRefreshChanged) diff --git a/meshroom/ui/qml/Controls/DelegateSelectionBox.qml b/meshroom/ui/qml/Controls/DelegateSelectionBox.qml index a6034c8ac3..da9d555946 100644 --- a/meshroom/ui/qml/Controls/DelegateSelectionBox.qml +++ b/meshroom/ui/qml/Controls/DelegateSelectionBox.qml @@ -21,7 +21,7 @@ SelectionBox { let selectedIndices = []; const mappedSelectionRect = mapToItem(container, selectionRect); for (var i = 0; i < modelInstantiator.count; ++i) { - const delegate = modelInstantiator.itemAt(i); + const delegate = modelInstantiator.getItemAt(i); const delegateRect = Qt.rect(delegate.x, delegate.y, delegate.width, delegate.height); if (Geom2D.rectRectIntersect(mappedSelectionRect, delegateRect)) { selectedIndices.push(i); diff --git a/meshroom/ui/qml/GraphEditor/Backdrop.qml b/meshroom/ui/qml/GraphEditor/Backdrop.qml new file mode 100644 index 0000000000..20795746f7 --- /dev/null +++ b/meshroom/ui/qml/GraphEditor/Backdrop.qml @@ -0,0 +1,426 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +import Utils 1.0 + +import Meshroom.Helpers + +/** + * Visual representation of a Graph Backdrop Node. + */ + +Item { + id: root + + /// The underlying Node object + property variant node + + /// Mouse related states + property bool mainSelected: false + property bool selected: false + property bool hovered: false + + // The Item instantiating the delegates. + property Item modelInstantiator: undefined + + // Node Children for the Backdrop + property var children: [] + + property bool dragging: mouseArea.drag.active + property bool resizing: leftDragger.drag.active || topDragger.drag.active + /// Combined x and y + property point position: Qt.point(x, y) + /// Styling + property color shadowColor: "#cc000000" + readonly property color defaultColor: node.color === "" ? "#fffb85" : node.color + property color baseColor: defaultColor + + readonly property int minimumWidth: 200 + readonly property int minumumHeight: 200 + + property point mousePosition: Qt.point(mouseArea.mouseX, mouseArea.mouseY) + + // Mouse interaction related signals + signal pressed(var mouse) + signal released(var mouse) + signal clicked(var mouse) + signal doubleClicked(var mouse) + signal moved(var position) + signal entered() + signal exited() + + // Size signal + signal resized(var width, var height) + signal resizedAndMoved(var width, var height, var position) + + // Already connected attribute with another edge in DropArea + signal edgeAboutToBeRemoved(var input) + + /// Emitted when child attribute pins are created + signal attributePinCreated(var attribute, var pin) + /// Emitted when child attribute pins are deleted + signal attributePinDeleted(var attribute, var pin) + + // use node name as object name to simplify debugging + objectName: node ? node.name : "" + + // initialize position with node coordinates + x: root.node ? root.node.x : undefined + y: root.node ? root.node.y : undefined + + // The backdrop node always needs to be at the back + z: -1 + + width: root.node ? root.node.nodeWidth : 300; + height: root.node ? root.node.nodeHeight : 200; + + implicitHeight: childrenRect.height + + SystemPalette { id: activePalette } + + Connections { + target: root.node + // update x,y when node position changes + function onPositionChanged() { + root.x = root.node.x + root.y = root.node.y + } + + function onInternalAttributesChanged() { + root.width = root.node.nodeWidth; + root.height = root.node.nodeHeight; + } + } + + // When the node is selected, update the children for it + // For node to consider another ndoe, it needs to be fully inside the backdrop area + onSelectedChanged: { + if (selected) { + updateChildren() + } + } + + function updateChildren() { + let delegates = []; + let nodes = []; + const backdropRect = Qt.rect(root.node.x, root.node.y, root.node.nodeWidth, root.node.nodeHeight); + + for (var i = 0; i < modelInstantiator.count; ++i) { + const delegate = modelInstantiator.itemAt(i).item; + if (delegate === this) + continue + + const delegateRect = Qt.rect(delegate.x, delegate.y, delegate.width, delegate.height); + if (Geom2D.rectRectFullIntersect(backdropRect, delegateRect)) { + delegates.push(delegate); + nodes.push(delegate.node); + } + } + children = delegates; + + // Update children for the underlying node + // This could get used in other operations like copy-paste and maybe getters if needed + root.node.updateChildren(nodes); + } + + function getChildrenNodes() { + /** + * Returns the current nodes which are a part of the Backdrop. + */ + return children + } + + // Main Layout + MouseArea { + id: mouseArea + width: root.width; + height: root.height; + drag.target: root + // Small drag threshold to avoid moving the node by mistake + drag.threshold: 2 + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + onPressed: (mouse) => root.pressed(mouse) + onReleased: (mouse) => root.released(mouse) + onClicked: (mouse) => root.clicked(mouse) + onDoubleClicked: (mouse) => root.doubleClicked(mouse) + onEntered: root.entered() + onExited: root.exited() + drag.onActiveChanged: { + if (!drag.active) { + root.moved(Qt.point(root.x, root.y)) + } + } + + cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.ArrowCursor + + /// Backdrop Resize Controls ??? + /// + /// Resize Right Side + /// + Rectangle { + width: 4 + height: nodeContent.height + + color: baseColor + opacity: 0 + + anchors.horizontalCenter: parent.right + + // This mouse area serves as the dragging rectangle + MouseArea { + id: rightDragger + + cursorShape: Qt.SizeHorCursor + anchors.fill: parent + + drag { target: parent; axis: Drag.XAxis } + + onMouseXChanged: { + if (drag.active) { + // Update the area width + root.width = root.width + mouseX; + + // Ensure we have a minimum width always + if (root.width < root.minimumWidth) { + root.width = root.minimumWidth; + } + } + } + + onReleased: { + // emit the width and height + root.resized(root.width, nodeContent.height); + } + } + } + + /// + /// Resize Left Side + /// + Rectangle { + width: 4 + height: nodeContent.height + + color: baseColor + opacity: 0 + + anchors.horizontalCenter: parent.left + + // This mouse area serves as the dragging rectangle + MouseArea { + id: leftDragger + + cursorShape: Qt.SizeHorCursor + anchors.fill: parent + + drag { target: parent; axis: Drag.XAxis } + + onMouseXChanged: { + if (drag.active) { + // Width of the Area + let w = 0 + + // Update the area width + w = root.width - mouseX + + // Ensure we have a minimum width always + if (w > root.minimumWidth) { + // Update the node's x position and the width + root.x = root.x + mouseX + root.width = w; + } + } + } + + onReleased: { + // emit the node width and height along with the root position + // Dragging from the left moves the node as well + root.resizedAndMoved(root.width, root.height, Qt.point(root.x, root.y)); + } + } + } + + /// + /// Resize Bottom + /// + Rectangle { + width: mouseArea.width + height: 4 + + color: baseColor + opacity: 0 + + anchors.verticalCenter: nodeContent.bottom + + MouseArea { + id: bottomDragger + + cursorShape: Qt.SizeVerCursor + anchors.fill: parent + + drag{ target: parent; axis: Drag.YAxis } + + onMouseYChanged: { + if (drag.active) { + // Update the height + root.height = root.height + mouseY; + + // Ensure a minimum height + if (root.height < root.minumumHeight) { + root.height = root.minumumHeight; + } + } + } + + onReleased: { + // emit the width and height for it to be updated + root.resized(mouseArea.width, root.height); + } + } + } + + /// + /// Resize Top + /// + Rectangle { + width: mouseArea.width + height: 4 + + color: baseColor + opacity: 0 + + anchors.verticalCenter: parent.top + + MouseArea { + id: topDragger + + cursorShape: Qt.SizeVerCursor + anchors.fill: parent + + drag{ target: parent; axis: Drag.YAxis } + + onMouseYChanged: { + if (drag.active) { + let h = root.height - mouseY; + + // Ensure a minimum height + if (h > root.minumumHeight) { + // Update the node's y position and the height + root.y = root.y + mouseY; + root.height = h; + } + } + } + + onReleased: { + // emit the node width and height along with the root position + // Dragging from the top moves the node as well + root.resizedAndMoved(root.width, root.height, Qt.point(root.x, root.y)); + } + } + } + + // Selection border + Rectangle { + anchors.fill: nodeContent + anchors.margins: -border.width + visible: root.mainSelected || root.hovered || root.selected + border.width: { + if(root.mainSelected) + return 3 + if(root.selected) + return 2.5 + return 2 + } + border.color: { + if(root.mainSelected) + return activePalette.highlight + if(root.selected) + return Qt.darker(activePalette.highlight, 1.2) + return Qt.lighter(activePalette.base, 3) + } + opacity: 0.9 + radius: background.radius + border.width + color: "transparent" + } + + Rectangle { + id: background + anchors.fill: nodeContent + color: Qt.darker(baseColor, 1.2) + layer.enabled: true + layer.effect: DropShadow { radius: 3; color: shadowColor } + radius: 3 + opacity: 0.7 + } + + Rectangle { + id: nodeContent + width: parent.width + height: parent.height + color: "transparent" + + // Data Layout + Column { + id: body + width: parent.width + + // Header + Rectangle { + id: header + width: parent.width + height: headerLayout.height + color: root.baseColor + radius: background.radius + + // Fill header's bottom radius + Rectangle { + width: parent.width + height: parent.radius + anchors.bottom: parent.bottom + color: parent.color + z: -1 + } + + // Header Layout + RowLayout { + id: headerLayout + width: parent.width + spacing: 0 + + // Node Name + Label { + id: nodeLabel + Layout.fillWidth: true + text: node ? node.label : "" + padding: 4 + color: "#2b2b2b" + elide: Text.ElideMiddle + font.pointSize: 8 + } + } + } + + // Vertical Spacer + Item { width: parent.width; height: 2 } + + // Node Comments Text which is visible on the backdrop + Rectangle { + y: header.height + + // Show only when we have a comment + visible: node.comment + + Text { + text: node.comment + padding: 4 + font.pointSize: node.fontSize + } + } + } + } + } +} diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 0f33730828..ffbdd90208 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -53,8 +53,8 @@ Item { /// Get node delegate for the given node object function nodeDelegate(node) { for(var i = 0; i < nodeRepeater.count; ++i) { - if (nodeRepeater.itemAt(i).node === node) - return nodeRepeater.itemAt(i) + if (nodeRepeater.getItemAt(i).node === node) + return nodeRepeater.getItemAt(i) } return undefined } @@ -833,141 +833,318 @@ Item { property bool updateSelectionOnClick: false property var temporaryEdgeAboutToBeRemoved: undefined - delegate: Node { - id: nodeDelegate + function getItemAt(index) { + /** + * Helper function to get actual item from the repeater at a given index. + */ + const loader = itemAt(index); - node: object - width: uigraph.layout.nodeWidth + if (loader && loader.item) { + return loader.item; + } - mainSelected: uigraph.selectedNode === node - hovered: uigraph.hoveredNode === node + return null; + } - // ItemSelectionModel.hasSelection triggers updates anytime the selectionChanged() signal is emitted. - selected: uigraph.nodeSelection.hasSelection ? uigraph.nodeSelection.isRowSelected(index) : false + delegate: Loader { + id: nodeLoader + Component { + id: nodeComponent + Node { + id: nodeDelegate - onAttributePinCreated: function(attribute, pin) { registerAttributePin(attribute, pin) } - onAttributePinDeleted: function(attribute, pin) { unregisterAttributePin(attribute, pin) } + node: object + width: uigraph.layout.nodeWidth - onPressed: function(mouse) { - nodeRepeater.updateSelectionOnClick = true; - nodeRepeater.ongoingDrag = true; + mainSelected: uigraph.selectedNode === node + hovered: uigraph.hoveredNode === node - let selectionMode = ItemSelectionModel.NoUpdate; + // ItemSelectionModel.hasSelection triggers updates anytime the selectionChanged() signal is emitted. + selected: uigraph.nodeSelection.hasSelection ? uigraph.nodeSelection.isRowSelected(index) : false - if(!selected) { - selectionMode = ItemSelectionModel.ClearAndSelect; - } + onAttributePinCreated: function(attribute, pin) { registerAttributePin(attribute, pin) } + onAttributePinDeleted: function(attribute, pin) { unregisterAttributePin(attribute, pin) } + + onPressed: function(mouse) { + nodeRepeater.updateSelectionOnClick = true; + nodeRepeater.ongoingDrag = true; + + let selectionMode = ItemSelectionModel.NoUpdate; + + if(!selected) { + selectionMode = ItemSelectionModel.ClearAndSelect; + } + + if (mouse.button === Qt.LeftButton) { + if(mouse.modifiers & Qt.ShiftModifier) { + selectionMode = ItemSelectionModel.Select; + } + if(mouse.modifiers & Qt.ControlModifier) { + selectionMode = ItemSelectionModel.Toggle; + } + if(mouse.modifiers & Qt.AltModifier) { + let selectFollowingMode = ItemSelectionModel.ClearAndSelect; + if(mouse.modifiers & Qt.ShiftModifier) { + selectFollowingMode = ItemSelectionModel.Select; + } + uigraph.selectFollowing(node, selectFollowingMode); + // Indicate selection has been dealt with by setting conservative Select mode. + selectionMode = ItemSelectionModel.Select; + } + } + else if (mouse.button === Qt.RightButton) { + if(selected) { + // Keep the full selection when right-clicking on an already selected node. + nodeRepeater.updateSelectionOnClick = false; + } + } + + if(selectionMode != ItemSelectionModel.NoUpdate) { + nodeRepeater.updateSelectionOnClick = false; + uigraph.selectNodeByIndex(index, selectionMode); + } + + // If the node is selected after this, make it the active selected node. + if(selected) { + uigraph.selectedNode = node; + } + + // Open the node context menu once selection has been updated. + if(mouse.button == Qt.RightButton) { + nodeMenuLoader.load(node) + } - if (mouse.button === Qt.LeftButton) { - if(mouse.modifiers & Qt.ShiftModifier) { - selectionMode = ItemSelectionModel.Select; } - if(mouse.modifiers & Qt.ControlModifier) { - selectionMode = ItemSelectionModel.Toggle; + + onReleased: function(mouse, wasDragged) { + nodeRepeater.ongoingDrag = false; } - if(mouse.modifiers & Qt.AltModifier) { - let selectFollowingMode = ItemSelectionModel.ClearAndSelect; - if(mouse.modifiers & Qt.ShiftModifier) { - selectFollowingMode = ItemSelectionModel.Select; + + // Only called when the node has not been dragged. + onClicked: function(mouse) { + if(!nodeRepeater.updateSelectionOnClick) { + return; } - uigraph.selectFollowing(node, selectFollowingMode); - // Indicate selection has been dealt with by setting conservative Select mode. - selectionMode = ItemSelectionModel.Select; + uigraph.selectNodeByIndex(index); } - } - else if (mouse.button === Qt.RightButton) { - if(selected) { - // Keep the full selection when right-clicking on an already selected node. - nodeRepeater.updateSelectionOnClick = false; + + onDoubleClicked: function(mouse) { root.nodeDoubleClicked(mouse, node) } + + onEntered: uigraph.hoveredNode = node + onExited: uigraph.hoveredNode = null + + onEdgeAboutToBeRemoved: function(input) { + /* + * Sometimes the signals are not in the right order because of weird Qt/QML update order + * (next DropArea entered signal before previous DropArea exited signal) so edgeAboutToBeRemoved + * must be set to undefined before it can be set to another attribute object. + */ + if (input === undefined) { + if (nodeRepeater.temporaryEdgeAboutToBeRemoved === undefined) { + root.edgeAboutToBeRemoved = input + } else { + root.edgeAboutToBeRemoved = nodeRepeater.temporaryEdgeAboutToBeRemoved + nodeRepeater.temporaryEdgeAboutToBeRemoved = undefined + } + } else { + if (root.edgeAboutToBeRemoved === undefined) { + root.edgeAboutToBeRemoved = input + } else { + nodeRepeater.temporaryEdgeAboutToBeRemoved = input + } + } } - } - if(selectionMode != ItemSelectionModel.NoUpdate) { - nodeRepeater.updateSelectionOnClick = false; - uigraph.selectNodeByIndex(index, selectionMode); - } + // Interactive dragging: move the visual delegates + onPositionChanged: { + if(!selected || !dragging) { + return; + } + // Compute offset between the delegate and the stored node position. + const offset = Qt.point(x - node.x, y - node.y); + + uigraph.nodeSelection.selectedIndexes.forEach(function(idx) { + if(idx != index) { + const delegate = nodeRepeater.itemAt(idx.row).item; + delegate.x = delegate.node.x + offset.x; + delegate.y = delegate.node.y + offset.y; + } + }); + } - // If the node is selected after this, make it the active selected node. - if(selected) { - uigraph.selectedNode = node; - } + // After drag: apply the final offset to all selected nodes + onMoved: function(position) { + const offset = Qt.point(position.x - node.x, position.y - node.y); + uigraph.moveSelectedNodesBy(offset); + } - // Open the node context menu once selection has been updated. - if(mouse.button == Qt.RightButton) { - nodeMenuLoader.load(node) + Behavior on x { + enabled: !nodeRepeater.ongoingDrag + NumberAnimation { duration: 100 } + } + Behavior on y { + enabled: !nodeRepeater.ongoingDrag + NumberAnimation { duration: 100 } + } } - } - onReleased: function(mouse, wasDragged) { - nodeRepeater.ongoingDrag = false; - } + Component { + id: backdropComponent + Backdrop { + id: backdropDelegate - // Only called when the node has not been dragged. - onClicked: function(mouse) { - if(!nodeRepeater.updateSelectionOnClick) { - return; - } - uigraph.selectNodeByIndex(index); - } + node: object + // The Item instantiating the delegates. + modelInstantiator: nodeRepeater - onDoubleClicked: function(mouse) { root.nodeDoubleClicked(mouse, node) } + property var nodes: [] - onEntered: uigraph.hoveredNode = node - onExited: uigraph.hoveredNode = null + mainSelected: uigraph.selectedNode === node + hovered: uigraph.hoveredNode === node - onEdgeAboutToBeRemoved: function(input) { - /* - * Sometimes the signals are not in the right order because of weird Qt/QML update order - * (next DropArea entered signal before previous DropArea exited signal) so edgeAboutToBeRemoved - * must be set to undefined before it can be set to another attribute object. - */ - if (input === undefined) { - if (nodeRepeater.temporaryEdgeAboutToBeRemoved === undefined) { - root.edgeAboutToBeRemoved = input - } else { - root.edgeAboutToBeRemoved = nodeRepeater.temporaryEdgeAboutToBeRemoved - nodeRepeater.temporaryEdgeAboutToBeRemoved = undefined + // ItemSelectionModel.hasSelection triggers updates anytime the selectionChanged() signal is emitted. + selected: uigraph.nodeSelection.hasSelection ? uigraph.nodeSelection.isRowSelected(index) : false + + onPressed: function(mouse) { + nodeRepeater.updateSelectionOnClick = true; + nodeRepeater.ongoingDrag = true; + + let selectionMode = ItemSelectionModel.NoUpdate; + + if(!selected) { + selectionMode = ItemSelectionModel.ClearAndSelect; + } + + if (mouse.button === Qt.LeftButton) { + if(mouse.modifiers & Qt.ShiftModifier) { + selectionMode = ItemSelectionModel.Select; + } + if(mouse.modifiers & Qt.ControlModifier) { + selectionMode = ItemSelectionModel.Toggle; + } + if(mouse.modifiers & Qt.AltModifier) { + let selectFollowingMode = ItemSelectionModel.ClearAndSelect; + if(mouse.modifiers & Qt.ShiftModifier) { + selectFollowingMode = ItemSelectionModel.Select; + } + uigraph.selectFollowing(node, selectFollowingMode); + // Indicate selection has been dealt with by setting conservative Select mode. + selectionMode = ItemSelectionModel.Select; + } + } + else if (mouse.button === Qt.RightButton) { + if(selected) { + // Keep the full selection when right-clicking on an already selected node. + nodeRepeater.updateSelectionOnClick = false; + } + } + + if(selectionMode != ItemSelectionModel.NoUpdate) { + nodeRepeater.updateSelectionOnClick = false; + uigraph.selectNodeByIndex(index, selectionMode); + } + + // If the node is selected after this, make it the active selected node. + if(selected) { + uigraph.selectedNode = node; + } + + // Open the node context menu once selection has been updated. + if(mouse.button == Qt.RightButton) { + nodeMenuLoader.load(node) + } } - } else { - if (root.edgeAboutToBeRemoved === undefined) { - root.edgeAboutToBeRemoved = input - } else { - nodeRepeater.temporaryEdgeAboutToBeRemoved = input + + onReleased: function(mouse, wasDragged) { + nodeRepeater.ongoingDrag = false; } - } - } - // Interactive dragging: move the visual delegates - onPositionChanged: { - if(!selected || !dragging) { - return; - } - // Compute offset between the delegate and the stored node position. - const offset = Qt.point(x - node.x, y - node.y); + // Only called when the node has not been dragged. + onClicked: function(mouse) { + if(!nodeRepeater.updateSelectionOnClick) { + return; + } + uigraph.selectNodeByIndex(index); + } - uigraph.nodeSelection.selectedIndexes.forEach(function(idx) { - if(idx != index) { - const delegate = nodeRepeater.itemAt(idx.row); - delegate.x = delegate.node.x + offset.x; - delegate.y = delegate.node.y + offset.y; + onDoubleClicked: function(mouse) { root.nodeDoubleClicked(mouse, node) } + + onResized: function(width, height) { + uigraph.resizeNode(node, width, height); } - }); - } - // After drag: apply the final offset to all selected nodes - onMoved: function(position) { - const offset = Qt.point(position.x - node.x, position.y - node.y); - uigraph.moveSelectedNodesBy(offset); - } + onResizedAndMoved: function(width, height, position) { + uigraph.resizeAndMoveNode(node, width, height, position); + } - Behavior on x { - enabled: !nodeRepeater.ongoingDrag - NumberAnimation { duration: 100 } + onEntered: uigraph.hoveredNode = node + onExited: uigraph.hoveredNode = null + + // Interactive dragging: move the visual delegates + onPositionChanged: { + if(!selected || !dragging) { + return; + } + + // Compute offset between the delegate and the stored node position. + const offset = Qt.point(x - node.x, y - node.y); + + nodes = [] + // Get all of the current children for the backdrop + let children = getChildrenNodes(); + + for (var i = 0; i < children.length; i++) { + const delegate = children[i]; + + // Ignore the selected delegates as they will be iterated upon separately + if (delegate.selected) + continue; + + delegate.x = delegate.node.x + offset.x; + delegate.y = delegate.node.y + offset.y; + + // If the delegate is not the current Node + if (delegate !== node) + nodes.push(delegate.node); + } + + uigraph.nodeSelection.selectedIndexes.forEach(function(idx) { + if(idx != index) { + const delegate = nodeRepeater.itemAt(idx.row).item; + delegate.x = delegate.node.x + offset.x; + delegate.y = delegate.node.y + offset.y; + + // If the delegate is not the current Node + if (delegate !== node) + nodes.push(delegate.node); + } + }); + } + + // After drag: apply the final offset to all selected nodes + onMoved: function(position) { + const offset = Qt.point(position.x - node.x, position.y - node.y); + uigraph.moveNodesBy(nodes, offset); + } + + Behavior on x { + enabled: !nodeRepeater.ongoingDrag && !resizing && !uigraph.animationsDisabled; + NumberAnimation { duration: 100 } + } + Behavior on y { + enabled: !nodeRepeater.ongoingDrag && !resizing && !uigraph.animationsDisabled; + NumberAnimation { duration: 100 } + } + } } - Behavior on y { - enabled: !nodeRepeater.ongoingDrag - NumberAnimation { duration: 100 } + + // Source Component for the delegate + sourceComponent: object.isBackdrop ? backdropComponent : nodeComponent; + + onLoaded: { + // Node's Z-Ordering + nodeLoader.z = nodeLoader.item.z; } } } @@ -1200,7 +1377,7 @@ Item { function nextItem() { // Compute bounding box - var node = nodeRepeater.itemAt(filteredNodes.itemAt(navigation.currentIndex).index_) + var node = nodeRepeater.getItemAt(filteredNodes.itemAt(navigation.currentIndex).index_) var bbox = Qt.rect(node.x, node.y, node.width, node.height) // Rescale to fit the bounding box in the view, zoom is limited to prevent huge text draggable.scale = Math.min(Math.min(root.width / bbox.width, root.height / bbox.height),maxZoom) @@ -1220,13 +1397,13 @@ Item { } function boundingBox() { - var first = nodeRepeater.itemAt(0) + var first = nodeRepeater.getItemAt(0) if (first === null) { return Qt.rect(0, 0, 0, 0) } var bbox = Qt.rect(first.x, first.y, first.x + first.width, first.y + first.height) for (var i = 0; i < root.graph.nodes.count; ++i) { - var item = nodeRepeater.itemAt(i) + var item = nodeRepeater.getItemAt(i) bbox.x = Math.min(bbox.x, item.x) bbox.y = Math.min(bbox.y, item.y) bbox.width = Math.max(bbox.width, item.x + item.width) diff --git a/meshroom/ui/qml/GraphEditor/qmldir b/meshroom/ui/qml/GraphEditor/qmldir index 4a4d4ca460..f52cb6b576 100644 --- a/meshroom/ui/qml/GraphEditor/qmldir +++ b/meshroom/ui/qml/GraphEditor/qmldir @@ -3,6 +3,7 @@ module GraphEditor GraphEditor 1.0 GraphEditor.qml NodeEditor 1.0 NodeEditor.qml Node 1.0 Node.qml +Backdrop 1.0 Backdrop.qml NodeChunks 1.0 NodeChunks.qml Edge 1.0 Edge.qml AttributePin 1.0 AttributePin.qml