From 3288b7c7d4f99643afd62926368f530f684bf042 Mon Sep 17 00:00:00 2001 From: Chirag Koyande Date: Fri, 13 Nov 2026 19:30:02 +0530 Subject: [PATCH 1/2] Fix component editability after simulation --- src/main/python/utils/Container.py | 258 ++++++++++---- src/main/python/utils/Graphics.py | 547 +++++++++++++++++++++++++++-- 2 files changed, 722 insertions(+), 83 deletions(-) diff --git a/src/main/python/utils/Container.py b/src/main/python/utils/Container.py index 97a8e16..dcd0d55 100644 --- a/src/main/python/utils/Container.py +++ b/src/main/python/utils/Container.py @@ -36,6 +36,7 @@ def __init__(self, msgbrowser, graphicsView): self.thermo_package = None self.compounds = None self.flowsheet = None + self.simulation_successful = False self.conn = defaultdict(list) self.op = defaultdict(list) self.ip = defaultdict(list) @@ -157,62 +158,67 @@ def delete(self, l): def update_components_editability(self): """ - Recalculate and update the editable/read-only state of every component - in the scene based strictly on its current connection state. - - - If a component has an incoming connection (connected to the output of an upstream component): - Its property window becomes read-only / disabled (e.g., product stream). - - If a component has NO incoming connections (feed stream or isolated component): - Its property window becomes editable immediately, even post-simulation. - - If a component is completely disconnected from all other components: - Its property window is immediately re-enabled. + Update component editability based on simulation state + and current incoming connections. """ + try: scene = getattr(self, "scene", None) + if scene is None and hasattr(self, "graphics"): scene = getattr(self.graphics, "scene", None) + if scene is None: return for item in scene.items(): + if not isinstance(item, NodeItem): continue dw = getattr(item, "dock_widget", None) + if dw is None: continue - # Check if item has any incoming connection from an upstream component's output - has_incoming_connection = False - for sock in getattr(item, "input", []): - if len(getattr(sock, "in_lines", [])) > 0: - has_incoming_connection = True - break + # Before successful simulation, all components remain editable. + if not self.simulation_successful: - # Check if item has any outgoing connection - has_outgoing_connection = False - for sock in getattr(item, "output", []): - if len(getattr(sock, "out_lines", [])) > 0: - has_outgoing_connection = True - break + if item.type == "MaterialStream": + if hasattr(item.obj, "enableInputDataTab"): + item.obj.enableInputDataTab(dw) + elif hasattr(dw, "set_read_only"): + dw.set_read_only(False) - is_connected = has_incoming_connection or has_outgoing_connection + elif hasattr(dw, "set_read_only"): + dw.set_read_only(False) + + continue + + # After successful simulation, check incoming connections. + has_incoming_connection = any( + len(getattr(sock, "in_lines", [])) > 0 + for sock in getattr(item, "input", []) + ) + + if item.type == "MaterialStream": - if getattr(item, "type", "") == "MaterialStream": if has_incoming_connection: if hasattr(item.obj, "disableInputDataTab"): item.obj.disableInputDataTab(dw) elif hasattr(dw, "set_read_only"): dw.set_read_only(True) + else: if hasattr(item.obj, "enableInputDataTab"): item.obj.enableInputDataTab(dw) elif hasattr(dw, "set_read_only"): dw.set_read_only(False) + else: - if not is_connected or not has_incoming_connection: - if hasattr(dw, "set_read_only"): - dw.set_read_only(False) + if hasattr(dw, "set_read_only"): + dw.set_read_only(has_incoming_connection) + except Exception as e: print(f"[DEBUG] update_components_editability failed: {e}") @@ -242,12 +248,16 @@ def msg_browser(self): self.signals.msg_signal.emit(f"{stderr}") # ----------------- Simulation ----------------- + def simulate(self, mode): """Run simulation (safe + verbose).""" # Lock UI while simulating self.disableInterfaceforSimulation(True) + # Reset simulation state before every new attempt + self.simulation_successful = False + try: print("[DEBUG] ==== Starting Container.simulate ====") print("[DEBUG] mode:", mode) @@ -275,29 +285,41 @@ def simulate(self, mode): pass show_warning = False - + # Calculate total connections - total_in = sum(len(getattr(ip, "in_lines", [])) for ip in getattr(item, "input", [])) - total_out = sum(len(getattr(op, "out_lines", [])) for op in getattr(item, "output", [])) - + total_in = sum( + len(getattr(ip, "in_lines", [])) + for ip in getattr(item, "input", []) + ) + total_out = sum( + len(getattr(op, "out_lines", [])) + for op in getattr(item, "output", []) + ) + if item.type in ['MaterialStream', 'EngStm']: - # Streams only need to be connected on one side (feed or product) + # Streams only need to be connected on one side if total_in == 0 and total_out == 0: show_warning = True missing_connections.append((item.name, "unconnected")) else: - # Standard operations must have all their created sockets connected + # Standard operations must have all created sockets connected for ip in getattr(item, "input", []): if len(getattr(ip, "in_lines", [])) == 0: show_warning = True missing_connections.append((item.name, "input")) + for op in getattr(item, "output", []): if len(getattr(op, "out_lines", [])) == 0: show_warning = True missing_connections.append((item.name, "output")) if show_warning: - warnings_html += f"
Warning: {item.name} - Missing Socket Connection(s)." + warnings_html += ( + f"
" + f"Warning: {item.name} - Missing Socket Connection(s)." + f"" + ) + except Exception as e: print("[DEBUG] Error checking connections:", item, e) @@ -309,6 +331,7 @@ def simulate(self, mode): # Step 2: Prepare Compounds # ---------------------------- compounds = getattr(self, "compounds", None) + if not compounds: try: compounds = globals().get("compound_selected", None) @@ -325,19 +348,22 @@ def simulate(self, mode): # ---------------------------- try: self.flowsheet = Flowsheet() + if compounds: compound_names = [ c[:c.index('(')].strip() if '(' in c else c.strip() for c in compounds ] + self.flowsheet.add_compound_list(compound_names) print("[DEBUG] Flowsheet compounds added:", compound_names) + except Exception as e: self.signals.msg_signal.emit( - f"[{self.current_time()}] Failed to prepare Flowsheet: {e}" + f"[{self.current_time()}] " + f"Failed to prepare Flowsheet: {e}" ) print("[DEBUG] Failed to create Flowsheet:", e) - self.disableInterfaceforSimulation(False) return # ---------------------------- @@ -347,7 +373,11 @@ def simulate(self, mode): try: self.flowsheet.add_unit_operations(uo) except Exception as e: - print("[DEBUG] Failed to add unit operation:", getattr(uo, "name", uo), e) + print( + "[DEBUG] Failed to add unit operation:", + getattr(uo, "name", uo), + e + ) print("[DEBUG] Added unit operations:", len(self.unit_operations)) @@ -359,122 +389,226 @@ def simulate(self, mode): def try_get_other_node_from_line(ln, this_item): for a, b in [ - ("start_item", "end_item"), ("end_item", "start_item"), - ("source", "target"), ("target", "source"), - ("from_node", "to_node"), ("to_node", "from_node"), + ("start_item", "end_item"), + ("end_item", "start_item"), + ("source", "target"), + ("target", "source"), + ("from_node", "to_node"), + ("to_node", "from_node"), ]: try: if hasattr(ln, a) and hasattr(ln, b): a_val, b_val = getattr(ln, a), getattr(ln, b) + if a_val is this_item: other = b_val elif b_val is this_item: other = a_val else: continue + if other: - return getattr(getattr(other, "obj", other), "name", getattr(other, "name", None)) + return getattr( + getattr(other, "obj", other), + "name", + getattr(other, "name", None) + ) + except Exception: continue + return getattr(ln, "name", "UnknownLine") for it in self.graphics.scene.items(): if isinstance(it, NodeItem): - uo_name = getattr(it.obj, "name", getattr(it, "name", None)) + uo_name = getattr( + it.obj, + "name", + getattr(it, "name", None) + ) + if not uo_name: continue + for idx, port in enumerate(getattr(it, "input", [])): for ln in getattr(port, "in_lines", []): other = try_get_other_node_from_line(ln, it) self.ip[uo_name].append((idx, other)) + for idx, port in enumerate(getattr(it, "output", [])): for ln in getattr(port, "out_lines", []): other = try_get_other_node_from_line(ln, it) self.op[uo_name].append((idx, other)) - print("[DEBUG] Built ip/op maps. IP:", len(self.ip), "OP:", len(self.op)) + print( + "[DEBUG] Built ip/op maps. IP:", + len(self.ip), + "OP:", + len(self.op) + ) # ---------------------------- # Step 6: Simulation # ---------------------------- print("[DEBUG] Calling Flowsheet simulate.") self.result = None + if mode == 'SM': self.signals.msg_signal.emit( - f"[{self.current_time()}] Simulating in Sequential mode ... " + f"[{self.current_time()}] " + f"Simulating in Sequential mode ... " ) + try: self.flowsheet.simulate_SM(self.ip, self.op) - self.result = getattr(self.flowsheet, "result_data", []) + self.result = getattr( + self.flowsheet, + "result_data", + [] + ) + except Exception as e: print("[DEBUG] Sequential Mode Simulation failed:", e) + self.signals.msg_signal.emit( - f"[{self.current_time()}] Sequential Mode failed: {e}" + f"[{self.current_time()}] " + f"Sequential Mode failed: {e}" ) + self.result = None + else: self.signals.msg_signal.emit( - f"[{self.current_time()}] Simulating in Equation mode ... " + f"[{self.current_time()}] " + f"Simulating in Equation mode ... " ) + self.flowsheet.simulate_EQN(self.msg) - self.result = getattr(self.flowsheet, "result_data", []) + + self.result = getattr( + self.flowsheet, + "result_data", + [] + ) # ---------------------------- # Step 7: Success / Failure Report # ---------------------------- - if isinstance(self.result, (list, tuple)) and len(self.result) >= 2: + simulation_succeeded = ( + isinstance(self.result, (list, tuple)) + and len(self.result) >= 2 + ) + + if simulation_succeeded: + + self.simulation_successful = True + self.signals.msg_signal.emit( - f"[{self.current_time()}] Simulation Successful." + f"[{self.current_time()}] " + f"Simulation Successful." ) - # Lock all dock widgets to read-only on success + + # Temporarily keep widgets enabled. + # Actual editability is applied in Step 8. for dw in dock_widget_lst: if hasattr(dw, 'set_read_only'): dw.set_read_only(False) + else: + + self.simulation_successful = False + self.signals.msg_signal.emit( - f"[{self.current_time()}] Simulation Failed." + f"[{self.current_time()}] " + f"Simulation Failed." ) + try: - error_detail = getattr(self.flowsheet, "last_error", "") + error_detail = getattr( + self.flowsheet, + "last_error", + "" + ) + if error_detail: - safe_error = html.escape(error_detail).replace("\n", "
") + safe_error = html.escape(error_detail).replace( + "\n", + "
" + ) + self.signals.msg_signal.emit( - f"OpenModelica Error:
{safe_error}
" + f"" + f"OpenModelica Error:
" + f"{safe_error}
" ) + except Exception as msg_err: - print("[DEBUG] Failed to display simulation error detail:", msg_err) + print( + "[DEBUG] Failed to display simulation error detail:", + msg_err + ) + # Keep dock widgets editable on failure for dw in dock_widget_lst: if hasattr(dw, 'set_read_only'): dw.set_read_only(False) + self.update_components_editability() + # ---------------------------- - # Step 8: Post-Simulation Updates (only on success) + # Step 8: Post-Simulation Updates # ---------------------------- - if isinstance(self.result, (list, tuple)) and len(self.result) >= 2: + if simulation_succeeded: + for it in self.graphics.scene.items(): - if isinstance(it, NodeItem) and getattr(it, "type", "") == 'MaterialStream': + + if ( + isinstance(it, NodeItem) + and getattr(it, "type", "") == 'MaterialStream' + ): try: it.update_tooltip_selectedVar() + except Exception as tooltip_err: - print(f"[DEBUG] update_tooltip_selectedVar failed for {it.name}: {tooltip_err}") + print( + f"[DEBUG] update_tooltip_selectedVar failed " + f"for {it.name}: {tooltip_err}" + ) + # Apply read-only state based on incoming connections self.update_components_editability() - # Step 9: Populate Results Tabs (via signal for thread-safety) + # ---------------------------- + # Step 9: Populate Results Tabs + # ---------------------------- self.signals.results_ready.emit() except Exception as e: + + self.simulation_successful = False + print("[DEBUG] Simulation crashed:", e) + self.signals.msg_signal.emit( - f"[{self.current_time()}] Simulation error: {e}" + f"[{self.current_time()}] " + f"Simulation error: {e}" ) + # Restore editable state after a crash + try: + self.update_components_editability() + except Exception as editability_error: + print( + "[DEBUG] Failed to restore component editability:", + editability_error + ) + finally: + # Always re-enable UI self.disableInterfaceforSimulation(False) - print("[DEBUG] ==== Container.simulate finished ====") + print("[DEBUG] ==== Container.simulate finished ====") # Results Population diff --git a/src/main/python/utils/Graphics.py b/src/main/python/utils/Graphics.py index 1b7d175..a4083c4 100644 --- a/src/main/python/utils/Graphics.py +++ b/src/main/python/utils/Graphics.py @@ -208,19 +208,27 @@ def _draw_connection(src_name, src_socket_id, tgt_name, tgt_socket_id): import traceback; traceback.print_exc() + def save_canvas(self): try: from python.utils.ComponentSelector import compound_selected - data = {"items": [], "connections": [], "compounds": list(compound_selected)} + data = { + "items": [], + "connections": [], + "partial_connections": [], + "compounds": list(compound_selected) + } for item in self.scene.items(): if isinstance(item, NodeItem): obj = item.obj has_stms = hasattr(obj, "input_stms") and hasattr(obj, "output_stms") + if has_stms: saved_in, saved_out = obj.input_stms, obj.output_stms obj.input_stms, obj.output_stms = {}, {} + try: obj_copy = copy.deepcopy(obj) finally: @@ -235,24 +243,61 @@ def save_canvas(self): }) for item in self.scene.items(): - if isinstance(item, NodeLine): - src = getattr(item, "source", None) - tgt = getattr(item, "target", None) - if src is not None and tgt is not None and src.parent is not None and tgt.parent is not None: - data["connections"].append({ - "source_name": src.parent.obj.name, - "source_id": src.id, - "target_name": tgt.parent.obj.name, - "target_id": tgt.id, - }) - - print(f"[DEBUG] save_canvas: {len(data['items'])} items, {len(data['connections'])} connections") + if not isinstance(item, NodeLine): + continue + + src = item.source + tgt = item.target + + # Complete connection + if src is not None and tgt is not None: + if src.parent is None or tgt.parent is None: + continue + + data["connections"].append({ + "source_name": src.parent.obj.name, + "source_id": src.id, + "target_name": tgt.parent.obj.name, + "target_id": tgt.id, + }) + + # Partial connection with attached source + elif src is not None and tgt is None: + if src.parent is None: + continue + + data["partial_connections"].append({ + "attached_side": "source", + "attached_name": src.parent.obj.name, + "attached_id": src.id, + "free_point": (item.pointB.x(), item.pointB.y()), + }) + + # Partial connection with attached target + elif src is None and tgt is not None: + if tgt.parent is None: + continue + + data["partial_connections"].append({ + "attached_side": "target", + "attached_name": tgt.parent.obj.name, + "attached_id": tgt.id, + "free_point": (item.pointA.x(), item.pointA.y()), + }) + + print( + f"[DEBUG] save_canvas: {len(data['items'])} items, " + f"{len(data['connections'])} connections, " + f"{len(data['partial_connections'])} partial connections" + ) + return data except Exception as e: print("[DEBUG] save_canvas failed:", e) return None + def push_snapshot(self): from python.utils.undo_manager import push, clean_file try: @@ -373,6 +418,92 @@ def load_canvas_from_snapshot(self, snapshot, container): except Exception as e: print("[DEBUG] load_canvas_from_snapshot: rebuild connection failed:", e) + # Restore partial connections + for conn in snapshot.get("partial_connections", []): + try: + attached_item = name_to_item.get(conn["attached_name"]) + + if attached_item is None: + continue + + free_point_data = conn["free_point"] + free_point = QtCore.QPointF( + free_point_data[0], + free_point_data[1] + ) + + attached_side = conn["attached_side"] + + if attached_side == "source": + socket = next( + ( + s for s in attached_item.output + if s.id == conn["attached_id"] + ), + None + ) + + if socket is None: + continue + + if socket.out_lines: + print("[DEBUG] Partial source socket already occupied.") + continue + + line = NodeLine( + socket.get_center(), + free_point, + 'op' + ) + + line.source = socket + socket.out_lines.append(line) + + elif attached_side == "target": + socket = next( + ( + s for s in attached_item.input + if s.id == conn["attached_id"] + ), + None + ) + + if socket is None: + continue + + if socket.in_lines: + print("[DEBUG] Partial target socket already occupied.") + continue + + line = NodeLine( + free_point, + socket.get_center(), + 'in' + ) + + line.target = socket + socket.in_lines.append(line) + + else: + continue + + self.scene.addItem(line) + + # Rebuild route and draggable endpoint marker. + line.pointA = line.pointA + line.pointB = line.pointB + + print( + f"[DEBUG] Restored partial connection: {attached_side}" + ) + + except Exception as e: + print( + "[DEBUG] load_canvas_from_snapshot: " + "restore partial connection failed:", + e + ) + if hasattr(container, "update_components_editability"): container.update_components_editability() @@ -396,7 +527,10 @@ def __init__(self, pointA, pointB , socket): self.socket = socket self._source = None self._target = None + self._dragging_free_endpoint = False + self._free_endpoint_radius = 12 self.setZValue(-1) + self.setAcceptedMouseButtons(QtCore.Qt.LeftButton) self.setBrush(QtGui.QColor(0,70,70,120)) self.pen = QtGui.QPen() self.pen.setStyle(QtCore.Qt.SolidLine) @@ -472,6 +606,7 @@ def _find_hops(self): hops.setdefault(i, []).append(vx) return hops + def _rebuild_path(self): """Draw the route, adding a semicircular hop at every crossing.""" path = QtGui.QPainterPath() @@ -482,24 +617,67 @@ def _rebuild_path(self): r = self.HOP_RADIUS hops = self._find_hops() path.moveTo(*self._route[0]) + for i, ((x1, y1), (x2, y2)) in enumerate(zip(self._route, self._route[1:])): if i in hops: direction = 1 if x2 > x1 else -1 last_x = x1 + for hx in sorted(set(hops[i]), key=lambda x: (x - x1) * direction): start_x = hx - direction * r if (start_x - last_x) * direction < 0: - continue # overlaps the previous hop + continue + path.lineTo(start_x, y1) - # Arc over the top of the crossing line + if direction > 0: path.arcTo(QtCore.QRectF(hx - r, y1 - r, 2 * r, 2 * r), 180, -180) else: path.arcTo(QtCore.QRectF(hx - r, y1 - r, 2 * r, 2 * r), 0, 180) + last_x = hx + direction * r + path.lineTo(x2, y2) + + # Show a draggable marker at the free endpoint of a partial line. + if self.source is None or self.target is None: + free_point = self._pointA if self.source is None else self._pointB + radius = self._free_endpoint_radius + + path.addEllipse( + QtCore.QRectF( + free_point.x() - radius, + free_point.y() - radius, + radius * 2, + radius * 2 + ) + ) + self.setZValue(1) + else: + self.setZValue(-1) + self.setPath(path) + def shape(self): + path = QtGui.QPainterPath() + + # For partial connections, only the free endpoint + # should receive mouse events. + if self.source is None or self.target is None: + free_point = self._pointA if self.source is None else self._pointB + radius = self._free_endpoint_radius + + path.addEllipse(QtCore.QRectF( + free_point.x() - radius, + free_point.y() - radius, + radius * 2, + radius * 2 + )) + return path + + return super(NodeLine, self).shape() + + def _refresh_crossing_lines(self, rect, scene=None): """Redraw other lines in `rect`, since their hops over this line may have changed.""" for other in self._other_lines(rect, scene): @@ -529,6 +707,146 @@ def paint(self, painter, option, widget): painter.setPen(self.pen) painter.drawPath(self.path()) + def mousePressEvent(self, event): + if self.source is None or self.target is None: + free_point = self._pointA if self.source is None else self._pointB + + if QtCore.QLineF( + event.scenePos(), free_point + ).length() <= self._free_endpoint_radius: + + self._dragging_free_endpoint = True + + # Show all component ports while dragging. + self._set_all_sockets_visible(True) + + event.accept() + return + + event.ignore() + + def mouseMoveEvent(self, event): + if self._dragging_free_endpoint: + new_point = event.scenePos() + + if self.source is None: + self.pointA = new_point + else: + self.pointB = new_point + + event.accept() + return + + event.ignore() + + def mouseReleaseEvent(self, event): + if self._dragging_free_endpoint: + self._dragging_free_endpoint = False + + self._try_reconnect(event.scenePos()) + + # Hide ports after completing the drag. + self._set_all_sockets_visible(False) + + event.accept() + return + + event.ignore() + + + def _set_all_sockets_visible(self, visible): + if self.scene() is None: + return + + for item in self.scene().items(): + if isinstance(item, NodeItem): + for socket in item.input: + if visible: + socket.show() + else: + socket.hide() + + for socket in item.output: + if visible: + socket.show() + else: + socket.hide() + + + def _try_reconnect(self, scene_pos): + """Reconnect a partial line to a compatible free socket.""" + + if self.source is not None and self.target is not None: + return False + + if self.scene() is None: + return False + + candidate = None + + for item in self.scene().items(scene_pos): + if isinstance(item, NodeSocket): + candidate = item + break + + if candidate is None: + return False + + stm = ['MaterialStream', 'EngStm'] + + # Reconnect free destination to an input socket. + if self.source is not None and self.target is None: + + if candidate.type != 'in' or candidate.in_lines: + return False + + if candidate.parent is self.source.parent: + return False + + self.target = candidate + candidate.in_lines.append(self) + self.pointB = candidate.get_center() + + # Reconnect free source to an output socket. + elif self.source is None and self.target is not None: + + if candidate.type != 'op' or candidate.out_lines: + return False + + if candidate.parent is self.target.parent: + return False + + self.source = candidate + candidate.out_lines.append(self) + self.pointA = candidate.get_center() + + else: + return False + + # Restore logical component connections. + if self.source.parent.obj.type not in stm: + self.source.parent.obj.add_connection( + 0, self.source.id, self.target.parent.obj + ) + + if self.target.parent.obj.type not in stm: + self.target.parent.obj.add_connection( + 1, self.target.id, self.source.parent.obj + ) + + # Update component editability. + container = getattr(self.source.parent, 'container', None) + + if container and hasattr(container, 'update_components_editability'): + container.update_components_editability() + + # Save state for Undo/Redo. + if container and hasattr(container, 'graphics'): + container.graphics.push_snapshot() + + print("[DEBUG] Partial line successfully reconnected.") + return True + @property def pointA(self): return self._pointA @@ -630,6 +948,7 @@ def mousePressEvent(self, event): self.new_line = NodeLine(pointA, pointB ,'op') self.out_lines.append(self.new_line) self.scene().addItem(self.new_line) + event.accept() elif self.type == 'in' and len(self.in_lines) == 0: rect = self.boundingRect() pointA = self.mapToScene(event.pos()) @@ -638,6 +957,7 @@ def mousePressEvent(self, event): self.new_line = NodeLine(pointA, pointB, 'in') self.in_lines.append(self.new_line) self.scene().addItem(self.new_line) + event.accept() else: super(NodeSocket, self).mousePressEvent(event) @@ -682,7 +1002,13 @@ def mouseReleaseEvent(self, event): cursor = QCursor(Qt.ArrowCursor) QApplication.instance().setOverrideCursor(cursor) - item = self.scene().itemAt(event.scenePos().toPoint(), QtGui.QTransform()) + item = None + + # Find the actual socket under the mouse, ignoring NodeLine markers. + for scene_item in self.scene().items(event.scenePos()): + if isinstance(scene_item, NodeSocket) and scene_item is not self: + item = scene_item + break stm = ['MaterialStream', 'EngStm'] item_type = getattr(item, 'type', None) @@ -882,12 +1208,90 @@ def mouseMoveEvent(self, event): else: super().mouseMoveEvent(event) + + def _try_reconnect(self, scene_pos): + """Reconnect a partial line to a compatible free socket.""" + + if self.source is not None and self.target is not None: + return False + + candidate = None + + # Check all items at the drop position, topmost first. + for item in self.scene().items(scene_pos): + if isinstance(item, NodeSocket): + candidate = item + break + + if candidate is None: + return False + + stm = ['MaterialStream', 'EngStm'] + + # Free destination: attached source -> compatible input socket. + if self.source is not None and self.target is None: + if candidate.type != 'in' or candidate.in_lines: + return False + + if candidate.parent is self.source.parent: + return False + + self.target = candidate + candidate.in_lines.append(self) + self.pointB = candidate.get_center() + + # Free source: compatible output socket -> attached target. + elif self.source is None and self.target is not None: + if candidate.type != 'op' or candidate.out_lines: + return False + + if candidate.parent is self.target.parent: + return False + + self.source = candidate + candidate.out_lines.append(self) + self.pointA = candidate.get_center() + + else: + return False + + # Restore logical component connections. + if self.source.parent.obj.type not in stm: + self.source.parent.obj.add_connection( + 0, self.source.id, self.target.parent.obj + ) + + if self.target.parent.obj.type not in stm: + self.target.parent.obj.add_connection( + 1, self.target.id, self.source.parent.obj + ) + + # Update component editability. + attached_node = self.source.parent + container = getattr(attached_node, 'container', None) + + if container and hasattr(container, 'update_components_editability'): + container.update_components_editability() + + # Save the new state for Undo/Redo. + if container and hasattr(container, 'graphics'): + container.graphics.push_snapshot() + + print("[DEBUG] Partial line successfully reconnected.") + return True + + def mouseReleaseEvent(self, event): - if self._dragging: - self._dragging = False + if self._dragging_free_endpoint: + self._dragging_free_endpoint = False + + scene_pos = event.scenePos() + self._try_reconnect(scene_pos) + event.accept() - else: - super().mouseReleaseEvent(event) + return + + event.ignore() class NodeItem(QtWidgets.QGraphicsItem): @@ -1933,17 +2337,118 @@ def itemChange(self, change, value): return super(NodeItem, self).itemChange(change, value) + + def _reroute_material_stream(self, direction): + """Disconnect one side of a Material Stream connection without deleting its line.""" + + if self.type != "MaterialStream": + return + + if direction == "destination": + if not self.output or not self.output[0].out_lines: + return + + line = self.output[0].out_lines[0] + old_target = line.target + old_source = line.source + + if old_target is None or old_source is None: + return + + # Keep the free endpoint at the old destination position. + free_point = old_target.get_center() + QtCore.QPointF(-30, 0) + + if line in old_target.in_lines: + old_target.in_lines.remove(line) + + # Remove the old logical connection. + stm = ["MaterialStream", "EngStm"] + + if old_source.parent.obj.type not in stm: + old_source.parent.obj.remove_connection(0, old_source.id) + + if old_target.parent.obj.type not in stm: + old_target.parent.obj.remove_connection(1, old_target.id) + + line.target = None + line.pointB = free_point + + elif direction == "source": + if not self.input or not self.input[0].in_lines: + return + + line = self.input[0].in_lines[0] + old_source = line.source + old_target = line.target + + if old_source is None or old_target is None: + return + + # Keep the free endpoint at the old source position. + free_point = old_source.get_center() + QtCore.QPointF(30, 0) + + if line in old_source.out_lines: + old_source.out_lines.remove(line) + + # Remove the old logical connection. + stm = ["MaterialStream", "EngStm"] + + if old_source.parent.obj.type not in stm: + old_source.parent.obj.remove_connection(0, old_source.id) + + if old_target.parent.obj.type not in stm: + old_target.parent.obj.remove_connection(1, old_target.id) + + line.source = None + line.pointA = free_point + + if self.container and hasattr(self.container, "update_components_editability"): + self.container.update_components_editability() + + # Save the rerouted state for Undo/Redo. + if self.container and hasattr(self.container, "graphics"): + self.container.graphics.push_snapshot() + + print(f"[DEBUG] Material Stream {direction} disconnected.") + + def contextMenuEvent(self, event): menu = QMenu() + + reroute_destination_action = None + reroute_source_action = None + + if self.type == "MaterialStream": + reroute_destination_action = menu.addAction("Reroute Destination") + reroute_source_action = menu.addAction("Reroute Source") + + # Show an action as disabled when that side is not connected. + reroute_destination_action.setEnabled( + bool(self.output and self.output[0].out_lines) + ) + reroute_source_action.setEnabled( + bool(self.input and self.input[0].in_lines) + ) + + menu.addSeparator() + delete_action = menu.addAction("Delete") action = menu.exec_(event.screenPos()) - if action == delete_action: + + if action == reroute_destination_action: + self._reroute_material_stream("destination") + + elif action == reroute_source_action: + self._reroute_material_stream("source") + + elif action == delete_action: if hasattr(self, "container") and self.container: self.container.delete([self]) else: main_window = findMainWindow(self) if main_window and hasattr(main_window, "container"): main_window.container.delete([self]) + def findMainWindow(self): ''' From 712dd10abcf41c781d585081de197f86676fccf7 Mon Sep 17 00:00:00 2001 From: Chirag Koyande Date: Sat, 14 Nov 2026 11:38:24 +0530 Subject: [PATCH 2/2] Fix component editability and Material Stream reconnection --- src/main/python/utils/Container.py | 46 +++++++++++++----------------- src/main/python/utils/Graphics.py | 4 +-- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/src/main/python/utils/Container.py b/src/main/python/utils/Container.py index dcd0d55..4f0c3e4 100644 --- a/src/main/python/utils/Container.py +++ b/src/main/python/utils/Container.py @@ -156,12 +156,12 @@ def delete(self, l): except Exception as e: print("[DEBUG] delete: outer exception:", e) + def update_components_editability(self): """ Update component editability based on simulation state and current incoming connections. """ - try: scene = getattr(self, "scene", None) @@ -172,7 +172,6 @@ def update_components_editability(self): return for item in scene.items(): - if not isinstance(item, NodeItem): continue @@ -181,47 +180,42 @@ def update_components_editability(self): if dw is None: continue - # Before successful simulation, all components remain editable. - if not self.simulation_successful: - - if item.type == "MaterialStream": - if hasattr(item.obj, "enableInputDataTab"): - item.obj.enableInputDataTab(dw) - elif hasattr(dw, "set_read_only"): - dw.set_read_only(False) - - elif hasattr(dw, "set_read_only"): - dw.set_read_only(False) - - continue - - # After successful simulation, check incoming connections. has_incoming_connection = any( len(getattr(sock, "in_lines", [])) > 0 for sock in getattr(item, "input", []) ) + # MaterialStream editability depends directly on + # whether it has an incoming connection. if item.type == "MaterialStream": if has_incoming_connection: - if hasattr(item.obj, "disableInputDataTab"): + try: item.obj.disableInputDataTab(dw) - elif hasattr(dw, "set_read_only"): + finally: dw.set_read_only(True) - else: - if hasattr(item.obj, "enableInputDataTab"): - item.obj.enableInputDataTab(dw) - elif hasattr(dw, "set_read_only"): - dw.set_read_only(False) + item.obj.enableInputDataTab(dw) + dw.set_read_only(False) + + continue - else: + # Other components remain editable before + # a successful simulation. + if not self.simulation_successful: if hasattr(dw, "set_read_only"): - dw.set_read_only(has_incoming_connection) + dw.set_read_only(False) + continue + + # After successful simulation, components with + # incoming connections become read-only. + if hasattr(dw, "set_read_only"): + dw.set_read_only(has_incoming_connection) except Exception as e: print(f"[DEBUG] update_components_editability failed: {e}") + def fetch_object(self, name): for i in self.unit_operations: if i.name == name: diff --git a/src/main/python/utils/Graphics.py b/src/main/python/utils/Graphics.py index a4083c4..1e7e284 100644 --- a/src/main/python/utils/Graphics.py +++ b/src/main/python/utils/Graphics.py @@ -640,7 +640,7 @@ def _rebuild_path(self): path.lineTo(x2, y2) # Show a draggable marker at the free endpoint of a partial line. - if self.source is None or self.target is None: + if (self.source is None) != (self.target is None): free_point = self._pointA if self.source is None else self._pointB radius = self._free_endpoint_radius @@ -663,7 +663,7 @@ def shape(self): # For partial connections, only the free endpoint # should receive mouse events. - if self.source is None or self.target is None: + if (self.source is None) != (self.target is None): free_point = self._pointA if self.source is None else self._pointB radius = self._free_endpoint_radius