ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零写一个CAD 05:以鼠标为中心的滚轮缩放实现与顺序陷阱

从零写一个CAD 05:以鼠标为中心的滚轮缩放实现与顺序陷阱 前面几篇已经把画布、网格、基本图元画出来了。这一篇处理一个看起来很小、但几乎所有自研 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解出offsetoffset 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):k1.1ifdelta0else1/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):k1.1ifdelta0else1/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.scale1.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.scaleself.offset[0],wy*self.scaleself.offset[1],)defzoom_at(self,mx,my,k):以屏幕点 (mx, my) 为中心缩放 k 倍。k 1 放大k 1 缩小。# 1. 先读旧值old_scaleself.scale old_offset(self.offset[0],self.offset[1])# 2. 用旧值算出鼠标下的世界点其实不需要显式算但便于理解# W (M - old_offset) / old_scale# 3. 更新 scalenew_scaleold_scale*k# 4. 用旧 offset、旧 scale、新 scale 算新 offsetrationew_scale/old_scale# 就是 knew_offset_xmx-(mx-old_offset[0])*ratio new_offset_ymy-(my-old_offset[1])*ratio# 5. 一起写回self.scalenew_scale self.offset[0]new_offset_x self.offset[1]new_offset_y关键就是第 1 步先把旧值全部取出来后面所有计算都基于快照最后一步才写回。这样顺序问题从根上消失了——你没法不小心用到新值因为变量名不一样。ratio这里等于k我保留这个中间变量是想让公式和推导对齐方便以后加边界限制比如限制最大最小缩放时改这里。验证一个能跑的最小测试不验证的实现都是自我安慰。写个 pytest 断言deftest_zoom_keeps_world_point_under_cursor():vView2D()v.scale2.0v.offset[30.0,-10.0]mx,my400.0,300.0w_beforev.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_afterv.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,sensitivity0.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_scale0.05,max_scale50.0):old_scaleself.scale old_offset(self.offset[0],self.offset[1])new_scalemax(min_scale,min(max_scale,old_scale*k))rationew_scale/old_scale# 用 clamp 后的实际比例self.scalenew_scale self.offset[0]mx-(mx-old_offset[0])*ratio self.offset[1]my-(my-old_offset[1])*ratio这段是最终版本前面所有讨论都收敛到这里。逻辑不复杂难的是把顺序和边界想清楚。写在最后这类视图变换的代码写对一次之后可以复用很久但写错一次会消耗掉大量调试时间因为症状是慢慢漂而不是直接崩。我的经验是换算公式只写一处屏幕↔世界互相调用。涉及新旧值的计算先把旧值取成局部变量最后统一写回。测试要连续缩放多次单次通过不算数。下一章准备处理平移和缩放同时发生时的交互那里顺序问题会更明显——拖拽过程中滚轮如果处理不当视图会跳一下。这个坑我还没开始踩等写完再补。
返回列表