新闻详情

新闻详情

首页 / 资讯中心 / 详情

从零写一个CAD 02:实体容器、Esc取消与键盘失灵的排查

发布时间:2026/9/30 13:48:54来源:尧图网络
从零写一个CAD 02:实体容器、Esc取消与键盘失灵的排查
第一篇结束时窗口里能画出一条线代码大概长这样一个QMainWindow子类重写mousePressEvent和mouseReleaseEvent在按下时记一个起点在松开时把线段塞进一个self.lines列表然后在paintEvent里遍历这个列表画出来。能跑但只能跑一次。只要你想加第二个工具、想撤销、想保存文件就会发现所有东西都黏在窗口类里。这一篇就处理三件事把图形数据从窗口里搬出来、让 Esc 能取消正在进行的操作、以及搞清楚为什么键盘事件有时候压根收不到。先把图形数据从窗口类里搬出来我一开始的想法很简单self.lines换成self.entities名字好听点。但真正写下去会发现问题不在名字在于窗口类同时承担了三种职责接收输入、维护数据、负责渲染。这三件事的变更频率完全不同混在一起之后每加一个功能都要动同一个文件。所以第一步是把实体这个概念单独定义出来。CAD 里的基本图元无非是点、线、圆、圆弧它们共享一些属性颜色、图层、是否选中又有各自不同的几何参数。用继承是最自然的写法# entities.py# Python 3.11, 无第三方依赖fromdataclassesimportdataclass,fieldfromtypingimportLiteraldataclassclassEntity:所有图元的基类只放通用属性color:str#000000layer:str0selected:boolFalsedefbounds(self)-tuple[float,float,float,float]:返回 (minx, miny, maxx, maxy)用于后续的框选和重绘优化raiseNotImplementedErrordataclassclassLine(Entity):x1:float0.0y1:float0.0x2:float0.0y2:float0.0defbounds(self):return(min(self.x1,self.x2),min(self.y1,self.y2),max(self.x1,self.x2),max(self.y1,self.y2))这里用dataclass是因为图元本质上就是一堆数据的组合不需要复杂的行为。bounds()现在看起来没什么用但等你做框选、做视图缩放、做脏矩形重绘的时候它会反复出现早点定下来比后面补要省事。Entity用dataclass装饰时有个细节子类Line的字段必须全部带默认值否则会因为非默认参数跟在默认参数后面直接报TypeError。这是 Python dataclass 的继承规则不是 bug。接下来是容器。容器要解决的核心问题是谁持有实体列表谁负责通知界面刷新。我的做法是让容器只做数据管理通过一个简单的信号机制通知外部# document.pyfromentitiesimportEntityclassDocument:def__init__(self):self._entities:list[Entity][]self._listeners:list[]defadd(self,entity:Entity)-None:self._entities.append(entity)self._notify()defremove(self,entity:Entity)-None:# 用 identity 比较而不是 因为 dataclass 默认按值相等fori,einenumerate(self._entities):ifeisentity:delself._entities[i]self._notify()returndefall(self)-tuple[Entity,...]:returntuple(self._entities)defsubscribe(self,fn)-None:self._listeners.append(fn)def_notify(self)-None:forfninself._listeners:fn()【踩坑提醒】remove这里我用的是e is entity而不是e entity。因为dataclass默认生成__eq__两条坐标完全相同的线在下是相等的。如果你用去删删掉的可能不是你点的那个对象——在撤销、图层删除这些场景下这个区别会变成很难查的 bug。现在窗口类只负责把鼠标事件翻译成对 Document 的调用# canvas.pyfromPySide6.QtWidgetsimportQWidgetfromPySide6.QtGuiimportQPainter,QPenfromPySide6.QtCoreimportQt,QPointFfromdocumentimportDocumentfromentitiesimportLineclassCanvas(QWidget):def__init__(self,doc:Document,parentNone):super().__init__(parent)self.docdoc self.doc.subscribe(self.update)# 数据一变就重绘self._start:QPointF|NoneNoneself._preview:QPointF|NoneNoneself.setMouseTracking(True)self.setFocusPolicy(Qt.StrongFocus)# 后面讲键盘要用到defmousePressEvent(self,ev):ifev.button()Qt.LeftButton:self._startev.position()self._previewself._startdefmouseMoveEvent(self,ev):ifself._startisnotNone:self._previewev.position()self.update()defmouseReleaseEvent(self,ev):ifev.button()Qt.LeftButtonandself._startisnotNone:self.doc.add(Line(x1self._start.x(),y1self._start.y(),x2ev.position().x(),y2ev.position().y()))self._startNoneself._previewNonedefpaintEvent(self,ev):pQPainter(self)p.setRenderHint(QPainter.Antialiasing)penQPen(Qt.black,1)p.setPen(pen)foreinself.doc.all():ifisinstance(e,Line):p.drawLine(QPointF(e.x1,e.y1),QPointF(e.x2,e.y2))# 拖动过程中的橡皮筋预览ifself._startisnotNoneandself._previewisnotNone:p.setPen(QPen(Qt.gray,1,Qt.DashLine))p.drawLine(self._start,self._preview)这段代码里self.doc.subscribe(self.update)是关键。QWidget.update()本身是槽函数可以被当普通可调用对象传递数据一变就排队一次重绘。这样做的好处是以后不管是谁改了 Document比如从文件加载、比如撤销界面都会自动跟上不需要在每个修改点手动调用update()。这个模式有时候叫观察者有时候叫信号名字不重要重要的是修改数据和刷新界面这两件事被解耦了。Esc 取消为什么不能直接删掉正在画的线有了上面的结构Esc 取消看起来很简单按下 Esc把self._start置空重绘一次就完事了。因为预览线根本没进 Document所以不需要删除。但等一下。现在只有画线一个工具等你有画圆、画多段线、移动对象、选择对象这些命令之后当前正在进行的操作就变成一个状态了。如果每个工具都在 Canvas 里塞几个self._xxx字段代码会迅速失控。我的做法是引入一个最小可用的命令状态机。不引入任何框架就是普通类# commands.pyfromabcimportABC,abstractmethodclassCommand(ABC):一个命令的生命周期start - (可选)update - commit 或 cancelabstractmethoddefstart(self,pos,canvas):...abstractmethoddefupdate(self,pos,canvas):...abstractmethoddefcommit(self,pos,canvas):...defcancel(self,canvas):canvas.previewNone画线命令# commands.py 续fromentitiesimportLineclassLineCommand(Command):def__init__(self):self.p1Nonedefstart(self,pos,canvas):self.p1posdefupdate(self,pos,canvas):canvas.preview(self.p1,pos)defcommit(self,pos,canvas):ifself.p1isNone:return# 过滤掉点击式误触起终点几乎重合dx,dypos.x()-self.p1.x(),pos.y()-self.p1.y()ifdx*dxdy*dy4.0:canvas.doc.add(Line(x1self.p1.x(),y1self.p1.y(),x2pos.x(),y2pos.y()))canvas.previewNoneself.p1Nonedefcancel(self,canvas):canvas.previewNoneself.p1NoneCanvas 只保留一个self.active: Command | None# canvas.py 修改片段defmousePressEvent(self,ev):ifev.button()Qt.LeftButton:ifself.activeisNone:self.activeLineCommand()self.active.start(ev.position(),self)else:self.active.commit(ev.position(),self)self.activeNonedefmouseMoveEvent(self,ev):ifself.activeisnotNone:self.active.update(ev.position(),self)self.update()defkeyPressEvent(self,ev):ifev.key()Qt.Key_Escapeandself.activeisnotNone:self.active.cancel(self)self.activeNoneself.update()returnsuper().keyPressEvent(ev)注意这里我把交互改成了点一下定起点、再点一下定终点的两点式而不是按住拖动。原因很实际CAD 里绝大多数命令都是这种点击流拖动式在需要连续绘制时反而别扭。这是设计选择不是对错问题。【关键结论】Esc 取消真正要做的不是删除已画的东西而是清掉当前命令的中间状态并重绘。因为中间状态预览线从来没有进入 Document所以不存在数据一致性问题。这个思路在后面的移动、复制、旋转命令里同样适用。键盘按了没反应焦点问题上面keyPressEvent写完很多人会立刻发现按 Esc 毫无反应。这不是代码逻辑问题是 Qt 的事件分发机制。键盘事件只会发给当前拥有焦点的 widget。QWidget默认的focusPolicy是Qt.NoFocus也就是说它根本不会主动获取焦点键盘事件自然不会落到它身上。我在 Canvas 构造函数里加的self.setFocusPolicy(Qt.StrongFocus)就是为了解决这个。StrongFocus的含义是既能通过 Tab 键获得焦点也能通过鼠标点击获得焦点。但光设置这个还不够。有两种情况仍然会失效窗口启动后用户没有点击过画布焦点可能在别的控件上比如工具栏的输入框。焦点被某个子控件抢走了。对于第一种可以在窗口显示后主动调用canvas.setFocus()。对于第二种需要理解 Qt 的焦点链。还有一个更隐蔽的坑如果你在 Canvas 上放了子 widget比如浮动输入框键盘事件会先给子 widget。子 widget 如果不处理事件会冒泡到父 widget——但前提是子 widget 没有accept()它。QLineEdit会接受所有按键所以焦点在输入框上时Esc 是不会传到画布的。我处理这个的方式是在输入框上单独装事件过滤器或者干脆在输入框里也处理 EscclassCommandInput(QLineEdit):defkeyPressEvent(self,ev):ifev.key()Qt.Key_Escape:self.clearFocus()# 焦点还给画布self.parent().setFocus()ev.accept()returnsuper().keyPressEvent(ev)【注意】setFocusPolicy和setFocus()是两件事。前者决定能不能获得焦点后者是现在就把焦点给它。只写前者不写后者程序启动时焦点仍然不在画布上。键盘事件的另一类问题修饰键Esc 通了之后我接着想加个按住 Shift 画正交线。写的时候踩到过一个细节ev.key()和ev.modifiers()是分开的。defkeyPressEvent(self,ev):ifev.key()Qt.Key_Escape:...# 判断 Shift 不要用 key()要用 modifiers()ifev.modifiers()Qt.ShiftModifier:...原因很直接按下 Shift 这个动作本身会触发一次keyPressEvent其key()是Qt.Key_Shift但当你按住 Shift 再按 A 时A 的那次事件里key()是Qt.Key_Amodifiers()里才带Qt.ShiftModifier。如果你用key()判断修饰键逻辑会时对时错。正交约束本身的实现不复杂在LineCommand.update里判断一下就行defupdate(self,pos,canvas):ifself.p1isNone:returnifcanvas.ortho:# 由 Shift 或 F8 切换dxpos.x()-self.p1.x()dypos.y()-self.p1.y()# 取绝对值大的方向锁定ifabs(dx)abs(dy):posQPointF(pos.x(),self.p1.y())else:posQPointF(self.p1.x(),pos.y())canvas.preview(self.p1,pos)数据流梳理到这里一次完整的画线交互涉及的对象和调用关系大致是Canvas 接收鼠标/键盘事件事件被翻译成对当前 Command 的 start/update/commit/cancel 调用Command 在 commit 时向 Document 添加 EntityDocument 通知所有订阅者目前只有 Canvas.updateCanvas 在 paintEvent 里遍历 Document 渲染每一层只做一件事改动的影响范围就清楚了。想加撤销在 Document 里加一个操作历史栈commit 时入栈。想加图层Entity 已经有 layer 字段Document 里按 layer 过滤渲染即可。想加选择在 Command 体系里加一个 SelectCommand它不改 Document 的实体只改实体的 selected 字段。关于版本和依赖这篇文章的代码在 Python 3.11 PySide6 上验证过。PySide6 的版本我这边是 6.6.x但上面用到的 APIQPainter.setRenderHint、ev.position()、Qt.StrongFocus、keyPressEvent都是长期稳定的接口从 PySide6 6.0 开始就应该没问题。ev.position()返回QPointFev.pos()返回QPoint——PySide6 里两者都存在用position()更精确因为它带浮点精度。有一点我没有验证在 macOS 上某些系统级快捷键比如 CmdTab会先被系统吃掉程序收不到。这在跨平台 CAD 里是个需要单独处理的问题我目前没在 macOS 上实测过。下一步实体容器、命令状态机、焦点处理这三块属于基础设施写起来不显眼但后面每一个功能都会用到。下一步我打算加选择集和撤销栈——选择集需要处理点击命中检测撤销栈需要处理命令的逆操作两者都会反过来考验现在的 Entity 和 Command 设计是否站得住。如果那时候需要大改说明现在的抽象粒度有问题反倒是一件好事。备用标题Python CAD 开发第二篇实体容器、命令状态机与 Esc 取消实现PySide6 画 CAD 时键盘收不到事件从焦点机制讲起从零写 CAD把图形数据从窗口类里拆出来之后CAD 交互基础命令状态机怎么处理 Esc 取消和正交约束PySide6 焦点与键盘事件为什么 Esc 按了没反应
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

老的宣传稿和新闻稿,能不能直接拿来做GEO内容? 2026/9/30 14:35:34

老的宣传稿和新闻稿,能不能直接拿来做GEO内容?

老的宣传稿和新闻稿,能不能直接拿来做GEO内容?企业手里一般都压着不少存货:产品宣传稿、发布会新闻稿、领导专访、获奖通稿。做 GEO 要大量内容,很多人第一反应是把这些旧稿直接发一轮。能不能用?能用,但直…

阅读更多 →
微信小程序模板(template)详解:复用UI结构 2026/9/30 14:35:27

微信小程序模板(template)详解:复用UI结构

前言 在微信小程序开发中,当多个页面或同一页面内有重复的UI结构时,可以使用模板(template)来提高代码复用性。本文通过一个案例,演示模板的定义和使用方法。一、案例描述 设计一个小程序,演示模板的定义和…

阅读更多 →
零基础嵌入式学习第三周 2026/9/30 14:35:06

零基础嵌入式学习第三周

嵌入式基础作业三:STM32F103C8T6 三种方式实现流水灯 课程作业范围:实验1(寄存器方式)、实验2(标准外设库方式及 Keil 逻辑分析仪)、实验3(HAL 库 按键外部中断暂停/恢复)。 目标器…

阅读更多 →
协同设计软件支持远程办公,云端存储和实时协作功能是怎样的? 2026/9/30 14:34:34

协同设计软件支持远程办公,云端存储和实时协作功能是怎样的?

导语:远程办公、异地协作越来越普遍,协同设计软件如何支持?本文讲清协同设计软件的云端存储、实时协作及远程办公能力。 一、远程办公对协同的新要求 远程办公打破了人在办公室才能协同的限制,对协同设计提出新要求:随…

阅读更多 →
Codex 直接驱动 DeepDraw 建模:一句话创建模型,再用一句话修改它 2026/9/30 14:34:34

Codex 直接驱动 DeepDraw 建模:一句话创建模型,再用一句话修改它

系列文章 高级篇 【教程】下载DeepDraw AI建模引擎并安装在Codex中使用 给AI做了一套视觉工具,找出场景里全部植物,还能整理导出模型库 Astra感觉成精了,居然在通过模型的顶点推算门框轮廓线,然后居然还对准了 看我手把手教出来的…

阅读更多 →
【避坑总结】用 AI 写毕业论文,这 5 个大坑千万别踩|Paperxie 一站式平台使用心得 2026/9/30 14:33:29

【避坑总结】用 AI 写毕业论文,这 5 个大坑千万别踩|Paperxie 一站式平台使用心得

前言 现在越来越多同学会借助 AI 工具辅助完成毕业论文,但是很多人在使用过程中踩了不少坑,轻则反复返工,重则影响论文送审。 很多同学盲目使用 AI,直接复制生成的全文,或者多个工具混用,文稿来回上传&…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉