云计算百科
云计算领域专业知识百科平台

视图适配革命:精准填充与框选缩放

视图命令改造:适配窗口(Fit)与窗口缩放(Zoom Box)——设计与分析

模块:src/ui/GraphicsView.{h,cpp}、src/ui/MainWindow.cpp、src/ui/icons.h
测试:tests/view_fit_zoom_test.cpp(新增 target ViewFitZoomTest)
关联文档:pan_root_cause_fix.md、选择功能完善-实现说明.md


0. 摘要

本次改造解决“视图菜单中缺少真正的内容适配,且没有框选缩放”的问题,共两条主线:

命令改造前改造后
适配窗口(Ctrl+0) zoom=4 + 视口中心为原点,完全没有适配内容 取所有可见图元的几何包围盒,等比缩放并居中,精确充满绘图工作区
窗口缩放(Ctrl+9) 不存在 进入框选模式 → 拖框 → 该区域精确充满绘图工作区,完成后自动退出

两条命令共同的核心约束:坐标轴与程序提示文字保持原有显示不变(不随内容缩放)。

最终结论一句话:fitView() 从“设死缩放比例”改为“按内容包围盒适配”;fitWorldRect() 是二者共用的适配内核;坐标轴/提示文字因处于装饰性覆盖层(不在 model_->elements() 中)而天然免疫,无需任何特殊处理。


1. 需求解析

1.1 原始需求

  • 视图菜单【适合窗口】(即现有【适配窗口】)被点击后,绘制的图形(不含坐标轴与程序提示文字)应精确缩放并充满整个绘图工作区域;坐标轴与提示文字保持原有显示不变。
  • 新增【窗口缩放】:点击后进入框选模式,用户拖动鼠标框选目标区域,框选完成后该区域缩放并充满整个绘图工作区域。
  • 1.2 拆解为可验证的验收标准

    编号验收标准验证方式
    A1 适配后,内容(图形)在视口中“充满”受限维度、另一维居中 断言内容屏幕矩形 == 视口在受限维上满占、另一维居中
    A2 适配范围不包含坐标轴与提示文字 坐标轴/HUD 为屏幕常量,不参与包围盒计算
    A3 适配后,坐标轴与提示文字的位置/尺寸不变 二者以 resetTransform 后按像素坐标绘制
    B1 存在“窗口缩放”菜单项,点击后进入框选模式 菜单/工具栏存在动作;zoomBoxMode()==true
    B2 拖框完成后,框选区域应精确充满工作区 断言 zoom == min(vw/w, vh/h),且框中心映射到视口中心
    B3 完成后自动退出框选模式 zoomBoxMode()==false

    2. 现状调研(代码探索过程)

    2.1 相关文件地图

    先用搜索确定涉及的源文件,得到如下关键路径:

    src/ui/GraphicsView.{h,cpp} ← 变换、绘制、鼠标事件、框选(marquee)
    src/ui/MainWindow.cpp ← 视图菜单 / 工具栏接线
    src/ui/icons.h ← 矢量图标工厂 icons::make(id)
    tests/view_render_test.cpp ← 依赖 fitView()(回归关注)
    tests/view_render_test2.cpp ← 依赖 fitView()(回归关注)
    tests/select_window_test.cpp ← 依赖 fitView()(回归关注)

    2.2 GraphicsView 的坐标 / 变换机制

    GraphicsView 是自定义 QGraphicsView,缩放 / 平移不直接操作 QTransform,,而是维护三个成员:

    • zoom_:当前缩放比例;
    • panX_ / panY_:世界原点在视口中的屏幕像素位置。

    再由 applyTransform() 统一合成变换:

    screenX = panX_ + worldX * zoom_
    screenY = panY_ – worldY * zoom_ // Y 轴翻转:世界 Y 向上,屏幕 Y 向下

    要点:panX_/panY_ 的语义是「世界原点落在视口的哪个像素位置」。逆映射即 worldX=(screenX-panX_)/zoom_。项目既有经验(见 pan_root_cause_fix.md)强调:任何变换偏差绝不能回写 panX_/panY_,否则平移漂移。本次改造严格遵循该约定——适配 / 框选缩放都是重新计算 panX_/panY_/zoom_,而非增量叠加。

    2.3 原 fitView() 的问题(核心缺陷)

    void GraphicsView::fitView() {
    zoom_ = 4.0; // ← 写死比例
    panX_ = viewport() ? viewport()->width() / 2.0 : 400;
    panY_ = viewport() ? viewport()->height() / 2.0 : 300;
    applyTransform();
    model_->setViewScale(zoom_);
    viewport()->update();
    }

    结论:它只是“把世界原点摆到视口中心、比例固定为 4”,与内容实际大小无关。因此用户点击【适配窗口】后,图形常常过大(跑出视口)或过小(缩在一角),即“未适配”。这是需求 1 的根因。

    对比:旁边已有的 fitToRect(r) 才具备正确的适配思路:

    void GraphicsView::fitToRect(const QRectF& r) {
    ...
    const double margin = 0.92; // 留边距
    double z = std::min(vw / r.width(), vh / r.height()) * margin;
    z = qBound(0.05, z, 500.0);
    zoom_ = z;
    const double cx = r.center().x(), cy = r.center().y();
    panX_ = vw / 2.0 cx * z;
    panY_ = vh / 2.0 + cy * z; // Y 翻转
    ...
    }

    即:对给定世界矩形,z = min(vw/W, vh/H) 可使矩形恰好嵌入视口,再把矩形中心移到视口中心。这正是我们要复用的内核,只是需求要求“精确充满”,因此边距系数需从 0.92 收紧到 1.0。

    2.4 坐标轴与提示文字的绘制层级(关键发现)

    需求特别强调「不包括坐标轴与程序提示文字」。调研绘制代码后确认:

    • 坐标轴(drawAxes)与提示文字 HUD(drawForeground 中 p->resetTransform() 后按视口像素坐标绘制)都是装饰性覆盖层:
      • 它们不是 model_->elements() 中的图元;
      • 绘制前调用 resetTransform(),因此与 zoom_/pan_ 无关,恒定按屏幕像素绘制。

    推论(结论一):只要「适配」与「框选缩放」的取景范围只由 model_->elements() 计算,坐标轴/提示文字就天然被排除,且因为按像素绘制,缩放后外观完全不变。无需为它们写任何排除逻辑。这直接满足验收标准 A2/A3。

    2.5 既有框选(marquee)机制

    发现视图中已有一套“框选选择图元”的交互(marqueeActive_ / marquee_):

    • mousePressEvent:左键按下 → marqueeActive_=true,marquee_ 记录起点;
    • mouseMoveEvent:更新 marquee_.setBottomRight(world) + viewport()->update();
    • mouseReleaseEvent:结算选中、清空 marquee_;
    • drawForeground:画蓝色虚线 + 半透明填充的橡皮筋(QPen 设 Cosmetic,线宽不随缩放变化)。

    推论(结论三):窗口缩放应复用这套交互骨架(同样的按下/移动/释放三段 + 橡皮筋绘制),但必须是独立的状态变量(zoomBoxMode_/zoomBoxActive_/zoomBoxRect_),因为两者语义不同(一个选图元、一个改视口)且需并存。

    2.6 视图菜单 / 工具栏接线

    检索 MainWindow.cpp 确认:

    • 视图菜单已存在 适配窗口 (Ctrl+0) → view_->fitView(),工具栏也有「适配」按钮 → 即需求 1 只需改 fitView() 的行为,菜单项无需新增;
    • 工具栏/菜单动作通过本地辅助 mk(menu, text, shortcut, lambda)(建菜单项)与 act(label, tooltip, shortcut, iconId, lambda)(建带图标工具按钮)创建。

    结论:需求 2 只需在【适配窗口】后新增一个动作,复用 mk()/act(),并给 icons::make() 补一枚 zoombox 图标。


    3. 关键推理与结论

    将调研结论归纳为四条设计决策:

    #问题结论依据
    坐标轴/提示文字怎么排除? 不排除,它们本就不在 model_->elements() 且按像素绘制 §2.4
    用哪个包围盒做「精确充满」? 不能用 boundingRect(),需新增 geometryBounds() §3.2
    框选交互怎么实现? 复用 marquee 骨架 + 独立状态 §2.5
    框选区域→视口的映射? 复用 fitWorldRect() 内核 §3.3

    3.2 结论二展开:为什么不能用 boundingRect()

    ElementItem::boundingRect() 为了让鼠标拾取更宽松,会在几何外包裹一层 cosmetic 描边余量(约 1.5 世界单位)。若用它来适配:

    包围盒被人为放大 → 缩放系数 z = min(vw/W, vh/H) 偏小 → 取景略微不满,达不到「精确充满」。

    对策:新增 ElementItem::geometryBounds() = rawBounds(纯几何、零描边膨胀),仅用于取景计算。elementSceneBounds()(拾取用,保留膨胀)保持不变。

    3.3 结论三 / 四展开:统一适配内核

    两个命令的公共动作是“把某个世界矩形 A 精确铺满视口”。因此提取私有内核:

    fitWorldRect(r, margin=1.0):
    z = min(vw / r.width(), vh / r.height()) * margin // 取受限维度
    z = qBound(ZMIN, z, ZMAX)
    zoom_ = z
    panX_ = vw/2 – r.center().x * z
    panY_ = vh/2 + r.center().y * z // Y 翻转
    applyTransform(); model_->setViewScale(z);

    • 适配窗口 = fitWorldRect(contentBounds(), 1.0)
    • 窗口缩放 = fitWorldRect(框选世界矩形, 1.0)

    margin=1.0 即“精确充满”(旧 fitToRect 的 0.92 是留边,不满足需求)。

    3.4 「精确充满」的几何含义

    设视口 vw×vh,内容 W×H,z = min(vw/W, vh/H):

    • 若 vw/W ≤ vh/H(宽为受限维):缩放后内容宽度 W·z = vw 恰好铺满视口宽,高度 H·z < vh 居中 → 内容屏幕矩形为 x ∈ [0, vw]、y 居中;
    • 反之亦然。

    这正是验收标准 A1/B2 的数学形式,也是新测试断言的依据。


    4. 设计方案

    4.1 新增 API(GraphicsView.h)

    public:
    void fitView(); // 语义变更:内容适配
    void fitToRect(const QRectF& world); // 保持不变(图幅适配用)

    // 窗口缩放(框选缩放)
    void setZoomBoxMode(bool on);
    bool zoomBoxMode() const;

    private:
    QRectF contentBounds() const; // 所有可见图元的几何并集
    void fitWorldRect(const QRectF& r, double margin = 1.0); // 适配内核

    bool zoomBoxMode_ = false; // 是否处于框选模式
    bool zoomBoxActive_ = false; // 是否正在拖框
    QRectF zoomBoxRect_; // 框选世界矩形

    // ElementItem:
    public:
    QRectF geometryBounds() const; // = rawBounds,零描边扩展(取景专用)

    4.2 状态机

    点击【窗口缩放】
    IDLE ───────────────────────► ZOOMBOX_MODE(十字光标)
    ▲ │ 左键按下
    │ Esc / 右键 ▼
    │ ZOOMBOX_DRAG(橡皮筋实时更新)
    │ │ 左键释放
    │ ┌─────────────────────┴─────────────────────┐
    │ │ 有面积:fitWorldRect(框) → 缩放并退出 │
    └────────────┘ 无面积(单击):退化为 fitView() → 退出 │

    4.3 事件接入点

    事件处理
    mousePressEvent 框选模式下左键 → zoomBoxActive_ = true、记录起点;右键 → 取消并退出
    mouseMoveEvent 框选拖拽中 → 更新 zoomBoxRect_ + 刷新(置于分发链最前,见 §8.2)
    mouseReleaseEvent 左键释放 → 结算缩放(或退化为适配)并退出模式
    drawForeground 绘制绿色虚线橡皮筋(Cosmetic 线宽)
    keyPressEvent Esc → 取消并退出模式

    5. 实现细节

    5.1 contentBounds():遍历可见图元取并集

    QRectF GraphicsView::contentBounds() const {
    QRectF b;
    bool first = true;
    for (const auto& e : model_->elements()) {
    if (!e.visible) continue;
    const QRectF r = elementGeometryBounds(e); // 用 geometryBounds,非拾取用 bounds
    if (r.isNull()) continue; // 跳过退化(空文本等)
    b = first ? r : b.united(r);
    first = false;
    }
    return b;
    }

    ⚠️ 单元素与整批:geo::elementBounds() 只接受单个 Element;整批包围盒必须 for 逐个 .united()(本项目既有约定,勿误用)。

    5.2 fitWorldRect():适配内核(Y 翻转是关键)

    void GraphicsView::fitWorldRect(const QRectF& r, double margin) {
    if (!viewport() || r.width() < 1e-6 || r.height() < 1e-6) return;
    const double vw = viewport()->width(), vh = viewport()->height();
    double z = std::min(vw / r.width(), vh / r.height()) * margin; // 受限维
    z = qBound(0.05, z, 500.0);
    zoom_ = z;
    const double cx = r.center().x(), cy = r.center().y();
    panX_ = vw / 2.0 cx * z;
    panY_ = vh / 2.0 + cy * z; // Y 翻转:世界 Y 向上 → 屏幕 Y 向下
    applyTransform();
    model_->setViewScale(z);
    viewport()->update();
    }

    • 除零保护:矩形退化时直接返回(保留原视图);
    • 缩放钳位 [0.05, 500] 防止极端缩放;
    • margin=1.0 → 精确充满。

    5.3 fitView() 重写

    void GraphicsView::fitView() {
    const QRectF b = contentBounds();
    if (b.isNull()) { // 空图纸:回退到原点居中
    zoom_ = 4.0;
    panX_ = viewport() ? viewport()->width() / 2.0 : 400;
    panY_ = viewport() ? viewport()->height() / 2.0 : 300;
    applyTransform(); model_->setViewScale(zoom_); viewport()->update();
    return;
    }
    fitWorldRect(b, 1.0); // 精确充满
    }

    兼容性设计:空图纸回退到旧的 zoom=4 行为,保证既有单元素场景(如只有一条线的测试)行为不变(见 §6)。

    5.4 框选三事件(节选)

    // press
    if (zoomBoxMode_) {
    if (e->button() == Qt::RightButton) { setZoomBoxMode(false); e->accept(); return; }
    if (e->button() == Qt::LeftButton) {
    zoomBoxActive_ = true;
    zoomBoxRect_ = QRectF(mapToScene(e->position().toPoint()), QSizeF(0, 0));
    e->accept(); return;
    }
    }
    // move(置于分发链最前)
    if (zoomBoxActive_) {
    zoomBoxRect_.setBottomRight(mapToScene(e->position().toPoint()));
    viewport()->update(); e->accept(); return;
    }
    // release
    if (zoomBoxActive_ && e->button() == Qt::LeftButton) {
    zoomBoxActive_ = false;
    const QRectF r = zoomBoxRect_.normalized();
    setZoomBoxMode(false); // 先退出模式
    if (r.width() > 1e-6 && r.height() > 1e-6) fitWorldRect(r, 1.0);
    else fitView(); // 单击 → 退化为适配
    e->accept(); return;
    }

    5.5 橡皮筋绘制(drawForeground)

    if (zoomBoxActive_) {
    p->save();
    QPen zp(QColor(0, 170, 0)); // 绿色,与蓝色选择框区分
    zp.setStyle(Qt::DashLine);
    zp.setCosmetic(true); // 线宽按屏幕像素,不随缩放
    p->setPen(zp);
    p->setBrush(QColor(0, 170, 0, 28));
    p->drawRect(zoomBoxRect_.normalized());
    p->restore();
    }

    5.6 图标与接线

    • icons.h 新增 "zoombox":虚线框、四角折线与右下角放大镜,风格与既有矢量图标一致。
    • MainWindow.cpp:视图菜单【适配窗口】后新增【窗口缩放】(Ctrl+9) → view_->setZoomBoxMode(true);工具栏同步新增按钮。

    6. 兼容性与回归风险分析

    fitView() 的语义从「zoom=4」变为「内容适配」,凡调用它的地方都需复核。检索到三处测试:

    测试是否受影响分析
    ViewRenderTest 只断言拖拽前后坐标差,与绝对变换无关
    ViewRenderTest2 断言拖拽 delta;单线场景触发零高回退 → zoom=4,与旧行为一致
    SelectWindowTest 世界↔屏幕映射自洽(用逆映射取点),变换无关

    结论:三个测试均为「变换自洽」或「零尺寸回退」,改造不影响其结果。此外 elementSceneBounds()(拾取用)保持原样,仅新增 geometryBounds(),不会改变选择/命中行为。


    7. 验证方法与结果

    7.1 新增测试 ViewFitZoomTest(11 断言,全绿)

    关键验证(800×600 视口):

    • 适配窗口:内容屏幕矩形精确为 x[0,800] y[100,500] —— 即受限维度(宽度)恰好铺满视口,另一维度(高度 400)居中 → 满足「精确充满」;
    • 窗口缩放:框选 40×30 区域后,zoom == min(800/40, 600/30) 且选框中心落在视口中心 → 满足 z=min(vw/W, vh/H) + 居中;
    • 退化:单击(未拖出有效面积)→ 退化为适配窗口;
    • 退出:缩放完成后 zoomBoxMode()==false。

    7.2 回归结果

    • ViewRenderTest PASS;
    • ViewRenderTest2 PASS;
    • SelectWindowTest 全部通过;
    • SketchCAD.exe(含对话框)编译链接通过,无回归。

    8. 踩坑记录

    8.1 构建 OOM(环境侧,非代码问题)

    并行 ninja 同时编译多个目标时,cc1plus.exe: out of memory 导致中途 FAILED。
    对策:改为 -j 1 逐个 target 编译;若仍中断,重跑增量续编即可通过。同一现象在 SketchCAD 主目标已单独编译成功,说明并非代码错误。

    ⚠️ 编译/链接时 PATH 必须含 mingw bin(C:\\Qt\\Tools\\mingw1310_64\\bin)。缺少它时 g++ 找不到 libgcc/libstdc++ 运行时 → 静默退出码 1、零诊断输出。

    8.2 事件分配链顺序(真实逻辑 bug)

    初版把「框选拖拽更新」放在 mouseMoveEvent 的绘制子工具守卫之后,导致若恰好有绘制工具处于激活态,框选拖拽会被提前 return 吞掉。
    修复:把框选块移到 mouseMoveEvent 分发链最前(紧随区域拾取模式判定),确保其优先于任何绘制预览逻辑。修复后所有测试依旧全绿。

    8.3 沙箱文件清理

    批量 Remove-Item 会被沙箱 safe-delete 拦截;逐文件 Remove-Item -LiteralPath 或 [System.IO.File]::Delete() 可用。


    9. 结论与后续

    结论:两条视图命令均已实现并通过闭环验证。

  • 适配窗口:由「写死 zoom=4」改为「内容包围盒精确充满」,坐标轴/提示文字作为覆盖层天然不变;
  • 窗口缩放:新增框选缩放,复用 marquee 交互骨架 + 独立状态 + 统一 fitWorldRect() 内核;
  • 关键设计点:① 覆盖层排除用「结构性隔离」而非特判;② 用 geometryBounds() 而非 boundingRect() 才能「精确充满」;③ 空图纸回退旧行为保兼容。
  • 后续可扩展:

    • 框选缩放可增加「上一步视图」回退(视图历史栈);
    • contentBounds() 目前遍历全部图元,元素量极大时可考虑 qgraphicsitem 的 sceneBoundingRect 缓存或空间索引加速;
    • 可选:适配时对选中集而非全集取景(选区适配)。

    文档状态:与当前实现一致。若后续修改 fitView()/fitWorldRect() 的边距或钳位,请同步更新 §5.2、§7.1。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 视图适配革命:精准填充与框选缩放
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!