diff --git a/README.md b/README.md index 0d82a05..661d49f 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ main() - [X] Parameters - [X] Node Browser. - [X] Node Parameters. -- [ ] Node Groups & Gizmos +- [X] Node Groups +- [ ] Parameter Promotion. +- [ ] Gizmos - [ ] Graph Events or Signals - [ ] Documentation diff --git a/example.py b/example.py index 54b86dc..788c430 100644 --- a/example.py +++ b/example.py @@ -11,14 +11,14 @@ import os from PySide6 import QtWidgets, QtGui -from radium.nodegraph import NodeGraphView +from radium.nodegraph import NodeGraphViewport from radium.nodegraph import NodeGraphController from radium.nodegraph import NodePrototype, PortPrototype os.environ["QT_SCALE_FACTOR"] = "2" app = QtWidgets.QApplication([]) -view = NodeGraphView() +view = NodeGraphViewport() controller = NodeGraphController() controller.attachView(view) diff --git a/src/radium/demo/controller.py b/src/radium/demo/controller.py index 3a6fc9b..e1cc1f0 100644 --- a/src/radium/demo/controller.py +++ b/src/radium/demo/controller.py @@ -57,19 +57,17 @@ def __init__(self): self.__current_filename = None self.__recent_files: typing.List[str] = json.loads( - self.settings.value("recent_files", "[]") + self.settings.value("recent_files", "[]") # noqa ) self.initMenuBar() self.initNodes() self.undo_stack.cleanChanged.connect(self.updateWindowTitle) - self.node_graph_controller.scene.parameterChanged.connect( - self.onParameterChanged - ) + self.node_graph_controller.parameterChanged.connect(self.onParameterChanged) - self.node_graph_controller.scene.nodeEdited.connect(self.onNodeEdited) - self.node_graph_controller.scene.nodeSelected.connect(self.onNodeSelected) + self.node_graph_controller.nodeEdited.connect(self.onNodeEdited) + self.node_graph_controller.nodeSelected.connect(self.onNodeSelected) def onParameterChanged(self, node, parameter, previous, value): pass @@ -91,7 +89,7 @@ def initNodes(self): self.node_factory.registerPortType( prototypes.PortType( "image", - color=(0, 96, 0, 255), + color=(32, 96, 32, 255), outline_color=(32, 32, 32, 255, 2), ) ) @@ -112,6 +110,7 @@ def initNodes(self): datatype="float", ) }, + color=(64, 64, 96, 255), ) ) self.node_factory.registerNodeType( @@ -262,7 +261,7 @@ def onResetAction(self): return False self.__current_filename = None - self.node_graph_controller.scene.clear() + self.node_graph_controller.reset() self.undo_stack.clear() self.updateWindowTitle() return True @@ -297,7 +296,7 @@ def onOpenAction(self, *args, filename=None): self.__storeRecentFile(self.__current_filename) - self.node_graph_controller.scene.loadDict(data, self.node_factory) + self.node_graph_controller.rootScene().loadDict(data, self.node_factory) self.updateWindowTitle() def updateWindowTitle(self): @@ -337,9 +336,9 @@ def onSaveAction(self, *_, save_as=False): "last_save_directory", os.path.dirname(self.__current_filename) ) - data = self.node_graph_controller.scene.toDict() + data = self.node_graph_controller.rootScene().toDict() with open(self.__current_filename, "w") as f: - json.dump(data, f) + json.dump(data, f, indent=2) self.undo_stack.setClean() self.__storeRecentFile(self.__current_filename) @@ -350,7 +349,7 @@ def onDeleteAction(self): """ When the delete action has triggered delete the currently selected nodes. """ - selection = self.node_graph_controller.scene.selectedNodes() + selection = self.node_graph_controller.activeScene().selectedNodes() self.undo_stack.beginMacro("Delete Selected Nodes") for node in selection: self.node_graph_controller.removeItem(node) diff --git a/src/radium/nodegraph/factory/factory.py b/src/radium/nodegraph/factory/factory.py index eb4f688..ca12631 100644 --- a/src/radium/nodegraph/factory/factory.py +++ b/src/radium/nodegraph/factory/factory.py @@ -7,13 +7,17 @@ import qtawesome from PySide6 import QtCore, QtGui + +from radium.nodegraph.graph.scene.scene import NodeGraphScene from radium.nodegraph.factory.prototypes import ( NodeType, PortType, ) from radium.nodegraph.graph.scene.node_base import NodeDataDict +from radium.nodegraph.graph.scene.group import Group from radium.nodegraph.graph.scene.node import Node +from radium.nodegraph.graph.scene.backdrop import Backdrop from radium.nodegraph.graph.scene.port import PortDataDict, Port from radium.nodegraph.factory.model import NodePrototypeModel from radium.nodegraph.parameters.parameter import Parameter, ParameterDataDict @@ -22,12 +26,31 @@ class NodeFactory(QtCore.QObject): def __init__(self, parent=None): super().__init__(parent) - self.__node_types = {} + self.__node_types = { + "Util/Group": NodeType( + name="Group", category="Util", node_class="group", color=(96, 96, 64) + ), + "Util/Backdrop": NodeType( + name="Backdrop", + category="Util", + node_class="backdrop", + color=(127, 127, 255, 64), + ), + } self.__port_types = {} self.__icon_cache = {} + self.__node_classes = { + "default": Node, + "group": Group, + "backdrop": Backdrop, + } + self.node_types_model = NodePrototypeModel() + for node_type in self.__node_types.values(): + self.node_types_model.addPrototype(node_type) + def registerPortType(self, port_type: PortType, exists_ok=False): if port_type.type_name in self.__port_types: if not exists_ok: @@ -57,6 +80,10 @@ def hasPortType(self, name: str) -> bool: def getPortType(self, name): return self.__port_types.get(name) + def createGroup(self, name: str = None): + name = name or "Group" + return Group(self, NodeGraphScene(), name) + def cloneNode(self, node: Node) -> Node: data = node.toDict() data["unique_id"] = uuid.uuid4().hex @@ -66,14 +93,17 @@ def createNode(self, node_type_name: str, data: NodeDataDict = None): node_type = self.getNodeType(node_type_name) if node_type is None: + cls = Node + if "/" in node_type_name: name = node_type_name[node_type_name.rindex("/") :] else: name = node_type_name else: name = node_type.name + cls = self.__node_classes[node_type.node_class] - instance = Node(self, node_type_name, name=name) + instance = cls(self, node_type_name, name=name) if node_type is not None: applyItemStyle(node_type, instance) diff --git a/src/radium/nodegraph/factory/prototypes.py b/src/radium/nodegraph/factory/prototypes.py index a5cadac..fd95872 100644 --- a/src/radium/nodegraph/factory/prototypes.py +++ b/src/radium/nodegraph/factory/prototypes.py @@ -41,3 +41,4 @@ def type_name(self) -> str: outputs: typing.Dict[str, str] = dataclasses.field(default_factory=dict) icon: str = "fa5s.toolbox" + node_class: str = "default" diff --git a/src/radium/nodegraph/graph/controller.py b/src/radium/nodegraph/graph/controller.py index 5b9edbf..fbe612b 100644 --- a/src/radium/nodegraph/graph/controller.py +++ b/src/radium/nodegraph/graph/controller.py @@ -1,7 +1,9 @@ import typing import logging + from PySide6 import QtCore, QtGui, QtWidgets +from radium.nodegraph.graph.scene.group import Group from radium.nodegraph.graph.scene.node import Node from radium.nodegraph.graph.scene.event_filter import SceneEventFilter from radium.nodegraph.graph.scene.backdrop import Backdrop @@ -9,6 +11,8 @@ from radium.nodegraph.graph.scene.port import InputPort, OutputPort from radium.nodegraph.graph.scene.connection import Connection from radium.nodegraph.factory.factory import NodeFactory +from radium.nodegraph.parameters.parameter import Parameter + if typing.TYPE_CHECKING: from radium.nodegraph.graph.view import NodeGraphView @@ -18,6 +22,13 @@ class NodeGraphController(QtCore.QObject): + sceneChanged = QtCore.Signal(NodeGraphScene) + scenePopped = QtCore.Signal() + + nodeEdited = QtCore.Signal(Node) + nodeSelected = QtCore.Signal(Node) + parameterChanged = QtCore.Signal(Node, Parameter, object, object) + def __init__( self, undo_stack: QtGui.QUndoStack = None, @@ -25,22 +36,73 @@ def __init__( parent=None, ): super().__init__(parent) - self.scene = NodeGraphScene() + self.node_factory = node_factory or NodeFactory() self.undo_stack = undo_stack or QtGui.QUndoStack() - self.scene_event_filter = SceneEventFilter( - self.scene, - self.undo_stack, - self.node_factory, - ) + self.__scene_stack: typing.List[NodeGraphScene] = [] + self.__event_filters: typing.List[SceneEventFilter] = [] + + self.pushScene(NodeGraphScene()) + + def activeScene(self): + return self.__scene_stack[-1] + + def rootScene(self): + return self.__scene_stack[0] + + def reset(self): + self.__scene_stack.clear() + self.__event_filters.clear() + + self.pushScene(NodeGraphScene()) + + def pushScene(self, scene: NodeGraphScene): + scene.nodeEdited.connect(self.nodeEdited) + scene.nodeSelected.connect(self.nodeSelected) + scene.parameterChanged.connect(self.parameterChanged) + + event_filter = SceneEventFilter(scene, self.undo_stack, self.node_factory) + + self.__scene_stack.append(scene) + self.__event_filters.append(event_filter) + + self.sceneChanged.emit(scene) + + def popScene(self): + if len(self.__scene_stack) <= 1: + return + + scene = self.__scene_stack.pop() + event_filter = self.__event_filters.pop() + scene.removeEventFilter(event_filter) + + scene.nodeEdited.disconnect(self.nodeEdited) + scene.nodeSelected.disconnect(self.nodeSelected) + scene.parameterChanged.disconnect(self.parameterChanged) + + self.sceneChanged.emit(self.__scene_stack[-1]) def attachView(self, view: "NodeGraphView"): - view.setScene(self.scene) + if self.__scene_stack: + view.setScene(self.activeScene()) + + view.setHudText("Root") view.createNodeRequested.connect(self.onNodeCreationRequested) - self.setupActions(view) + view.itemDoubleClicked.connect(self.onItemDoubleClicked) + self.sceneChanged.connect(view.setScene) - def setupActions(self, view: "NodeGraphView"): + def groupNameCallback(scene): + group = scene.group() + if not group: + view.setHudText("Root") + else: + view.setHudText("Root>" + group.qualifiedName().replace(".", ">")) + + self.sceneChanged.connect(groupNameCallback) + self.setupActions(view.viewer) + + def setupActions(self, view: "NodeGraphViewport"): view_action = QtGui.QAction("Edit Node", self) view_action.setData(view) view_action.setShortcut("V") @@ -59,9 +121,31 @@ def setupActions(self, view: "NodeGraphView"): action.triggered.connect(self.onEditActionTriggered) view.addAction(action) + action = QtGui.QAction("Group", self) + action.setData(view) + action.setShortcut("Ctrl+G") + action.triggered.connect(self.onGroupActionTriggered) + view.addAction(action) + + action = QtGui.QAction("ParentGroup", self) + action.setData(view) + action.setShortcut("Backspace") + action.triggered.connect(self.onParentSceneActionTriggered) + view.addAction(action) + + def createGroup(self, name=None): + item = self.node_factory.createGroup(name=name) + cmd = commands.AddItemCommand(self.activeScene(), item) + cmd.setText(f"Create Group: {item.name()}") + self.undo_stack.push(cmd) + + return item + def createNode(self, node_type) -> Node: logger.info(f"Creating node of type: {node_type}") - cmd = commands.CreateNodeCommand(self.scene, node_type, self.node_factory) + cmd = commands.CreateNodeCommand( + self.activeScene(), node_type, self.node_factory + ) cmd.setText(f"Create: {node_type}") self.undo_stack.push(cmd) @@ -69,17 +153,17 @@ def createNode(self, node_type) -> Node: def createBackdrop(self, name): backdrop = Backdrop(name) - cmd = commands.AddItemCommand(self.scene, backdrop) + cmd = commands.AddItemCommand(self.activeScene(), backdrop) cmd.setText("Create Backdrop") self.undo_stack.push(cmd) def removeItem(self, item): - cmd = commands.RemoveItemCommand(self.scene, item) + cmd = commands.RemoveItemCommand(self.activeScene(), item) cmd.setText(f"Remove: {item}") self.undo_stack.push(cmd) def selectedNodes(self): - return [n for n in self.scene.selectedItems() if isinstance(n, Node)] + return [n for n in self.activeScene().selectedItems() if isinstance(n, Node)] def createConnection( self, output_port: OutputPort, input_port: InputPort @@ -90,7 +174,9 @@ def createConnection( if not isinstance(input_port, InputPort): raise TypeError(f"Not an input port: {input_port}") - cmd = commands.CreateConnectionCommand(self.scene, output_port, input_port) + cmd = commands.CreateConnectionCommand( + self.activeScene(), output_port, input_port + ) self.undo_stack.push(cmd) return cmd.connection @@ -102,17 +188,15 @@ def onNodeCreationRequested(self, node_type: str, position: QtCore.QPointF): @QtCore.Slot() def onViewActionTriggered(self): action = self.sender() - view: "NodeGraphView" = action.data() - cursor = QtGui.QCursor.pos() - view_cursor = view.mapFromGlobal(cursor) - scene_pos = view.mapToScene(view_cursor) + view: "NodeGraphViewport" = action.data() + scene_pos = get_scene_position(view) - hovered_item = self.scene.itemAt(scene_pos, QtGui.QTransform()) + hovered_item = self.activeScene().itemAt(scene_pos, QtGui.QTransform()) if isinstance(hovered_item, Node): hovered_item.setViewed(not hovered_item.isViewed()) - for node in self.scene.nodes(): + for node in self.activeScene().nodes(): if node is hovered_item: continue node.setViewed(False) @@ -120,13 +204,11 @@ def onViewActionTriggered(self): @QtCore.Slot() def onEditActionTriggered(self): action = self.sender() - view: "NodeGraphView" = action.data() - cursor = QtGui.QCursor.pos() - view_cursor = view.mapFromGlobal(cursor) - scene_pos = view.mapToScene(view_cursor) + view: "NodeGraphViewport" = action.data() + scene_pos = get_scene_position(view) modifiers = QtWidgets.QApplication.keyboardModifiers() - hovered_item = self.scene.itemAt(scene_pos, QtGui.QTransform()) + hovered_item = self.activeScene().itemAt(scene_pos, QtGui.QTransform()) if isinstance(hovered_item, Node): hovered_item.setEdited(not hovered_item.isEdited()) @@ -134,7 +216,31 @@ def onEditActionTriggered(self): if modifiers & QtCore.Qt.KeyboardModifier.ShiftModifier: return - for node in self.scene.nodes(): + for node in self.activeScene().nodes(): if node is hovered_item: continue node.setEdited(False) + + @QtCore.Slot() + def onGroupActionTriggered(self): + action = self.sender() + view: "NodeGraphViewport" = action.data() + scene_pos = get_scene_position(view) + group = self.createGroup() + group.setPos(scene_pos) + + @QtCore.Slot(QtWidgets.QGraphicsItem) + def onItemDoubleClicked(self, item: QtWidgets.QGraphicsItem): + if isinstance(item, Group): + self.pushScene(item.subScene()) + + @QtCore.Slot() + def onParentSceneActionTriggered(self): + self.popScene() + + +def get_scene_position(view): + cursor = QtGui.QCursor.pos() + view_cursor = view.mapFromGlobal(cursor) + scene_pos = view.mapToScene(view_cursor) + return scene_pos diff --git a/src/radium/nodegraph/graph/scene/backdrop.py b/src/radium/nodegraph/graph/scene/backdrop.py index a686e64..dd68622 100644 --- a/src/radium/nodegraph/graph/scene/backdrop.py +++ b/src/radium/nodegraph/graph/scene/backdrop.py @@ -1,4 +1,5 @@ from PySide6 import QtCore, QtGui, QtWidgets +from radium.nodegraph.graph.scene.element import SerializableBaseElement class BackdropHandle(QtWidgets.QGraphicsEllipseItem): @@ -16,27 +17,23 @@ def itemChange(self, change, value): return super().itemChange(change, value) -class Backdrop(QtWidgets.QGraphicsItem): - def __init__(self, name, parent=None): - super().__init__(parent=parent) - self._name = name +class Backdrop(SerializableBaseElement): + def __init__(self, factory, node_type, name=None, parent=None): + super().__init__(factory, type_name=node_type, name=name, parent=parent) + self._name = name or node_type self.corner_a = BackdropHandle(parent=self) self.corner_b = BackdropHandle(parent=self) self.corner_b.setPos(100, 100) self.corner_a.setPos(-100, -100) - self.__pen = QtCore.Qt.PenStyle.NoPen - self.__brush = QtGui.QColor(255, 127, 127, 64) self.setZValue(-5) self.__font = QtGui.QFont("Consolas", 10) self.__font_metrics = QtGui.QFontMetrics(self.__font) - self.__rect = QtCore.QRectF() - self.__text_rect = self.__font_metrics.boundingRect(self._name) def paint(self, painter, option, widget=...): - painter.setBrush(self.__brush) - painter.setPen(self.__pen) + painter.setBrush(self.brush()) + painter.setPen(self.pen()) painter.drawRoundedRect(self.boundingRect(), 6, 6) painter.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0), 2)) diff --git a/src/radium/nodegraph/graph/scene/element.py b/src/radium/nodegraph/graph/scene/element.py new file mode 100644 index 0000000..5564f6d --- /dev/null +++ b/src/radium/nodegraph/graph/scene/element.py @@ -0,0 +1,75 @@ +import typing +import uuid + +from PySide6 import QtWidgets, QtGui, QtCore + +if typing.TYPE_CHECKING: + from radium.nodegraph.factory.factory import NodeFactory + + +class BaseElementData(typing.TypedDict): + node_type: str + name: str + position: typing.Tuple[float, float] + unique_id: str + + +class SerializableBaseElement(QtWidgets.QGraphicsItem): + """ + This is the base class for all serializable graph elements. + """ + + def __init__( + self, + factory: "NodeFactory", + type_name: str, + name: str = None, + parent=None, + ): + super().__init__(parent=parent) + self.__unique_id = uuid.uuid4().hex + self.__type_name = type_name + self.__name = name or type_name + self.__factory = factory + self.__brush = QtGui.QBrush() + self.__pen = QtGui.QPen() + + def uniqueId(self): + return self.__unique_id + + def pen(self): + return self.__pen + + def setPen(self, pen): + self.__pen = pen + self.update() + + def brush(self): + return self.__brush + + def setBrush(self, brush): + self.__brush = brush + self.update() + + def name(self): + return self.__name + + def setName(self, name): + self.__name = name + + def typeName(self): + return self.__type_name + + def toDict(self): + return BaseElementData( + node_type=self.__type_name, + name=self.__name, + position=(self.pos().x(), self.pos().y()), + unique_id=self.__unique_id, + ) + + def fromDict(self, data: BaseElementData): + self.__type_name = data["node_type"] + self.__name = data["name"] + self.__unique_id = data["unique_id"] + self.setPos(QtCore.QPointF(data["position"][0], data["position"][1])) diff --git a/src/radium/nodegraph/graph/scene/event_filter.py b/src/radium/nodegraph/graph/scene/event_filter.py index 840f090..5a5237d 100644 --- a/src/radium/nodegraph/graph/scene/event_filter.py +++ b/src/radium/nodegraph/graph/scene/event_filter.py @@ -8,6 +8,7 @@ from PySide6 import QtCore, QtGui, QtWidgets from radium.nodegraph.graph.scene import commands +from radium.nodegraph.graph.scene.group import Group from radium.nodegraph.graph.scene.node import Node from radium.nodegraph.graph.scene.dot import Dot from radium.nodegraph.graph.scene.connection import Connection @@ -222,7 +223,13 @@ def mouseMoveEvent(self, event): scene_item = self.itemAt(event.scenePos()) - if isinstance(scene_item, (Port, Dot, Node)): + if ( + isinstance(scene_item, Group) + and event.modifiers() & QtCore.Qt.KeyboardModifier.ControlModifier + ): + self.preview_rect.hide() + + elif isinstance(scene_item, (Port, Dot, Node)): end_port = get_potential_port(self.start_port, scene_item, event.scenePos()) if end_port and end_port.canConnectTo(self.start_port): self.preview_rect.setRect( @@ -246,9 +253,27 @@ def mouseReleaseEvent(self, event): scene_item = self.controller.scene.itemAt(event.scenePos(), QtGui.QTransform()) - if isinstance(scene_item, (Port, Dot, Node)): + if ( + isinstance(scene_item, Group) + and event.modifiers() & QtCore.Qt.KeyboardModifier.ControlModifier + ): + + if isinstance(self.start_port, InputPort): + name = scene_item.getUniqueOutputName(self.start_port.name()) + output_port = scene_item.addOutput(name, self.start_port.datatype()) + input_port = self.start_port + else: + name = scene_item.getUniqueInputName(self.start_port.name()) + input_port = scene_item.addInput(name, self.start_port.datatype()) + output_port = self.start_port + cmd = commands.CreateConnectionCommand( + self.controller.scene, output_port, input_port + ) + self.controller.undo_stack.push(cmd) + + elif isinstance(scene_item, (Port, Dot, Node)): end_port = get_potential_port(self.start_port, scene_item, event.scenePos()) - if end_port.canConnectTo(self.start_port): + if end_port and end_port.canConnectTo(self.start_port): output_port, input_port = sort_ports(self.start_port, end_port) cmd = commands.CreateConnectionCommand( diff --git a/src/radium/nodegraph/graph/scene/group.py b/src/radium/nodegraph/graph/scene/group.py new file mode 100644 index 0000000..af33a7a --- /dev/null +++ b/src/radium/nodegraph/graph/scene/group.py @@ -0,0 +1,124 @@ +import typing +from PySide6 import QtGui, QtCore + +from radium.nodegraph.graph.scene.node import Node +from radium.nodegraph.graph.scene.node_base import NodeDataDict +from radium.nodegraph.graph.scene.scene import NodeGraphScene +from radium.nodegraph.graph.scene.util import get_unique_name + +if typing.TYPE_CHECKING: + from radium.nodegraph.factory import NodeFactory + from radium.nodegraph.graph.scene.scene import SceneDataDict + + +class GroupDataDict(NodeDataDict): + scene: "SceneDataDict" + + +class Group(Node): + def __init__( + self, + factory: "NodeFactory", + type_name: str, + name: str, + parent=None, + ): + super().__init__(factory, type_name, name=name, parent=parent) + self.__scene = NodeGraphScene() + self.__scene.setGroup(self) + + def qualifiedName(self) -> str: + if self.scene() is None: + return self.name() + + if self.scene().group() is None: + return self.name() + + else: + return self.scene().group().name() + "." + self.name() + + def subScene(self): + return self.__scene + + def addInput(self, name: str, port_type: str): + if name not in self.__scene.inputs(): + group_input = self.factory().createNode("group_input") + group_input.setName(name) + group_input.addOutput(name, port_type) + group_input.calculateLayout() + + rect = group_input.boundingRect() + width = rect.width() + height = rect.height() + + x = ((width + 10) * len(self.__scene.inputs())) + 10 - width * 0.5 + y = -height * 2 + + group_input.setPos(x, y) + self.__scene.addItem(group_input) + + return super().addInput(name, port_type) + + def addOutput(self, name: str, port_type: str): + if name not in self.__scene.outputs(): + group_output = self.factory().createNode("group_output") + group_output.setName(name) + group_output.addInput(name, port_type) + group_output.calculateLayout() + + rect = group_output.boundingRect() + width = rect.width() + height = rect.height() + + x = ((width + 10) * len(self.__scene.outputs())) + 10 - width * 0.5 + y = height * 2 + + group_output.setPos(x, y) + self.__scene.addItem(group_output) + + return super().addOutput(name, port_type) + + def getUniqueInputName(self, prefix: str) -> str: + return get_unique_name(prefix, self.inputs().keys()) + + def getUniqueOutputName(self, prefix: str) -> str: + return get_unique_name(prefix, self.outputs().keys()) + + def toDict(self): + data = super().toDict() + data["scene"] = self.__scene.toDict() + return data + + def loadDict(self, data: GroupDataDict) -> None: + self.__scene.loadDict(data["scene"], self.factory()) + super().loadDict(data) + + def clipPath(self): + rect = self.baseBoundingRect() + path = QtGui.QPainterPath() + + x0 = rect.left() + x3 = rect.right() + y0 = rect.top() + y3 = rect.bottom() + + h = rect.height() + offset = h * 0.2 + + points = [ + QtCore.QPoint(x0 + offset, y0), + QtCore.QPoint(x3 - offset, y0), + QtCore.QPoint(x3, y0 + offset), + QtCore.QPoint(x3, y3 - offset), + QtCore.QPoint(x3 - offset, y3), + QtCore.QPoint(x0 + offset, y3), + QtCore.QPoint(x0, y3 - offset), + QtCore.QPoint(x0, y0 + offset), + ] + path.moveTo(points[0]) + for point in points[1:]: + path.lineTo(point) + + path.closeSubpath() + + return path diff --git a/src/radium/nodegraph/graph/scene/node.py b/src/radium/nodegraph/graph/scene/node.py index a5fe274..bd644e3 100644 --- a/src/radium/nodegraph/graph/scene/node.py +++ b/src/radium/nodegraph/graph/scene/node.py @@ -40,13 +40,20 @@ def addInput(self, name: str, port_type: str): raise ValueError(f"input: {name} already exists") port = self.factory().createPort(InputPort, name, port_type) - self.__inputs[name] = port port.setParentItem(self) + self.__inputs[name] = port + self.invalidateLayout() + + return port + def addOutput(self, name: str, datatype: str): if self.hasOutput(name): raise ValueError(f"output: {name} already exists") port = self.factory().createPort(OutputPort, name, datatype) - self.__outputs[name] = port port.setParentItem(self) + + self.__outputs[name] = port + self.invalidateLayout() + return port diff --git a/src/radium/nodegraph/graph/scene/node_base.py b/src/radium/nodegraph/graph/scene/node_base.py index 9267c1c..b99ed70 100644 --- a/src/radium/nodegraph/graph/scene/node_base.py +++ b/src/radium/nodegraph/graph/scene/node_base.py @@ -33,7 +33,7 @@ class NodeDataDict(typing.TypedDict): parameters: typing.Dict[str, "ParameterDataDict"] -class _NodeBase(QtWidgets.QGraphicsItem): +class NodeCore(QtWidgets.QGraphicsItem): def __init__( self, factory: "NodeFactory", @@ -107,9 +107,7 @@ def isEdited(self): def setEdited(self, edited: bool): self.__edited = edited - if hasattr(self.scene(), "nodeEdited"): - self.scene().nodeEdited.emit(self) # noqa - + self.scene().nodeEdited.emit(self) # noqa self.update() def isViewed(self): @@ -121,8 +119,7 @@ def setViewed(self, viewed: bool): def setSelected(self, selected: bool): super().setSelected(selected) - if hasattr(self.scene(), "nodeSelected"): - self.scene().nodeSelected.emit(self) # noqa + self.scene().nodeSelected.emit(self) # noqa self.update() @@ -174,7 +171,7 @@ def loadDict(self, data: NodeDataDict) -> None: ) -class _DrawableNode(_NodeBase): +class PillNode(NodeCore): """ This class encapsulates the drawing logic for nodes. """ @@ -204,8 +201,8 @@ def __init__( self.__pen = QtGui.QPen(QtGui.QPen(QtGui.QColor(24, 24, 24, 255), 4)) self.__brush = QtGui.QBrush(QtGui.QBrush(QtGui.QColor(64, 64, 64, 255))) - self.__edited_brush = QtGui.QBrush(QtGui.QColor(255, 64, 64, 255)) - self.__viewed_brush = QtGui.QBrush(QtGui.QColor(64, 64, 255, 255)) + self.__edited_brush = QtGui.QBrush(QtGui.QColor(255, 64, 64, 128)) + self.__viewed_brush = QtGui.QBrush(QtGui.QColor(64, 64, 255, 128)) self.__font = QtGui.QFont() self.__font_metrics = QtGui.QFontMetrics(self.__font) @@ -246,6 +243,9 @@ def brush(self): def boundingRect(self): return self.__bounding_rect + def baseBoundingRect(self): + return self.__bounding_rect + def setName(self, name: str): super().setName(name) self.__layout_required = True @@ -266,7 +266,7 @@ def calculateLayout(self, force=False): self.__text_rect = self.__font_metrics.boundingRect(self.name()) self.__text_rect.moveCenter(QtCore.QPoint(0, 0)) - # calculate the w/h of of the left/right indicator boxes + # calculate the w/h of the left/right indicator boxes indicator_rect_side = self.__text_rect.height() # calculate the nodes total width accounting for a min width, text width and input/output ports @@ -319,18 +319,19 @@ def calculateLayout(self, force=False): self.__layout_required = False + def clipPath(self): + path = QtGui.QPainterPath() + path.addRoundedRect( + self.__bounding_rect, self.__corner_radius, self.__corner_radius + ) + return path + def paint(self, painter: QtGui.QPainter, option, widget=None): self.calculateLayout() lod = option.levelOfDetailFromTransform(painter.transform()) - # create a clipping rect for the node with round corners if the border radius is above 0 - if self.__corner_radius and lod > 0.5: - path = QtGui.QPainterPath() - path.addRoundedRect( - self.__bounding_rect, self.__corner_radius, self.__corner_radius - ) - painter.setClipPath(path) + painter.setClipPath(self.clipPath()) # draw the nodes background painter.setPen(QtCore.Qt.PenStyle.NoPen) @@ -365,13 +366,10 @@ def paint(self, painter: QtGui.QPainter, option, widget=None): painter.setClipping(False) painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) - painter.setPen(self.__pen) - painter.drawRoundedRect( - self.__bounding_rect, self.__corner_radius, self.__corner_radius - ) + painter.strokePath(self.clipPath(), self.__pen) -class _SelectableNode(_DrawableNode): +class _SelectableNode(PillNode): """ This class encapsulates any custom node selection painting/logic """ diff --git a/src/radium/nodegraph/graph/scene/scene.py b/src/radium/nodegraph/graph/scene/scene.py index 2440038..8df8708 100644 --- a/src/radium/nodegraph/graph/scene/scene.py +++ b/src/radium/nodegraph/graph/scene/scene.py @@ -1,5 +1,5 @@ import typing -from PySide6 import QtWidgets, QtCore +from PySide6 import QtWidgets, QtGui, QtCore from radium.nodegraph.graph.scene.connection import Connection, ConnectionDataDict from radium.nodegraph.graph.scene.port import Port @@ -8,6 +8,7 @@ if typing.TYPE_CHECKING: from radium.nodegraph.factory import NodeFactory + from radium.nodegraph.graph.scene.group import Group class SceneDataDict(typing.TypedDict): @@ -29,6 +30,20 @@ def __init__(self, parent=None): super().__init__(parent) self.setSceneRect(-10000, -10000, 20000, 20000) self.__port_to_connections: typing.Dict[Port, typing.List[Connection]] = {} + self.__view_transform = QtGui.QTransform() + self.__group: typing.Optional[Group] = None + + def group(self): + return self.__group + + def setGroup(self, group): + self.__group = group + + def inputs(self): + return {n.name(): n for n in self.nodes(node_type="group_input")} + + def outputs(self): + return {n.name(): n for n in self.nodes(node_type="group_output")} def addItem(self, item): if isinstance(item, Connection): @@ -45,8 +60,16 @@ def removeItem(self, item): self.itemRemoved.emit(item) - def nodes(self): - return [n for n in self.items() if isinstance(n, Node)] + def nodes(self, node_type=None): + if node_type is None: + return [n for n in self.items() if isinstance(n, Node)] + else: + return [ + n + for n in self.items() + if isinstance(n, Node) + if n.nodeType() == node_type + ] def selectedNodes(self): return [n for n in self.selectedItems() if isinstance(n, Node)] @@ -90,6 +113,7 @@ def toDict(self) -> SceneDataDict: for node in self.nodes(): nodes[node.uniqueId()] = node.toDict() + for port in node.inputs().values(): found_connections.update(self.getConnections(port)) for port in node.outputs().values(): diff --git a/src/radium/nodegraph/graph/scene/util.py b/src/radium/nodegraph/graph/scene/util.py new file mode 100644 index 0000000..9251c8e --- /dev/null +++ b/src/radium/nodegraph/graph/scene/util.py @@ -0,0 +1,13 @@ +import sys +import typing + + +def get_unique_name(prefix, names: typing.Iterable[str]): + names = {n for n in names if n.startswith(prefix)} + if not names: + return prefix + + for i in range(1, sys.maxsize): + name = f"{prefix}_{i:03d}" + if name not in names: + return name diff --git a/src/radium/nodegraph/graph/view/view.py b/src/radium/nodegraph/graph/view/view.py index 1a8450f..e16749c 100644 --- a/src/radium/nodegraph/graph/view/view.py +++ b/src/radium/nodegraph/graph/view/view.py @@ -1,4 +1,8 @@ +import typing +import logging + from PySide6 import QtCore, QtWidgets, QtGui, QtOpenGLWidgets + from radium.nodegraph.graph import util from radium.nodegraph.graph.view.event_filter import ( @@ -6,8 +10,37 @@ DragDropEventFilter, ) +from radium.nodegraph.graph.scene import NodeGraphScene + +logger = logging.getLogger(__name__) + + +class NodeGraphView(QtWidgets.QWidget): + itemDoubleClicked = QtCore.Signal(QtWidgets.QGraphicsItem) + createNodeRequested = QtCore.Signal(str, QtCore.QPointF) + + def __init__(self, parent=None): + super().__init__(parent) + self.viewer = NodeGraphViewport() + self.top_text = QtWidgets.QLineEdit() + self.top_text.setReadOnly(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.top_text) + layout.addWidget(self.viewer) + + self.viewer.itemDoubleClicked.connect(self.itemDoubleClicked) + self.viewer.createNodeRequested.connect(self.createNodeRequested) + + def setScene(self, scene: NodeGraphScene): + self.viewer.setScene(scene) + + def setHudText(self, text: str): + self.top_text.setText(text) -class NodeGraphView(QtWidgets.QGraphicsView): + +class NodeGraphViewport(QtWidgets.QGraphicsView): + itemDoubleClicked = QtCore.Signal(QtWidgets.QGraphicsItem) createNodeRequested = QtCore.Signal(str, QtCore.QPointF) def __init__(self, parent=None): @@ -29,12 +62,24 @@ def __init__(self, parent=None): self.__hovered_item = None self.__node_creation_pos = QtCore.QPointF(0, 0) + self.__hud_text = "" + + def setHudText(self, text): + self.__hud_text = text + self.update() def onNodeTypeDropped(self, node_type: str): cursor = QtGui.QCursor.pos() scene_pos = self.mapToScene(self.mapFromGlobal(cursor)) self.createNodeRequested.emit(node_type, scene_pos) + def mouseDoubleClickEvent(self, event): + super().mouseDoubleClickEvent(event) + + item = self.itemAt(event.pos()) + if item: + self.itemDoubleClicked.emit(item) + def drawBackground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: """ Fill in the background of the graph, and draw a grid. @@ -47,7 +92,7 @@ def drawBackground(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None: def installEventFilter(self, filterObj): if isinstance(filterObj, NavigationEventFilter): - print( + logger.warning( "WARNING: NodeGraphViewEventFilter should be installed" " on the views view, not the view" )