diff --git a/GUI/README.md b/GUI/README.md index b02c515..04deffd 100644 --- a/GUI/README.md +++ b/GUI/README.md @@ -8,7 +8,13 @@ FilmForge 热压模具温控板(GD32F405, RS485 数据口)的桌面上位机 - **实时温度曲线**(自绘 QPainter 控件,无第三方图表依赖):实测温度折线 + 目标温度橙色阶梯线同图对照(闭环监视),NTC 开路/短路处曲线断开并打故障标记; - 时间窗 2/5/15/60 分钟/全部,暂停/恢复,窗口统计(最小/最大/平均)。 + 时间窗 2/5/15/60 分钟/全部,暂停/恢复,窗口统计(最小/最大/平均); + Y 轴(温度轴)量程可选**自动**(随可见数据自适应)或**手动**——直接拖动 + 图表右缘滑条(固定参考域 -20~120℃,把手位置=℃ 读数):上把手=上限、 + 下把手=下限、拖动把手中段上下=整段平移并把跨度保持不变、点击轨道空白直接 + 把对应边界设到该处;首次任意拖拽自动切入手动("Y轴自动/手动"按钮同步), + 把手数值标签仅悬停/拖拽时显示(不常驻遮挡曲线),曲线越界时裁剪回绘图区; + 模式与量程记忆在 `filmforge_gui.json`。 - **温度数据**:温度监控统一走固件 M2 周期上报帧(M2S 开启后主动推送入曲线); M1 仅作手动单点查询(命令区按钮/预设)。 - **控制面板**:目标温度 M4S(0~200 整数℃,滑条+输入框)、加热 M3S1/M3S0 @@ -92,6 +98,16 @@ python publish_gitea.py release/FilmForge-GUI-v<版本>-win64.zip --replace --in 直接 `setStyleSheet` 会在切主题时被库重刷(DLPilot 同款坑);标题字号走 QFont。 - 图表刷新双节流:数据到达即重绘(≤10Hz)+ 0.5s 时间轴推进;“全部”窗口超 4000 点 按步进抽稀(NaN 断口保留)。 +- **是否有数据由绘制时实时判定**(`paintEvent` 直接看数据列表引用):图表只在 + 构建时调用一次 `set_source`,旧版在这里缓存过 `_has_data` 布尔——数据经引用 + 追加后永不刷新,“暂无数据”提示文字在有数据后仍绘在曲线上(实测截图暴露)。 + 手动 Y 轴模式下曲线/目标线**裁剪回绘图区**(`setClipRect`),当前值标记 + 超出量程时不画(实时读数卡片仍可见)。v0.2.10。 +- **Y 轴滑条的两套坐标系别混用**:把手像素位置在**参考域**(全量数据范围 + ±15%,`_y_ref`)上映射,与"显示量程"是两回事;拖拽中的界值必须取**实时 + 显示范围**(manual=`_y_manual`,auto=当前自适应值),不能取绘制缓存 + `_cached_layout` 的陈旧范围——连续两次拖拽之间没有重绘,实测曾把 pan 的 + 量程整段拖跳(v0.2.11)。 ## 已知事项 diff --git a/GUI/app_fluent.py b/GUI/app_fluent.py index efb2792..f04e8bd 100644 --- a/GUI/app_fluent.py +++ b/GUI/app_fluent.py @@ -45,8 +45,8 @@ if getattr(sys, "frozen", False) and os.name == "nt": except (AttributeError, OSError): pass -from PySide6.QtCore import QPointF, QRectF, Qt, QTimer -from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPainterPath, QPen, QPalette +from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal +from PySide6.QtGui import QColor, QCursor, QFont, QIcon, QPainter, QPainterPath, QPen, QPalette from PySide6.QtWidgets import ( QApplication, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPlainTextEdit, QScrollArea, QSplitter, QVBoxLayout, QWidget, @@ -60,7 +60,7 @@ import config import filmforge_rs485 as ff APP_NAME = "FilmForge" -APP_VERSION = "0.2.9" +APP_VERSION = "0.2.13" REPO_URL = "https://gitea.hulk.wang/hulk/FilmForge" MONO = QFont("Consolas", 10) CHIP_IDLE = "#666666" @@ -77,6 +77,13 @@ CHART_COLORS_LIGHT = dict(bg="#ffffff", grid="#e3e8ee", text="#5a6572", measured="#0f6cbd", target="#d96f1a", fault=CHIP_ERR) X_TICK_STEPS = (1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600) +RIGHT_MARGIN = 12 # 主绘图区右缘留白(px) +GUTTER_W = 18 # 右侧 Y 轴滑条栏宽(px) +HANDLE_H = 9 # 滑条把手厚(px) +HANDLE_HIT = 12 # 把手命中半径(px) +MIN_Y_SPAN = 0.5 # 手动 Y 量程最小间距(℃) +Y_RAIL_LO = -20.0 # 滑条固定参考域(℃, 用户拍板): 把手位置=℃ 读数 +Y_RAIL_HI = 120.0 # (覆盖工艺全程 0~120 与冷启动低温, 不随数据漂移) DANGER_QSS = ("PushButton, PushButton:hover, PushButton:pressed, PushButton:disabled " "{ background-color: #D9534F; color: white; border: none; }") @@ -84,10 +91,16 @@ DANGER_QSS = ("PushButton, PushButton:hover, PushButton:pressed, PushButton:disa class TemperatureChart(QWidget): """自绘滚动温度曲线(QWidget + QPainter, 不引第三方图表库)。 - 数据由 App 持有, 经 set_source() 传入引用; 视窗/冻结/配色独立设置。 + 数据由 App 持有, 经 set_source() 传入引用; 视窗/冻结/配色/Y 轴模式独立设置。 曲线语义: 实测=折线(NTC 故障处 NaN 断开+底部红标), 目标=阶梯横线(橙虚线)。 + Y 轴(auto)=按可见数据自适应; Y 轴(manual)=右缘滑条双把手固定量程 + (上把手=上限/下把手=下限/拖中带=整段平移, 曲线裁剪回绘图区)。 """ + yManualEngaged = Signal() # 首次拖拽滑条: 从 auto 切入 manual + yRangeChanged = Signal(float, float) # 松手: 量化 0.1℃ 后的范围 + MIN_Y_SPAN = MIN_Y_SPAN # 手动量程最小间距(℃, 供测试断言) + def __init__(self, parent=None): super().__init__(parent) self.setMinimumHeight(300) @@ -98,13 +111,24 @@ class TemperatureChart(QWidget): self._window_sec = config.DEFAULT_WINDOW_SEC self._frozen_at = None # None=实时跟随; 否则冻结该时刻 self._colors = dict(CHART_COLORS_DARK) - self._has_data = False + self._y_mode = "auto" # 'auto'=按可见数据自适应; 'manual'=固定范围 + self._y_manual = None # 手动范围 (ymin, ymax); None=未设 + self._y_drag = None # 拖拽态: dict(kind/y0/lo/hi/h/ref) 或 None + self._drag_cursor = False # 拖拽中 override 光标(保证 release 还原) + self._y_hover = False # 鼠标在滑条栏上: 值标签仅此时显示(不常驻遮曲线) + self._paint_area = None # 最近绘制的布局缓存: 命中/拖拽复用免重算 + self._paint_range = (-5.0, 55.0) + self._paint_ref = None + self.setMouseTracking(True) # 悬停把手换光标(mouseMove 无按键也来) + self.last_auto_range = (-5.0, 55.0) # 最近绘制的自适应范围(切手动时预填) # ---- 数据/状态注入 ---- def set_source(self, samples_t, samples_v, target_hist, faults): + """注入数据列表引用(原地变更可见)。仅在构建时调用一次: 是否有数据 + 必须由绘制时实时判定, 旧版在这里缓存 _has_data 布尔, 数据经引用 + 追加后永不刷新 → "暂无数据"提示文字不消失(实测踩坑)。""" self._samples_t, self._samples_v = samples_t, samples_v self._target_hist, self._faults = target_hist, faults - self._has_data = bool(samples_t) or bool(faults) self.update() def set_window(self, sec): @@ -119,6 +143,18 @@ class TemperatureChart(QWidget): self._colors = dict(colors) self.update() + def set_y_mode(self, mode): + self._y_mode = mode if mode in ("auto", "manual") else "auto" + self.update() + + def set_y_manual(self, lo, hi): + lo, hi = float(lo), float(hi) + if not lo < hi: + return False + self._y_manual = (lo, hi) + self.update() + return True + # ---- 视窗 ---- def _view(self): t1 = self._frozen_at if self._frozen_at is not None else time.time() @@ -186,28 +222,249 @@ class TemperatureChart(QWidget): return pts # ---- 绘制 ---- + def _layout(self): + """一次算齐布局(转换区/S 轴范围), paint 与滑条命中/拖拽共用, 保证一致""" + area = QRectF(self.rect()).adjusted(52, 10, -(RIGHT_MARGIN + GUTTER_W), -26) + t0, t1 = self._view() + vis_t, vis_v = self._visible(t0, t1) + tgt = self._target_points(t0, t1) + ymin, ymax = self._resolve_y_range(vis_v, tgt) + return area, t0, t1, vis_t, vis_v, tgt, ymin, ymax + def paintEvent(self, _event): p = QPainter(self) c = self._colors p.fillRect(self.rect(), QColor(c["bg"])) - area = QRectF(self.rect()).adjusted(52, 10, -12, -26) - t0, t1 = self._view() - vis_t, vis_v = self._visible(t0, t1) - tgt = self._target_points(t0, t1) - ymin, ymax = self._y_range(vis_v, tgt) + area, t0, t1, vis_t, vis_v, tgt, ymin, ymax = self._layout() + self._paint_area = area + self._paint_range = (ymin, ymax) + self._paint_ref = self._y_ref() sy = self._scale_y(area, ymin, ymax) sx = self._scale_x(area, t0, t1) self._draw_grid(p, area, t0, t1, ymin, ymax, sx, sy) + p.save() + p.setClipRect(area) # 手动范围下数据可越界, 裁剪回绘图区 self._draw_target(p, tgt, sx, sy) self._draw_measured(p, vis_t, vis_v, sx, sy) + p.restore() + self._draw_marker(p, area, vis_t, vis_v, sx, sy) self._draw_faults(p, area, t0, t1) - self._draw_legend(p) - if not self._has_data: + self._draw_y_gutter(p, area, ymin, ymax) + self._draw_legend(p, area) + if not (self._samples_t or self._faults): # 实时判定: 数据到位即消失 p.setPen(QColor(c["text"])) p.drawText(self.rect(), Qt.AlignCenter, "暂无数据 — 连接设备后开启周期上报(M2S)即可实时推送; 单点查询用 M1") + def _resolve_y_range(self, vis_v, tgt_pts): + """本次绘制使用的 Y 轴范围: manual 模式用固定值, 否则按可见数据自适应; + 空数据时自适应值是缺省 (-5,55), 不得覆盖 last_auto_range(切手动预填用)""" + auto = self._y_range(vis_v, tgt_pts) + if self._y_mode == "manual" and self._y_manual is not None: + return self._y_manual + pts = tgt_pts or [] + if [v for v in vis_v if v == v] or [v for _t, v in pts]: + self.last_auto_range = auto + return auto + + # ---- 右缘 Y 轴滑条(手动量程) ---- + def _y_ref(self): + """滑条固定参考域(用户拍板 -20~120℃): 把手像素位置直接对应 ℃ 读数, + 空数据/冷机不再出现随数据漂移的误导位置""" + return Y_RAIL_LO, Y_RAIL_HI + + def _gutter(self): + w = self.width() + return w - RIGHT_MARGIN - GUTTER_W, w - RIGHT_MARGIN + + def _cached_layout(self): + """命中/拖拽用绘制缓存; 从未绘制过(如测试直接调)则补算一次""" + if self._paint_area is None: + area, _t0, _t1, _vt, _vv, _tgt, ymin, ymax = self._layout() + self._paint_area = area + self._paint_range = (ymin, ymax) + self._paint_ref = self._y_ref() + return self._paint_area, self._paint_range, self._paint_ref + + def _rail_px(self, area, ref, v): + ref_lo, ref_hi = ref + v = min(max(v, ref_lo), ref_hi) + return area.bottom() - (v - ref_lo) / max(ref_hi - ref_lo, 1e-9) * area.height() + + def _rail_value(self, area, ref, y): + ref_lo, ref_hi = ref + frac = min(max((area.bottom() - y) / max(area.height(), 1e-6), 0.0), 1.0) + return ref_lo + frac * (ref_hi - ref_lo) + + def _gutter_hit(self, x, y): + """右栏命中: 就近把手优先, 轨道空白区视为就近边界点击, 上下把手间为整段平移""" + x0, x1 = self._gutter() + if not x0 - 3 <= x <= x1 + 3: + return None + area, (lo, hi), ref = self._cached_layout() + y_hi = self._rail_px(area, ref, hi) + y_lo = self._rail_px(area, ref, lo) + d_hi, d_lo = abs(y - y_hi), abs(y - y_lo) + if min(d_hi, d_lo) <= HANDLE_HIT: + return "max" if d_hi <= d_lo else "min" + if y_hi <= y <= y_lo: + return "win" + return "max" if y < y_hi else "min" + + def start_y_drag(self, kind, y): + """按下滑条(测试可直接调): auto 模式以当前显示范围起坡并切手动。 + 几何(绘图区/参考域)可用绘制缓存, 界值必须取实时显示值——拖拽间存在 + 未重绘的域间修改(实测: pan 用陈旧界值把量程拖跳)。 + kind: 'min'(下把手)=下限 / 'max'(上把手)=上限 / 'win'=整段平移""" + area, rng, ref = self._cached_layout() + if self._y_mode != "manual": + lo, hi = rng + self._y_manual = (lo, hi) + self._y_mode = "manual" + self.yManualEngaged.emit() + else: + lo, hi = self._y_manual + self._y_drag = {"kind": kind, "y0": y, "lo": lo, "hi": hi, + "h": area.height(), "ref": ref} + if not self._drag_cursor: + QApplication.setOverrideCursor(QCursor(Qt.SizeVerCursor)) + self._drag_cursor = True + self.update() + return True + + def update_y_drag(self, y): + """拖拽中: 手把直接跟鼠标(参考域映射+钳位), 中带按参考跨度平移""" + d = self._y_drag + if d is None: + return False + area, _rng, _ref_unused = self._cached_layout() + ref = d["ref"] + ref_span = max(ref[1] - ref[0], 1e-9) + h = max(d["h"], 1e-6) + lo, hi = d["lo"], d["hi"] + if d["kind"] == "max": + hi = max(self._rail_value(area, ref, y), lo + MIN_Y_SPAN) + elif d["kind"] == "min": + lo = min(self._rail_value(area, ref, y), hi - MIN_Y_SPAN) + else: + delta = -(y - d["y0"]) / h * ref_span + delta = min(max(delta, ref[0] - lo), ref[1] - hi) + lo, hi = lo + delta, hi + delta + self._y_manual = (lo, hi) + self.update() + return True + + def end_y_drag(self): + """松手: 量化 0.1℃ 并通知(防御四舍五入挤掉间距)""" + if self._y_drag is None: + return False + lo, hi = self._y_manual + lo, hi = round(lo, 1), round(hi + 1e-9, 1) + if not lo < hi: + hi = lo + 0.1 + self._y_manual = (lo, hi) + self._y_drag = None + self.update() + self.yRangeChanged.emit(lo, hi) + return True + + def mousePressEvent(self, event): + pos = event.position() + kind = self._gutter_hit(pos.x(), pos.y()) + if kind is not None: + self.start_y_drag(kind, pos.y()) + event.accept() + else: + event.ignore() + + def mouseMoveEvent(self, event): + pos = event.position() + if self._y_drag is not None: + self.update_y_drag(pos.y()) + self._set_y_hover(True) + return + hit = self._gutter_hit(pos.x(), pos.y()) is not None + self._set_y_hover(hit) # 值标签仅悬停/拖拽时显示 + if hit: + self.setCursor(Qt.SizeVerCursor) + else: + self.unsetCursor() + + def mouseReleaseEvent(self, event): + pos = event.position() + if self._y_drag is not None: + self.end_y_drag() + if self._drag_cursor: + QApplication.restoreOverrideCursor() + self._drag_cursor = False + self.update_y_drag_hover(pos) + event.ignore() + + def update_y_drag_hover(self, pos): + hit = self._gutter_hit(pos.x(), pos.y()) is not None + self._set_y_hover(hit) + if hit: + self.setCursor(Qt.SizeVerCursor) + else: + self.unsetCursor() + + def _set_y_hover(self, on): + """滑条悬停态翻转才重绘(悬停/拖拽期间才画把手值标签)""" + if self._y_hover == on: + return + self._y_hover = on + self.update() + + def leaveEvent(self, _event): + if self._y_drag is None: + self.unsetCursor() + self._set_y_hover(False) # 悬停态途中拖拽不退出(松手后按位置重算) + + def _draw_y_gutter(self, p, area, ymin, ymax): + """右缘滑条栏: 轨道 + 手动量程带 + 双把手。值标签仅鼠标悬停/拖拽时显示 + (常驻会遮挡曲线, 用户标注); auto 模式把手半透明——直接按住即可切手动。""" + c = self._colors + accent = QColor(c["measured"]) + manual = self._y_mode == "manual" + show_pills = self._y_hover or self._y_drag is not None + ref = self._y_ref() + x0, x1 = self._gutter() + x_c = (x0 + x1) / 2 + y_hi = self._rail_px(area, ref, ymax) + y_lo = self._rail_px(area, ref, ymin) + + p.setPen(QPen(QColor(c["grid"]), 1)) + p.drawLine(QPointF(x_c, area.top()), QPointF(x_c, area.bottom())) + if manual and y_hi + 2 < y_lo: + band = QColor(accent) + band.setAlpha(38) + p.setPen(Qt.NoPen) + p.setBrush(band) + p.drawRect(QRectF(x_c - 2, y_hi, 4, y_lo - y_hi)) + + for y_px, label in ((y_hi, f"{ymax:.1f}"), (y_lo, f"{ymin:.1f}")): + bar = QColor(accent) + if not manual: + bar.setAlpha(110) + p.setPen(Qt.NoPen) + p.setBrush(bar) + p.drawRoundedRect(QRectF(x_c - 7, y_px - HANDLE_H / 2, 14, HANDLE_H), 3, 3) + p.setPen(QPen(QColor(c["bg"]), 1)) + for dy in (-1.5, 1.5): + p.drawLine(QPointF(x_c - 4, y_px + dy), QPointF(x_c + 4, y_px + dy)) + if not show_pills: + continue + pill = QRectF(area.right() - 41, y_px - 8, 38, 16) + p.setBrush(QColor(c["bg"])) + p.setPen(QPen(QColor(c["grid"]), 1)) + p.drawRoundedRect(pill, 4, 4) + font = self.font() + font.setPointSizeF(7.5) + p.setFont(font) + p.setPen(QColor(c["measured"])) + p.drawText(pill, Qt.AlignCenter, label) + @staticmethod def _y_range(vis_v, tgt_pts): vals = [v for v in vis_v if v == v] + [v for _t, v in tgt_pts] @@ -312,23 +569,31 @@ class TemperatureChart(QWidget): path.lineTo(x, y) p.drawPath(path) - last_t = last_v = None # 当前值标记: 视窗内最后一个有效样本 + def _draw_marker(self, p, area, ts, vs, sx, sy): + """当前值标记: 视窗内最后一个有效样本。超出当前 Y 范围(手动模式)不画。""" + last_t = last_v = None for t, v in zip(reversed(ts), reversed(vs)): if v == v: last_t, last_v = t, v break - if last_t is not None: - x, y = sx(last_t), sy(last_v) - p.setPen(Qt.NoPen) - p.setBrush(QColor(self._colors["measured"])) - p.drawEllipse(QPointF(x, y), 3.2, 3.2) - p.setPen(QColor(self._colors["measured"])) - font = self.font() - font.setPointSizeF(8.5) - font.setBold(True) - p.setFont(font) - p.drawText(QPointF(min(x + 6, self.width() - 54), max(y - 6, 14)), - f"{last_v:.1f}℃") + if last_t is None: + return + x, y = sx(last_t), sy(last_v) + if not area.top() <= y <= area.bottom(): + return + p.setPen(Qt.NoPen) + p.setBrush(QColor(self._colors["measured"])) + p.drawEllipse(QPointF(x, y), 3.2, 3.2) + p.setPen(QColor(self._colors["measured"])) + font = self.font() + font.setPointSizeF(8.5) + font.setBold(True) + p.setFont(font) + # 文字通常画在点上方; 贴顶边放不下时换到点下方, 再钳回绘图区内 + ty = y - 6 if y - 14 >= area.top() else y + 12 + ty = min(max(ty, area.top() + 9), area.bottom() - 2) + p.drawText(QPointF(min(x + 6, area.right() - 44), ty), + f"{last_v:.1f}℃") def _draw_faults(self, p, area, t0, t1): flt = [(t, s) for t, s in self._faults if t0 <= t <= t1] @@ -349,12 +614,13 @@ class TemperatureChart(QWidget): last_x = x labeled += 1 - def _draw_legend(self, p): + def _draw_legend(self, p, area): c = self._colors font = self.font() font.setPointSizeF(8.5) p.setFont(font) - x = self.width() - 150 + # 图例放绘图区左上: 右侧被滑条把手/值标签占用(用户标注) + x = area.left() + 6 y = 16 for color, label in ((c["measured"], "实测"), (c["target"], "目标")): p.setPen(Qt.NoPen) @@ -550,15 +816,36 @@ class App(QMainWindow): win_combo.setCurrentIndex(1) # 先设默认再连信号, 避免触发时 chart 未建 win_combo.currentIndexChanged.connect(self._on_window) self.cbo_window = win_combo - row = QHBoxLayout() + + card = CardWidget(self) + body = QVBoxLayout(card) + body.setContentsMargins(14, 12, 14, 14) + body.setSpacing(8) + # ---- 标题行 = [标题][暂停][清空] ... [Y轴手动][窗口] 一行放齐: + # 暂停/清空并入标题行、Y 轴按钮不再单独换行(用户标注: 两个按钮组都 + # 悬在半空布局脱节)。不走 self._card 的通用头, 需要多 trailing 控件。 + header = QHBoxLayout() + header.addWidget(StrongBodyLabel("实时温度曲线", card)) + header.addSpacing(8) self.btn_pause = self._button("暂停", self._toggle_pause) + header.addWidget(self.btn_pause) self.btn_clear = self._button("清空", self._clear_plot) - row.addWidget(self.btn_pause) - row.addWidget(self.btn_clear) - card, body = self._card("实时温度曲线", trailing=win_combo) - body.addLayout(row) + header.addWidget(self.btn_clear) + header.addStretch(1) + # ---- Y 轴(温度轴)量程: 自动适配 / 图表右缘滑条手动拖拽 ---- + self.btn_ymode = PushButton("Y轴自动", card) + self.btn_ymode.setCheckable(True) + self.btn_ymode.setChecked(True) # 默认自动; 先就位默认态再连信号 + self.btn_ymode.setToolTip("勾选=随曲线数据自适应量程; 取消勾选或直接拖动" + "图表右缘滑条=固定量程, 再勾选恢复自动") + self.btn_ymode.toggled.connect(self._on_ymode) + header.addWidget(self.btn_ymode) + header.addWidget(win_combo) + body.addLayout(header) self.chart = TemperatureChart(card) self.chart.set_source(self.samples_t, self.samples_v, self.target_hist, self.faults) + self.chart.yManualEngaged.connect(self._on_y_drag_engaged) + self.chart.yRangeChanged.connect(self._on_y_range_changed) body.addWidget(self.chart, 1) layout.addWidget(card, 2) @@ -719,6 +1006,17 @@ class App(QMainWindow): self.cbo_window.setCurrentIndex(widx) self.chart.set_window(config.WINDOW_SEC) self.cbo_theme.setCurrentText(config.THEME) + # ---- Y 轴量程记忆恢复: 存有合法手动值且上次为手动时才复用, 否则回自动 ---- + stored = (config.Y_MIN is not None and config.Y_MAX is not None + and config.Y_MIN < config.Y_MAX) + manual = stored and config.Y_MODE == "manual" + self.btn_ymode.blockSignals(True) + self.btn_ymode.setChecked(not manual) + self.btn_ymode.blockSignals(False) + self.btn_ymode.setText("Y轴手动" if manual else "Y轴自动") + if manual: + self.chart.set_y_manual(config.Y_MIN, config.Y_MAX) + self.chart.set_y_mode("manual") # ================= 主题 ================= def _set_theme(self, value): @@ -1233,6 +1531,29 @@ class App(QMainWindow): config.save_settings() self.chart.set_window(sec) + # ---- Y 轴(温度轴)量程 ---- + def _on_ymode(self, checked): + """勾选=随数据自适应量程; 取消勾选=固定量程(右缘滑条拖拽)""" + manual = not checked + self.chart.set_y_mode("manual" if manual else "auto") + self.btn_ymode.setText("Y轴手动" if manual else "Y轴自动") + config.Y_MODE = "manual" if manual else "auto" + config.save_settings() + + def _on_y_drag_engaged(self): + """滑条首次拖拽: 图表内部已切手动, 这里同步按钮态并落设置""" + self.btn_ymode.blockSignals(True) + self.btn_ymode.setChecked(False) + self.btn_ymode.blockSignals(False) + self.btn_ymode.setText("Y轴手动") + config.Y_MODE = "manual" + config.save_settings() + + def _on_y_range_changed(self, lo, hi): + """滑条松手: 图表已量化 0.1℃, 直接范围记忆""" + config.Y_MIN, config.Y_MAX = lo, hi + config.save_settings() + def _toggle_pause(self): if self._frozen_at is None: self._frozen_at = time.time() diff --git a/GUI/config.py b/GUI/config.py index 81169d8..762458e 100644 --- a/GUI/config.py +++ b/GUI/config.py @@ -32,6 +32,9 @@ SERIAL_CFG = {'port': '', 'baud': filmforge_rs485.DEFAULT_BAUD, REPORT_MS = 500 # 上报间隔输入框默认值 WINDOW_SEC = DEFAULT_WINDOW_SEC THEME = 'System' +Y_MODE = 'auto' # 曲线 Y 轴(温度轴): 'auto'=自适应 / 'manual'=固定范围 +Y_MIN = None # 手动 Y 轴上下限(℃), 仅 Y_MODE='manual' 且合法时生效 +Y_MAX = None def load_settings(): @@ -68,6 +71,19 @@ def load_settings(): theme = str(data.get('theme') or '') if theme in ('Dark', 'Light', 'System'): THEME = theme + ax = data.get('y_axis') + if isinstance(ax, dict): + mode = str(ax.get('mode') or '') + if mode in ('auto', 'manual'): + globals()['Y_MODE'] = mode + try: + globals()['Y_MIN'] = round(float(ax.get('min')), 2) + except (TypeError, ValueError): + pass + try: + globals()['Y_MAX'] = round(float(ax.get('max')), 2) + except (TypeError, ValueError): + pass def save_settings(): @@ -77,6 +93,7 @@ def save_settings(): 'report_ms': REPORT_MS, 'window_sec': WINDOW_SEC, 'theme': THEME, + 'y_axis': {'mode': Y_MODE, 'min': Y_MIN, 'max': Y_MAX}, } try: with open(SETTINGS_FILE, 'w', encoding='utf-8') as f: diff --git a/GUI/selftest_gui.py b/GUI/selftest_gui.py index 555a3d1..520aaf6 100644 --- a/GUI/selftest_gui.py +++ b/GUI/selftest_gui.py @@ -8,6 +8,7 @@ import math import os from collections import deque import sys +import tempfile import time os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -15,9 +16,16 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import filmforge_rs485 as ff +import config +from PySide6.QtCore import QRectF from PySide6.QtGui import QImage, QPainter from PySide6.QtWidgets import QApplication +# 设置文件指向临时目录: 隔离用户真实 filmforge_gui.json(用户可能展存着手动 +# Y 轴等状态), 测试一律从"首次启动默认"出发, 残留写入不留真实配置盘 +_tmpdir = tempfile.mkdtemp(prefix='filmforge_selftest_') +config.SETTINGS_FILE = os.path.join(_tmpdir, 'settings.json') + fails = [] @@ -73,6 +81,18 @@ def count_pixels(img, rgb, tol=30): return n +def count_in(img, x0, x1, y0, y1, rgb, tol=30): + """区域内逐像素计数(stride 1): 用于条带级断言(如越界裁剪)与空态提示""" + n = 0 + for y in range(y0, y1): + for x in range(x0, x1): + c = img.pixelColor(x, y) + if (abs(c.red() - rgb[0]) < tol and abs(c.green() - rgb[1]) < tol + and abs(c.blue() - rgb[2]) < tol): + n += 1 + return n + + measured = (0, 200, 215) # Dark 主题实测曲线色 #00c8d7 target = (232, 131, 58) # 目标线 #E8833A fault = (217, 83, 79) # 故障 #D9534F @@ -279,5 +299,126 @@ ts, _vs = c._visible(0, 99) check('实时切片完整', ts, [1.0, 2.0, 3.0, 4.0]) c._samples_t, c._samples_v = window.samples_t, window.samples_v +# 8) v0.2.11 回归: 右缘双把手滑条拖拽量程 + 空态提示实时消失。 +# (提示不消失根因: 旧版 set_source 构建期缓存 _has_data 布尔, 数据经引用追加 +# 后永不刷新 → 改绘制时实时判定; 本段同时覆盖滑条命中/拖拽/钳位/平移/裁剪) +c = window.chart +check('Y 轴初始自动', c._y_mode, 'auto') +check('Y 轴按钮默认自动', window.btn_ymode.isChecked() + and window.btn_ymode.text() == 'Y轴自动', True) + +engaged, ranges = [], [] +c.yManualEngaged.connect(lambda: engaged.append(1)) +c.yRangeChanged.connect(lambda lo, hi: ranges.append((lo, hi))) + +c.update() +app.processEvents() # 同步一次绘制: 命中缓存与当前数据一致 +area, _t0, _t1, _vt, _vv, _tgt, ymin0, ymax0 = c._layout() +gx0, gx1 = c._gutter() +ref0 = c._y_ref() +y_hi_px = c._rail_px(area, ref0, ymax0) +y_lo_px = c._rail_px(area, ref0, ymin0) +check('把手命中(上把手)', c._gutter_hit(gx0 + 6, y_hi_px), 'max') +check('把手命中(下把手)', c._gutter_hit(gx0 + 6, y_lo_px), 'min') +check('轨道空白亦视为边界', c._gutter_hit(gx0 + 6, area.bottom() - 3), 'min') +check('栏外不命中', c._gutter_hit(10, 100) is None, True) + +c.start_y_drag('min', y_lo_px) +check('拖拽即切手动', c._y_mode, 'manual') +check('手动信号已发', engaged, [1]) +# 滑条域固定 -20~120 后拖拽映射恒定(0.53C/px 量级), 断言用目标值反算像素, +# 不再依赖域宽: 拖到 ymin0+30 对应位置 → 下限抬升 30℃ +c.update_y_drag(c._rail_px(area, ref0, ymin0 + 30.0)) +lo1, hi1 = c._y_manual +check('下限随拖拽抬升', lo1 > ymin0 + 1e-9, True) +check('上限不受牵动', abs(hi1 - ymax0) < 1e-9, True) +check('松手生效', c.end_y_drag(), True) +lo_e, hi_e = ranges[-1] +check('范围量化 0.1', abs(lo_e * 10 - round(lo_e * 10)) < 1e-6 + and abs(hi_e * 10 - round(hi_e * 10)) < 1e-6, True) + +c.start_y_drag('min', y_lo_px) # 极端上拉 → 钳到 上限-最小间距 +c.update_y_drag(area.top() - 500) +check('最小间距约束', + c._y_manual[1] - c._y_manual[0] >= c.MIN_Y_SPAN - 1e-9, True) +c.end_y_drag() + +lo_prev, hi_prev = c._y_manual[0], c._y_manual[1] # 中带平移: 整段同移 +c.start_y_drag('win', (y_hi_px + y_lo_px) / 2) +c.update_y_drag(area.bottom() + 30) +c.end_y_drag() +lo_pan, hi_pan = c._y_manual +# 松手量化 0.1℃ 各自取整, 跨度允许 ±0.05 偏差(两段半界) +check('平移不改跨度(量化容差)', abs((hi_pan - lo_pan) - (hi_prev - lo_prev)) < 0.12, True) +check('平移已生效', lo_pan < lo_prev - 1e-9, True) +check('上下限同步移动', abs((lo_prev - lo_pan) - (hi_prev - hi_pan)) < 0.12, True) +check('范围通知已发 3 次', len(ranges), 3) + +window.btn_ymode.setChecked(True) # 按钮勾回自动 +check('切回自动', c._y_mode, 'auto') +check('按钮文本回自动', window.btn_ymode.text(), 'Y轴自动') + +# 确定形状的像素断言: 手动 [28,80], 数据(~19~60℃)出界部分必被裁剪回绘图区 +c.set_y_manual(28.0, 80.0) +_hx = c._colors['measured'].lstrip('#') +measured_now = (int(_hx[0:2], 16), int(_hx[2:4], 16), int(_hx[4:6], 16)) +window.chart.update() +img = QImage(window.chart.size(), QImage.Format_ARGB32) +window.chart.render(img) +area = QRectF(window.chart.rect()).adjusted( + 52, 10, -(app_fluent.RIGHT_MARGIN + app_fluent.GUTTER_W), -26) +x0, x1 = int(area.left()) + 2, int(area.right()) - 2 +check('手动范围下曲线仍绘制', count_pixels(img, measured_now) > 0, True) +check('裁剪: 上条带无曲线像素', + count_in(img, x0, x1, 0, int(area.top()) - 1, measured_now), 0) +check('裁剪: 下条带无曲线像素', + count_in(img, x0, x1, int(area.bottom()), window.chart.height(), measured_now), 0) +c.set_y_mode('auto') + +hint = (201, 201, 201) # 提示文字色 #c9c9c9 +c2 = app_fluent.TemperatureChart() +c2.resize(640, 320) +lst_t, lst_v = [], [] +c2.set_source(lst_t, lst_v, [], []) # 模拟构建期一次性注入(空) +img2 = QImage(c2.size(), QImage.Format_ARGB32) +c2.render(img2) +ax0, ax1 = 60, c2.width() - 20 +ax_top, ax_bot = 30, c2.height() - 30 +n_hint0 = count_in(img2, ax0, ax1, ax_top, ax_bot, hint) +lst_t.append(time.time()) # 经同一引用追加数据(复现旧 bug 路径) +lst_v.append(50.0) +c2.update() +img3 = QImage(c2.size(), QImage.Format_ARGB32) +c2.render(img3) +n_hint1 = count_in(img3, ax0, ax1, ax_top, ax_bot, hint) +print(f'提示像素: 空={n_hint0} 有数据={n_hint1}') +check('空态提示已绘出', n_hint0 > 50, True) +check('数据到位后提示消失(实时判定)', n_hint1, 0) + +# 8b) 值标签悬停门控: 非悬停不画(不常驻遮挡曲线, 用户标注), 悬停/拖拽才显示 +lt2, lv2 = [time.time() - 100], [35.0] +c2.set_source(lt2, lv2, [], []) +c2.set_y_manual(20.0, 60.0) +c2.set_y_mode('manual') +c2.update() +img4 = QImage(c2.size(), QImage.Format_ARGB32) +c2.render(img4) +zx0, zx1 = c2.width() - 71, c2.width() - 31 # 把手值标签区(绘图区右缘) +zy0, zy1 = 110, 230 # 覆盖上下两个把手 y 位 +n_pill0 = count_in(img4, zx0, zx1, zy0, zy1, (0, 200, 215)) +c2._set_y_hover(True) +c2.update() +img5 = QImage(c2.size(), QImage.Format_ARGB32) +c2.render(img5) +n_pill1 = count_in(img5, zx0, zx1, zy0, zy1, (0, 200, 215)) +print(f'值标签像素: 未悬停={n_pill0} 悬停={n_pill1}') +check('未悬停时值标签隐藏(不遮曲线)', n_pill0, 0) +check('悬停/拖拽时值标签显示', n_pill1 > 20, True) +c2._set_y_hover(False) + +# 还原模块配置, 避免测试残留写入用户 filmforge_gui.json +config.Y_MODE, config.Y_MIN, config.Y_MAX = 'auto', None, None +config.save_settings() + print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS') sys.exit(1 if fails else 0)