diff --git a/src/main/python/utils/Container.py b/src/main/python/utils/Container.py
index 97a8e16..4f0c3e4 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)
@@ -155,22 +156,18 @@ def delete(self, l):
except Exception as e:
print("[DEBUG] delete: outer exception:", e)
+
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
@@ -179,43 +176,46 @@ def update_components_editability(self):
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
-
- # 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
+ has_incoming_connection = any(
+ len(getattr(sock, "in_lines", [])) > 0
+ for sock in getattr(item, "input", [])
+ )
- is_connected = has_incoming_connection or has_outgoing_connection
+ # MaterialStream editability depends directly on
+ # whether it has an incoming connection.
+ if item.type == "MaterialStream":
- if getattr(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)
- else:
- if not is_connected or not has_incoming_connection:
- if hasattr(dw, "set_read_only"):
- dw.set_read_only(False)
+ item.obj.enableInputDataTab(dw)
+ dw.set_read_only(False)
+
+ continue
+
+ # Other components remain editable before
+ # a successful simulation.
+ if not self.simulation_successful:
+ if hasattr(dw, "set_read_only"):
+ 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:
@@ -242,12 +242,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 +279,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 +325,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 +342,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 +367,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 +383,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..1e7e284 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) != (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) != (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):
'''