☰
从零写一个CAD 05:以鼠标为中心的滚轮缩放实现与顺序陷阱
2026/10/8 12:49:49 网站建设 项目流程

前面几篇已经把画布、网格、基本图元画出来了。这一篇处理一个看起来很小、但几乎所有自研 CAD 都会在第一版写错的功能:以鼠标为中心的滚轮缩放。

说它小,是因为逻辑就几行。说它容易错,是因为这几行里藏着一个顺序问题——顺序反了,代码不报错,滚动也能缩放,但缩放中心会漂。你盯着屏幕看半天,觉得"好像有点不对",又说不上哪不对。

这篇把这个问题拆开讲清楚,给一个能直接跑的实现,并且说明为什么顺序不能反。

先明确视图变换的约定

在动手之前必须先把约定定死,否则后面全是玄学。

我用的是一个(scale, offset)的二维视图模型:

  • scale:缩放比例,1.0 表示 1 个世界单位对应 1 个屏幕像素。
  • offset:平移量,单位是屏幕像素。
  • 屏幕坐标 = 世界坐标 × scale + offset

反解一下:世界坐标 = (屏幕坐标 − offset) / scale

这个模型比直接维护一个 3x3 矩阵更好调试,因为只有两个变量,出问题时能一眼看出是 scale 错了还是 offset 错了。代价是后面如果要加旋转,得升级成矩阵。当前阶段不需要,先不上。

【关键结论】约定不统一是这类 bug 的最大来源。屏幕到世界的换算公式,在写第一行代码之前就要确定,并且全项目只允许存在一处实现。

缩放的目标:鼠标下的那个世界点不动

"以鼠标为中心缩放"这句话,翻译成可验证的定义是:

缩放前后,鼠标屏幕位置(mx, my)所对应的世界坐标保持不变。

设缩放前世界点为W,缩放后仍为W,鼠标屏幕位置不变。代入公式:

缩放前:W = (M − offset) / scale
缩放后:W = (M − offset') / scale'

两式相等:

(M − offset) / scale = (M − offset') / scale'

解出offset':

offset' = M − (M − offset) × (scale' / scale)

令k = scale' / scale(本次的缩放倍数),就得到:

offset' = M − (M − offset) × k

这就是全部推导。注意这里的M是鼠标的屏幕坐标,offset是缩放前的平移量,k是本次的缩放倍数。三个量都必须是"旧值",一个都不能提前更新。

错误写法:先更新 scale 再算 offset

我第一版写的是这样:

defon_wheel_bad(self,mx,my,delta):k=1.1ifdelta>0else1/1.1self.scale*=k# 先改了 scaleself.offset=(mx,my)-((mx,my)-self.offset)*k# 再用 self.scale

问题在于:算offset时公式里的k已经隐含地用了新scale,但offset还是旧值,看起来"用到了新 scale"其实没有——上面这段里k是独立的,反而是self.scale被提前污染了。真正的错法是这种:

defon_wheel_bad2(self,mx,my,delta):k=1.1ifdelta>0else1/1.1self.scale*=k# 错误:用新 scale 去反推旧的世界点world=(mx-self.offset[0])/self.scale self.offset=(mx-world*self.scale,my-world*self.scale)

这段代码第二行已经把self.scale更新了,第三行拿新 scale 去算世界坐标,算出来的世界点根本不对,补偿自然也是错的。表现就是滚轮越滚,图越往一个方向漂。

【踩坑提醒】这类 bug 不会抛异常,不会打印警告,只会让视图慢慢偏离。它比崩溃更难查,因为你的第一反应是"是不是坐标算错了",而不是"是不是顺序错了"。

正确写法

importmathclassView2D:def__init__(self):self.scale=1.0self.offset=[0.0,0.0]defscreen_to_world(self,sx,sy):return((sx-self.offset[0])/self.scale,(sy-self.offset[1])/self.scale,)defworld_to_screen(self,wx,wy):return(wx*self.scale+self.offset[0],wy*self.scale+self.offset[1],)defzoom_at(self,mx,my,k):"""以屏幕点 (mx, my) 为中心缩放 k 倍。k > 1 放大,k < 1 缩小。"""# 1. 先读旧值old_scale=self.scale old_offset=(self.offset[0],self.offset[1])# 2. 用旧值算出鼠标下的世界点(其实不需要显式算,但便于理解)# W = (M - old_offset) / old_scale# 3. 更新 scalenew_scale=old_scale*k# 4. 用旧 offset、旧 scale、新 scale 算新 offsetratio=new_scale/old_scale# 就是 knew_offset_x=mx-(mx-old_offset[0])*ratio new_offset_y=my-(my-old_offset[1])*ratio# 5. 一起写回self.scale=new_scale self.offset[0]=new_offset_x self.offset[1]=new_offset_y

关键就是第 1 步:先把旧值全部取出来,后面所有计算都基于快照,最后一步才写回。这样顺序问题从根上消失了——你没法"不小心用到新值",因为变量名不一样。

ratio这里等于k,我保留这个中间变量是想让公式和推导对齐,方便以后加边界限制(比如限制最大最小缩放)时改这里。

验证:一个能跑的最小测试

不验证的实现都是自我安慰。写个 pytest 断言:

deftest_zoom_keeps_world_point_under_cursor():v=View2D()v.scale=2.0v.offset=[30.0,-10.0]mx,my=400.0,300.0w_before=v.screen_to_world(mx,my)forkin(1.1,1.1,1.1,1/1.1,1/1.1,1/1.1):v.zoom_at(mx,my,k)w_after=v.screen_to_world(mx,my)assertabs(w_after[0]-w_before[0])<1e-9assertabs(w_after[1]-w_before[1])<1e-9

注意我用的是"连续缩放多次后仍然不漂"这个断言,而不是只测一次。单次缩放即使公式有小误差也可能通过,多次累积才会暴露。浮点误差用1e-9而不是==,因为缩放是乘法,必然有精度损失。

跑下来是过的。把上面on_wheel_bad2那种写法换成同样的测试,第一次就可能过,第三次开始漂——这就是为什么单次测试不靠谱。

为什么"招法能复用,顺序不能反"

标题里的这句话,落到代码上就是:

  • 招法:offset' = M − (M − offset) × k这个公式,在画布缩放、图片查看器、地图引擎、甚至 DOM 里手动做 transform 时都能用。它不依赖具体框架。
  • 顺序:先取旧值 → 算新 scale → 算新 offset → 一起写回。这个顺序一旦打乱,公式再对也没用。

框架封装得好,比如某些图形库直接给你一个zoomToPoint,顺序被藏在内部,你感知不到。但自研 CAD 早晚要碰这块,因为你要处理吸附、坐标标注、拾取这些和视图强耦合的逻辑,不可能全交给框架。

一个容易忽略的细节:滚轮 delta 的平台差异

浏览器里wheel事件的deltaY在不同设备上量级差很多:

来源deltaY 典型值说明
普通鼠标滚轮±100 左右离散,一格一跳
触控板双指±1 ~ ±10连续,量小但频率高
Firefox部分场景 ±3行模式,需要乘系数

如果直接k = 1.1 if deltaY > 0 else 1/1.1,触控板上会缩放得极其迟钝,因为每帧 deltaY 很小但你仍然只在两个固定档位之间跳。

我的处理是把 deltaY 归一化成一个连续因子:

defwheel_to_factor(delta_y,sensitivity=0.0015):# 用指数映射,保证放大和缩小对称returnmath.exp(-delta_y*sensitivity)

这样delta_y = 100时因子约 0.86(缩小),delta_y = -100时约 1.16(放大),乘起来正好抵消,符合直觉。触控板的小 delta 也能平滑响应。

【注意】deltaMode这个字段我没有在所有浏览器上验证过它的行为差异,如果你要兼容 Firefox 的行模式,建议自己实测确认,不要照搬我这里没写的东西。

和后续功能的衔接

视图模型定成(scale, offset)之后,后面几件事会顺很多:

  1. 拾取:鼠标点击时,先把屏幕点转成世界点,再用世界坐标做几何判断。缩放逻辑和拾取逻辑共用同一套换算,不会出现"看起来点中了但没选中"的偏差。
  2. 坐标标注:文字大小想保持屏幕像素恒定,就除以scale再画。
  3. 限制缩放范围:在zoom_at里对new_scale做 clamp,注意 clamp 之后ratio要用实际的new_scale/old_scale重算,不能还用原始k,否则边界处会漂。

第 3 点我踩过:一开始在函数开头就把kclamp 了,结果在最大缩放下继续滚轮,视图还是会缓慢平移。原因是 clamp 后的k和实际scale变化不匹配。正确做法是先算new_scale并 clamp,再用new_scale/old_scale当ratio。

defzoom_at(self,mx,my,k,min_scale=0.05,max_scale=50.0):old_scale=self.scale old_offset=(self.offset[0],self.offset[1])new_scale=max(min_scale,min(max_scale,old_scale*k))ratio=new_scale/old_scale# 用 clamp 后的实际比例self.scale=new_scale self.offset[0]=mx-(mx-old_offset[0])*ratio self.offset[1]=my-(my-old_offset[1])*ratio

这段是最终版本,前面所有讨论都收敛到这里。逻辑不复杂,难的是把顺序和边界想清楚。

写在最后

这类视图变换的代码,写对一次之后可以复用很久,但写错一次会消耗掉大量调试时间,因为症状是"慢慢漂"而不是"直接崩"。我的经验是:

  • 换算公式只写一处,屏幕↔世界互相调用。
  • 涉及"新旧值"的计算,先把旧值取成局部变量,最后统一写回。
  • 测试要连续缩放多次,单次通过不算数。

下一章准备处理平移和缩放同时发生时的交互,那里顺序问题会更明显——拖拽过程中滚轮,如果处理不当,视图会跳一下。这个坑我还没开始踩,等写完再补。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询