从零写一个CAD:中键拖动平移,让图形跟着鼠标走不跑偏
前面几篇里我已经把画布、网格、世界坐标到屏幕坐标的变换搭起来了。这一篇要处理视图交互里最基础、也最容易被写歪的一个动作按住鼠标中键拖动让画面跟着鼠标走。这件事的直觉描述特别简单——“抓住画布上的一个点拖动的时候让它一直待在鼠标底下”。但真落到代码里会发现这句话其实已经蕴含了全部数学鼠标位移量、视图偏移量、缩放系数三者的关系一个都不能错。错一个拖动就会变成画面跑得比鼠标快或者方向反了。我一开始以为这只是加个offset delta后来发现得先把坐标系理清楚否则调参数调到怀疑人生。先把抓住一个点翻译成公式假设当前视图有一个平移量pan也就是世界坐标原点在屏幕上被推到了哪里还有一个缩放系数scale表示一个世界单位对应多少像素。屏幕坐标和世界坐标的关系是screen world * scale pan反过来world (screen - pan) / scale现在用户按下中键时鼠标位于屏幕位置s0。这一刻鼠标底下压着的那个世界点w是固定的——我们抓住的就是它w (s0 - pan0) / scale拖动过程中鼠标来到s1。我们希望刚才抓的那个世界点w此时出现在s1的位置s1 w * scale pan1把w代进去s1 (s0 - pan0) pan1整理一下pan1 pan0 (s1 - s0)【关键结论】视图的平移增量就等于鼠标的屏幕位移增量和scale无关。这个结论挺反直觉的很多人会下意识写成pan delta / scale或者pan delta * scale结果就是缩放级别一变拖动手感就跟着变。实际上因为位移和偏移都在屏幕像素空间里缩放系数会在推导中自然约掉。反过来说如果哪天你发现放大之后拖动变慢了基本可以确定是哪里多乘了一个scale。用 PySide6 搭一个最小可跑的例子为了能真跑起来我用 PySide6。版本上PySide6 目前 6.x 系列都能跑我本地是 6.7 附近接口没变化。先搭一个最小的画布控件只做两件事画网格、处理中键拖动。# canvas.pyfromPySide6.QtCoreimportQt,QPointFfromPySide6.QtGuiimportQPainter,QPen,QColorfromPySide6.QtWidgetsimportQWidgetclassCanvas(QWidget):def__init__(self,parentNone):super().__init__(parent)self.setMouseTracking(True)self.setFocusPolicy(Qt.StrongFocus)# 视图状态self.scale1.0# 世界单位 - 像素self.panQPointF(0.0,0.0)# 世界原点在屏幕上的位置# 拖动状态self._draggingFalseself._last_posQPointF()defworld_to_screen(self,wx:float,wy:float)-QPointF:returnQPointF(wx*self.scaleself.pan.x(),wy*self.scaleself.pan.y())defscreen_to_world(self,sx:float,sy:float)-QPointF:returnQPointF((sx-self.pan.x())/self.scale,(sy-self.pan.y())/self.scale)这里有个我想强调的点pan用的是屏幕坐标不是世界坐标。这样world_to_screen就是一次乘加screen_to_world就是一次减除两个函数互为逆运算后面排查问题的时候非常直观。网格绘制我直接用屏幕坐标循环简单粗暴够用defpaintEvent(self,event):painterQPainter(self)painter.fillRect(self.rect(),QColor(30,30,30))painter.setPen(QPen(QColor(70,70,70),1))step50*self.scaleifstep5:step50*self.scale*5# 太小就跳一级# 竖线xself.pan.x()%stepwhilexself.width():painter.drawLine(int(x),0,int(x),self.height())xstep# 横线yself.pan.y()%stepwhileyself.height():painter.drawLine(0,int(y),self.width(),int(y))ystep注意这里取模% step的用法网格线的屏幕位置永远落在pan的周期性偏移上所以拖动pan的时候网格自然就跟着走不需要额外计算哪条是第一条线。这个技巧在无限网格里特别省事。中键拖动的状态机拖动逻辑分三步按下、移动、抬起。Qt 里对应的就是mousePressEvent、mouseMoveEvent、mouseReleaseEvent。defmousePressEvent(self,event):ifevent.button()Qt.MiddleButton:self._draggingTrueself._last_posevent.position()self.setCursor(Qt.ClosedHandCursor)event.accept()else:super().mousePressEvent(event)defmouseMoveEvent(self,event):ifnotself._dragging:returncurevent.position()deltacur-self._last_pos self.pandelta self._last_poscur self.update()event.accept()defmouseReleaseEvent(self,event):ifevent.button()Qt.MiddleButton:self._draggingFalseself.unsetCursor()event.accept()else:super().mouseReleaseEvent(event)核心就一句self.pan delta正好对应前面推导的pan1 pan0 (s1 - s0)。这里有几个地方容易出问题我一个个说。第一个坑用event.pos()还是event.position()。Qt5 里pos()返回的是QPoint整数像素Qt6 里推荐position()返回QPointF浮点。如果你用pos()在慢慢拖动的时候鼠标每次位移不到 1 像素整数截断会让画面一顿一顿的。PySide6 里position()是首选。第二个坑不要用event.globalPos()。全局坐标在多屏、系统缩放比如 Windows 的 125% DPI 缩放下会和你窗口里的坐标不一致你抓的那个点会漂。用窗口内的局部坐标最稳。第三个坑_last_pos的更新时机。必须每次移动后立刻更新成当前位置不能等抬起再更新。否则第二帧的 delta 会从最初的位置算画面会加速乱跑。验证一下抓点是不是真的不动光看代码不够得验证抓住的点一直待在鼠标底下这句话是不是成立。我写了一个最简单的手动验证在按下时记下鼠标下的世界点移动过程中不断重算它现在落在屏幕哪里打印出来。defmousePressEvent(self,event):ifevent.button()Qt.MiddleButton:self._draggingTrueself._last_posevent.position()self._grab_worldself.screen_to_world(event.position().x(),event.position().y())self.setCursor(Qt.ClosedHandCursor)event.accept()defmouseMoveEvent(self,event):ifnotself._dragging:returncurevent.position()self.pancur-self._last_pos self._last_poscur# 验证被抓的世界点现在应该正好在鼠标位置backself.world_to_screen(self._grab_world.x(),self._grab_world.y())print(fmouse({cur.x():.2f},{cur.y():.2f}) fgrabbed({back.x():.2f},{back.y():.2f}))self.update()event.accept()跑起来拖动控制台里两个坐标应该是一模一样的。如果不一样说明你的公式里混进了多余的scale或者符号错了。【踩坑提醒】如果你把screen_to_world里的- pan写成了 pan验证时两个坐标会差出一个2 * pan一眼能看出来别硬调参数回头检查公式。缩放和拖动叠加时的顺序问题上一段推导里scale被约掉了说明拖动本身和缩放无关。但一旦你再加上滚轮缩放就得想清楚缩放的锚点在哪。常见的错误做法是滚轮时直接改scalepan不动。这样缩放会围绕世界原点进行视觉上就是画面从左上角飞出去。正确的做法是以鼠标位置为锚点缩放也就是保持鼠标下的世界点不动defwheelEvent(self,event):factor1.1ifevent.angleDelta().y()0else1/1.1beforeself.screen_to_world(event.position().x(),event.position().y())self.scale*factor afterself.screen_to_world(event.position().x(),event.position().y())# 让 before after反解 pan 的修正量self.pan(after-before)*self.scale self.update()这段逻辑和拖动其实是同一个思路先确定哪个世界点要保持不动再反解视图参数。拖动时保持的是按下瞬间抓的那个点缩放时保持的是鼠标当前位置下的点。把这两个场景套进同一个思维框架代码就不容易写乱。浮点精度和长时间拖动的漂移pan是QPointF底层是双精度浮点。正常用法下拖几个小时也不会明显漂。但如果你玩一个花活——每次移动都pan pan delta而delta又恰好是很小的浮点数长期累积理论上会有微小误差实际影响小到肉眼看不见。真正需要注意的是别把pan存成整数。我见过有实现图省事用QPoint结果放大到 100 倍时网格边缘会出现 1 像素的抖动。用QPointF就没这问题。另一个不太起眼的地方pan的数值会随着拖动不断增大或减小。如果用户一直往一个方向拖pan能到几十万。这时候world_to_screen里的wx * scale pan会因为大数加小数损失精度。解决办法是定期把pan归一化——比如当|pan|超过阈值时把它减去若干倍的网格间距因为网格是周期性的视觉上完全看不出来。这一点我在自己的项目里没做到那么严格只做了个简单的范围限制够用就行。和用 QGraphicsView 现成的拖动对比一下Qt 自带的QGraphicsView有ScrollHandDrag模式也能实现类似的拖动。到底该用哪个方案优点缺点适用场景自写pan平移完全掌控变换方便做无限网格、自定义坐标需要自己处理所有交互细节自研 CAD、绘图工具QGraphicsViewScrollHandDrag开箱即用滚动条、缩放都现成场景坐标系固定做无限画布要 hack简单图形编辑器我选自写原因是 CAD 里视图变换是最核心的一块交给框架反而会在后面做捕捉、吸附、标注的时候被它绑住手脚。但如果你只是做个简单的图形查看器QGraphicsView确实省事。最后再说两个小细节光标反馈。按下中键时把光标换成ClosedHandCursor抬起时unsetCursor()用户一眼就知道自己正在拖。这个不算功能但体验差别很大。中途失去焦点。如果拖动过程中用户按了 AltTab 切走mouseReleaseEvent可能收不到_dragging会一直卡在True。稳妥的做法是重写focusOutEvent或者leaveEvent把_dragging强制置为False。这一点我一开始没加后来调试的时候发现切窗口再回来鼠标一动画面就跟着跑才补上。deffocusOutEvent(self,event):self._draggingFalseself.unsetCursor()super().focusOutEvent(event)整个平移做下来代码不到一百行但背后的坐标推导和边界情况不少。我的体会是这类看起来最简单的交互往往是最值得先把数学写清楚再动手的。公式对了代码几乎是照抄公式错了调参数调到天亮也调不对。下一篇我打算处理滚轮缩放的完整版本包括缩放级别限制和以鼠标为中心的平滑缩放。

相关新闻

MRAM与8位MCU工业数据存储设计:可靠性、时序与架构实战

MRAM与8位MCU工业数据存储设计:可靠性、时序与架构实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 2:52:16 阅读更多 →
KV缓存从11.25GB缩到0.21GB:WARP中Kimi Delta Attention线性注意力原理剖析

KV缓存从11.25GB缩到0.21GB:WARP中Kimi Delta Attention线性注意力原理剖析

KV缓存从11.25GB缩到0.21GB:WARP中Kimi Delta Attention线性注意力原理剖析 【免费下载链接】warp Run the full 2.78-trillion-parameter Kimi K3 model, DeepSeek V4.1 Flash or GLM-5.3-Flash beyond available RAM by streaming activated weights directly fro…

2026/10/4 2:52:16 阅读更多 →
Brocade与Cisco光纤交换机级联配置:跨厂商ISL实战指南

Brocade与Cisco光纤交换机级联配置:跨厂商ISL实战指南

简介:这份PDF文档聚焦Brocade与Cisco光纤交换机级联配置,面向从事存储网络(SAN)运维与实施的网络工程师、系统集成人员,以及需要打通异构交换机平台协同工作模式的技术学习者。内容围绕Cisco 9120等设备的实际配置展开…

2026/10/4 2:52:16 阅读更多 →

最新新闻

统一管理54+AI编程工具的Agent技能:我如何构建技能中枢

统一管理54+AI编程工具的Agent技能:我如何构建技能中枢

1. 为什么需要这么个“技能中枢”:54工具下的碎片化困局先说我碰到的真实情况。去年开始,我的主力机里装了Cursor、Windsurf、Trae、Codex CLI、Cline、Continue、Zed,还有几个叫得上名的Agent框架,加起来十几个AI编程工具。每个工…

2026/10/4 5:55:07 阅读更多 →
Python读取Excel的底层原理与工程化实践

Python读取Excel的底层原理与工程化实践

1. 为什么“把Excel的数据导入Python”不是个简单操作,而是一道必须跨过的数据工程门槛?你刚在Excel里整理完销售报表,想用Python画个趋势图,结果卡在第一步:怎么把表格里的数字塞进Python?不是点几下鼠标就…

2026/10/4 5:55:07 阅读更多 →
OpenShell:Windows 开始菜单增强工具与 WSL 桌面桥接实践

OpenShell:Windows 开始菜单增强工具与 WSL 桌面桥接实践

1. OpenShell 是什么?它不是 Shell,也不是“开源 Shell”,更不是某个 Linux 发行版 OpenShell 这个名字在当前技术社区里确实容易引发第一层误解——听到“Shell”,下意识会联想到 bash、zsh、fish;看到“Open”&…

2026/10/4 5:55:07 阅读更多 →
Java双人联机小游戏源码解析:森林冰火人Socket通信与碰撞判定实战

Java双人联机小游戏源码解析:森林冰火人Socket通信与碰撞判定实战

简介:这是一份面向Java初学者与课程设计需求的「森林冰火人」双人联机小游戏源码,适合想通过完整项目练手、理解游戏开发基本流程的学生与自学者。资源围绕角色设计、地图搭建、移动跳跃与敌人AI等核心机制展开,可作为Java课设或小游戏开发的…

2026/10/4 5:55:07 阅读更多 →
OpenShell:跨平台终端前端统一开发体验

OpenShell:跨平台终端前端统一开发体验

1. OpenShell 是什么?它不是 Shell,而是一把“跨平台终端体验重构钥匙”OpenShell 这个名字乍一听容易让人误以为是某个 Linux 发行版的定制 shell(比如 bash、zsh 的变种),或者像 Oh My Zsh 那样的配置框架。但实际完…

2026/10/4 5:55:07 阅读更多 →
PNG编解码核心机制详解:从文件结构到滤波压缩算法

PNG编解码核心机制详解:从文件结构到滤波压缩算法

提到PNG编解码,很多人的第一反应是“这不是图像处理库早就封装好的东西吗”。但实际去碰底层的人才会发现,真到踩坑的时候,比如前端构建工具突然报出类似“failed to resolve import”这种崩溃,或者是自己在嵌入式设备上想用一张透…

2026/10/4 5:54:07 阅读更多 →

日新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →

周新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/4 1:00:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/2 10:36:31 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/3 9:42:35 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/3 9:42:36 阅读更多 →