从零写一个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 按了没反应

相关新闻

计算机组成原理第5章:CPU微操作、控制器、流水线与中断习题解析

计算机组成原理第5章:CPU微操作、控制器、流水线与中断习题解析

第5章是《计算机组成原理》整门课里第一道真正的分水岭。前面几章讲数据怎么表示、存储怎么组织、指令系统长什么样,作业题大多是套公式、画个图就能过去;到了第5章,题目开始让你写微操作序列、画数据通路、设计控制器、算流水线效率&#xf…

2026/10/1 14:17:41 阅读更多 →
基于卷积神经网络的鲜茶叶分选:从数据集构建到产线部署的工程实践

基于卷积神经网络的鲜茶叶分选:从数据集构建到产线部署的工程实践

简介:这份PDF文献面向从事茶叶加工、智能装备研发及计算机视觉应用的研究人员与工程技术人员,针对机采鲜茶叶中风选、筛选难以精确细分等级的问题,提出结合计算机视觉与深度学习的智能分选方案。资源包内仅含1个PDF文件,大小约2.3…

2026/9/30 13:49:08 阅读更多 →
Redis原生AI能力实战:向量检索、MCP协议与agent-skills编排

Redis原生AI能力实战:向量检索、MCP协议与agent-skills编排

1. 项目概述:Redis 已正式接入 AI —— 这不是营销话术,而是架构级融合的实操落地“Redis 已正式接入 AI!”——看到这个标题,你第一反应可能是:又一个蹭热点的标题党?AI 和 Redis 一个跑在 GPU 上&#xf…

2026/10/1 14:28:20 阅读更多 →

最新新闻

Python3数据类型转换避坑指南:字符串拼接、Decimal精度与pandas批量转换实战

Python3数据类型转换避坑指南:字符串拼接、Decimal精度与pandas批量转换实战

先讲一个我实际踩过的坑。某次项目里从数据库读出一批订单金额,代码里直接用total fee计算合计数,结果数据全部变成了字符串拼接,比如"199" "1" "1991",不是 200。查了半天才发现,数…

2026/10/1 19:03:58 阅读更多 →
WorkBuddy接入自定义MCP连接器:SSE长连接实战与排查指南

WorkBuddy接入自定义MCP连接器:SSE长连接实战与排查指南

1. 为什么要在 WorkBuddy 里接一个自定义 MCP 连接器WorkBuddy 这类 AI 工作台用久了,你会发现一个很现实的问题:内置能力再全,也覆盖不了你手头那些"私有工具链"。比如团队内部的设计素材库、自研的图片生成服务、某个只在公司内网…

2026/10/1 19:03:58 阅读更多 →
[光学原理与应用-651]:低频电磁波走电路介质,超高频电磁波走光学介质,所谓光电差异,只是频率跨越了多个数量级、换了一套传输介质,底层物理体系完全统一。

[光学原理与应用-651]:低频电磁波走电路介质,超高频电磁波走光学介质,所谓光电差异,只是频率跨越了多个数量级、换了一套传输介质,底层物理体系完全统一。

详解:低频电磁波走电路介质,超高频电磁波走光学介质核心观点:电信号与光信号都属于电磁波。二者之间的光电差异,本质并不是两套完全不一样的物理,主要是频率跨越十几个数量级,传输与调控介质发生切换&#…

2026/10/1 19:03:58 阅读更多 →
杭州前端工程师如何度过职业发展的瓶颈期?

杭州前端工程师如何度过职业发展的瓶颈期?

对于大多数Web前端工程师来说, 职业瓶颈这个问题几乎是每个人都会碰到的。但是每个人具体的情况不一样, 所以这个瓶颈会出现在不同的工作时期和具体的时间点上。针对这种情况, 比较推荐的处理办法就是主动离开自己那个习惯的区域, 打破原有的思考方式, 并且从提高自身的业务水平…

2026/10/1 19:03:58 阅读更多 →
Agent记忆系统实战:从存储选型到混合检索与安全防护

Agent记忆系统实战:从存储选型到混合检索与安全防护

做Agent开发的人,几乎都会在某个阶段被同一个问题卡住:系统越做越像一个“对话接口”,而不是一个有记忆、能成长的个体。用户上一轮刚说过“我现在搬到上海了”,下一轮问“我上次说的地址你记得吗”,Agent只能沉默——…

2026/10/1 19:03:58 阅读更多 →
Agent上生产:系统接入才是拦路虎,MCP与适配层实战复盘

Agent上生产:系统接入才是拦路虎,MCP与适配层实战复盘

这个项目上线那天,我们在会议室里等第一个真实工单。演示环境里模型表现得像个十年老员工,能总结、能推断、能把完整执行计划列得清清楚楚。但生产环境里,它要做的第一件事,是把 OA 里一张审批单读进来,再对着 ERP 里的…

2026/10/1 19:02:58 阅读更多 →

日新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/1 0:00:30 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/1 0:00:30 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/1 1:01:17 阅读更多 →

周新闻

如何划分训练/验证集:Spirula Studio五种eval_mode策略详解

如何划分训练/验证集:Spirula Studio五种eval_mode策略详解

如何划分训练/验证集:Spirula Studio五种eval_mode策略详解 【免费下载链接】spirula-studio Cross-vendor 3D Gaussian Splatting trainer - video to splat to mesh, Vulkan or CUDA. 项目地址: https://gitcode.com/GitHub_Trending/sp/spirula-studio Sp…

2026/9/30 13:14:22 阅读更多 →
SEO怎么推广速查手册新手避坑实战指南

SEO怎么推广速查手册新手避坑实战指南

SEO怎么推广速查手册新手避坑实战指南 模板网站太丑不够用?别急着加滤镜,那是治标不治本。很多老板盯着后台流量掉得眼红,却还在纠结首页Banner的圆角是不是3像素。这就像穿着西装去挖土,姿势不对,努力白费。我整理这份 速查手册…

2026/9/30 18:13:06 阅读更多 →
FireRed-OpenStoryline少样本仿写深度解析:AI Agent如何复刻你的独特文案风格与节奏

FireRed-OpenStoryline少样本仿写深度解析:AI Agent如何复刻你的独特文案风格与节奏

FireRed-OpenStoryline少样本仿写深度解析:AI Agent如何复刻你的独特文案风格与节奏 【免费下载链接】FireRed-OpenStoryline FireRed-OpenStoryline is an AI video editing agent that transforms manual editing into intention-driven directing through natural language …

2026/9/30 13:14:49 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/1 0:00:30 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/1 0:00:30 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/1 1:01:17 阅读更多 →