From 144bc86f2dbbbbd44db06623422caa86eed3fc74 Mon Sep 17 00:00:00 2001 From: anushkapareek026-alt Date: Thu, 9 Apr 2026 19:09:36 +0530 Subject: [PATCH 1/6] feat: complete UI enhancements matching new specs --- .../dialogs/tabs/custom_vehicle_diagrams.py | 271 ++++++++++++++++++ .../ui/dialogs/tabs/custom_vehicle_dialog.py | 65 ++--- 2 files changed, 294 insertions(+), 42 deletions(-) create mode 100644 src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_diagrams.py diff --git a/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_diagrams.py b/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_diagrams.py new file mode 100644 index 000000000..5bb444197 --- /dev/null +++ b/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_diagrams.py @@ -0,0 +1,271 @@ +import math +from PySide6.QtWidgets import QWidget +from PySide6.QtGui import QPainter, QPen, QColor, QFont, QBrush, QPolygonF +from PySide6.QtCore import Qt, QRectF, QPointF + +def draw_arrow_down(painter: QPainter, x: float, start_y: float, end_y: float): + # Draw vertical line + painter.drawLine(QPointF(x, start_y), QPointF(x, end_y)) + # Draw arrow head + head_size = 6 + polygon = QPolygonF([ + QPointF(x, end_y), + QPointF(x - head_size/2, end_y - head_size), + QPointF(x + head_size/2, end_y - head_size) + ]) + painter.setBrush(painter.pen().color()) + painter.drawPolygon(polygon) + +class TrackedBogieDiagram(QWidget): + def __init__(self, label_force, label_dist, parent=None): + super().__init__(parent) + self.label_force = label_force + self.label_dist = label_dist + self.setMinimumSize(280, 80) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + # Draw background and border (gray rounded rect) + rect = self.rect().adjusted(1, 1, -2, -2) + painter.setPen(QPen(QColor("#a0a0a0"), 1)) + painter.setBrush(QBrush(QColor("#dfdfdf"))) # Light gray background + painter.drawRoundedRect(rect, 8, 8) + + w, h = self.width(), self.height() + + # Left title text + painter.setPen(QPen(QColor("#cc0000"), 1)) # Red text + font = QFont("Arial", 9) + font.setBold(True) + painter.setFont(font) + + if "Tracked" in self.label_force or self.label_force == "P": + title = "Tracked\nVehicles" + else: + title = "Bogie\nCars" + + painter.drawText(QRectF(15, 0, 70, h), Qt.AlignLeft | Qt.AlignVCenter, title) + + # Coordinates for arrows and lines + start_x = 90 + end_x = w - 25 + arrow_start_y = 20 + arrow_end_y = 45 + line_y = 52 # dashed line between arrows + dist_y = 65 # distance line + + # Force Labels (Black) + painter.setPen(QPen(QColor("#000000"), 1)) + font_normal = QFont("Arial", 8) + font_normal.setBold(False) + painter.setFont(font_normal) + painter.drawText(QRectF(start_x-15, 5, 30, 15), Qt.AlignCenter, self.label_force) + painter.drawText(QRectF(end_x-15, 5, 30, 15), Qt.AlignCenter, self.label_force) + + # Arrows (Red) + painter.setPen(QPen(QColor("#cc0000"), 1.5)) + draw_arrow_down(painter, start_x, arrow_start_y, arrow_end_y) + draw_arrow_down(painter, end_x, arrow_start_y, arrow_end_y) + + # Horizontal dashed line between arrows + painter.setPen(QPen(QColor("#000000"), 1, Qt.DashLine)) + painter.drawLine(start_x, line_y, end_x, line_y) + + # Distance line below (Solid black) + painter.setPen(QPen(QColor("#000000"), 1)) + painter.drawLine(start_x, dist_y, end_x, dist_y) + # Vertical ticks for distance + painter.drawLine(start_x, line_y, start_x, dist_y + 5) + painter.drawLine(end_x, line_y, end_x, dist_y + 5) + + # Distance Label + painter.drawText(QRectF(start_x, dist_y + 2, end_x - start_x, 15), Qt.AlignCenter, self.label_dist) + +class WheeledAxlesDiagram(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumSize(220, 90) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + w, h = self.width(), self.height() + + # Fill white background + painter.setBrush(QBrush(QColor("#ececec"))) # very light gray back box + painter.setPen(QPen(QColor("#a0a0a0"), 1)) + painter.drawRoundedRect(self.rect().adjusted(1, 1, -2, -2), 8, 8) + + # Define axles with unicode subscripts and spacing + axles = [ + ("P₁", 20, "D₁"), + ("P₂", 60, "D₂"), + ("P₃", 100, ""), + ("Pₙ₋₁", 160, "Dₙ₋₁"), + ("Pₙ", 200, "") + ] + + arrow_start_y = 15 + arrow_end_y = 40 + line_y = 47 + dist_y = 70 # moved farther from the dashed line + + painter.setFont(QFont("Arial", 8)) + + for idx, (label, x, dist_label) in enumerate(axles): + # Force label (black) + painter.setPen(QPen(QColor("#000000"), 1)) + painter.drawText(QRectF(x-15, 0, 30, 15), Qt.AlignCenter, label) + + # Arrow (red) + painter.setPen(QPen(QColor("#cc0000"), 1.5)) + draw_arrow_down(painter, x, arrow_start_y, arrow_end_y) + + painter.setPen(QPen(QColor("#000000"), 1)) + # Vertical tick down to dimension line + painter.drawLine(x, line_y, x, dist_y + 5) + + if dist_label: + next_x = axles[idx+1][1] + painter.drawText(QRectF(x, dist_y + 2, next_x - x, 15), Qt.AlignCenter, dist_label) + painter.drawLine(x, dist_y, next_x, dist_y) + + # Arrow points on dimension lines + painter.drawLine(x, dist_y, x+4, dist_y-3) + painter.drawLine(x, dist_y, x+4, dist_y+3) + painter.drawLine(next_x, dist_y, next_x-4, dist_y-3) + painter.drawLine(next_x, dist_y, next_x-4, dist_y+3) + + # Continuous dashed horizontal road line + painter.setPen(QPen(QColor("#000000"), 1, Qt.DashLine)) + painter.drawLine(axles[0][1], line_y, axles[-1][1], line_y) + + # Dots logic for axle section and distance section + painter.setPen(QPen(QColor("#cc0000"), 2)) + painter.drawText(QRectF( axles[2][1], 15, axles[3][1]-axles[2][1], 35 ), Qt.AlignCenter, ". . . . . . . .") + + # Continuity dots for dimension line + painter.setPen(QPen(QColor("#000000"), 2)) + painter.drawText(QRectF( axles[2][1], dist_y - 12, axles[3][1]-axles[2][1], 25 ), Qt.AlignCenter, ". . . . . . . .") + +class ClearCarriagewayWidthDiagram(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumSize(380, 200) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + painter.setBrush(QBrush(QColor("#ffffff"))) + painter.setPen(Qt.NoPen) + painter.drawRect(self.rect()) + + w, h = self.width(), self.height() + + painter.setPen(QPen(QColor("#000000"), 1)) + painter.setFont(QFont("Arial", 8)) + + # Dimensions setup + y_carr = h - 65 + y_dim = h - 25 + y_top_dim = 40 + + # Left and right bounds + start_x = w/2 - 120 + end_x = w/2 + 120 + + # Title + painter.setPen(QPen(QColor("#003399"), 1)) # Dark blue like Figma + font_title = QFont("Arial", 8) + font_title.setBold(True) + painter.setFont(font_title) + painter.drawText(QRectF(0, y_top_dim - 20, w, 20), Qt.AlignCenter, "CLEAR CARRIAGEWAY WIDTH") + + painter.setPen(QPen(QColor("#000000"), 1)) + painter.setFont(QFont("Arial", 8)) + + # Top dimension line for carriageway + # Vertical boundary gap with the road + painter.drawLine(start_x, 30, start_x, y_carr - 15) + painter.drawLine(end_x, 30, end_x, y_carr - 15) + # Horizontal top dimension + painter.drawLine(start_x, y_top_dim, end_x, y_top_dim) + # Arrows for carriageway + painter.drawLine(start_x, y_top_dim, start_x+8, y_top_dim-4) + painter.drawLine(start_x, y_top_dim, start_x+8, y_top_dim+4) + painter.drawLine(end_x, y_top_dim, end_x-8, y_top_dim-4) + painter.drawLine(end_x, y_top_dim, end_x-8, y_top_dim+4) + + # Carriageway line + painter.drawLine(start_x, y_carr, end_x, y_carr) + # Ground hatching + for gx in range(int(start_x), int(end_x), 10): + painter.drawLine(gx, y_carr, gx-5, y_carr+5) + + # Left vehicle + vx1 = start_x + 20 + painter.drawRect(vx1, y_carr - 40, 60, 30) # Body + painter.drawRect(vx1 + 10, y_carr - 10, 10, 10) # Left wheel + painter.drawRect(vx1 + 40, y_carr - 10, 10, 10) # Right wheel + + # Right vehicle + vx2 = end_x - 80 + painter.drawRect(vx2, y_carr - 40, 60, 30) # Body + painter.drawRect(vx2 + 10, y_carr - 10, 10, 10) # Left wheel + painter.drawRect(vx2 + 40, y_carr - 10, 10, 10) # Right wheel + + # Bottom dimension line + painter.setPen(QPen(QColor("#000000"), 1, Qt.DashLine)) + painter.drawLine(start_x, y_dim, end_x, y_dim) + + # Solid vertical ticks down to bottom dimension + painter.setPen(QPen(QColor("#000000"), 1)) + + # Correctly map ticks based on outer limits of the wheels + t_wl1 = vx1 + 10 + t_wr1 = vx1 + 50 + t_wl2 = vx2 + 10 + t_wr2 = vx2 + 50 + + # Vertical boundary guides from wheels towards the dashed line + painter.setPen(QPen(QColor("#a0a0a0"), 1, Qt.DashLine)) + painter.drawLine(t_wl1, y_carr + 5, t_wl1, y_dim - 8) + painter.drawLine(t_wr1, y_carr + 5, t_wr1, y_dim - 8) + painter.drawLine(t_wl2, y_carr + 5, t_wl2, y_dim - 8) + painter.drawLine(t_wr2, y_carr + 5, t_wr2, y_dim - 8) + + # Draw explicit end guides that match the road start/end + painter.drawLine(start_x, y_carr + 5, start_x, y_dim - 8) + painter.drawLine(end_x, y_carr + 5, end_x, y_dim - 8) + + painter.setPen(QPen(QColor("#000000"), 1)) + + ticks = [ + (start_x, t_wl1, "f"), + (t_wl1, t_wr1, "w"), + (t_wr1, t_wl2, "g"), + (t_wl2, t_wr2, "w"), + (t_wr2, end_x, "f") + ] + + font_dim = QFont("Arial", 8) + font_dim.setBold(True) + painter.setFont(font_dim) + + for t1, t2, lab in ticks: + # Tick marks + painter.setPen(QPen(QColor("#000000"), 1)) + painter.drawLine(t1, y_dim-6, t1, y_dim+6) + painter.drawLine(t2, y_dim-6, t2, y_dim+6) + + # Label in the center of the span + painter.setPen(QPen(QColor("#b06b00"), 1)) # Brown/orange text color matching figma + painter.drawText(QRectF(t1, y_dim + 4, t2 - t1, 15), Qt.AlignCenter, lab) + + # Underline the w labels + if lab == "w": + painter.setPen(QPen(QColor("#808080"), 1)) + painter.drawLine(t1+3, y_dim+18, t2-3, y_dim+18) diff --git a/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_dialog.py b/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_dialog.py index 5deabbb98..9b0dd6f07 100644 --- a/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_dialog.py +++ b/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_dialog.py @@ -20,6 +20,7 @@ ) from osdagbridge.desktop.ui.dialogs.tabs.common import apply_field_style from osdagbridge.desktop.ui.utils.custom_titlebar import CustomTitleBar +from osdagbridge.desktop.ui.dialogs.tabs.custom_vehicle_diagrams import TrackedBogieDiagram, WheeledAxlesDiagram, ClearCarriagewayWidthDiagram class CustomVehicleDialog(QDialog): """Dialog for adding or editing custom live load vehicles""" @@ -201,21 +202,9 @@ def init_ui(self): self.axle_table.setMaximumHeight(118) table_diagram_row.addWidget(self.axle_table, 1) - # Axle diagram placeholder - self.axle_diagram = QLabel("Axle Layout Diagram") - self.axle_diagram.setAlignment(Qt.AlignCenter) - self.axle_diagram.setMinimumHeight(118) - self.axle_diagram.setMaximumHeight(118) - self.axle_diagram.setStyleSheet(""" - QLabel { - border: 1px solid #8a8a8a; - border-radius: 4px; - background: #ffffff; - color: #6a6a6a; - font-size: 10px; - } - """) - table_diagram_row.addWidget(self.axle_diagram, 1) + self.wheeled_diagram = WheeledAxlesDiagram() + self.wheeled_diagram.setMinimumHeight(118) + table_diagram_row.addWidget(self.wheeled_diagram, 1) wheeled_layout.addLayout(table_diagram_row) self.stacked_widget.addWidget(self.wheeled_page) @@ -270,21 +259,14 @@ def init_ui(self): left_widget.setLayout(tb_inputs_layout) tb_bottom_row.addWidget(left_widget, 1) - # Right side diagram - self.tb_axle_diagram = QLabel("Tracked Layout Diagram") - self.tb_axle_diagram.setAlignment(Qt.AlignCenter) - self.tb_axle_diagram.setMinimumHeight(118) - self.tb_axle_diagram.setMaximumHeight(118) - self.tb_axle_diagram.setStyleSheet(""" - QLabel { - border: 1px solid #8a8a8a; - border-radius: 4px; - background: #ffffff; - color: #6a6a6a; - font-size: 10px; - } - """) - tb_bottom_row.addWidget(self.tb_axle_diagram, 1) + self.tb_diagram_stack = QStackedWidget() + self.tracked_diagram = TrackedBogieDiagram("P", "D") + self.bogie_diagram = TrackedBogieDiagram("Pb", "Db") + self.tb_diagram_stack.addWidget(self.tracked_diagram) + self.tb_diagram_stack.addWidget(self.bogie_diagram) + self.tb_diagram_stack.setMinimumHeight(118) + self.tb_diagram_stack.setMaximumHeight(118) + tb_bottom_row.addWidget(self.tb_diagram_stack, 1) tb_layout.addLayout(tb_bottom_row) @@ -329,17 +311,13 @@ def init_ui(self): # Keep heading clear from the last input row. layout.addSpacing(16) - bottom_diagram = QLabel("") - bottom_diagram.setAlignment(Qt.AlignCenter) - bottom_diagram.setMinimumHeight(62) - bottom_diagram.setStyleSheet(""" - QLabel { - border: 1px solid #8a8a8a; - border-radius: 4px; - background: #ffffff; - } - """) - layout.addWidget(bottom_diagram) + carr_layout = QHBoxLayout() + self.carr_diagram = ClearCarriagewayWidthDiagram() + self.carr_diagram.setFixedSize(380, 160) + carr_layout.addStretch() + carr_layout.addWidget(self.carr_diagram) + carr_layout.addStretch() + layout.addLayout(carr_layout) button_row = QHBoxLayout() button_row.addStretch() @@ -385,7 +363,10 @@ def _on_vehicle_type_changed(self, text): is_bogie = text == "Bogie" self.tb_p_label.setText("Pb (kN)" if is_bogie else "P (kN)") self.tb_d_label.setText("Db (m)" if is_bogie else "D (m)") - self.tb_axle_diagram.setText("Bogie Layout Diagram" if is_bogie else "Tracked Layout Diagram") + if is_bogie: + self.tb_diagram_stack.setCurrentWidget(self.bogie_diagram) + else: + self.tb_diagram_stack.setCurrentWidget(self.tracked_diagram) def _refresh_axle_buttons_state(self): enabled = self._selected_axle_row is not None From 0dfa97f4e4993c5d4ab2b3c5379719b68339654c Mon Sep 17 00:00:00 2001 From: anushkapareek026-alt Date: Thu, 30 Apr 2026 05:09:02 +0530 Subject: [PATCH 2/6] Refine Custom Vehicle Diagrams UI components This commit improves the accuracy and aesthetics of the Custom Vehicle diagrams, including Tracked, Bogie, Wheeled, and Clear Carriageway layouts, strictly following the provided UI references. --- .../dialogs/tabs/custom_vehicle_diagrams.py | 491 +++++++++++++----- .../ui/dialogs/tabs/custom_vehicle_dialog.py | 11 +- 2 files changed, 358 insertions(+), 144 deletions(-) diff --git a/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_diagrams.py b/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_diagrams.py index 5bb444197..d08838d3d 100644 --- a/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_diagrams.py +++ b/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_diagrams.py @@ -16,6 +16,100 @@ def draw_arrow_down(painter: QPainter, x: float, start_y: float, end_y: float): painter.setBrush(painter.pen().color()) painter.drawPolygon(polygon) +def draw_horizontal_arrow(painter: QPainter, x_tip: float, y_tip: float, direction: int, length: float = 12): + """Draw a horizontal arrow with filled triangular arrowhead. + direction: 1 for right-pointing, -1 for left-pointing + """ + painter.setBrush(QBrush(painter.pen().color())) + arrow_w = 7 + arrow_h = 3 + if direction > 0: # > + p1 = QPointF(x_tip, y_tip) + p2 = QPointF(x_tip - arrow_w, y_tip - arrow_h) + p3 = QPointF(x_tip - arrow_w, y_tip + arrow_h) + x_tail = x_tip - length + else: # < + p1 = QPointF(x_tip, y_tip) + p2 = QPointF(x_tip + arrow_w, y_tip - arrow_h) + p3 = QPointF(x_tip + arrow_w, y_tip + arrow_h) + x_tail = x_tip + length + painter.drawPolygon(QPolygonF([p1, p2, p3])) + painter.drawLine(x_tail, y_tip, x_tip, y_tip) + +def draw_dim_line(painter: QPainter, x1: float, x2: float, y_pos: float, label: str, + tick_above: float = 0, tick_below: float = 0, label_below: bool = True): + """Draw a standardized dimension line with outward-pointing filled arrowheads, + optional vertical tick extensions, and a centered label. + Used across ALL diagrams for consistent dimensioning style. + """ + span = x2 - x1 + arm = min(10, span * 0.35) + + # Outward-pointing arrows at both ends + draw_horizontal_arrow(painter, x1, y_pos, -1, int(arm)) # left-pointing at left edge + draw_horizontal_arrow(painter, x2, y_pos, 1, int(arm)) # right-pointing at right edge + + # Connecting line between arrowheads + painter.drawLine(int(x1 + arm), int(y_pos), int(x2 - arm), int(y_pos)) + + # Optional vertical tick lines + if tick_above > 0: + painter.drawLine(int(x1), int(y_pos - tick_above), int(x1), int(y_pos)) + painter.drawLine(int(x2), int(y_pos - tick_above), int(x2), int(y_pos)) + if tick_below > 0: + painter.drawLine(int(x1), int(y_pos), int(x1), int(y_pos + tick_below)) + painter.drawLine(int(x2), int(y_pos), int(x2), int(y_pos + tick_below)) + + # Centered label + if label: + if label_below: + label_y = y_pos + 2 + else: + label_y = y_pos - 14 + painter.drawText(QRectF(x1, label_y, span, 14), Qt.AlignCenter, label) + + +def draw_text_with_subscript(painter: QPainter, rect: QRectF, base: str, sub: str, alignment=Qt.AlignCenter): + """Draw text with a subscript character, e.g. P with subscript b.""" + fm = painter.fontMetrics() + base_w = fm.horizontalAdvance(base) + + # Save current font + orig_font = painter.font() + sub_font = QFont(orig_font) + sub_size = max(5, int(orig_font.pointSize() * 0.7)) + sub_font.setPointSize(sub_size) + sub_fm = painter.fontMetrics() # we'll measure after setting + + # Measure subscript width + painter.setFont(sub_font) + sub_w = painter.fontMetrics().horizontalAdvance(sub) + painter.setFont(orig_font) + + total_w = base_w + sub_w + + # Compute starting X based on alignment + if alignment & Qt.AlignCenter: + start_x = rect.x() + (rect.width() - total_w) / 2 + elif alignment & Qt.AlignRight: + start_x = rect.x() + rect.width() - total_w + else: + start_x = rect.x() + + # Draw base text + base_y = rect.y() + rect.height() * 0.75 # baseline position + painter.setFont(orig_font) + painter.drawText(QPointF(start_x, base_y), base) + + # Draw subscript (shifted down and smaller) + painter.setFont(sub_font) + sub_y = base_y + fm.descent() # shift down for subscript + painter.drawText(QPointF(start_x + base_w, sub_y), sub) + + # Restore original font + painter.setFont(orig_font) + + class TrackedBogieDiagram(QWidget): def __init__(self, label_force, label_dist, parent=None): super().__init__(parent) @@ -23,6 +117,9 @@ def __init__(self, label_force, label_dist, parent=None): self.label_dist = label_dist self.setMinimumSize(280, 80) + def _is_bogie(self): + return "Pb" in self.label_force or self.label_force == "Pb" + def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) @@ -34,6 +131,7 @@ def paintEvent(self, event): painter.drawRoundedRect(rect, 8, 8) w, h = self.width(), self.height() + is_bogie = self._is_bogie() # Left title text painter.setPen(QPen(QColor("#cc0000"), 1)) # Red text @@ -41,10 +139,10 @@ def paintEvent(self, event): font.setBold(True) painter.setFont(font) - if "Tracked" in self.label_force or self.label_force == "P": - title = "Tracked\nVehicles" - else: + if is_bogie: title = "Bogie\nCars" + else: + title = "Tracked\nVehicles" painter.drawText(QRectF(15, 0, 70, h), Qt.AlignLeft | Qt.AlignVCenter, title) @@ -56,32 +154,51 @@ def paintEvent(self, event): line_y = 52 # dashed line between arrows dist_y = 65 # distance line - # Force Labels (Black) - painter.setPen(QPen(QColor("#000000"), 1)) - font_normal = QFont("Arial", 8) - font_normal.setBold(False) - painter.setFont(font_normal) - painter.drawText(QRectF(start_x-15, 5, 30, 15), Qt.AlignCenter, self.label_force) - painter.drawText(QRectF(end_x-15, 5, 30, 15), Qt.AlignCenter, self.label_force) - - # Arrows (Red) - painter.setPen(QPen(QColor("#cc0000"), 1.5)) - draw_arrow_down(painter, start_x, arrow_start_y, arrow_end_y) - draw_arrow_down(painter, end_x, arrow_start_y, arrow_end_y) + # Labels and Arrows + if is_bogie: + # Draw separate P with subscript b + painter.setPen(QPen(QColor("#000000"), 1)) + draw_text_with_subscript(painter, QRectF(start_x-15, 0, 30, 20), "P", "b") + draw_text_with_subscript(painter, QRectF(end_x-15, 0, 30, 20), "P", "b") + + # Arrows (Red, separate downward arrows) + painter.setPen(QPen(QColor("#cc0000"), 1.5)) + draw_arrow_down(painter, start_x, arrow_start_y, arrow_end_y) + draw_arrow_down(painter, end_x, arrow_start_y, arrow_end_y) + else: + # Single centered label P + painter.setPen(QPen(QColor("#000000"), 1)) + span = end_x - start_x + label_rect = QRectF(start_x, 0, span, 20) + painter.drawText(label_rect, Qt.AlignCenter, self.label_force) + + # Arrows (Red, continuous downward bracket) + painter.setPen(QPen(QColor("#cc0000"), 1.5)) + painter.drawLine(start_x, arrow_start_y, end_x, arrow_start_y) + draw_arrow_down(painter, start_x, arrow_start_y, arrow_end_y) + draw_arrow_down(painter, end_x, arrow_start_y, arrow_end_y) # Horizontal dashed line between arrows painter.setPen(QPen(QColor("#000000"), 1, Qt.DashLine)) - painter.drawLine(start_x, line_y, end_x, line_y) + painter.drawLine(start_x - 10, line_y, end_x + 10, line_y) - # Distance line below (Solid black) + # Dimension line with proper arrowheads (black, consistent style) painter.setPen(QPen(QColor("#000000"), 1)) - painter.drawLine(start_x, dist_y, end_x, dist_y) - # Vertical ticks for distance + font_normal = QFont("Arial", 8) + painter.setFont(font_normal) + + # Vertical ticks from dashed line down to dimension line painter.drawLine(start_x, line_y, start_x, dist_y + 5) painter.drawLine(end_x, line_y, end_x, dist_y + 5) - # Distance Label - painter.drawText(QRectF(start_x, dist_y + 2, end_x - start_x, 15), Qt.AlignCenter, self.label_dist) + # Draw dimension line with outward arrowheads + if is_bogie: + draw_dim_line(painter, start_x, end_x, dist_y, "", label_below=True) + # Draw D with subscript b as the label + span = end_x - start_x + draw_text_with_subscript(painter, QRectF(start_x, dist_y + 2, span, 15), "D", "b") + else: + draw_dim_line(painter, start_x, end_x, dist_y, self.label_dist, label_below=True) class WheeledAxlesDiagram(QWidget): def __init__(self, parent=None): @@ -93,8 +210,8 @@ def paintEvent(self, event): painter.setRenderHint(QPainter.Antialiasing) w, h = self.width(), self.height() - # Fill white background - painter.setBrush(QBrush(QColor("#ececec"))) # very light gray back box + # Background with border + painter.setBrush(QBrush(QColor("#dfdfdf"))) # light gray back box painter.setPen(QPen(QColor("#a0a0a0"), 1)) painter.drawRoundedRect(self.rect().adjusted(1, 1, -2, -2), 8, 8) @@ -107,7 +224,7 @@ def paintEvent(self, event): ("Pₙ", 200, "") ] - arrow_start_y = 15 + arrow_start_y = 22 # top of arrow shaft (raised to avoid label overlap) arrow_end_y = 40 line_y = 47 dist_y = 70 # moved farther from the dashed line @@ -117,9 +234,9 @@ def paintEvent(self, event): for idx, (label, x, dist_label) in enumerate(axles): # Force label (black) painter.setPen(QPen(QColor("#000000"), 1)) - painter.drawText(QRectF(x-15, 0, 30, 15), Qt.AlignCenter, label) + painter.drawText(QRectF(x-20, 2, 40, 18), Qt.AlignCenter, label) - # Arrow (red) + # Arrow (RED — original color theme) painter.setPen(QPen(QColor("#cc0000"), 1.5)) draw_arrow_down(painter, x, arrow_start_y, arrow_end_y) @@ -129,143 +246,233 @@ def paintEvent(self, event): if dist_label: next_x = axles[idx+1][1] + # Draw dimension line with proper filled arrowheads (black) + painter.setPen(QPen(QColor("#000000"), 1)) + draw_dim_line(painter, x, next_x, dist_y, "", label_below=True) + # Dimension label below the line painter.drawText(QRectF(x, dist_y + 2, next_x - x, 15), Qt.AlignCenter, dist_label) - painter.drawLine(x, dist_y, next_x, dist_y) - - # Arrow points on dimension lines - painter.drawLine(x, dist_y, x+4, dist_y-3) - painter.drawLine(x, dist_y, x+4, dist_y+3) - painter.drawLine(next_x, dist_y, next_x-4, dist_y-3) - painter.drawLine(next_x, dist_y, next_x-4, dist_y+3) - - # Continuous dashed horizontal road line + + # Continuous dashed horizontal road line (extended slightly) painter.setPen(QPen(QColor("#000000"), 1, Qt.DashLine)) - painter.drawLine(axles[0][1], line_y, axles[-1][1], line_y) + painter.drawLine(axles[0][1] - 15, line_y, axles[-1][1] + 15, line_y) - # Dots logic for axle section and distance section - painter.setPen(QPen(QColor("#cc0000"), 2)) + # Dots for axle continuation (Black) + painter.setPen(QPen(QColor("#000000"), 2)) painter.drawText(QRectF( axles[2][1], 15, axles[3][1]-axles[2][1], 35 ), Qt.AlignCenter, ". . . . . . . .") - # Continuity dots for dimension line + # Continuity dots for dimension line (black) painter.setPen(QPen(QColor("#000000"), 2)) painter.drawText(QRectF( axles[2][1], dist_y - 12, axles[3][1]-axles[2][1], 25 ), Qt.AlignCenter, ". . . . . . . .") + class ClearCarriagewayWidthDiagram(QWidget): def __init__(self, parent=None): super().__init__(parent) - self.setMinimumSize(380, 200) + self.setMinimumSize(460, 260) def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) - + + # White background with subtle border painter.setBrush(QBrush(QColor("#ffffff"))) - painter.setPen(Qt.NoPen) - painter.drawRect(self.rect()) - + painter.setPen(QPen(QColor("#a0a0a0"), 1)) + painter.drawRoundedRect(self.rect().adjusted(1, 1, -2, -2), 8, 8) + w, h = self.width(), self.height() + pen = QColor("#000000") + + # ──────────────────────────────────── + # Layout constants + # ──────────────────────────────────── + start_x = 40 # left kerb boundary + end_x = w - 40 # right kerb boundary + y_title = 28 # title / top dimension arrow Y + y_road = h - 52 # road surface (zigzag baseline) Y + y_kerb_line = y_road - 20 # kerb top horizontal line Y + y_w_dim = h - 18 # w dimension line Y + + # Vehicle geometry + h_body = 80 # vehicle body height + w_body = 110 # vehicle body width + h_connector = 8 # small connector rectangle height + road_clearance = 0 # gap between vehicle assembly and road + h_wheel = 28 # wheel height + w_wheel = 16 # wheel width + wheel_inset = 16 # wheel offset from body edge + + # Derived Y positions + y_wheel_bottom = y_road - road_clearance # road_clearance is 0 now + y_wheel_top = y_wheel_bottom - h_wheel - painter.setPen(QPen(QColor("#000000"), 1)) - painter.setFont(QFont("Arial", 8)) - - # Dimensions setup - y_carr = h - 65 - y_dim = h - 25 - y_top_dim = 40 - - # Left and right bounds - start_x = w/2 - 120 - end_x = w/2 + 120 - - # Title - painter.setPen(QPen(QColor("#003399"), 1)) # Dark blue like Figma + body_gap = 0 # Wheels touch the main body directly + y_body_bottom = y_wheel_top - body_gap + y_body_top = y_body_bottom - h_body + + # f/g dimension line – at midpoint of wheel area + y_fg = y_wheel_top + h_wheel // 2 + + # ──────────────────────────────────── + # 1. TITLE: "CLEAR CARRIAGEWAY WIDTH" – BOLD + # ──────────────────────────────────── font_title = QFont("Arial", 8) font_title.setBold(True) painter.setFont(font_title) - painter.drawText(QRectF(0, y_top_dim - 20, w, 20), Qt.AlignCenter, "CLEAR CARRIAGEWAY WIDTH") - - painter.setPen(QPen(QColor("#000000"), 1)) - painter.setFont(QFont("Arial", 8)) - - # Top dimension line for carriageway - # Vertical boundary gap with the road - painter.drawLine(start_x, 30, start_x, y_carr - 15) - painter.drawLine(end_x, 30, end_x, y_carr - 15) - # Horizontal top dimension - painter.drawLine(start_x, y_top_dim, end_x, y_top_dim) - # Arrows for carriageway - painter.drawLine(start_x, y_top_dim, start_x+8, y_top_dim-4) - painter.drawLine(start_x, y_top_dim, start_x+8, y_top_dim+4) - painter.drawLine(end_x, y_top_dim, end_x-8, y_top_dim-4) - painter.drawLine(end_x, y_top_dim, end_x-8, y_top_dim+4) - - # Carriageway line - painter.drawLine(start_x, y_carr, end_x, y_carr) - # Ground hatching - for gx in range(int(start_x), int(end_x), 10): - painter.drawLine(gx, y_carr, gx-5, y_carr+5) - - # Left vehicle - vx1 = start_x + 20 - painter.drawRect(vx1, y_carr - 40, 60, 30) # Body - painter.drawRect(vx1 + 10, y_carr - 10, 10, 10) # Left wheel - painter.drawRect(vx1 + 40, y_carr - 10, 10, 10) # Right wheel - - # Right vehicle - vx2 = end_x - 80 - painter.drawRect(vx2, y_carr - 40, 60, 30) # Body - painter.drawRect(vx2 + 10, y_carr - 10, 10, 10) # Left wheel - painter.drawRect(vx2 + 40, y_carr - 10, 10, 10) # Right wheel - - # Bottom dimension line - painter.setPen(QPen(QColor("#000000"), 1, Qt.DashLine)) - painter.drawLine(start_x, y_dim, end_x, y_dim) - - # Solid vertical ticks down to bottom dimension - painter.setPen(QPen(QColor("#000000"), 1)) - - # Correctly map ticks based on outer limits of the wheels - t_wl1 = vx1 + 10 - t_wr1 = vx1 + 50 - t_wl2 = vx2 + 10 - t_wr2 = vx2 + 50 - - # Vertical boundary guides from wheels towards the dashed line - painter.setPen(QPen(QColor("#a0a0a0"), 1, Qt.DashLine)) - painter.drawLine(t_wl1, y_carr + 5, t_wl1, y_dim - 8) - painter.drawLine(t_wr1, y_carr + 5, t_wr1, y_dim - 8) - painter.drawLine(t_wl2, y_carr + 5, t_wl2, y_dim - 8) - painter.drawLine(t_wr2, y_carr + 5, t_wr2, y_dim - 8) - - # Draw explicit end guides that match the road start/end - painter.drawLine(start_x, y_carr + 5, start_x, y_dim - 8) - painter.drawLine(end_x, y_carr + 5, end_x, y_dim - 8) - - painter.setPen(QPen(QColor("#000000"), 1)) - - ticks = [ - (start_x, t_wl1, "f"), - (t_wl1, t_wr1, "w"), - (t_wr1, t_wl2, "g"), - (t_wl2, t_wr2, "w"), - (t_wr2, end_x, "f") + painter.setPen(QPen(pen, 1)) + + title = "CLEAR CARRIAGEWAY WIDTH" + fm = painter.fontMetrics() + title_w = fm.horizontalAdvance(title) + title_x = (w - title_w) / 2 + + painter.drawText(QRectF(title_x, y_title - 7, title_w, 14), + Qt.AlignCenter, title) + + # Reset to non-bold for everything else + font8 = QFont("Arial", 8) + painter.setFont(font8) + + # Left arrow: from start_x to just before text + left_arm = int(title_x - 8 - start_x) + if left_arm > 0: + draw_horizontal_arrow(painter, start_x, y_title, -1, left_arm) + # Right arrow: from just after text to end_x + right_arm = int(end_x - (title_x + title_w + 8)) + if right_arm > 0: + draw_horizontal_arrow(painter, end_x, y_title, 1, right_arm) + + # ──────────────────────────────────── + # 2. VERTICAL BOUNDARY LINES (left & right kerb edges) + # Extended down past the f/g dimension line so f-arrows touch + # ──────────────────────────────────── + painter.setPen(QPen(pen, 1)) + painter.drawLine(start_x, y_title - 7, start_x, y_road) + painter.drawLine(end_x, y_title - 7, end_x, y_road) + + # ──────────────────────────────────── + # 3. KERB: horizontal line + diagonal hatching (left & right) + # ──────────────────────────────────── + kerb_left_start = 1 + kerb_right_end = w - 1 + + # Left kerb + painter.drawLine(kerb_left_start, y_kerb_line, start_x, y_kerb_line) + for gx in range(kerb_left_start, int(start_x) + 1, 6): + painter.drawLine(gx, y_kerb_line, gx - 3, y_kerb_line + 5) + + # Right kerb + painter.drawLine(end_x, y_kerb_line, kerb_right_end, y_kerb_line) + for gx in range(int(end_x) + 6, kerb_right_end + 1, 6): + painter.drawLine(gx, y_kerb_line, gx - 3, y_kerb_line + 5) + + # ──────────────────────────────────── + # 4. ROAD SURFACE: bold zigzag between kerb boundaries + # ──────────────────────────────────── + painter.setPen(QPen(pen, 1.8)) + zz_step = 5 + zz_amp = 4 + zx = float(start_x) + zx_end = float(end_x) + while zx < zx_end: + pk = min(zx + zz_step, zx_end) + vl = min(zx + 2 * zz_step, zx_end) + painter.drawLine(QPointF(zx, y_road), + QPointF(pk, y_road - zz_amp)) + if pk < zx_end: + painter.drawLine(QPointF(pk, y_road - zz_amp), + QPointF(vl, y_road)) + zx = vl + if zx >= zx_end: + break + painter.setPen(QPen(pen, 1)) + + # ──────────────────────────────────── + # 5. VEHICLES (two identical trucks) + # ──────────────────────────────────── + available = end_x - start_x + + def draw_vehicle(vx): + painter.setBrush(QBrush(QColor("#ffffff"))) + painter.setPen(QPen(pen, 1)) + + # Main body rectangle (tall box) + painter.drawRect(int(vx), int(y_body_top), w_body, h_body) + + # Central horizontal block attached to the big block + w_center_block = 30 + h_center_block = 14 + cx = vx + (w_body - w_center_block) / 2 + painter.drawRect(int(cx), int(y_body_bottom), w_center_block, h_center_block) + + # Left side: wheel (straight lines) + wl = vx + wheel_inset + # Wheel rectangle + painter.drawRect(int(wl), int(y_wheel_top), w_wheel, h_wheel) + # Wheel center axle line (extended down to touch the road) + painter.drawLine(QPointF(wl + w_wheel / 2, y_wheel_top), + QPointF(wl + w_wheel / 2, y_road)) + + # Right side: wheel (straight lines) + wr = vx + w_body - wheel_inset - w_wheel + # Wheel rectangle + painter.drawRect(int(wr), int(y_wheel_top), w_wheel, h_wheel) + # Wheel center axle line (extended down to touch the road) + painter.drawLine(QPointF(wr + w_wheel / 2, y_wheel_top), + QPointF(wr + w_wheel / 2, y_road)) + + return wl, wr + + # Increase f space to 12% of available width + f_prop = 0.12 + vx1 = start_x + available * f_prop + wl1, wr1 = draw_vehicle(vx1) + + vx2 = end_x - available * f_prop - w_body + wl2, wr2 = draw_vehicle(vx2) + + # ──────────────────────────────────── + # 6. f / g DIMENSION LINES (consistent arrowhead style) + # ──────────────────────────────────── + painter.setPen(QPen(pen, 1)) + painter.setFont(font8) + + # f: from left boundary to left wheel of vehicle 1 + draw_dim_line(painter, start_x, wl1, y_fg, "f", label_below=False) + # g: from right wheel of vehicle 1 to left wheel of vehicle 2 + draw_dim_line(painter, wr1 + w_wheel, wl2, y_fg, "g", label_below=False) + # f: from right wheel of vehicle 2 to right boundary + draw_dim_line(painter, wr2 + w_wheel, end_x, y_fg, "f", label_below=False) + + # ──────────────────────────────────── + # 7. w DIMENSION LINES (consistent arrowhead style) + # ──────────────────────────────────── + wheels = [ + (wl1, wl1 + w_wheel), + (wr1, wr1 + w_wheel), + (wl2, wl2 + w_wheel), + (wr2, wr2 + w_wheel), ] - - font_dim = QFont("Arial", 8) - font_dim.setBold(True) - painter.setFont(font_dim) - - for t1, t2, lab in ticks: - # Tick marks - painter.setPen(QPen(QColor("#000000"), 1)) - painter.drawLine(t1, y_dim-6, t1, y_dim+6) - painter.drawLine(t2, y_dim-6, t2, y_dim+6) + + painter.setPen(QPen(pen, 1)) + painter.setFont(font8) + + for wx1, wx2 in wheels: + # Vertical tick lines at wheel edges (extending up from w-dim line) + tick_up = 18 + painter.drawLine(int(wx1), y_w_dim - tick_up, + int(wx1), y_w_dim) + painter.drawLine(int(wx2), y_w_dim - tick_up, + int(wx2), y_w_dim) + + # Dimension line with outward arrowheads + overshoot extensions + overshoot = 14 + # Continuous horizontal line across the wheel and extensions + painter.drawLine(int(wx1 - overshoot), y_w_dim, int(wx2 + overshoot), y_w_dim) - # Label in the center of the span - painter.setPen(QPen(QColor("#b06b00"), 1)) # Brown/orange text color matching figma - painter.drawText(QRectF(t1, y_dim + 4, t2 - t1, 15), Qt.AlignCenter, lab) + # Inward-pointing filled arrowheads at wheel edges + draw_horizontal_arrow(painter, wx1, y_w_dim, 1, 10) # right-pointing at left edge + draw_horizontal_arrow(painter, wx2, y_w_dim, -1, 10) # left-pointing at right edge - # Underline the w labels - if lab == "w": - painter.setPen(QPen(QColor("#808080"), 1)) - painter.drawLine(t1+3, y_dim+18, t2-3, y_dim+18) + # Label "w" below + painter.drawText(QRectF(wx1, y_w_dim + 6, wx2 - wx1, 14), + Qt.AlignCenter, "w") diff --git a/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_dialog.py b/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_dialog.py index 9b0dd6f07..9ef65803c 100644 --- a/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_dialog.py +++ b/src/osdagbridge/desktop/ui/dialogs/tabs/custom_vehicle_dialog.py @@ -12,6 +12,7 @@ QLineEdit, QMessageBox, QPushButton, + QScrollArea, QStackedWidget, QTableWidget, QTableWidgetItem, @@ -107,9 +108,16 @@ def setupWrapper(self): self.title_bar.setTitle("Live Load Custom Vehicle Add/Edit") main_layout.addWidget(self.title_bar) + self.scroll_area = QScrollArea(self) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShape(QScrollArea.NoFrame) + self.scroll_area.setStyleSheet("QScrollArea { background-color: #ffffff; border: none; } QScrollBar { width: 12px; }") + self.content_widget = QWidget(self) self.content_widget.setStyleSheet("background-color: #ffffff;") - main_layout.addWidget(self.content_widget, 1) + + self.scroll_area.setWidget(self.content_widget) + main_layout.addWidget(self.scroll_area, 1) def init_ui(self): layout = QVBoxLayout(self.content_widget) @@ -313,7 +321,6 @@ def init_ui(self): carr_layout = QHBoxLayout() self.carr_diagram = ClearCarriagewayWidthDiagram() - self.carr_diagram.setFixedSize(380, 160) carr_layout.addStretch() carr_layout.addWidget(self.carr_diagram) carr_layout.addStretch() From 091a7e8e3710908ba92239384b61f235fdd5c694 Mon Sep 17 00:00:00 2001 From: anu Date: Tue, 19 May 2026 17:28:06 +0530 Subject: [PATCH 3/6] U.I changes --- .../desktop/resources/carriageway_diagram.png | Bin 0 -> 68690 bytes .../desktop/ui/dialogs/additional_inputs.py | 4 ++ .../ui/dialogs/tabs/section_properties_tab.py | 4 ++ .../section_properties/girder_details_tab.py | 47 +++++++++++++++--- .../dialogs/tabs/typical_section_details.py | 3 ++ .../desktop/ui/docks/cad_cross_section.py | 42 +++++++++++++++- src/osdagbridge/desktop/ui/template_page.py | 14 +++--- .../desktop/ui/utils/custom_3dviewer.py | 3 +- 8 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 src/osdagbridge/desktop/resources/carriageway_diagram.png diff --git a/src/osdagbridge/desktop/resources/carriageway_diagram.png b/src/osdagbridge/desktop/resources/carriageway_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..2a87ed40d8a006ac1cc36a4c9dfa6ac110d82169 GIT binary patch literal 68690 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 Path: @@ -1275,6 +1276,7 @@ def init_ui(self): content_layout.setSpacing(12) content_layout.addWidget(self._build_overview_card()) + self._setup_girder_selector() # content_layout.addWidget(self._build_section_card()) content_layout.addStretch() @@ -1435,8 +1437,21 @@ def _build_overview_card(self): top_cad_layout.setContentsMargins(12, 10, 12, 10) top_cad_layout.setSpacing(12) - self.girder_cad_view = _GirderCad2DView() - top_cad_layout.addWidget(self.girder_cad_view, 1) + self.girder_cad_view = CrossSectionCADWidget() + self.girder_cad_view.scale_factor = 0.65 + self.girder_cad_view.show_dimensions = False + self.girder_cad_view.show_minimal_dimensions = True + self.girder_cad_view.show_girder_labels = True + self.girder_cad_view.setMinimumHeight(0) + + cad_scroll = QScrollArea() + cad_scroll.setFixedHeight(160) + cad_scroll.setWidgetResizable(True) + cad_scroll.setFrameShape(QFrame.NoFrame) + cad_scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }") + cad_scroll.setWidget(self.girder_cad_view) + + top_cad_layout.addWidget(cad_scroll, 1) view_switch_col = QVBoxLayout() view_switch_col.setContentsMargins(0, 0, 0, 0) @@ -1580,16 +1595,20 @@ def _update_girder_cad_view(self, girder: str, segments: Optional[List[Dict[str, if not self.girder_cad_view: return cad_segments = segments if segments is not None else self._ensure_girder_segments(girder) - self.girder_cad_view.set_view_mode(self._girder_view_mode) - self.girder_cad_view.set_segments(cad_segments) + if hasattr(self.girder_cad_view, "set_view_mode"): + self.girder_cad_view.set_view_mode(self._girder_view_mode) + if hasattr(self.girder_cad_view, "set_segments"): + self.girder_cad_view.set_segments(cad_segments) if not cad_segments: - self.girder_cad_view.set_selected_member("") + if hasattr(self.girder_cad_view, "set_selected_member"): + self.girder_cad_view.set_selected_member("") return idx = self._current_segment_index if selected_index is None else int(selected_index) idx = max(0, min(idx, len(cad_segments) - 1)) selected_member_id = str(cad_segments[idx].get("id") or "") - self.girder_cad_view.set_selected_member(selected_member_id) + if hasattr(self.girder_cad_view, "set_selected_member"): + self.girder_cad_view.set_selected_member(selected_member_id) def _set_girder_cad_view_mode(self, mode: str) -> None: normalized = str(mode or "").strip().lower() @@ -1597,7 +1616,7 @@ def _set_girder_cad_view_mode(self, mode: str) -> None: normalized = "side" self._girder_view_mode = normalized - if self.girder_cad_view is not None: + if self.girder_cad_view is not None and hasattr(self.girder_cad_view, "set_view_mode"): self.girder_cad_view.set_view_mode(normalized) is_cross = normalized == "cross" @@ -1627,6 +1646,15 @@ def _set_girder_cad_view_mode(self, mode: str) -> None: if self.side_view_btn is not None: self.side_view_btn.setStyleSheet(active_style if not is_cross else inactive_style) + def update_cad_params(self, params: dict): + if hasattr(self, "girder_cad_view") and hasattr(self.girder_cad_view, "update_params"): + self.girder_cad_view.update_params(params) + self.girder_cad_view.show_dimensions = False + self.girder_cad_view.show_minimal_dimensions = True + self.girder_cad_view.show_girder_labels = True + self.girder_cad_view.show_span_values = False + self.girder_cad_view.show_carriageway_values = False + def _refresh_segment_list(self, girder: str) -> None: segments = self._ensure_girder_segments(girder) self._update_girder_cad_view(girder, segments, self._current_segment_index) @@ -3065,6 +3093,11 @@ def _refresh_girder_combo_items(self, preferred_selection: Optional[List[str]] = self.select_girder_combo.blockSignals(block) def _on_girders_selection_changed(self, *args): + if hasattr(self, "girder_cad_view"): + selected = self._get_selected_girders() + self.girder_cad_view.highlighted_girders = selected + self.girder_cad_view.update() + if self.span_combo.currentText() == "Full Length": self._update_member_id_edit_state() return diff --git a/src/osdagbridge/desktop/ui/dialogs/tabs/typical_section_details.py b/src/osdagbridge/desktop/ui/dialogs/tabs/typical_section_details.py index 0e713ed30..db2e8c5d5 100644 --- a/src/osdagbridge/desktop/ui/dialogs/tabs/typical_section_details.py +++ b/src/osdagbridge/desktop/ui/dialogs/tabs/typical_section_details.py @@ -86,6 +86,7 @@ class TypicalSectionDetailsTab(QWidget): footpath_changed = Signal(str) girder_count_changed = Signal(int) + cad_params_changed = Signal(dict) def __init__(self, footpath_value="None", carriageway_width=7.5, parent=None, initial_cad_state=None): self._initial_cad_state = initial_cad_state or {} @@ -427,6 +428,8 @@ def _update_cad_preview(self): if params: self.cad_preview.update_params(params) + if hasattr(self, "cad_params_changed"): + self.cad_params_changed.emit(params) diff --git a/src/osdagbridge/desktop/ui/docks/cad_cross_section.py b/src/osdagbridge/desktop/ui/docks/cad_cross_section.py index 8a89c2674..6c07ee2ec 100644 --- a/src/osdagbridge/desktop/ui/docks/cad_cross_section.py +++ b/src/osdagbridge/desktop/ui/docks/cad_cross_section.py @@ -37,6 +37,10 @@ def __init__(self, parent=None): self.show_dimensions = True self.show_span_values = False self.show_carriageway_values = False + self.show_girder_labels = False + self.show_minimal_dimensions = False + self.show_girder_spacing_only = False + self.highlighted_girders = [] self.setMouseTracking(True) # enable mouse tracking for hover self.concrete_brush = self.create_concrete_brush() self.crash_barrier_params = {} @@ -1659,10 +1663,17 @@ def draw_wearing_segment(x_start, x_end, num_points=50): else: tf_top = tf_bottom = self.girder['flange_thickness'] * scale * self.girder_visual_scale['flange_thickness'] # Draw girders and stiffeners - for girder_x in positions: - self.draw_i_section(painter, girder_x, base_y, scale, self.GIRDER_COLOR) + for i, girder_x in enumerate(positions): + girder_id = f"Girder {i+1}" + is_highlighted = girder_id in self.highlighted_girders or "All" in self.highlighted_girders + color = QColor(144, 175, 19) if is_highlighted else self.GIRDER_COLOR + self.draw_i_section(painter, girder_x, base_y, scale, color) self.draw_stiffeners(painter, girder_x, base_y, scale, self.STIFFENER_COLOR) + if getattr(self, "show_girder_labels", False): + label_y = base_y + 40 + self.draw_text_with_background(painter, girder_x - 30, label_y, f"Girder {i+1}", bg_color=QColor(255, 255, 255, 200)) + @@ -1796,6 +1807,33 @@ def draw_wearing_segment(x_start, x_end, num_points=50): crash_barrier_width_px, left_barrier_end_x, right_barrier_end_x, DIM_OFFSET, DIM_OFFSET_SMALL ) + elif getattr(self, "show_minimal_dimensions", False) and len(positions) > 0: + first_girder_x = positions[0] + Y_OVERHANG = base_y + 20 + overhang_m = self.params.get('deck_overhang', 1000) / 1000 + label_overhang = f"Overhang = {overhang_m:.2f} m" + self.draw_dimension_arrow(painter, deck_left_x, Y_OVERHANG, first_girder_x, Y_OVERHANG, + label_overhang, True, extension_direction='up', + extension_end_y=deck_bottom_y) + + if len(positions) >= 2 and "girder_spacing" in self.params: + girder_spacing = float(self.params.get('girder_spacing', 2500)) / 1000.0 + # Draw only one girder spacing to avoid clutter + x_left = positions[0] + x_right = positions[1] + Y_GIRDER_SPACING = base_y + 70 + self.draw_dimension_arrow(painter, x_left, Y_GIRDER_SPACING, x_right, Y_GIRDER_SPACING, + f"Girder Spacing = {girder_spacing:.2f} m", extension_direction='up', + extension_end_y=deck_bottom_y) + elif getattr(self, "show_girder_spacing_only", False) and "girder_spacing" in self.params and len(positions) > 1: + girder_spacing = float(self.params.get('girder_spacing', 2500)) + for i in range(len(positions) - 1): + x_left = positions[i] + x_right = positions[i+1] + Y_GIRDER_SPACING = base_y + 80 * scale + self.draw_dimension_arrow(painter, x_left, Y_GIRDER_SPACING, x_right, Y_GIRDER_SPACING, + f"{girder_spacing:.0f}", extension_direction='down', + extension_end_y=Y_GIRDER_SPACING - 30 * scale) # Add hover labels self.add_cross_section_hover_labels( diff --git a/src/osdagbridge/desktop/ui/template_page.py b/src/osdagbridge/desktop/ui/template_page.py index 6b3bd9164..79b687151 100644 --- a/src/osdagbridge/desktop/ui/template_page.py +++ b/src/osdagbridge/desktop/ui/template_page.py @@ -563,7 +563,7 @@ def cad_3d_view_toggle(self, force_show=False): self.cad_3d_view_active = not self.cad_3d_view_active if self.cad_3d_view_active or force_show: - # 3D CAD is mutually exclusive — deactivate Plots & update icon + # 3D CAD is mutually exclusive — deactivate Plots & update icon self.plots_view_active = False self.plots_control.load(":/vectors/view_btn/plots_inactive.svg") # Hide dual sub-views & update icons @@ -576,7 +576,7 @@ def cad_3d_view_toggle(self, force_show=False): # Switch central area to 3D CAD widget self._set_central_view('3d') else: - # 3D CAD turned off — mark inactive & update icon + # 3D CAD turned off — mark inactive & update icon self.cad_3d_control.load(":/vectors/view_btn/3d_cad_inactive.svg") # Restore dual view button states & update icons self.cross_section_active = True @@ -595,7 +595,7 @@ def plots_view_toggle(self): self.plots_view_active = not self.plots_view_active if self.plots_view_active: - # Plots is mutually exclusive — deactivate 3D CAD & update icon + # Plots is mutually exclusive — deactivate 3D CAD & update icon self.cad_3d_view_active = False self.cad_3d_control.load(":/vectors/view_btn/3d_cad_inactive.svg") # Hide dual sub-views & update icons @@ -608,7 +608,7 @@ def plots_view_toggle(self): # Switch central area to Plots widget self._set_central_view('plots') else: - # Plots turned off — mark inactive & update icon + # Plots turned off — mark inactive & update icon self.plots_control.load(":/vectors/view_btn/plots_inactive.svg") # Restore dual view button states & update icons self.cross_section_active = True @@ -1259,7 +1259,7 @@ def __init__(self, parent): toggle_layout.setSpacing(0) toggle_layout.setAlignment(Qt.AlignVCenter | Qt.AlignRight) # Align to right for input dock - self.toggle_btn = QPushButton("❯") # Right-pointing chevron for input dock + self.toggle_btn = QPushButton("❯") # Right-pointing chevron for input dock self.toggle_btn.setFixedSize(6, 60) self.toggle_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.toggle_btn.clicked.connect(self.parent.input_dock_toggle) @@ -1305,7 +1305,7 @@ def __init__(self, parent): toggle_layout.setSpacing(0) toggle_layout.setAlignment(Qt.AlignVCenter | Qt.AlignLeft) - self.toggle_btn = QPushButton("❮") # Show state initially + self.toggle_btn = QPushButton("❮") # Show state initially self.toggle_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.toggle_btn.setFixedSize(6, 60) self.toggle_btn.clicked.connect(self.parent.output_dock_toggle) @@ -1346,4 +1346,4 @@ def __init__(self, text: str, parent=None): label.setAlignment(Qt.AlignCenter) label.setStyleSheet("font-size: 18px; color: #90AF13; font-weight: bold;") layout.addWidget(label) - self.setStyleSheet("background-color: #F8FAF0; border: 1px solid #90AF13;") \ No newline at end of file + self.setStyleSheet("background-color: #F8FAF0; border: 1px solid #90AF13;") diff --git a/src/osdagbridge/desktop/ui/utils/custom_3dviewer.py b/src/osdagbridge/desktop/ui/utils/custom_3dviewer.py index 99727324c..04dbd7ec1 100644 --- a/src/osdagbridge/desktop/ui/utils/custom_3dviewer.py +++ b/src/osdagbridge/desktop/ui/utils/custom_3dviewer.py @@ -430,7 +430,8 @@ def display_view_cube(self): def _show_navcube_when_ready(self): self._resize_navcube() self._position_navcube() - self.navcube.mark_ready() + if hasattr(self.navcube, 'mark_ready'): + self.navcube.mark_ready() self.navcube.update() # ------------------------------------------------------------------ From acf3566af8a45a06de4904420b68dbd8752540f7 Mon Sep 17 00:00:00 2001 From: anu Date: Fri, 29 May 2026 14:16:58 +0530 Subject: [PATCH 4/6] chore: make third-party dependencies optional for robust GUI launch --- .../bridge_types/plate_girder/analyser.py | 7 +- .../plate_girder/analysis_results.py | 8 +- .../plate_girder/plot_generator.py | 8 +- src/osdagbridge/desktop/ui/cad_3d.py | 43 ++++++++--- src/osdagbridge/desktop/ui/mpl_plot_widget.py | 75 +++++++++++-------- 5 files changed, 97 insertions(+), 44 deletions(-) diff --git a/src/osdagbridge/core/bridge_types/plate_girder/analyser.py b/src/osdagbridge/core/bridge_types/plate_girder/analyser.py index ae85af34a..d5139a1c8 100644 --- a/src/osdagbridge/core/bridge_types/plate_girder/analyser.py +++ b/src/osdagbridge/core/bridge_types/plate_girder/analyser.py @@ -1,4 +1,9 @@ -import ospgrillage as og +HAS_OSP_GRILLAGE = True +try: + import ospgrillage as og +except (ImportError, ModuleNotFoundError): + HAS_OSP_GRILLAGE = False + # from math import sqrt, pi # import openseespy.opensees as ops from osdagbridge.core.utils.codes.irc6_2017 import IRC6_2017 diff --git a/src/osdagbridge/core/bridge_types/plate_girder/analysis_results.py b/src/osdagbridge/core/bridge_types/plate_girder/analysis_results.py index 0973887a2..efc48fbd2 100644 --- a/src/osdagbridge/core/bridge_types/plate_girder/analysis_results.py +++ b/src/osdagbridge/core/bridge_types/plate_girder/analysis_results.py @@ -134,7 +134,13 @@ import math from collections import defaultdict, deque -import openseespy.opensees as ops + +HAS_OPENSEES = True +try: + import openseespy.opensees as ops +except (ImportError, ModuleNotFoundError): + HAS_OPENSEES = False + import pandas as pd from osdagbridge.core.utils.common import kN, m, m2 from osdagbridge.core.utils.codes.irc6_2017 import IRC6_2017 diff --git a/src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py b/src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py index 14cf5921c..735b219d4 100644 --- a/src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py +++ b/src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py @@ -7,7 +7,13 @@ from matplotlib.ticker import FuncFormatter from matplotlib.ticker import MaxNLocator import numpy as np -import openseespy.opensees as ops + +HAS_OPENSEES = True +try: + import openseespy.opensees as ops +except (ImportError, ModuleNotFoundError): + HAS_OPENSEES = False + from mpl_toolkits.mplot3d.art3d import Line3DCollection try: diff --git a/src/osdagbridge/desktop/ui/cad_3d.py b/src/osdagbridge/desktop/ui/cad_3d.py index 2c77f3d01..4d6aeb89e 100644 --- a/src/osdagbridge/desktop/ui/cad_3d.py +++ b/src/osdagbridge/desktop/ui/cad_3d.py @@ -19,16 +19,20 @@ ) from PySide6.QtCore import QTimer, Qt -from OCC.Core.Quantity import Quantity_Color, Quantity_TOC_RGB -from OCC.Display.backend import load_backend - -# CAD generator -from osdagbridge.core.bridge_types.plate_girder.cad_generator import ( - PlateGirderCADGenerator -) +HAS_OCC = True +try: + from OCC.Core.Quantity import Quantity_Color, Quantity_TOC_RGB + from OCC.Display.backend import load_backend + + # CAD generator + from osdagbridge.core.bridge_types.plate_girder.cad_generator import ( + PlateGirderCADGenerator + ) -# Custom 3D Viewer -from osdagbridge.desktop.ui.utils.custom_3dviewer import CustomViewer3d + # Custom 3D Viewer + from osdagbridge.desktop.ui.utils.custom_3dviewer import CustomViewer3d +except (ImportError, ModuleNotFoundError) as e: + HAS_OCC = False from osdagbridge.core.bridge_types.plate_girder.dto import ( BridgeParametersDTO, @@ -46,6 +50,23 @@ class CAD3DWindow(QWidget): def __init__(self, parent=None): super().__init__(parent) + if not HAS_OCC: + from PySide6.QtWidgets import QLabel + self.layout = QVBoxLayout(self) + self.layout.setAlignment(Qt.AlignCenter) + + self.label = QLabel( + "

3D CAD Viewer Unavailable

" + "

The 3D viewer requires pythonocc-core, which is not installed in your Python environment.

" + "

To enable 3D viewing, please install it via conda:
" + "conda install -c conda-forge pythonocc-core=7.6.2

" + ) + self.label.setTextFormat(Qt.RichText) + self.label.setAlignment(Qt.AlignCenter) + self.label.setStyleSheet("color: #555555; font-size: 14px; padding: 20px; border: 1px solid #cccccc; border-radius: 8px; background-color: #fafafa;") + self.layout.addWidget(self.label) + return + # CAD generator self.generator = PlateGirderCADGenerator() @@ -77,6 +98,8 @@ def init_display(self): Does NOT generate or render any geometry. Call render_3d_cad() to render the model. """ + if not HAS_OCC: + return load_backend("pyside6") self.viewer = CustomViewer3d(self) @@ -112,6 +135,8 @@ def _complete_cad_init(self): self.create_cad_view_controls() def _is_display_ready(self): + if not HAS_OCC: + return False return self.display is not None and not self._cad_init_pending # ── RENDER / CLEAR ──────────────────────────────────────────────────────── diff --git a/src/osdagbridge/desktop/ui/mpl_plot_widget.py b/src/osdagbridge/desktop/ui/mpl_plot_widget.py index c49be8c08..0e323fdeb 100644 --- a/src/osdagbridge/desktop/ui/mpl_plot_widget.py +++ b/src/osdagbridge/desktop/ui/mpl_plot_widget.py @@ -11,8 +11,12 @@ ) from PySide6.QtCore import Qt, QEvent, QTimer -from navcube import NavCubeOverlay, NavCubeStyle -from osdagbridge.desktop.ui.utils.mpl_widget_navcube_sync import MatplotlibNavCubeSync +HAS_NAVCUBE = True +try: + from navcube import NavCubeOverlay, NavCubeStyle + from osdagbridge.desktop.ui.utils.mpl_widget_navcube_sync import MatplotlibNavCubeSync +except (ImportError, ModuleNotFoundError): + HAS_NAVCUBE = False from osdagbridge.core.bridge_types.plate_girder.plot_generator import ( build_figure_sfd, @@ -141,27 +145,31 @@ def __init__(self, parent=None): self._summary_overlay.hide() # ── NavCube: create overlay + sync bridge ────────────────── - self._navcube = NavCubeOverlay(self._canvas, overlay=False, style=NavCubeStyle( - size=65, theme="light", - face_color=(242, 244, 247), edge_color=(218, 224, 232), - corner_color=(228, 232, 238), text_color=(45, 55, 72), - border_color=(30, 30, 30), border_secondary_color=(80, 80, 80), - border_width_main=1.6, border_width_secondary=0.9, - hover_color=(145, 176, 20, 235), hover_text_color=(255, 255, 255), - dot_color=(60, 60, 60, 180), shadow_color=(20, 20, 20, 45), - shadow_offset_x=2.0, shadow_offset_y=2.5, - face_color_dark=(52, 62, 76), edge_color_dark=(42, 52, 65), - corner_color_dark=(47, 57, 70), text_color_dark=(210, 220, 232), - border_color_dark=(200, 200, 200), border_secondary_color_dark=(130, 130, 130), - hover_color_dark=(145, 176, 20, 235), - show_gizmo=False, inactive_opacity=0.70, animation_ms=300, - light_direction=(-0.5, -1.0, -1.5), - )) - self._navcube.hide() - self._navcube_sync = MatplotlibNavCubeSync(self._canvas, self._navcube) - self._canvas.mpl_connect("button_press_event", lambda e: self._navcube_sync.set_interaction_active(True) if e.button == 1 else None) - self._canvas.mpl_connect("button_release_event", lambda e: self._navcube_sync.set_interaction_active(False) if e.button == 1 else None) - self._canvas.mpl_connect("motion_notify_event", lambda e: self._navcube_sync.force_sync() if e.button == 1 else None) + if HAS_NAVCUBE: + self._navcube = NavCubeOverlay(self._canvas, overlay=False, style=NavCubeStyle( + size=65, theme="light", + face_color=(242, 244, 247), edge_color=(218, 224, 232), + corner_color=(228, 232, 238), text_color=(45, 55, 72), + border_color=(30, 30, 30), border_secondary_color=(80, 80, 80), + border_width_main=1.6, border_width_secondary=0.9, + hover_color=(145, 176, 20, 235), hover_text_color=(255, 255, 255), + dot_color=(60, 60, 60, 180), shadow_color=(20, 20, 20, 45), + shadow_offset_x=2.0, shadow_offset_y=2.5, + face_color_dark=(52, 62, 76), edge_color_dark=(42, 52, 65), + corner_color_dark=(47, 57, 70), text_color_dark=(210, 220, 232), + border_color_dark=(200, 200, 200), border_secondary_color_dark=(130, 130, 130), + hover_color_dark=(145, 176, 20, 235), + show_gizmo=False, inactive_opacity=0.70, animation_ms=300, + light_direction=(-0.5, -1.0, -1.5), + )) + self._navcube.hide() + self._navcube_sync = MatplotlibNavCubeSync(self._canvas, self._navcube) + self._canvas.mpl_connect("button_press_event", lambda e: self._navcube_sync.set_interaction_active(True) if e.button == 1 else None) + self._canvas.mpl_connect("button_release_event", lambda e: self._navcube_sync.set_interaction_active(False) if e.button == 1 else None) + self._canvas.mpl_connect("motion_notify_event", lambda e: self._navcube_sync.force_sync() if e.button == 1 else None) + else: + self._navcube = None + self._navcube_sync = None # ────────────────────────────────────────────────────────── # zoom toolbar @@ -410,6 +418,8 @@ def update_plot(self, *_args): # ── NavCube helpers ──────────────────────────────────────────── def _update_navcube_visibility(self): + if not HAS_NAVCUBE: + return from mpl_toolkits.mplot3d import Axes3D has_3d = any(isinstance(ax, Axes3D) for ax in self._fig.axes) if has_3d: @@ -424,6 +434,8 @@ def _update_navcube_visibility(self): def _resize_navcube(self): """Scale NavCube to 8% of the shorter canvas edge, DPI-aware. (mirrors CustomViewer3d)""" + if not HAS_NAVCUBE: + return vp_logical = min(self._canvas.width(), self._canvas.height()) if vp_logical < 10: return @@ -447,18 +459,12 @@ def _resize_navcube(self): nc._update_dpi() def _position_navcube(self): + if not HAS_NAVCUBE: + return padding = 10 x = max(0, self._canvas.width() - self._navcube.width() - padding) self._navcube.move(x, padding) - def eventFilter(self, obj, event): - if obj is self._canvas and event.type() == QEvent.Type.Resize: - self._resize_navcube() - self._position_navcube() - if self._navcube.isVisible(): - self._navcube.raise_() - return super().eventFilter(obj, event) - # ────────────────────────────────────────────────────────────── # NATIVE SLOTS: The 'checked' variable is now passed instantly by Qt! @@ -658,7 +664,7 @@ def _apply_zoom(self): # return True # return super().eventFilter(obj, event) def eventFilter(self, obj, event): - """Intercepts the mouse wheel at the OS level to guarantee zoom triggers.""" + """Intercepts the mouse wheel at the OS level to guarantee zoom triggers and handles NavCube resizing.""" from PySide6.QtCore import QEvent if obj is self._canvas and event.type() == QEvent.Type.Wheel: @@ -671,6 +677,11 @@ def eventFilter(self, obj, event): self._zoom_out() return True + elif HAS_NAVCUBE and obj is self._canvas and event.type() == QEvent.Type.Resize: + self._resize_navcube() + self._position_navcube() + if self._navcube and self._navcube.isVisible(): + self._navcube.raise_() return super().eventFilter(obj, event) # def _zoom_step(self, factor): From b3cdc99d9b68e6f029b1c163a043e3ff734f85a1 Mon Sep 17 00:00:00 2001 From: anu Date: Fri, 29 May 2026 14:23:03 +0530 Subject: [PATCH 5/6] fix: instantiate _GirderCad2DView instead of CrossSectionCADWidget for segment previews --- .../tabs/sub_tabs/section_properties/girder_details_tab.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/osdagbridge/desktop/ui/dialogs/tabs/sub_tabs/section_properties/girder_details_tab.py b/src/osdagbridge/desktop/ui/dialogs/tabs/sub_tabs/section_properties/girder_details_tab.py index bd2bfa270..71faf1bce 100644 --- a/src/osdagbridge/desktop/ui/dialogs/tabs/sub_tabs/section_properties/girder_details_tab.py +++ b/src/osdagbridge/desktop/ui/dialogs/tabs/sub_tabs/section_properties/girder_details_tab.py @@ -1437,11 +1437,7 @@ def _build_overview_card(self): top_cad_layout.setContentsMargins(12, 10, 12, 10) top_cad_layout.setSpacing(12) - self.girder_cad_view = CrossSectionCADWidget() - self.girder_cad_view.scale_factor = 0.65 - self.girder_cad_view.show_dimensions = False - self.girder_cad_view.show_minimal_dimensions = True - self.girder_cad_view.show_girder_labels = True + self.girder_cad_view = _GirderCad2DView() self.girder_cad_view.setMinimumHeight(0) cad_scroll = QScrollArea() From d7191ba20e438467f4c827b9dca9d2c9f4f2bb06 Mon Sep 17 00:00:00 2001 From: anu Date: Fri, 29 May 2026 15:55:42 +0530 Subject: [PATCH 6/6] feat: dynamic girder highlighting in cross-section CAD + fix blank view on open --- .../section_properties/girder_details_tab.py | 470 +++++++++++++----- .../desktop/ui/docks/cad_cross_section.py | 19 +- .../desktop/ui/docks/input_dock.py | 2 +- 3 files changed, 374 insertions(+), 117 deletions(-) diff --git a/src/osdagbridge/desktop/ui/dialogs/tabs/sub_tabs/section_properties/girder_details_tab.py b/src/osdagbridge/desktop/ui/dialogs/tabs/sub_tabs/section_properties/girder_details_tab.py index 71faf1bce..87be942c8 100644 --- a/src/osdagbridge/desktop/ui/dialogs/tabs/sub_tabs/section_properties/girder_details_tab.py +++ b/src/osdagbridge/desktop/ui/dialogs/tabs/sub_tabs/section_properties/girder_details_tab.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Callable, Dict, List, Optional -from PySide6.QtCore import Qt, QRectF, QSize +from PySide6.QtCore import Qt, QRectF, QSize, QPointF, QTimer from PySide6.QtGui import QDoubleValidator, QColor, QPalette, QPen, QPainter, QIntValidator, QIcon, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, @@ -25,6 +25,7 @@ QListWidget, QScrollArea, QSizePolicy, + QStackedLayout, QStyledItemDelegate, QStyle, QStyleOptionViewItem, @@ -517,102 +518,17 @@ def result_bounds(self) -> Optional[dict]: return self._result -class _GirderCad2DView(QWidget): - """Simple 2D segmented girder view driven by member lengths.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setFixedHeight(160) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.setStyleSheet("QWidget { background: #f8f8f8; border: 1px solid #d8d8d8; border-radius: 8px; }") - self._segments: List[dict] = [] - self._selected_member_id: str = "" - self._flange_thickness: float = 15.0 - self._view_mode: str = "side" - - @staticmethod - def _fmt_length(length_m: float) -> str: - text = f"{float(length_m):.3f}".rstrip("0").rstrip(".") - return text if text else "0" - - def set_segments(self, segments: List[Dict[str, float]]) -> None: - cleaned: List[dict] = [] - for segment in segments or []: - start = float(segment.get("start", 0.0)) - end = float(segment.get("end", 0.0)) - length = max(0.0, end - start) - if length <= 0.0: - continue - cleaned.append( - { - "id": str(segment.get("id") or ""), - "length": float(length), - } - ) - self._segments = cleaned - self.update() - - def set_selected_member(self, member_id: str) -> None: - self._selected_member_id = str(member_id or "").strip() - self.update() - - def set_view_mode(self, mode: str) -> None: - normalized = str(mode or "").strip().lower() - if normalized not in {"cross", "side"}: - normalized = "side" - if self._view_mode != normalized: - self._view_mode = normalized - self.update() - - def _paint_cross_section(self, painter: QPainter, drawing_rect: QRectF) -> None: - clear_pen = QPen(QColor("#d0d0d0")) - clear_pen.setWidth(1) - painter.setPen(clear_pen) - painter.setBrush(QColor("#ffffff")) - painter.drawRect(drawing_rect) - - usable = drawing_rect.adjusted(drawing_rect.width() * 0.18, 12.0, -drawing_rect.width() * 0.18, -22.0) - if usable.width() <= 0.0 or usable.height() <= 0.0: - return - - top_width = usable.width() * 0.82 - bottom_width = usable.width() * 0.74 - flange_thickness = max(10.0, min(self._flange_thickness, usable.height() * 0.20)) - web_thickness = max(8.0, min(20.0, usable.width() * 0.10)) - - center_x = usable.center().x() - top_flange = QRectF(center_x - (top_width / 2.0), usable.top(), top_width, flange_thickness) - bottom_flange = QRectF(center_x - (bottom_width / 2.0), usable.bottom() - flange_thickness, bottom_width, flange_thickness) - web_top = top_flange.bottom() - web_bottom = bottom_flange.top() - web = QRectF(center_x - (web_thickness / 2.0), web_top, web_thickness, max(2.0, web_bottom - web_top)) - - painter.setPen(Qt.NoPen) - painter.setBrush(QColor("#c9c9c9")) - painter.drawRect(top_flange) - painter.setBrush(QColor("#dcdcdc")) - painter.drawRect(web) - painter.setBrush(QColor("#c9c9c9")) - painter.drawRect(bottom_flange) - - outline = QPen(QColor("#5e5e5e")) - outline.setWidth(1) - painter.setPen(outline) - painter.setBrush(Qt.NoBrush) - painter.drawRect(top_flange) - painter.drawRect(web) - painter.drawRect(bottom_flange) +class _GirderSideViewWidget(QWidget): + def __init__(self, parent_view): + super().__init__() + self.parent_view = parent_view + self.setFixedHeight(140) + self.setStyleSheet("QWidget { background: #f8f8f8; border: none; }") - label_member = self._selected_member_id or (str(self._segments[0].get("id") or "") if self._segments else "") - label = f"Cross Section • {label_member}" if label_member else "Cross Section" - painter.setPen(QPen(QColor("#2a2a2a"))) - painter.drawText(drawing_rect.adjusted(8.0, 0.0, -8.0, -2.0), Qt.AlignHCenter | Qt.AlignBottom, label) - - def paintEvent(self, event): # noqa: N802 (Qt naming) - super().paintEvent(event) + def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing, True) - + drawing_rect = QRectF(self.rect()).adjusted(10.0, 24.0, -10.0, -18.0) if drawing_rect.width() <= 0 or drawing_rect.height() <= 0: return @@ -624,19 +540,15 @@ def paintEvent(self, event): # noqa: N802 (Qt naming) painter.setBrush(outer_fill) painter.drawRect(drawing_rect) - if not self._segments: + if not self.parent_view._segments: painter.setPen(QPen(QColor("#5a5a5a"))) painter.drawText(drawing_rect, Qt.AlignCenter, "No member segments") return - total_length = sum(float(segment["length"]) for segment in self._segments) + total_length = sum(float(segment["length"]) for segment in self.parent_view._segments) if total_length <= 0.0: return - if self._view_mode == "cross": - self._paint_cross_section(painter, drawing_rect) - return - # Monochrome palette for a clean technical look. fill_palette = [QColor("#f5f5f5"), QColor("#eeeeee"), QColor("#e7e7e7")] partition_pen = QPen(QColor("#888888")) @@ -644,7 +556,7 @@ def paintEvent(self, event): # noqa: N802 (Qt naming) partition_pen.setStyle(Qt.SolidLine) # Keep flanges visually meaningful even for compact/tall drawing areas. - flange_thickness = max(10.0, min(self._flange_thickness, drawing_rect.height() * 0.24)) + flange_thickness = max(10.0, min(self.parent_view._flange_thickness, drawing_rect.height() * 0.24)) web_top = drawing_rect.top() + flange_thickness web_bottom = drawing_rect.bottom() - flange_thickness web_height = max(2.0, web_bottom - web_top) @@ -657,10 +569,10 @@ def paintEvent(self, event): # noqa: N802 (Qt naming) x = drawing_rect.left() partition_xs: List[float] = [] - for index, segment in enumerate(self._segments): + for index, segment in enumerate(self.parent_view._segments): ratio = float(segment["length"]) / total_length segment_width = drawing_rect.width() * ratio - if index == len(self._segments) - 1: + if index == len(self.parent_view._segments) - 1: segment_width = max(1.0, drawing_rect.right() - x) segment_rect = QRectF(x, drawing_rect.top(), segment_width, drawing_rect.height()) @@ -669,7 +581,7 @@ def paintEvent(self, event): # noqa: N802 (Qt naming) bottom_flange_rect = QRectF(segment_rect.left(), web_bottom, segment_rect.width(), flange_thickness) base_fill = fill_palette[index % len(fill_palette)] member_id = str(segment.get("id") or "") - is_selected = bool(self._selected_member_id) and member_id == self._selected_member_id + is_selected = bool(self.parent_view._selected_member_id) and member_id == self.parent_view._selected_member_id top_fill = QColor("#c9c9c9") web_fill = QColor("#dcdcdc") @@ -691,16 +603,16 @@ def paintEvent(self, event): # noqa: N802 (Qt naming) if is_selected: painter.setPen(Qt.NoPen) - painter.setBrush(QColor(144, 175, 19, 42)) + painter.setBrush(QColor(144, 175, 19, 120)) painter.drawRect(segment_rect.adjusted(2.0, 2.0, -2.0, -2.0)) - selected_pen = QPen(QColor("#6f850f")) - selected_pen.setWidth(2) + selected_pen = QPen(QColor(144, 175, 19)) + selected_pen.setWidth(3) painter.setPen(selected_pen) painter.setBrush(Qt.NoBrush) painter.drawRect(segment_rect.adjusted(1.5, 1.5, -1.5, -1.5)) - label = f"{segment['id']} ({self._fmt_length(segment['length'])} m)" + label = f"{segment['id']} ({self.parent_view._fmt_length(segment['length'])} m)" painter.setPen(QPen(QColor("#121212"))) text_margin = 6 text_rect = segment_rect.adjusted(text_margin, 0, -text_margin, 0) @@ -708,7 +620,7 @@ def paintEvent(self, event): # noqa: N802 (Qt naming) elided = painter.fontMetrics().elidedText(label, Qt.ElideRight, int(text_rect.width())) painter.drawText(text_rect, Qt.AlignCenter, elided) - if index < len(self._segments) - 1: + if index < len(self.parent_view._segments) - 1: partition_xs.append(segment_rect.right()) x = segment_rect.right() @@ -728,6 +640,168 @@ def paintEvent(self, event): # noqa: N802 (Qt naming) ) +class _GirderCad2DView(QWidget): + """Simple 2D segmented girder view driven by member lengths with an embedded CrossSectionCADWidget for full cross section view.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedHeight(160) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setStyleSheet("QWidget { background: transparent; border: none; }") + + self._segments: List[dict] = [] + self._selected_member_id: str = "" + self._flange_thickness: float = 15.0 + self._view_mode: str = "side" + + self.stacked_layout = QStackedLayout(self) + self.stacked_layout.setContentsMargins(0, 0, 0, 0) + self.stacked_layout.setSpacing(0) + + self.side_view_widget = _GirderSideViewWidget(self) + self.cross_section_widget = CrossSectionCADWidget(self) + + # Configure CrossSectionCADWidget defaults + self.cross_section_widget.scale_factor = 0.65 + self.cross_section_widget.show_dimensions = False + self.cross_section_widget.show_minimal_dimensions = True + self.cross_section_widget.show_girder_labels = True + self.cross_section_widget.show_span_values = False + self.cross_section_widget.show_carriageway_values = False + + self.stacked_layout.addWidget(self.side_view_widget) + self.stacked_layout.addWidget(self.cross_section_widget) + + def set_dimensions(self, dims: Optional[dict]) -> None: + pass + + @staticmethod + def _fmt_length(length_m: float) -> str: + text = f"{float(length_m):.3f}".rstrip("0").rstrip(".") + return text if text else "0" + + def set_segments(self, segments: List[Dict[str, float]]) -> None: + cleaned: List[dict] = [] + for segment in segments or []: + start = float(segment.get("start", 0.0)) + end = float(segment.get("end", 0.0)) + length = max(0.0, end - start) + if length <= 0.0: + continue + cleaned.append( + { + "id": str(segment.get("id") or ""), + "length": float(length), + } + ) + self._segments = cleaned + self.side_view_widget.update() + + def _sync_highlighted_girders(self, highlighted: list) -> None: + """Push highlighted_girders to every visible CAD cross-section widget in the application.""" + from PySide6.QtWidgets import QApplication + for top in QApplication.topLevelWidgets(): + cls = top.__class__.__name__ + if cls == "AdditionalInputs": + try: + cad = top.typical_section_tab.cad_preview + cad.highlighted_girders = highlighted + cad.update() + except Exception: + pass + elif cls == "CustomWindow": + try: + cad = top.cad_comp_widget.cross_section_widget + cad.highlighted_girders = highlighted + cad.update() + except Exception: + pass + + def set_selected_member(self, member_id: str) -> None: + self._selected_member_id = str(member_id or "").strip() + + # Auto highlight the selected girder in full cross section view (e.g. "G1M1" -> "G1" -> "Girder 1") + highlighted = [] + if self._selected_member_id: + parts = self._selected_member_id.split('M') + if parts and parts[0].startswith('G'): + girder_num = parts[0][1:] + if girder_num.isdigit(): + highlighted = [f"Girder {girder_num}"] + self.cross_section_widget.highlighted_girders = highlighted + + self._sync_highlighted_girders(highlighted) + self.side_view_widget.update() + self.cross_section_widget.update() + + def set_view_mode(self, mode: str) -> None: + normalized = str(mode or "").strip().lower() + if normalized not in {"cross", "side"}: + normalized = "side" + self._view_mode = normalized + + if normalized == "side": + self.stacked_layout.setCurrentWidget(self.side_view_widget) + else: + self.stacked_layout.setCurrentWidget(self.cross_section_widget) + QTimer.singleShot(100, self.cross_section_widget.fit_to_screen) + self.update() + + def update_params(self, params: dict): + self.cross_section_widget.update_params(params) + QTimer.singleShot(100, self.cross_section_widget.fit_to_screen) + + # Delegate all CrossSectionCADWidget properties for seamless integration + @property + def scale_factor(self): + return self.cross_section_widget.scale_factor + @scale_factor.setter + def scale_factor(self, val): + self.cross_section_widget.scale_factor = val + + @property + def show_dimensions(self): + return self.cross_section_widget.show_dimensions + @show_dimensions.setter + def show_dimensions(self, val): + self.cross_section_widget.show_dimensions = val + + @property + def show_minimal_dimensions(self): + return self.cross_section_widget.show_minimal_dimensions + @show_minimal_dimensions.setter + def show_minimal_dimensions(self, val): + self.cross_section_widget.show_minimal_dimensions = val + + @property + def show_girder_labels(self): + return self.cross_section_widget.show_girder_labels + @show_girder_labels.setter + def show_girder_labels(self, val): + self.cross_section_widget.show_girder_labels = val + + @property + def show_span_values(self): + return self.cross_section_widget.show_span_values + @show_span_values.setter + def show_span_values(self, val): + self.cross_section_widget.show_span_values = val + + @property + def show_carriageway_values(self): + return self.cross_section_widget.show_carriageway_values + @show_carriageway_values.setter + def show_carriageway_values(self, val): + self.cross_section_widget.show_carriageway_values = val + + @property + def highlighted_girders(self): + return self.cross_section_widget.highlighted_girders + @highlighted_girders.setter + def highlighted_girders(self, val): + self.cross_section_widget.highlighted_girders = val + + class _ThicknessSelectionDialog(QDialog): def __init__(self, title: str, selected_values: List[str], allowed_values: List[str], parent=None): super().__init__(parent) @@ -1445,6 +1519,8 @@ def _build_overview_card(self): cad_scroll.setWidgetResizable(True) cad_scroll.setFrameShape(QFrame.NoFrame) cad_scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }") + cad_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + cad_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) cad_scroll.setWidget(self.girder_cad_view) top_cad_layout.addWidget(cad_scroll, 1) @@ -1606,6 +1682,17 @@ def _update_girder_cad_view(self, girder: str, segments: Optional[List[Dict[str, if hasattr(self.girder_cad_view, "set_selected_member"): self.girder_cad_view.set_selected_member(selected_member_id) + # Retrieve full dimensions of the selected member and set them on the CAD view + dims = None + if selected_member_id: + dims = self.get_member_full_dimensions(selected_member_id) + else: + if cad_segments: + dims = self.get_member_full_dimensions(cad_segments[0]["id"]) + + if hasattr(self.girder_cad_view, "set_dimensions"): + self.girder_cad_view.set_dimensions(dims) + def _set_girder_cad_view_mode(self, mode: str) -> None: normalized = str(mode or "").strip().lower() if normalized not in {"cross", "side"}: @@ -2309,16 +2396,35 @@ def _load_segment_details(self, girder: str, index: int) -> None: finally: self._suppress_distance_updates = False + def _sync_highlighted_girders(self, highlighted: list) -> None: + """Push highlighted_girders to every visible CAD cross-section widget in the application.""" + from PySide6.QtWidgets import QApplication + for top in QApplication.topLevelWidgets(): + cls = top.__class__.__name__ + if cls == "AdditionalInputs": + try: + cad = top.typical_section_tab.cad_preview + cad.highlighted_girders = highlighted + cad.update() + except Exception: + pass + elif cls == "CustomWindow": + try: + cad = top.cad_comp_widget.cross_section_widget + cad.highlighted_girders = highlighted + cad.update() + except Exception: + pass + def _on_girder_changed(self, girder: str) -> None: if not girder: return girder = str(girder).strip() - if not girder or girder == getattr(self, "_current_girder", ""): - return + is_same = girder == getattr(self, "_current_girder", "") # Autosave dirty member state before switching girder. - if self._is_current_member_dirty(): + if not is_same and self._is_current_member_dirty(): self._commit_current_member_state() self._current_girder = girder @@ -2326,6 +2432,16 @@ def _on_girder_changed(self, girder: str) -> None: self._select_segment_index(0) self._sync_remove_button_visibility() + # Highlight the selected girder in cross section view (e.g. "G1" -> "Girder 1") + girder_num = girder[1:] if girder.startswith("G") else girder + if girder_num.isdigit(): + highlighted = [f"Girder {girder_num}"] + if hasattr(self, "girder_cad_view") and self.girder_cad_view is not None: + if hasattr(self.girder_cad_view, "cross_section_widget"): + self.girder_cad_view.cross_section_widget.highlighted_girders = highlighted + self.girder_cad_view.cross_section_widget.update() + self._sync_highlighted_girders(highlighted) + def _on_segment_row_changed(self, current_row: int, _current_column: int, _previous_row: int, _previous_column: int) -> None: if current_row is None or current_row < 0: return @@ -3089,10 +3205,19 @@ def _refresh_girder_combo_items(self, preferred_selection: Optional[List[str]] = self.select_girder_combo.blockSignals(block) def _on_girders_selection_changed(self, *args): + selected = self._get_selected_girders() if hasattr(self, "girder_cad_view"): - selected = self._get_selected_girders() self.girder_cad_view.highlighted_girders = selected self.girder_cad_view.update() + # Convert "G1" -> "Girder 1" for syncing across all CAD views + highlighted = [] + for g in selected: + if g.startswith("G") and g[1:].isdigit(): + highlighted.append(f"Girder {g[1:]}") + else: + highlighted.append(g) + if hasattr(self, "_sync_highlighted_girders"): + self._sync_highlighted_girders(highlighted) if self.span_combo.currentText() == "Full Length": self._update_member_id_edit_state() @@ -3528,6 +3653,10 @@ def _update_preview(self): self.preview_caption.setText(caption) self._update_section_properties() + # Dynamically refresh the girder 2D preview CAD (side view/cross section) + if hasattr(self, "girder_cad_view") and self.girder_cad_view is not None: + self._update_girder_cad_view(self._current_girder, None, self._current_segment_index) + def _gather_welded_dimensions(self): depth = self._parse_float(self.total_depth_input.text()) top_width = self._parse_float(self.top_width_input.text()) @@ -4062,6 +4191,121 @@ def is_member_optimized(self, member_id: str) -> bool: pass return True + def get_member_full_dimensions(self, member_id: str) -> Optional[dict]: + """Return full section dimensions for the given member.""" + member_id = str(member_id or "").strip() + if not member_id: + return None + + girder, _idx = self._split_member_id(member_id) + + # Check if the member_id is the currently selected member in the UI + try: + current_girder, current_member_id = self._current_member_key() + if current_girder == girder and current_member_id == member_id: + is_welded = self.type_combo.currentText().lower() == "welded" + if is_welded: + return self._gather_welded_dimensions() + else: + designation = self.is_section_combo.currentText() + beam = girder_properties.get_beam_profile(designation) + outline = girder_properties.get_rolled_section(designation) if beam is None else None + if beam: + return { + "section_type": "rolled", + "depth_mm": float(beam.depth_mm), + "top_flange_width_mm": float(beam.flange_width_mm), + "bottom_flange_width_mm": float(beam.flange_width_mm), + "top_flange_thickness_mm": float(beam.flange_thickness_mm), + "bottom_flange_thickness_mm": float(beam.flange_thickness_mm), + "web_thickness_mm": float(beam.web_thickness_mm), + } + if outline: + return { + "section_type": "rolled", + "depth_mm": float(outline.get("depth_mm") or 0.0), + "top_flange_width_mm": float(outline.get("top_flange_width_mm") or 0.0), + "bottom_flange_width_mm": float(outline.get("bottom_flange_width_mm") or 0.0), + "top_flange_thickness_mm": float(outline.get("top_flange_thickness_mm") or 0.0), + "bottom_flange_thickness_mm": float(outline.get("bottom_flange_thickness_mm") or 0.0), + "web_thickness_mm": float(outline.get("web_thickness_mm") or 0.0), + } + except Exception: + pass + + # Otherwise, fall back to the stored member state + stored = (self._member_state.get(girder) or {}).get(member_id) or {} + inputs = (stored.get("inputs") or {}) + if not inputs: + # Fall back to default template + template = getattr(self, "_default_member_state", None) or {} + inputs = (template.get("inputs") or {}) + + if not isinstance(inputs, dict): + return None + + section_type = str(inputs.get("type") or "").strip().lower() + if section_type == "welded": + depth = self._parse_float(inputs.get("total_depth")) or 1200.0 + top_width = self._parse_float(inputs.get("top_width")) or 300.0 + bottom_width = self._parse_float(inputs.get("bottom_width")) or top_width + + top_thickness = None + if str(inputs.get("top_thickness") or "").strip().lower() == "custom": + top_thickness = self._parse_float(inputs.get("top_thickness_value")) + if not top_thickness: + top_thickness = 25.0 + + bottom_thickness = None + if str(inputs.get("bottom_thickness") or "").strip().lower() == "custom": + bottom_thickness = self._parse_float(inputs.get("bottom_thickness_value")) + if not bottom_thickness: + bottom_thickness = 25.0 + + web_thickness = None + if str(inputs.get("web_thickness") or "").strip().lower() == "custom": + web_thickness = self._parse_float(inputs.get("web_thickness_value")) + if not web_thickness: + web_thickness = max(8.0, depth * 0.02) + + return { + "section_type": "welded", + "depth_mm": depth, + "top_flange_width_mm": top_width, + "bottom_flange_width_mm": bottom_width, + "top_flange_thickness_mm": top_thickness, + "bottom_flange_thickness_mm": bottom_thickness, + "web_thickness_mm": web_thickness, + } + + designation = str(inputs.get("is_section") or "").strip() + if not designation: + designation = "ISMB 500" + + beam = girder_properties.get_beam_profile(designation) + outline = girder_properties.get_rolled_section(designation) if beam is None else None + if beam: + return { + "section_type": "rolled", + "depth_mm": float(beam.depth_mm), + "top_flange_width_mm": float(beam.flange_width_mm), + "bottom_flange_width_mm": float(beam.flange_width_mm), + "top_flange_thickness_mm": float(beam.flange_thickness_mm), + "bottom_flange_thickness_mm": float(beam.flange_thickness_mm), + "web_thickness_mm": float(beam.web_thickness_mm), + } + if outline: + return { + "section_type": "rolled", + "depth_mm": float(outline.get("depth_mm") or 0.0), + "top_flange_width_mm": float(outline.get("top_flange_width_mm") or 0.0), + "bottom_flange_width_mm": float(outline.get("bottom_flange_width_mm") or 0.0), + "top_flange_thickness_mm": float(outline.get("top_flange_thickness_mm") or 0.0), + "bottom_flange_thickness_mm": float(outline.get("bottom_flange_thickness_mm") or 0.0), + "web_thickness_mm": float(outline.get("web_thickness_mm") or 0.0), + } + return None + def get_member_section_dimensions(self, member_id: str) -> Optional[dict]: """Return basic section dimensions for the given member. diff --git a/src/osdagbridge/desktop/ui/docks/cad_cross_section.py b/src/osdagbridge/desktop/ui/docks/cad_cross_section.py index 6c07ee2ec..65d7810f2 100644 --- a/src/osdagbridge/desktop/ui/docks/cad_cross_section.py +++ b/src/osdagbridge/desktop/ui/docks/cad_cross_section.py @@ -499,6 +499,11 @@ def resizeEvent(self, event): """Position zoom controls in top-right corner""" super().resizeEvent(event) self._position_zoom_buttons() + + def showEvent(self, event): + """Fit diagram to viewport size once the widget becomes visible.""" + super().showEvent(event) + QTimer.singleShot(120, self.fit_to_screen) def update_params(self, params: dict): self.params.update(params) @@ -1662,10 +1667,14 @@ def draw_wearing_segment(x_start, x_end, num_points=50): tf_bottom = self.girder['bottom_flange_thickness'] * scale * self.girder_visual_scale['flange_thickness'] else: tf_top = tf_bottom = self.girder['flange_thickness'] * scale * self.girder_visual_scale['flange_thickness'] - # Draw girders and stiffeners for i, girder_x in enumerate(positions): girder_id = f"Girder {i+1}" - is_highlighted = girder_id in self.highlighted_girders or "All" in self.highlighted_girders + is_highlighted = ( + girder_id in self.highlighted_girders + or f"G{i+1}" in self.highlighted_girders + or f"Girder {i+1}" in self.highlighted_girders + or "All" in self.highlighted_girders + ) color = QColor(144, 175, 19) if is_highlighted else self.GIRDER_COLOR self.draw_i_section(painter, girder_x, base_y, scale, color) self.draw_stiffeners(painter, girder_x, base_y, scale, self.STIFFENER_COLOR) @@ -2430,7 +2439,11 @@ def draw_i_section(self, painter, x, base_y, scale, girder_color): else: painter.setBrush(QBrush(girder_color)) - painter.setPen(QPen(QColor(0, 0, 0), 1.5)) + # High-contrast bold pen outline for highlighted girder + if girder_color == QColor(144, 175, 19): + painter.setPen(QPen(QColor(144, 175, 19), 3)) + else: + painter.setPen(QPen(QColor(0, 0, 0), 1.5)) # Draw bottom flange painter.drawRect(QRectF(x - bf_bottom/2, base_y - tf_bottom, bf_bottom, tf_bottom)) diff --git a/src/osdagbridge/desktop/ui/docks/input_dock.py b/src/osdagbridge/desktop/ui/docks/input_dock.py index dcc38c326..019d3dfc0 100644 --- a/src/osdagbridge/desktop/ui/docks/input_dock.py +++ b/src/osdagbridge/desktop/ui/docks/input_dock.py @@ -712,7 +712,7 @@ def _open_additional_inputs(self, target_tab=None): footpath_value = self._text(KEY_FOOTPATH) or "None" carriageway_width = self._get_effective_carriageway_width() - self.additional_inputs = AdditionalInputs(footpath_value, carriageway_width) + self.additional_inputs = AdditionalInputs(footpath_value, carriageway_width, parent=self.parent) if self._additional_inputs_saved_data: try: