前面几篇已经把画布、网格、基本图元画出来了。这一篇处理一个看起来很小、但几乎所有自研 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)之后,后面几件事会顺很多:
- 拾取:鼠标点击时,先把屏幕点转成世界点,再用世界坐标做几何判断。缩放逻辑和拾取逻辑共用同一套换算,不会出现"看起来点中了但没选中"的偏差。
- 坐标标注:文字大小想保持屏幕像素恒定,就除以
scale再画。 - 限制缩放范围:在
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这段是最终版本,前面所有讨论都收敛到这里。逻辑不复杂,难的是把顺序和边界想清楚。
写在最后
这类视图变换的代码,写对一次之后可以复用很久,但写错一次会消耗掉大量调试时间,因为症状是"慢慢漂"而不是"直接崩"。我的经验是:
- 换算公式只写一处,屏幕↔世界互相调用。
- 涉及"新旧值"的计算,先把旧值取成局部变量,最后统一写回。
- 测试要连续缩放多次,单次通过不算数。
下一章准备处理平移和缩放同时发生时的交互,那里顺序问题会更明显——拖拽过程中滚轮,如果处理不当,视图会跳一下。这个坑我还没开始踩,等写完再补。