diff --git a/Example_Project/Add_node.py b/Example_Project/Add_node.py new file mode 100644 index 0000000..d1cd508 --- /dev/null +++ b/Example_Project/Add_node.py @@ -0,0 +1,13 @@ +from node_editor.gui.node import Node + + +class Add_Node(Node): + def __init__(self): + super().__init__() + + self.title = "Add" + self.type_text = "Logic Nodes" + self.add_port(name="input A", is_output=False) + self.add_port(name="input B", is_output=False) + self.add_port(name="output", is_output=True) + self.build() diff --git a/Example_Project/test.json b/Example_Project/test.json new file mode 100644 index 0000000..0aee684 --- /dev/null +++ b/Example_Project/test.json @@ -0,0 +1,84 @@ +{ + "nodes": [ + { + "type": "Add_Node", + "x": 5374, + "y": 4897, + "uuid": "6e09cc9a-8b79-4129-bf61-637c6b8eb39b" + }, + { + "type": "Add_Node", + "x": 5165, + "y": 5007, + "uuid": "0fe0381a-29bc-4987-ba94-ba4a14e33a13" + }, + { + "type": "Add_Node", + "x": 4949, + "y": 4926, + "uuid": "0a3b1313-e73c-4f3a-b878-7a7a8a76518b" + }, + { + "type": "Add_Node", + "x": 4943, + "y": 5114, + "uuid": "e06066a5-6375-4a40-8d14-c7e5e9c28f02" + }, + { + "type": "Add_Node", + "x": 4617, + "y": 5014, + "uuid": "a06586f8-3c66-48ca-8be7-fd99b9ee82c3" + }, + { + "type": "Add_Node", + "x": 4666, + "y": 4864, + "uuid": "24ac8fd5-b91a-4c35-bfdc-bf96961da280" + }, + { + "type": "Add_Node", + "x": 4794, + "y": 5130, + "uuid": "f20b2350-0bc9-40be-9ddd-510d16ae8cc8" + } + ], + "connections": [ + { + "start_id": "24ac8fd5-b91a-4c35-bfdc-bf96961da280", + "end_id": "0a3b1313-e73c-4f3a-b878-7a7a8a76518b", + "start_port": "output", + "end_port": "input A" + }, + { + "start_id": "a06586f8-3c66-48ca-8be7-fd99b9ee82c3", + "end_id": "0a3b1313-e73c-4f3a-b878-7a7a8a76518b", + "start_port": "output", + "end_port": "input B" + }, + { + "start_id": "e06066a5-6375-4a40-8d14-c7e5e9c28f02", + "end_id": "0fe0381a-29bc-4987-ba94-ba4a14e33a13", + "start_port": "output", + "end_port": "input B" + }, + { + "start_id": "0a3b1313-e73c-4f3a-b878-7a7a8a76518b", + "end_id": "0fe0381a-29bc-4987-ba94-ba4a14e33a13", + "start_port": "output", + "end_port": "input A" + }, + { + "start_id": "0fe0381a-29bc-4987-ba94-ba4a14e33a13", + "end_id": "6e09cc9a-8b79-4129-bf61-637c6b8eb39b", + "start_port": "output", + "end_port": "input B" + }, + { + "start_id": "f20b2350-0bc9-40be-9ddd-510d16ae8cc8", + "end_id": "e06066a5-6375-4a40-8d14-c7e5e9c28f02", + "start_port": "output", + "end_port": "input B" + } + ] +} \ No newline at end of file diff --git a/main.py b/main.py index 97e9856..d95f416 100644 --- a/main.py +++ b/main.py @@ -11,20 +11,26 @@ """ import logging +from pathlib import Path +import importlib +import inspect from PySide6 import QtCore, QtGui, QtWidgets from node_editor.gui.node_list import NodeList -from node_editor.gui.node_type_editor import NodeTypeEditor from node_editor.gui.node_widget import NodeWidget logging.basicConfig(level=logging.DEBUG) class NodeEditor(QtWidgets.QMainWindow): + OnProjectPathUpdate = QtCore.Signal(Path) + def __init__(self, parent=None): super().__init__(parent) self.settings = None + self.project_path = None + self.imports = None # we will store the project import node types here for now. icon = QtGui.QIcon("resources\\app.ico") self.setWindowIcon(icon) @@ -32,6 +38,18 @@ def __init__(self, parent=None): self.setWindowTitle("Simple Node Editor") settings = QtCore.QSettings("node-editor", "NodeEditor") + # create a "File" menu and add an "Export CSV" action to it + file_menu = QtWidgets.QMenu("File", self) + self.menuBar().addMenu(file_menu) + + load_action = QtGui.QAction("Load Project", self) + load_action.triggered.connect(self.load_project) + file_menu.addAction(load_action) + + save_action = QtGui.QAction("Save Project", self) + save_action.triggered.connect(self.save_project) + file_menu.addAction(save_action) + # Layouts main_widget = QtWidgets.QWidget() self.setCentralWidget(main_widget) @@ -41,23 +59,20 @@ def __init__(self, parent=None): left_layout.setContentsMargins(0, 0, 0, 0) # Widgets - self.node_list = NodeList() + self.node_list = NodeList(self) left_widget = QtWidgets.QWidget() self.splitter = QtWidgets.QSplitter() self.node_widget = NodeWidget(self) - new_node_type_btn = QtWidgets.QPushButton("New Node Type") - new_node_type_btn.setFixedHeight(50) # Add Widgets to layouts self.splitter.addWidget(left_widget) self.splitter.addWidget(self.node_widget) left_widget.setLayout(left_layout) left_layout.addWidget(self.node_list) - left_layout.addWidget(new_node_type_btn) main_layout.addWidget(self.splitter) - # Logic - new_node_type_btn.clicked.connect(self.new_node_cmd) + # Signals + self.load_project("C:/Users/Howard/simple-node-editor/Example_project") # Restore GUI from last state if settings.contains("geometry"): @@ -66,19 +81,47 @@ def __init__(self, parent=None): s = settings.value("splitterSize") self.splitter.restoreState(s) - def new_node_cmd(self): - """ - Handles the New Node Type button click event by showing the NodeTypeEditor dialog. + def save_project(self): + file_dialog = QtWidgets.QFileDialog() + file_dialog.setAcceptMode(QtWidgets.QFileDialog.AcceptSave) + file_dialog.setDefaultSuffix("json") + file_dialog.setNameFilter("JSON files (*.json)") + file_path, _ = file_dialog.getSaveFileName() + self.node_widget.save_project(file_path) - Returns: - None. - """ - node_editor = NodeTypeEditor() + def load_project(self, project_path=None): + if not project_path: + return + + project_path = Path(project_path) + if project_path.exists() and project_path.is_dir(): + self.project_path = project_path - if node_editor.exec() == QtWidgets.QDialog.Accepted: - print("Dialog accepted") - else: - print("Dialog canceled") + self.imports = {} + + for file in project_path.glob("*.py"): + spec = importlib.util.spec_from_file_location(file.stem, file) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + for name, obj in inspect.getmembers(module): + if inspect.isclass(obj): + self.imports[obj.__name__] = {"class": obj, "module": module} + break + + self.node_list.update_project(self.imports) + + # work on just the first json file. add the ablitity to work on multiple json files later + for json_path in project_path.glob("*.json"): + self.node_widget.load_scene(json_path, self.imports) + break + + def get_project_path(self): + project_path = QtWidgets.QFileDialog.getExistingDirectory(None, "Select Project Folder", "") + if not project_path: + return + + self.load_project(project_path) def closeEvent(self, event): """ @@ -90,6 +133,10 @@ def closeEvent(self, event): Returns: None. """ + + # debugging lets save the scene: + # self.node_widget.save_project("C:/Users/Howard/simple-node-editor/Example_Project/test.json") + self.settings = QtCore.QSettings("node-editor", "NodeEditor") self.settings.setValue("geometry", self.saveGeometry()) self.settings.setValue("splitterSize", self.splitter.saveState()) diff --git a/node_editor/gui/connection.py b/node_editor/gui/connection.py index ba0f7f4..2c36d31 100644 --- a/node_editor/gui/connection.py +++ b/node_editor/gui/connection.py @@ -80,7 +80,7 @@ def nodes(self): Returns: tuple: A tuple of the two Node objects connected by this Connection. """ - return (self._start_port().node(), self._end_port().node()) + return (self._start_port.node(), self._end_port.node()) def update_start_and_end_pos(self): """ diff --git a/node_editor/gui/node.py b/node_editor/gui/node.py index b46fe1f..f704ddc 100644 --- a/node_editor/gui/node.py +++ b/node_editor/gui/node.py @@ -46,6 +46,7 @@ def __init__(self): self._width = 30 # The Width of the node self._height = 30 # the height of the node self._ports = [] # A list of ports + self.uuid = None # An identifier to used when saving and loading the scene self.node_color = QtGui.QColor(20, 20, 20, 200) @@ -96,6 +97,11 @@ def paint(self, painter, option=None, widget=None): painter.drawPath(self.type_path) painter.drawPath(self.misc_path) + def get_port(self, name): + for port in self._ports: + if port.name() == name: + return port + def add_port(self, name, is_output=False, flags=0, ptr=None): """ Adds a new port to the node. diff --git a/node_editor/gui/node_list.py b/node_editor/gui/node_list.py index 85d8541..e89bad8 100644 --- a/node_editor/gui/node_list.py +++ b/node_editor/gui/node_list.py @@ -1,15 +1,22 @@ from PySide6 import QtCore, QtGui, QtWidgets +import sys +import importlib +import inspect class NodeList(QtWidgets.QListWidget): def __init__(self, parent=None): super().__init__(parent) + self.setDragEnabled(True) # enable dragging - for node in ["Input", "Output", "And", "Not", "Nor", "Empty"]: - item = QtWidgets.QListWidgetItem(node) - self.addItem(item) + def update_project(self, imports): + # make an item for each custom class - self.setDragEnabled(True) # enable dragging + for name, data in imports.items(): + item = QtWidgets.QListWidgetItem(name) + item.module = data["module"] + item.class_name = data["class"] + self.addItem(item) def mousePressEvent(self, event): item = self.itemAt(event.pos()) @@ -19,6 +26,7 @@ def mousePressEvent(self, event): drag = QtGui.QDrag(self) mime_data = QtCore.QMimeData() mime_data.setText(name) + mime_data.item = item drag.setMimeData(mime_data) # Drag needs a pixmap or else it'll error due to a null pixmap diff --git a/node_editor/gui/node_type_editor.py b/node_editor/gui/node_type_editor.py deleted file mode 100644 index 78ea850..0000000 --- a/node_editor/gui/node_type_editor.py +++ /dev/null @@ -1,39 +0,0 @@ -from PySide6 import QtWidgets - - -class NodeTypeEditor(QtWidgets.QDialog): - """ - A dialog window for editing node types. - - Attributes: - edit (QtWidgets.QLineEdit): A line edit widget for editing the node type. - """ - - def __init__(self, parent=None): - """ - Constructs a NodeTypeEditor object. - - Args: - parent (QWidget): The parent widget of this dialog. - """ - super().__init__(parent) - - self.setWindowTitle("Node Type Editor") - - # create the UI elements - label = QtWidgets.QLabel("Node Type:") - self.edit = QtWidgets.QLineEdit() - button_ok = QtWidgets.QPushButton("OK") - button_cancel = QtWidgets.QPushButton("Cancel") - - # set the layout - layout = QtWidgets.QHBoxLayout() - layout.addWidget(label) - layout.addWidget(self.edit) - layout.addWidget(button_ok) - layout.addWidget(button_cancel) - self.setLayout(layout) - - # connect the signals and slots - button_ok.clicked.connect(self.accept) - button_cancel.clicked.connect(self.reject) diff --git a/node_editor/gui/node_widget.py b/node_editor/gui/node_widget.py index 9005a76..6284ff3 100644 --- a/node_editor/gui/node_widget.py +++ b/node_editor/gui/node_widget.py @@ -1,65 +1,16 @@ +import json +import uuid +from collections import OrderedDict + from PySide6 import QtGui, QtWidgets from node_editor.gui.node import Node from node_editor.gui.node_editor import NodeEditor from node_editor.gui.view import View - -def create_input(): - node = Node() - node.title = "A" - node.type_text = "input" - node.add_port(name="output", is_output=True) - node.build() - return node - - -def create_output(): - node = Node() - node.title = "A" - node.type_text = "output" - node.add_port(name="input", is_output=False) - node.build() - return node - - -def create_and(): - node = Node() - node.title = "AND" - node.type_text = "built-in" - node.add_port(name="input A", is_output=False) - node.add_port(name="input B", is_output=False) - node.add_port(name="output", is_output=True) - node.build() - return node - - -def create_not(): - node = Node() - node.title = "NOT" - node.type_text = "built-in" - node.add_port(name="input", is_output=False) - node.add_port(name="output", is_output=True) - node.build() - return node - - -def create_nor(): - node = Node() - node.title = "NOR" - node.type_text = "built-in" - node.add_port(name="input", is_output=False) - node.add_port(name="output", is_output=True) - node.build() - return node - - -def create_empty(): - node = Node() - node.title = "NOR" - node.type_text = "empty node" - node.build() - return node +from node_editor.gui.connection import Connection +from node_editor.gui.node import Node +from node_editor.gui.port import Port class NodeScene(QtWidgets.QGraphicsScene): @@ -95,6 +46,8 @@ def __init__(self, parent): parent (QWidget): The parent widget. """ super().__init__(parent) + + self.node_lookup = {} # A dictionary of nodes, by uuids for faster looking up. Refactor this in the future main_layout = QtWidgets.QVBoxLayout() main_layout.setContentsMargins(0, 0, 0, 0) self.setLayout(main_layout) @@ -110,32 +63,94 @@ def __init__(self, parent): self.view.request_node.connect(self.create_node) - def create_node(self, name): - """ - Creates a new node and adds it to the node editor. - - Args: - name (str): The name of the node to be created. - """ - print("creating node:", name) - - if name == "Input": - node = create_input() - elif name == "Output": - node = create_output() - elif name == "And": - node = create_and() - elif name == "Not": - node = create_not() - elif name == "Nor": - node = create_nor() - elif name == "Empty": - node = create_empty() - else: - print(f"Can't find a premade node for {name}") - return - + def create_node(self, node): + node.uuid = uuid.uuid4() self.scene.addItem(node) - pos = self.view.mapFromGlobal(QtGui.QCursor.pos()) node.setPos(self.view.mapToScene(pos)) + + def load_scene(self, json_path, imports): + # load the scene json file + data = None + with open(json_path) as f: + data = json.load(f) + + # clear out the node lookup + self.node_lookup = {} + + # Add the nodes + if data: + for node in data["nodes"]: + info = imports[node["type"]] + node_item = info["class"]() + node_item.uuid = node["uuid"] + self.scene.addItem(node_item) + node_item.setPos(node["x"], node["y"]) + + self.node_lookup[node["uuid"]] = node_item + + # Add the connections + for c in data["connections"]: + connection = Connection(None) + self.scene.addItem(connection) + + start_port = self.node_lookup[c["start_id"]].get_port(c["start_port"]) + end_port = self.node_lookup[c["end_id"]].get_port(c["end_port"]) + + connection.start_port = start_port + connection.end_port = end_port + connection.update_start_and_end_pos() + + def save_project(self, json_path): + # print(f"json path: {json_path}") + + from collections import OrderedDict + + # TODO possibly an ordered dict so things stay in order (better for git changes, and manual editing) + # Maybe connections will need a uuid for each so they can be sorted and kept in order. + scene = {"nodes": [], "connections": []} + + # Need the nodes, and connections of ports to nodes + for item in self.scene.items(): + # Connections + if isinstance(item, Connection): + # print(f"Name: {item}") + nodes = item.nodes() + start_id = str(nodes[0].uuid) + end_id = str(nodes[1].uuid) + start_port = item.start_port.name() + end_port = item.end_port.name() + # print(f"Node ids {start_id, end_id}") + # print(f"connected ports {item.start_port.name(), item.end_port.name()}") + + connection = { + "start_id": start_id, + "end_id": end_id, + "start_port": start_port, + "end_port": end_port, + } + scene["connections"].append(connection) + continue + + # Ports + if isinstance(item, Port): + continue + + # Nodes + if isinstance(item, Node): + # print("found node") + pos = item.pos().toPoint() + x, y = pos.x(), pos.y() + # print(f"pos: {x, y}") + + obj_type = type(item).__name__ + # print(f"node type: {obj_type}") + + node_id = str(item.uuid) + + node = {"type": obj_type, "x": x, "y": y, "uuid": node_id} + scene["nodes"].append(node) + + # Write the items_info dictionary to a JSON file + with open(json_path, "w") as f: + json.dump(scene, f, indent=4) diff --git a/node_editor/gui/view.py b/node_editor/gui/view.py index 141d8d9..03de4ec 100644 --- a/node_editor/gui/view.py +++ b/node_editor/gui/view.py @@ -18,7 +18,7 @@ class View(QtWidgets.QGraphicsView): _mouse_wheel_zoom_rate = 0.0015 - request_node = QtCore.Signal(str) + request_node = QtCore.Signal(object) def __init__(self, parent): super().__init__(parent) @@ -184,8 +184,8 @@ def dropEvent(self, e): This method is called when a drag and drop event is dropped onto the view. It retrieves the name of the dropped node from the mime data and emits a signal to request the creation of the corresponding node. """ - drop_node_name = e.mimeData().text() - self.request_node.emit(drop_node_name) + node = e.mimeData().item.class_name + self.request_node.emit(node()) def mousePressEvent(self, event): """