视图命令改造:适配窗口(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. 结论与后续
结论:两条视图命令均已实现并通过闭环验证。
后续可扩展:
- 框选缩放可增加「上一步视图」回退(视图历史栈);
- contentBounds() 目前遍历全部图元,元素量极大时可考虑 qgraphicsitem 的 sceneBoundingRect 缓存或空间索引加速;
- 可选:适配时对选中集而非全集取景(选区适配)。
文档状态:与当前实现一致。若后续修改 fitView()/fitWorldRect() 的边距或钳位,请同步更新 §5.2、§7.1。
网硕互联帮助中心






评论前必须登录!
注册