Qt中利用GraphicsView实现可部分擦除的Item的思路解析

Posted on 2024-09-03 16:33  Aderversa  阅读(85)  评论(0编辑  收藏  举报

可部分擦除Item的抽象概念解析

数学意义上来说,一条线其实并没有宽度的概念,它是由无数的点连接而成的。而面积,是由无数的线所构成的。

在现实中却并非如此,无论你用什么笔,点都会具有面积。这就意味着我们的线也有面积。这就意味着,我们的白板上的笔所留下来的痕迹也需要“模拟”这一特性。

比如说,我们画矩形的时候,矩形的边并非是由一条线组成,而是另一些有着面积的矩形构成的,只不过这个矩形的宽非常窄。就像下面这样:

只是我平常没有注意到,下意识地认为自己所连的一条线,真的就是数学意义上的线。因此,我们使用Qt中有关连线的接口,所画出来的线Line,也不是我们所想要的“线”。我们所想要的线,应该是Qt所称的填充区域的东西!

我们所要完成的笔画,其实就是一种填充区域,这个填充区域模仿了现实中的笔画。比如,我们想要在程序中画一条直线,它并不是一条直线,而是经过看起来像是直线的填充区域,就像下面这张图所示:

因为其宽度足够小,所以人在感官上就觉得这是一条直线,但实际上不是,它是一个有面积的填充区域。

这样抽象的好处是什么?

假如,我们将Qt中所提供的线,当做我们所想要模拟的现实中的“线”,情况怎么样?

Qt中的线,完全是按照数学中的定义来进行处理的。

这样做的话,假如我们有一条Qt中的线line,和一个橡皮擦所覆盖的区域S。我们规定:与橡皮擦S碰撞的所有线的部分,都必须被擦除。

line与S确实发生了碰撞,然后我们试图使用S(用QPainterPath表示)的intersected(line)来获取碰撞的区域,你什么也不会得到,因为Qt中碰撞产生的基础是碰撞的双方必须具有面积,而line没有面积,因此它不会和任何东西产生碰撞!这就与我们现实中的抽象产生了冲突,在我们的认知中line和S它们确实发生了碰撞。

这个问题产生的根本原因就在于,数学意义上的“线”它没有面积,它本不应该有任何现实意义的呈现方式。但是Qt为了表现出线是一条线,它在渲染的时候给线添加了宽度,让我们擅自认为Qt所渲染的线,其实和现实中的线一样是有宽度的,但其实并非如此。

Qt中渲染的线的宽度并非是线本身的概念,而是渲染它的QPen所赋予的。

可部分擦除的Item的行为逻辑抽象

正是因为Qt中的线无法模拟现实中的线,所以我们必须要自定义自己的线用来模拟现实中的笔所画的线。

以矩形为例,我们的矩形,并不是由Qt中的线概念形成的矩形,也就是QRect并不能表示我们想要的矩形(QRect认为由矩形围起来的区域都是矩形本身)。

我们的矩形仅包括模拟的线的填充区域本身。也就是这张图的红色区域。

这两者是完全不同的概念,举个例子:对于QRect来说,S(橡皮擦)只要和矩形内部有相交区域,那么就可以被当做出现了碰撞;然而,对于我们的矩形来说,S可以和红色框内部的区域相交,且不认为这是碰撞。这对我们简化了我们处理橡皮擦碰撞的处理逻辑,你不用再担心橡皮擦有没有真正碰撞到矩形的边框了(这个边框的宽度会随着QPen而变动,且无法通过Qt中给定的碰撞接口来获取)。

那么我们的矩形在遇到擦除的时候该怎么处理呢?假设矩形名为myRect(用QPainterPath表示),橡皮擦区域为为S(用QPainterPath表示),你就可以直接调用
remain = myRect.subtracted(S);就可以将剩余的矩形轨迹获取出来存放到remain中。剩余的矩形区域可能是什么样的呢?可以是这样的:

擦除之后就不再是矩形了,那用什么表示呢?

我想出了这样一种抽象:

其实不论是矩形,还是圆形,都是使用笔所画出来的填充区域,擦除之后依旧可以认为是一个笔画。而矩形只是使用特殊的笔画顺序所生成的一个整体而已。同时,这个整体在初始化时因碰撞而连接,在初始化完成之后不会因为碰撞而连接(这个初始化阶段类比于我们下笔到笔离开纸的过程),当我们再次下笔,那么新形成的笔画不应该和原来在纸上的笔画有任何形式的联系。

同时,笔画在形成后不会因新笔画的碰撞而连接,但却有可能因为与橡皮擦碰撞而分离。橡皮擦的碰撞不一定导致笔画的分离,比如:

但有的时候,擦除会使得一个笔画分离,比如:

这样的情况下,一个笔画就被分成了两个笔画,我们应该将这原本是一个笔画的部分分成两个笔画。这样做的技术难度也不大,就是使用QPainterPath::toFillPolygons()将返回的填充区域都封装成一个新笔画,然后自己再消失就行了。这样笔画之间的关系就被分离,每个笔画可以被单独移动、擦除。

UML类图设计参考

最终,我设计出了如下的UML类图结构,用来抽象表示Item层次:

下面我来详细解释一下各个类对象的功能和作用分别是什么。

BaseGraphicsItem

BaseGraphicsItem继承了QGraphicsItem类,并实现了ItemShaper接口。

QGraphicsItem我们都很熟悉,但问题是ItemShaper是什么呢?顾名思义,就是用来塑造BaseGraphicsItem笔画形状的一个接口。这个接口存在的目的在于:让使用该接口的对象能获取我们希望他遵循的数据信息,比如:笔画的宽度,以及设置笔画形状的方法setStrokePath();同时,我们提供了一些他可能会使用到的、用于辅助其开发的lineToStroke()方法。

这就把收集坐标信息,并通过特定算法形成形状的任务交给了通过ItemShaper引用BaseGraphicsItem的类对象。BaseGraphicsItem只需要关注如何处理笔画本身,而不是把所有的坐标处理、形成形状的算法都集成在BaseGraphicsItem中。

ControlGroupObserver

ControlGroupObserver类对象负责在ItemShaper给定的约束条件下,利用ControlPointGroup形成的矩形信息,通过一些算法处理,形成一个特定的形状。这个特定形状的构成由不同的子类来实现,比如ControlRectangleObserver复杂将收到的rect信息形成一个rect填充区域并将该区域对ItemShaper进行塑造。

这个塑造的过程直到ControlPointGroup发出destroySignal()结束,这时的ControlGroupObserver的使命也结束了,需要发送destroySigal()向外界请求删除自己。

ControlPointGroup

ControlPointGroup类对象搭建了一套控制点,通过该控制点形成一个矩形形状rect,并且将rect通过信号的方式实时同步修改。

ControlPointGroup并不需要管接收该信号的对象使用rect的信息用来干什么,它只需要关注如何形成一套合理的控制点移动逻辑,让rect可以正确地形成。


EightWayMovementGroup代表的是我们可以通过继承ControlPointGroup实现不同移动逻辑的控制点组,因为不同的Item需要不同的控制点组来进行形状的控制。如果你给ControlSquareObserver(假设有)一个矩形框,但它必须塑造一个正方形,它使用矩形坐标(如果你希望那确实可以)形成正方形,但是最终BaseGraphicsItem显示出来的正方形大概率会和控制点的位置不一致。因此,单纯地拥有正方形的形成算法是不够的,你必须有一个特殊的控制点移动逻辑才能做到BaseGraphicsItem形状与控制点位置对应的效果。

只要我们规定好了控制点形成的形状每时每刻都是正方形,那么就可以直接利用ControlRectangleItem来形成正方形。

我们要明确的点就是BaseGraphicsItem的形状不是通过ControlGroupObserver来形成的,它塑造BaseGraphicsItem的原始信息来自ControlPointGroup,想要规定好BaseGraphicsItem的形状,最终需要以ControlPointGroup为抓手。


在我一开始实现控制点组控制矩形Item的形状绘画的时候,我将控制点组集成到了矩形Item中,我发现这样子做非常不合理,比如:如果SquareItem继承了矩形Item,那么矩形Item的控制点组的移动逻辑其实不适用于SquareItem。但矩形Item的控制点移动逻辑却和EllipseItem是相同的。因此,我意识到,控制点的移动逻辑不应该集成到Item中,它应该是一个可插拔的对象。

ControlPointGroup父项或者子项都失去焦点时,它就会发出destroySignal()通知外界删除自己。

这里仅当我提出一种解决方案,读者如果更好的看法可以自己设计一套更符合自己所面临问题的设计。总之记住一点,不论什么设计都需要为自己的面临的实际问题服务,而非照搬别人的设计(马克思主义也不是直接照搬就用,而是需要适应中国时代而产生变化。)。

详细类图

在通过编码完成上述UML类之后,对一些细节进行补充:

解决方案探索过程

  1. 利用Qt中的线来表示白板中的线。
  2. 试图制造有宽度的点对象,将多个点Item组合形成一个Item。即,我们将笔划切割成一个极小的组成部分,以实现精确擦除功能,但是这样做的时候绘图的逻辑复杂,且处理的计算复杂度高。并且这个极小的组成部分的管理逻辑不好把控。用点组成线的粒度不好把控,按照数学逻辑来说需要定义无数个点才能形成一条线,并且点和点之间可能会存在渲染冲突(特别是渲染激光笔的时候的冲突,外围的阴影效果非常不好实现)。
  3. 将笔画抽象成填充区域,利用Qt提供的处理填充区域的接口实现精确擦除功能,让可以将单个笔画看做整体来处理(而不是由极多的小部分组成的),降低了计算的复杂度。但绘图逻辑仍然需要设计,但相较于第2种,复杂度是降低了的。

我们还考虑到撤销操作的实现(Memento设计模式):

  • 对于第2种来说,我们可以记录多出来什么item,少了什么item都可以当做是Scene/Item状态改变的一部分,通过管理这些状态可以实现撤销操作。但是由于原子粒度是一个对象,它本身占据空间来组成一个大Item,所以必然需要巨量的这些Item,这即需要存储的控件,又需要处理的时间。

  • 对于第3中来说,我们也是通过记录Scene中Item的状态改变。

    • 在空间方面,我们利用Qt的接口处理不同的填充区域,如果我们添加一个笔画,那就只是添加了一个填充区域Item,而并非由非常多的Item组成的一条笔画;而如果我们擦除笔划,那么造成的笔画分离也不会产出太多了填充区域,这对空间是极大的减负。
    • 而在处理速度方面,一方面我们处理一个笔画不再需要处理笔画里大量的点Item,只是进行填充区域间的运算;另一方面,由于单个笔画Item对象得到了简化,所以处理速度得到加快

从原理上来说,第2种方案和第3种方案实现撤销的原理是一样的。但第2种方案由于要处理笔画Item本身的复杂性导致其时间和空间复杂度都高,而第3种方案通过优化笔画的实现,减小了时间和空间的复杂度。

经验教训总结

一切都是需求分析错误导致的灾难

在我为了实现画曲线效果的时候,我确实详细学习过QPainterPath的每一个接口的功能。

在我实验QPainterPath的时候,我发现它规定只有填充区域之间才能够进行一系列的相交等操作,它无法对直线与直线之间,直线与填充区域之间进行相交操作。于是我认为,无法通过QPainterPath的接口完成擦除功能,因为它没有办法检测线与擦除区域之间的相交。

后来,我实践了第二种方法,发现这样完成的笔画线及其复杂,时间和空间复杂度都极其高。于是,从性能角度来看,我认为这种方式实现可擦除的笔画Item不可取。

这对我打击很大,花了2、3天找出的解决方案没有得到应有的成果,我暂时没有想到第3种方案,但提出第2中方法的改进方案:

  1. 通过缩小点Item的粒度来减少性能损耗。
  2. 通过优化算法来优化性能。

我考虑到画曲线的优化可能会运用到贝赛尔曲线去优化算法,所以用QPainterPath去看看怎样运用贝塞尔曲线。

机缘巧合之下,我重新试了试用直线和填充区域相交的实验,发现依然行不通,只有填充区域之间才能相交。那时的我已经通过实验第2种解决方案明白了一件事:用来组成线的点存在宽度,那么这条线肯定也是有宽度的!线具有填充区域!但实际情况确是:线和填充区域之间却不能够相交,这是为什么?

而探索这个问题的答案,得出的结论就是上面的第3种解决方案。

发现一个细小的差别,然后探索原因,最终得出一个答案:因为Qt的线是数学意义上的线,无法与任何填充区域相交,而我理想中的线却有与填充区域相交的能力,因此我理想中的线并非数学意义上的线!一开始没有意识到这一点是因为我认为白板软件想要的线是一种数学意义上的线,但实际上它不是一种数学意义上的线!

归根接地,是现实世界到软件世界的概念映射没有真正意义上的完成,所以导致一开始没有思路。后面通过实践发现,白板软件的线跟数学意义上的线是有差别的,但被Qt渲染的时候掩盖了(能画出来说明有面积,但Qt的底层处理中线并没有面积,它的呈现状态和它底层的情况有差别!),让我忽略了一个点:数学上的线不应该有面积,因此又误导我认为Qt中的线有面积(但实际上Qt中的线没有面积)。

总结一开始没有想到好的解决方案的原因:

  1. 认为白板软件中线没有面积(需求分析错误)。
  2. Qt中渲染的线状态和实际底层处理线的逻辑存在矛盾,但我没有深入研究“线不能和填充区域相交”这样现象的原因。这里其实是需求分析不够彻底甚至是错误导致的,因为没有对白板软件的线进行明确的定义和需求分析,所以不知道白板软件想要的线是什么就擅自任务它是数学意义上的线,而Qt中对线不能与填充区域相交是符合数学定义的,于是我没有认为该现象错误(不符合需求),因为我没有意识到自己认知的线和白板软件需要线是完全不同的两个东西。

项目我正在逐步搭建,后续你可以在这里面看到我对本思路的实现:白板的GitHub代码。但就目前而言(2024-09-07),还没有实现到擦除功能。

Copyright © 2024 Aderversa
Powered by .NET 9.0 on Kubernetes