👓️博主简介:
@[TOC](文章目录)
前言
回顾上一篇结尾:
paintEvent → GraphicView 刷新 → 遍历实体 → 调用 draw() → RS_Painter → QPainter。
但是有的人可能回有问题了:RS_Line 的起点是 (10, 20),屏幕上画出来的却是 (250, 180)。这个差异是怎么来的?所以本篇我们要回答三个问题:
- 谁负责把 CAD 坐标变成屏幕坐标?
- 用的什么公式?
- 缩放、平移又是如何改变这套映射的?
一、两套坐标系:CAD 世界坐标 vs 屏幕坐标
1.1、CAD 坐标(世界坐标)
类型:`RS_Vector`,成员是 `double`。 方向:数学坐标系,Y 轴向上。 单位:图纸单位(mm / inch 等,由图纸决定)。 原点:由图纸内容决定,不一定在视口中心。在 LibreCAD 里,一条直线、一个圆到底画在图纸的什么位置,是用 CAD 坐标描述的。这套坐标最典型的表现就是 librecad/src/lib/engine/rs_vector.h 里的 RS_Vector:
classRS_Vector{public:doublex=0.;doubley=0.;doublez=0.;boolvalid=false;};它有几个明显特征:
用 double 存储,是一套连续、精确的几何坐标,而不是离散的像素。 单位是图纸单位,比如毫米或英寸,具体由图纸决定。 Y 轴向上,符合我们学数学时的习惯。 原点不是屏幕左上角,而是图纸坐标系的原点,可能在绘图区中间,也可能在很远的地方。比如第 7 篇里创建的直线,就保存了两个 CAD 坐标:
startPoint = (10, 20) endPoint = (100, 80)这两个数字描述的是“这条直线在图纸里的几何位置”。
1.2、屏幕坐标
类型:`QWidget` / `QPainter` 使用的像素坐标,通常是 `int`。 方向:屏幕坐标系,Y 轴向下。 原点:窗口左上角 `(0, 0)`。但用户最终看到的是窗口,是屏幕上的一堆像素。Qt 在真正绘制时,QPainter 使用的是屏幕坐标:
单位:像素,通常是 int。 原点:窗口左上角 (0, 0)。 X 轴:向右增大。 Y 轴:向下增大。也就是说,屏幕坐标的 Y 方向和 CAD 坐标是相反的。同一个几何点,到了屏幕上就变成了另一种表达:
CAD 坐标 (10, 20) 屏幕坐标 (250, 180)1.3、同一个点的两种表达
CAD 坐标:(10, 20) 屏幕坐标:(250, 180) 两者不是简单加一个常量就能互相转换的。为了直观对比,可以这样理解:
| CAD | 世界坐标 | 屏幕坐标 |
|---|---|---|
| 数据类型 | RS_Vector,double | 像素,通常 int |
| 原点 | 图纸原点 | 窗口左上角 |
| Y 轴方向 | 向上 | 向下 |
| 单位 | 图纸单位 | 像素 |
| 是否随缩放变化 | 不变化 | 会变化 |
注意最后一行的关键点:同一条直线,CAD 坐标始终是 (10,20) -> (100,80),不会因为你滚轮缩放而改变;但它在屏幕上的像素位置会变。这说明 CAD 坐标是“图形的本质”,屏幕坐标只是“图形的某一次显示”。
1.4、为什么不能直接画
单位不同(图纸单位 vs 像素)。 方向不同(Y 轴一个向上一个向下)。 还受缩放比例、平移位置、窗口大小共同影响。既然直线已经存了 (10,20) 和 (100,80),能不能直接把这几个数当成屏幕像素传给 QPainter?
不能。原因有三个:
单位不一样:一个是图纸单位,一个是像素。 方向不一样:CAD 的 Y 轴向上,屏幕的 Y 轴向下。 还要叠加缩放和平移:用户缩放后,1 个图纸单位可能对应 2 个像素,也可能对应 0.5 个像素; 平移后,图纸原点也会偏离屏幕原点。所以中间一定需要一个“翻译官”,负责把 CAD 坐标翻译成屏幕坐标;反过来,也要把鼠标点击的屏幕坐标翻译回 CAD 坐标。
在 LibreCAD 里,这个翻译官就是 RS_GraphicView,它保存着一组视图参数,并提供了 toGui 和 toGraph 两套方法来完成双向变换。下一节我们就来看这套映射具体是怎么实现的。
二、RS_GraphicView:视图就是一台“坐标转换器”
2.1、RS_GraphicView 的角色
它是视图层的抽象基类,继承 `QWidget`。 不是某个具体 Qt 控件,而是给 `QC_GraphicView` 这类真实控件提供公共视图能力。 核心职责:保存“当前视图状态”,并把坐标在两个坐标系之间来回转换。在 LibreCAD 里,负责管理视图状态、完成坐标变换的核心类就是 RS_GraphicView,定义在 rs_graphicview.h 里。
classRS_GraphicView:publicQWidget{Q_OBJECT...};需要注意的是,RS_GraphicView 并不是某个具体的 Qt 控件。它更像一个“视图层的公共接口”:凡是跟 CAD 绘图区有关、又和具体窗口实现无关的能力,都放在这个基类里,真正负责创建窗口、响应重绘的,是继承它的 QC_GraphicView 这类具体控件。
所以我们可以先把它抽象成一句话:RS_GraphicView 负责记住“当前图纸是怎么显示在窗口上的”,并提供 CAD 坐标和屏幕坐标之间的双向转换。
2.2、三个核心成员
在rs_graphicview.h:
RS_Vector factor=RS_Vector(1.,1.);intoffsetX=0;intoffsetY=0;它记住的这套“显示状态”,其实只靠三个变量就能描述:
RS_Vector factor=RS_Vector(1.,1.);intoffsetX=0;intoffsetY=0;在这个代码中:
factor:缩放系数。 offsetX:水平平移量。 offsetY:垂直平移量。这三个量合在一起,就决定了一个 CAD 点会被画到屏幕的哪个像素上。
2.3、factor 是什么
factor:缩放系数,含义是“每个图纸单位对应多少个屏幕像素”。 factor.x 和 factor.y 可以不同,用于非等比显示。factor 的含义是“每个图纸单位对应多少个屏幕像素”。比如:
factor = (2, 2)就表示 1 个图纸单位在屏幕上画成 2 个像素,这时图形看起来是放大的。
factor=(0.5,0.5)则表示 1 个图纸单位只占 0.5 个像素,图形看起来是缩小的。它之所以是一个 RS_Vector,而不是一个 double,是因为 LibreCAD 允许 X 和 Y 两个方向使用不同的缩放比例:
factor.x=2factor.y=1这种非等比显示,在打印预览或某些特殊视图下会用到。
2.4、offset 是什么
offsetX / offsetY:平移偏移量。 可以理解为“CAD 原点映射到屏幕后,相对屏幕原点偏移了多少像素”。offsetX 和 offsetY 是平移量,可以粗略理解成 CAD 原点 (0, 0) 映射到屏幕后,相对屏幕原点偏移了多少像素。用户按住鼠标拖动画布时,图形本身没有动,变的就是这两个 offset。它决定了“图纸的哪个位置正好显示在窗口中间”。
2.5、一套线性映射
整体规律:
屏幕坐标 = factor * CAD 坐标 + offset但 Y 方向还要做一次翻转,这正是下一节要展开的重点。把 factor 和 offset 放在一起,坐标变换的总体规律就是:
屏幕坐标 = factor × CAD 坐标 + offset但是有一个细节不能直接照搬公式:屏幕的 Y 轴是向下的,CAD 的 Y 轴是向上的。所以 Y 方向不能简单乘完就加,而是要先翻转。
三、正向变换:toGui(CAD → 屏幕)
3.1、核心代码
RS_GraphicView 提供了一组 toGui 方法,负责把 CAD 坐标转换成屏幕坐标。核心实现位于 rs_graphicview.cpp:
RS_VectorRS_GraphicView::toGui(RS_Vector v)const{returnRS_Vector(toGuiX(v.x),toGuiY(v.y));}doubleRS_GraphicView::toGuiX(doublex)const{returnx*factor.x+offsetX;}doubleRS_GraphicView::toGuiY(doubley)const{return-y*factor.y+getHeight()-offsetY;}doubleRS_GraphicView::toGuiDX(doubled)const{returnd*factor.x;}doubleRS_GraphicView::toGuiDY(doubled)const{returnd*factor.y;}先看最直观的 X 方向:
toGuiX(x)=x*factor.x+offsetX它完全符合我们上一节说的规律:先按缩放系数放大,再加上平移量。
3.2、为什么 toGuiY 有负号
CAD 中 Y 轴向上,屏幕中 Y 轴向下。 乘以 -factor.y 就是完成 Y 轴翻转。再看 Y 方向:
toGuiY(y)=-y*factor.y+getHeight()-offsetY这里的 -y 是整个公式最关键的细节。
CAD 坐标中,Y 越大表示越靠上。 屏幕坐标中,Y 越大表示越靠下。两个方向正好相反,所以要乘一个负号,把 CAD 的“向上增长”翻转成屏幕的“向下增长”。
3.3、为什么还要加 getHeight()
屏幕原点在左上角,CAD 原点通常在图纸左下方向。 加上 getHeight() 是为了把“向上增长”的 CAD 坐标换算成“向下增长”的屏幕坐标。如果只有 -y * factor.y,得到的是一个“向下为正”的相对量,但还没有确定这个点落在窗口的哪个高度上。
屏幕原点在左上角,而 CAD 原点通常对应图纸的左下方向。为了把“以图纸原点为基准的位置”换算成“以窗口左上角为基准的位置”,就需要加上窗口高度 getHeight()。
所以 toGuiY 完整地做了三件事:
先乘以 factor.y:完成单位换算和缩放 再取负号:完成 Y 轴方向翻转 最后加 getHeight() 并减 offsetY:完成原点对齐和平移3.4、距离变换为什么没有 offset
toGuiDX / toGuiDY 只乘 factor,不加 offset。 因为距离是两个坐标的差值,平移量会被抵消。toGuiDX 和 toGuiDY 只乘 factor,没有加 offset:
toGuiDX(d)=d*factor.xtoGuiDY(d)=d*factor.y原因在于,这两个函数转换的是“距离”,而不是“位置”。距离是两个坐标的差值。比如两个屏幕点的 X 差是:
(x2*factor.x+offsetX)-(x1*factor.x+offsetX)=(x2-x1)*factor.x两个 offsetX 一减就抵消了,所以距离变换只关心缩放,不关心平移。
四、反向变换:toGraph(屏幕 → CAD)
4.1、什么时候需要反向变换
用户点击绘图区时,QMouseEvent给的是屏幕像素。但画线、捕捉、框选都需要 CAD 坐标。所以鼠标事件进入 Action 后,必须先做一次屏幕 → 图形的转换。
前面讲的 toGui,解决的是“CAD 图形怎么显示到屏幕上”。但在绘图过程中,还有一个完全相反的方向:
用户在窗口里点击了一下鼠标,程序拿到的只是一个屏幕像素点,怎么知道这个点对应图纸里的哪个 CAD 坐标?
这就是反向变换要做的事。用户画线时,第一次点击的 QMouseEvent 里只有类似 (120, 510) 这样的屏幕坐标;如果不把它转回 CAD 坐标,RS_ActionDrawLine 就没法记录真正的起点和终点。
4.2、核心代码
RS_GraphicView 提供的反向变换方法叫 toGraph,实现同样在 rs_graphicview.cpp:
RS_VectorRS_GraphicView::toGraph(intx,inty)const{returnRS_Vector(toGraphX(x),toGraphY(y));}doubleRS_GraphicView::toGraphX(intx)const{return(x-offsetX)/factor.x;}doubleRS_GraphicView::toGraphY(inty)const{return-(y-getHeight()+offsetY)/factor.y;}先看 X 方向:
toGraphX(x)=(x-offsetX)/factor.x它正好是 toGuiX 的逆过程:先减去平移量,再除以缩放系数。
4.3、和正向互为逆运算
toGui:乘 factor、加 offset。 toGraph:减 offset、除以 factor。两者正好相反。把正向和反向放在一起对比,规律非常清楚:
正向:屏幕X = CAD_X * factor.x + offsetX 反向:CAD_X = (屏幕X - offsetX) / factor.xY 方向同理,只是多了一个负号翻转:
正向:屏幕Y = -CAD_Y * factor.y + getHeight() - offsetY 反向:CAD_Y = -(屏幕Y - getHeight() + offsetY) / factor.y可以用上一节的小算例验证。已知:
factor.x = 2, factor.y = 2 offsetX = 100, offsetY = 50 getHeight() = 600正向把 CAD (10, 20) 转成了屏幕 (120, 510)。现在把这个屏幕点反向转回去:
toGraphX(120) = (120 - 100) / 2 = 10 toGraphY(510) = -(510 - 600 + 50) / 2 = 20又回到了 CAD (10, 20)。这就是“互为逆运算”的含义。
五、缩放:本质是修改 factor 和 offset
5.1、zoomIn 的实现思路
zoomIn 的核心设计思路主要是:先取缩放中心,若未指定就用鼠标位置getMousePosition()。核心是调用zoomWindow(),用两个经过缩放的角点重新计算视图参数。最后redraw()。
缩放看起来是图形“变大变小”,但从代码层面看,它并没有改动任何一个实体,只是在重新计算 factor 和 offset。rs_graphicview.cpp 里的 zoomIn 核心逻辑是这样的:
voidRS_GraphicView::zoomIn(doublef,constRS_Vector¢er){if(f<1.0e-6){RS_DEBUG->print(RS_Debug::D_WARNING,"RS_GraphicView::zoomIn: invalid factor");return;}RS_Vector c=center;if(!c.valid){// 没有指定缩放中心时,取当前鼠标位置c=getMousePosition();}zoomWindow(toGraph(0,0).scale(c,RS_Vector(1.0/f,1.0/f)),toGraph(getWidth(),getHeight()).scale(c,RS_Vector(1.0/f,1.0/f)));redraw();}可以分成三步理解:
- 先确定缩放中心 c,通常是鼠标位置。
- 把当前视口的两个角点,围绕中心 c 按 1/f 收缩。
- 把收缩后的两个角点交给 zoomWindow,由它反推出新的 factor / offset。
5.2、zoomOut 就是 zoomIn 的倒数
voidRS_GraphicView::zoomOut(doublef,constRS_Vector¢er){...zoomIn(1/f,center);}放大和缩小没有两套独立逻辑。zoomOut 的实现非常短:
voidRS_GraphicView::zoomOut(doublef,constRS_Vector¢er){if(f<1.0e-6){...return;}zoomIn(1/f,center);}缩小 2 倍,本质上就是放大 1/2 倍。这样“放大”和“缩小”就复用了同一套代码。
5.3、zoomWindow:由两个角点反推 factor / offset
关键思路:
zoomX=getWidth()/(v2.x-v1.x)zoomY=getHeight()/(v2.y-v1.y)然后由zoomX / zoomY反推出新的factor和offset,使指定矩形刚好铺满视口。
真正干活的是 zoomWindow。它的作用可以概括为:给定 CAD 空间里的两个角点 v1、v2,调整 factor / offset,让这个矩形区域正好铺满当前窗口。关键计算在 rs_graphicview.cpp:
zoomX=getWidth()/(v2.x-v1.x);zoomY=getHeight()/(v2.y-v1.y);然后根据是否保持比例,决定 factor.x 和 factor.y 的取值,再由角点重新算出 offsetX / offsetY,最后 redraw()。这就是“框选放大”的底层原理:你用鼠标框出的两个角点,最终变成了新的 factor 和 offset。
5.4、zoomAuto:自动适应全部图形
先调用container->calculateBorders()计算所有实体的包围盒。再根据包围盒反推factor / offset,实现“全部显示”。“缩放到全图”对应的是 zoomAuto。它的思路是:
if(container){container->calculateBorders();autoconstdV=container->getMax()-container->getMin();sx=std::max(dV.x,0.);sy=std::max(dV.y,0.);fx=(getWidth()-borderLeft-borderRight)/sx;fy=(getHeight()-borderTop-borderBottom)/sy;if(keepAspectRatio){fx=fy=std::min(fx,fy);}setFactorX(fx);setFactorY(fy);centerOffsetX();centerOffsetY();redraw();}它的计算也很有规律:
- 先让容器计算所有实体的包围盒 calculateBorders()。
- 用包围盒的宽高 sx / sy,除以窗口可用宽高,得到两个方向的缩放系数。
- 保持比例时,取较小的那个系数,保证整个图形不会被裁掉。
- 最后居中并重绘。
所以 zoomAuto 就是“根据图形大小反推视图参数”。
六、平移:只改 offset
6.1、zoomPan 的实现
平移比缩放还要简单。缩放要同时调整 factor 和 offset,而平移只动 offset。rs_graphicview.cpp 里的实现非常短:
voidRS_GraphicView::zoomPan(intdx,intdy){offsetX+=dx;offsetY-=dy;adjustOffsetControls();redraw();}用户按住鼠标拖动时,传入的 dx / dy 就是鼠标移动的距离,zoomPan 把它们累加到 offset 上,然后重绘。
6.2、为什么 offsetY 是减 dx / dy
注意这里有个方向问题:
offsetX+=dx;offsetY-=dy;X 方向是加,Y 方向却是减。原因还是两套坐标系的 Y 轴方向相反:
- 鼠标在屏幕上向下拖,dy 是正数。
- 此时我们希望图纸内容也跟着往下走,也就是 CAD 原点相对屏幕向下移动。
- 但屏幕 Y 轴向下、CAD Y 轴向上,所以要用减法来抵消这个方向差异。
理解不了细节也没关系,记住结论即可:平移仍然只改 offsetX / offsetY,不会改 factor,更不会动任何实体。
6.3、zoomScroll:键盘/滚轮方向
上下左右滚动只改变offsetX / offsetY。除了鼠标拖动,zoomScroll 也属于平移,用于处理上下左右滚动。它同样只是在改 offset:
voidRS_GraphicView::zoomScroll(RS2::Direction direction){switch(direction){caseRS2::Up:offsetY-=50;break;caseRS2::Down:offsetY+=50;break;caseRS2::Right:offsetX+=50;break;caseRS2::Left:offsetX-=50;break;...}redraw();}可以看到,每次滚动就是让对应方向的 offset 变化 50 个像素。
6.4、平移同样不动图形数据
平移只改变视图参数,不改变实体坐标。把“缩放”和“平移”放到一起,就能得到一个统一的结论:
| 操作 | 改动的变量 | 是否修改实体 |
|---|---|---|
| 缩放 | zoomIn / zoomOut factor + offset | 否 |
| 平移 | zoomPan / zoomScroll 只改 offset | 否 |
无论是缩放还是平移,最终都只是:
修改视图参数 factor / offset -> 调用 redraw() -> 用新的参数重新执行 toGui ->重新绘制
图形数据始终不变。这就是 LibreCAD 里“数据与视图分离”最直观的体现:实体负责保存几何信息,视图负责决定以什么比例、什么位置把它显示出来。
七、把两条链路完整串起来
7.1、输入链路:屏幕 → 图形
画线时,程序真正走的是这样一条链路:
用户点击鼠标 -> Qt 生成 QMouseEvent(屏幕像素坐标)-> EventHandler 分发给当前 Action -> snapPoint() 获取绘图点 -> toGraph 反向变换 -> 得到 RS_Vector 的 CAD 坐标 -> RS_ActionDrawLine 记录起点 / 终点 -> new RS_Line(…)
这条链路解决的是:用户点在哪里,图纸上的哪个坐标被选中了。
7.2、输出链路:图形 → 屏幕
画完线后,程序要把这条线显示出来,走的是另一条完全相反的链路:
RS_Line(保存 CAD 坐标)-> GraphicView 触发刷新 -> 遍历 Graphic 中的实体 -> 调用 RS_Line::draw() -> view->toGui 正向变换 -> 得到屏幕像素坐标 -> RS_PainterQt::drawLine() -> QPainter 最终输出
这条链路解决的是:图纸上的这条线,应该在窗口的哪些像素上出现。
7.3、关键代码佐证
在 rs_line.cpp 里,RS_Line::draw 就把“正向变换”这一步写得很清楚:
voidRS_Line::draw(RS_Painter*painter,RS_GraphicView*view,double&patternOffset){...RS_Vector pStart{view->toGui(endPoints.at(0))};RS_Vector pEnd{view->toGui(endPoints.at(1))};...painter->drawLine(pStart,pEnd);}注意这里的两个动作:
- view->toGui(…):把 CAD 端点变成屏幕坐标。
- painter->drawLine(pStart, pEnd):把屏幕坐标交给绘图系统输出。
7.4、核心认知
串完整条链路后,可以得出本篇最重要的一句话:RS_Line 保存的 CAD 坐标从头到尾都没有变;缩放、平移改变的只是 RS_GraphicView 里的 factor 和 offset。
这正是 LibreCAD 架构优雅的地方:
- 实体负责“是什么”:RS_Line 保存两个端点。
- 容器负责“属于谁”:Graphic 管理这些实体。
- 视图负责“在哪里、何时画”:RS_GraphicView 保存视图参数并决定刷新。
- 绘图接口负责“怎么画”:RS_Painter / QPainter 完成像素输出。
四个层次各司其职,所以无论怎么缩放、平移,甚至切换打印预览,底层的图形数据都保持稳定,只是视图参数不同而已。
总结
本篇围绕 RS_GraphicView,补上了 LibreCAD 绘图链路里“坐标变换”这一环。从表面上看,用户只是点击鼠标、看到一条线画出来;但从源码看,中间始终绕不开两套坐标系之间的双向转换。
可以提炼成几个要点:
- LibreCAD 中存在两套坐标:CAD 世界坐标用 double 表示、Y 轴向上;屏幕坐标用像素表示、Y 轴向下。
- RS_GraphicView 只靠三个变量就定义了整个视图:factor、offsetX、offsetY。
- toGui 负责把 CAD 坐标变成屏幕坐标,核心公式是 x * factor + offset,Y 方向还要翻转。
- toGraph 负责把鼠标点击的屏幕坐标变回 CAD 坐标,是 toGui 的逆运算。
- 缩放同时修改 factor 和 offset,平移只修改 offset,两者都不会改动任何实体。
- 整个过程中,RS_Line 保存的几何数据始终不变,变的只是视图参数,最后通过 redraw() 重新渲染。
所以整条链路可以概括为:
鼠标输入 -> toGraph 转成 CAD 坐标 -> Action 创建实体 -> Graphic 保存实体 -> GraphicView 刷新 -> toGui 转成屏幕坐标 -> RS_Painter/QPainter 输出像素
理解这一层之后,LibreCAD 的“数据与视图分离”就不再是一句空话,而是体现在 factor、offset 和 toGui / toGraph 这几个具体实现里。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど