简介:面向需要在 WPF 桌面应用中呈现 CAD 图纸的开发者,这份示例工程围绕 DXF 解析与 Canvas 图形绘制展开,覆盖实体读取、数据模型映射到线条、多段线、圆等图元,并预留悬停高亮、点击选中、缩放平移等交互扩展,适合具备 C# 与 WPF 基础、希望打通 DXF 预览链路的读者参考。压缩包共 50 个文件,约 143KB,以 cs 源码、csproj/sln 工程配置、resx 资源与 ico 图标为主,另含 exe、pdb 编译产物及两份 dxf 示例图,便于打开解决方案对照样例调试导入效果。已有 1857 人学习下载,说明其在 DXF 转 WPF 显示场景中具备参考价值。工程将解析、绘图对象封装与界面展示分模块组织,读者可据此梳理 DXF 实体到 WPF Shape 的转换思路,理解 Canvas 子元素动态添加、图层与颜色线型处理,并借鉴大量图元下的性能优化方向,快速搭建可运行的图纸预览原型。
1. DXF 文件读入 WPF Canvas 显示:这条链路上横着四个坐标系
有位做设备上位机的同行发来一张截图:客户给的 DXF 图纸,在他的 WPF 界面里打开后只剩左下角几根线,其余全是空白。他以为是渲染没触发,加了半天断点,最后发现坐标根本不在视口里。DXF 文件读入 WPF Canvas 显示这件事,真正难的地方很少在于「画不出线」,而在于坐标在四个空间里来回倒手——文件里的模型空间(Y 向上、单位可能是英寸)、块引用的局部空间(带缩放和旋转)、Canvas 的布局空间(Y 向下、设备无关像素)、以及最终屏幕的物理像素。任何一层换算错位,用户看到的现象都只有两个:图没了,或者图变形了。
这里要拆的是一套从零手写、不依赖商业库的做法:读 ASCII DXF 的组码流,把 ENTITIES 段里的实体翻译成 WPF 的 Geometry,收进一个 Path 容器里做缩放、平移和命中测试。它适合手上已经有画板界面、需要补一个「打开图纸」功能的从业者,也适合只想搞明白 DXF 组码结构、不想被第三方 CAD 控件绑死的人。DWG 不在讨论范围内,那是另一套二进制格式,自己啃的成本和风险都不划算。
2. 拆组码流:从 SECTION 到 ENTITIES 的解析骨架
2.1 组码流不是标记语言,别用 XmlReader 的直觉去读
ASCII DXF 的物理结构极其简单:一行组码,一行值,交替出现到文件结束。比如一条直线长这样:
0 LINE 8 轮廓线 10 0.0 20 0.0 11 1200.0 21 800.00是分隔符,它的值永远是实体类型或结构关键字(SECTION、ENDSEC、BLOCK、ENDBLK、EOF);8是图层名;10/20/30是第一个点的 X/Y/Z;11/21/31是第二个点。数值型组码的含义是全局约定的:10 到 18 是 X 坐标,20 到 28 是 Y 坐标,30 到 38 是 Z 坐标,40 到 48 是浮点参数(半径、高度、比例),50 到 58 是角度(度,逆时针),62 是颜色索引,420 是真彩色,370 是线宽。
第一件容易翻车的事是:很多人上来就按「关键字 → 值」去建字典,结果发现10在同一个实体里会出现多次(多段线的每个顶点都有一组 10/20),1在 MTEXT 里也会重复出现(长文本被拆成若干段,每段一个3,最后一段是1)。组码的语义依赖「当前实体类型」和「出现顺序」,所以正确的做法是:读到0就切换当前实体对象,其余组码一律交给该实体自己的Apply(code, value)方法处理,由实体自己维持状态。这本质上是个状态机,不是一个 DOM。
public sealed class DxfCodeReader { private readonly TextReader _tr; private int _lineNo; public int Code { get; private set; } public string Value { get; private set; } = string.Empty; public DxfCodeReader(Stream stream, Encoding encoding) { // detectEncodingFromByteOrderMarks 必须关掉,DXF 没有 BOM 概念 _tr = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: false); } public bool Read() { var codeLine = _tr.ReadLine(); if (codeLine == null) return false; var valueLine = _tr.ReadLine(); if (valueLine == null) return false; _lineNo += 2; if (!int.TryParse(codeLine.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var code)) throw new InvalidDataException($"第 {_lineNo - 1} 行不是合法组码: '{codeLine}'"); Code = code; // 只去行尾,不去行首:TEXT 实体的字符串前导空格是有排版意义的 Value = valueLine.TrimEnd('\r', '\n'); return true; } }这里有两个参数必须说清楚。编码不能硬编码 UTF-8,DXF R2007($ACADVER为AC1021)之后是 UTF-8,之前的版本用 ANSI 代码页,具体写在 HEADER 段的$DWGCODEPAGE里,中文环境常见的是ANSI_936,这一段在 5.3 节还会展开。另外那个TrimEnd而不是Trim,是因为组码行本身允许前后有空白,但值行的尾部空白如果被吃掉,某些手写生成的 DXF 里的文本对齐会偏。
2.2 实体分发器与一次遍历的状态机
整个文件按 SECTION 分块,我们只关心三块:HEADER(拿单位、图形范围、版本)、TABLES(拿图层颜色和线型)、ENTITIES 和 BLOCKS(拿几何)。其余 CLASSES、OBJECTS 直接跳过,不用解析,也不要因为不认识就抛异常——市面上不少导出工具会往里塞私有数据。
public DxfDocument Load(Stream stream, Encoding enc) { var doc = new DxfDocument(); var reader = new DxfCodeReader(stream, enc); string section = null; bool inBlock = false; DxfEntity current = null; while (reader.Read()) { if (reader.Code == 0) { switch (reader.Value) { case "SECTION": section = null; current = null; break; case "ENDSEC": section = null; inBlock = false; break; case "BLOCK": inBlock = true; doc.Blocks.Begin(); break; case "ENDBLK": inBlock = false; doc.Blocks.End(); break; case "EOF": return doc; default: if (section == "ENTITIES" || inBlock) { // 工厂对未知类型返回 UnknownEntity,保证后续组码有地方落,不丢流 current = EntityFactory.Create(reader.Value); if (!(current is UnknownEntity)) doc.Add(current, inBlock); } else current = null; break; } continue; } // SECTION 之后紧跟的 2 组码就是段名 if (reader.Code == 2 && section == null) { section = reader.Value; continue; } current?.Apply(reader.Code, reader.Value); } return doc; }这段代码里最关键的一行是current?.Apply(...)前面的空判断。UnknownEntity的设计不是偷懒,是保命:DXF 里 HATCH、MLINE、ACAD_PROXY_ENTITY 这些实体的组码又多又杂,如果你在default分支里直接throw,一张图纸里只要有一个不认识的实体,整个文件就打不开了——这是我在实际项目里踩过的坑,用户发来的图纸永远比你测试用例脏。
另外要留意BLOCK段的嵌套:块定义内部可以再出现0/BLOCK吗?标准里不允许,但实际文件里偶有出现,所以inBlock用布尔值而不是计数器是有风险的,稳妥一点用深度计数器并设一个 32 的上限,超了就当坏数据处理。
2.3 LINE、LWPOLYLINE、ARC:三种最常翻车的实体
统计下来,机械和建筑类图纸里 85% 以上的可见图元就是线段、多段线、圆和圆弧,把这几个做对,观感立刻到位。
LINE 最简单,收到 10/20 和 11/21 两组点就能生成LineSegment。真正的坑在 LWPOLYLINE:它的顶点不是一次给全的,而是每个顶点一组10(X)、20(Y)、42(凸度 bulge)。10出现就意味着「开始一个新顶点」,所以解析器必须在遇到10时 push 一个新的顶点对象,20和42填到当前顶点上。凸度这个东西是 DXF 里最反直觉的参数之一:它等于圆心角四分之一的正切值tan(θ/4),正数表示逆时针,负数表示顺时针,0 表示直线。
// 把 p0 → p1 这一段按 bulge 追加到 figure static void AppendBulge(PathFigure figure, Point p0, Point p1, double bulge) { if (Math.Abs(bulge) < 1e-12) { figure.Segments.Add(new LineSegment(p1, true)); return; } double chord = (p1 - p0).Length; if (chord < 1e-12) return; double sweep = 4.0 * Math.Atan(bulge); // 带符号的圆心角 double radius = chord / (2.0 * Math.Sin(Math.Abs(sweep) / 2.0)); // 半圆以上要置 IsLargeArc,否则 WPF 会画成小弧,方向反了整条多段线就飞出去 bool isLargeArc = Math.Abs(sweep) > Math.PI; var dir = bulge > 0 ? SweepDirection.Counterclockwise : SweepDirection.Clockwise; figure.Segments.Add(new ArcSegment(p1, new Size(radius, radius), 0, isLargeArc, dir, true)); }radius的算法是从弦长和圆心角反推的,注意这里用的是sin(|sweep|/2)而不是sin(sweep/2),符号交给SweepDirection处理,混在一起写会得到负半径,ArcSegment直接抛异常。还有个极端情况:bulge 绝对值极大时sweep逼近 2π,sin趋近 0,半径会溢出成无穷大。实际文件里 bulge 超过 1000 基本就是导出工具算错了,加个if (Math.Abs(bulge) > 1e3) bulge = 0;兜底比让它崩强。
ARC 实体的参数是中心点10/20、半径40、起始角50、终止角51,单位是度,逆时针为正。坑在于终止角小于起始角时表示跨过 0 度线,必须先a1 += 2π再判断跨度是否大于 π。CIRCLE 则是 360 度全弧,直接生成一个闭合的EllipseGeometry更省事。
2.4 INSERT 与块表:矩阵叠加和递归深度
图纸里的标准件、标题栏、螺栓孔阵列,几乎都是以块引用(INSERT)的形式存在的。INSERT 的组码给出块名2、插入点10/20、XYZ 缩放41/42/43、旋转角50(度)、阵列的行列数70/71和间距44/45。要正确渲染,得先查 BLOCKS 段里同名块定义的实体列表,再把这些实体按「平移(-块基点) → 缩放 → 旋转 → 平移到插入点」的顺序变换过去。
// 注意 WPF 的 Matrix 是行向量约定,后 Append 的变换在右侧 static Matrix BuildInsertMatrix(Point insert, Point blockBase, double sx, double sy, double rotDeg) { var m = Matrix.Identity; m.Translate(-blockBase.X, -blockBase.Y); // 先把块基点挪到原点 m.Scale(sx, sy); m.Rotate(rotDeg); m.Translate(insert.X, insert.Y); return m; }顺序写反是最常见的错误,表现是块跑到离插入点几百米外,或者尺寸差了几个数量级。验证办法很土但有效:拿一个基点不在原点的块,单独插一次,把变换后第一个实体的坐标打印出来,和 CAD 里查询到的坐标对比。至于块内嵌套块,用递归处理,深度上限设 32 层,超过就跳过并记一条日志——正常的图纸不会超过 8 层,超过的一定是导出工具循环引用了。
顺带一个实用技巧:DIMENSION(标注)实体不要试图自己重建箭头和文字,它的2组码指向一个匿名块(名字形如*D12),那个块里已经包含了预先算好的线段和文字实体。直接按 INSERT 的方式渲染这个块,标注就能原样显示出来,省掉一整套标注样式解析。
3. 坐标系对齐:Y 轴翻转、单位与包围盒
3.1 Y 轴方向:为什么我选择在模型空间建几何、在显示层翻转
DXF 是右手系,Y 向上;WPF 是屏幕系,Y 向下。两种主流做法:一是解析时就把所有 Y 取反,二是在模型空间老老实实按 Y 向上构建,最后用一个ScaleTransform(1, -1)在渲染层翻转。
我选第二种,理由很实在。第一,圆弧和凸度的方向语义在 Y 向上时是自然的(逆时针就是SweepDirection.Counterclockwise),一旦提前翻转,所有SweepDirection判断都要反过来写,看代码的人得在脑子里做一次变换,这种玄学式的负担最后一定会变成 bug。第二,做命中测试和包围盒计算时,模型坐标和 CAD 里标注的坐标是一致的,和客户对数据的时候不用来回换算。第三,将来如果要导出回 DXF,不需要再做一次翻转。
代价是文字会跟着翻。这个在 4.3 节单独处理,不难,但必须记住。
3.2 单位、$INSUNITS 与包围盒的取舍
DXF 里没有强制单位。HEADER 段的$INSUNITS是提示值:0 无单位,1 英寸,2 英尺,4 毫米,5 厘米,6 米。很多导出工具根本不写这个变量,所以不能依赖它。真正可靠的是$EXTMIN和$EXTMAX,它们给出图形范围的左下角和右上角。但这两个值也经常是错的(比如图形被编辑过但没保存范围),所以我的做法是三个来源都看:优先用自己遍历实体算出来的包围盒,其次用$EXTMIN/$EXTMAX做交叉校验,如果两者差距超过 10 倍,就把$EXTMIN/$EXTMAX直接忽略。
包围盒本身还有一个细节:文本实体的包围盒不能只算插入点。如果只按点算,一张全是文字的图纸包围盒会退化成零尺寸,fit-to-view时除以 0,画面直接白屏。文本至少要按「插入点 + 估算宽度」撑开,估算宽度可以用字符数乘以高度的 0.7 倍,粗糙但够用。
3.3 fit-to-view:一个能直接抄的矩阵推导
拿到模型包围盒和视口尺寸后,要算一个矩阵,让整张图刚好装下。关键是把模型空间的四个角映射到视口坐标里,我习惯让模型的左上角(注意是模型空间的左上,也就是 Y 最大的那个点)落到视口的左上角内边距处。
public void FitToView(Rect modelBounds, Size viewport, double padding = 12) { if (modelBounds.IsEmpty || modelBounds.Width <= 0 || modelBounds.Height <= 0) return; double sx = (viewport.Width - 2 * padding) / modelBounds.Width; double sy = (viewport.Height - 2 * padding) / modelBounds.Height; double s = Math.Min(sx, sy); // 等比缩放,避免图纸被拉扁 var m = Matrix.Identity; m.Scale(s, -s); // 负号就是 Y 翻转 // 模型左上角 (X, Y+Height) 翻转到 (s*X, -s*(Y+Height)), // 平移量让它落到 (padding, padding) m.Translate(padding - modelBounds.X * s, padding + (modelBounds.Y + modelBounds.Height) * s); _matrix.Matrix = m; UpdateStrokeThickness(s); }这段代码里最容易写错的是第三个参数Translate的 Y 分量。Matrix.Scale(s, -s)之后,模型里的 Y 值越大,屏幕上的 y 越小,所以包围盒的顶边(Y + Height)才是屏幕上的最上方。很多人照着「左加右减、上加下减」的直觉写,结果图形整体偏出视口一个图高的距离。
padding建议至少 12 个设备无关像素,一来给边框留白,二来看起来不憋屈。缩放系数s要取Math.Min而不是Math.Max,否则图纸会溢出视口。
平移和滚轮缩放同样在这个矩阵上叠加,WPF 的Matrix.ScaleAt(factor, factor, cx, cy)能直接以某个屏幕点为中心缩放,不用自己推。
public void ZoomAt(Point screenPoint, double factor) { var m = _matrix.Matrix; m.ScaleAt(factor, factor, screenPoint.X, screenPoint.Y); _matrix.Matrix = m; UpdateStrokeThickness(Math.Sqrt(Math.Abs(m.M11 * m.M22 - m.M12 * m.M21))); }最后一行的行列式开方就是当前的等效缩放倍数,用它来反算线宽,下一章展开。
4. 渲染层:从一堆 Shape 到一个冻结的 Geometry
4.1 别给每个实体 new 一个 Shape
新手最容易写出来的结构是「遍历实体,每遇到一条线就new Line { X1=..., Y1=..., Stroke=... }加进 Canvas 的 Children」。一千个实体以内勉强能跑,一旦上万,界面打开会卡三到五秒,滚动时帧率掉到个位数。原因很直接:每个Shape都是一个UIElement,要参与布局、命中测试、渲染遍历,开销跟图形复杂度无关,跟元素个数线性相关。
正确的做法是把同一支画笔下的所有图元合并成一个PathGeometry,再放进一个Path。一张典型的机械图纸合并后通常只剩十几个到几十个Path,元素数量从「实体数」降到「画笔种类数」。
// figures 是按画笔分好组的 PathFigure 列表 var geometry = new PathGeometry(); foreach (var fig in figures) geometry.Figures.Add(fig); // 关键一步:Freeze。冻结后的几何不再有变更通知,渲染线程可以直接用 geometry.Freeze(); var path = new Path { Data = geometry, Stroke = pen.Brush, StrokeThickness = pen.Thickness, StrokeLineJoin = PenLineJoin.Round, // 折线拐角不出现缺口 StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round, SnapsToDevicePixels = false, // 缩放场景下开这个反而会抖 IsHitTestVisible = false // 命中测试自己做,别让 WPF 遍历 }; path.Freeze(); // Path 是 Freezable,冻结后省掉变更监听Freeze()是这套方案里性价比最高的一行。没冻结的几何在渲染时 WPF 要额外维护依赖属性变更通知,冻结后就退化成一个纯数据块。IsHitTestVisible = false同样重要,否则鼠标移动时 WPF 会沿着视觉树一路做命中测试,几万个图元合在一起照样慢。
如果实体数量真的到了十几万,再往上一步是把PathFigure手工写进StreamGeometryContext。StreamGeometry没有PathFigure、PathSegment这些 DependencyObject 的包装,构建期内存和耗时都能再降一截,代价是构建完就不能改了,只能整体重建。
4.2 按画笔分桶:颜色、线宽、线型的合并策略
分组键由三样东西决定:颜色、线宽、线型。颜色优先取实体自己的420(真彩色,24 位 RGB),没有就取62(ACI 索引色),都没有就查图层表里该图层的62。图层颜色为负值表示图层被关闭,直接跳过。
ACI 索引色到 RGB 需要一张 256 项的对照表,工程上至少要覆盖前 9 个基础色,剩下的可以按同格式补齐:
| 索引 | 颜色 | RGB |
|---|---|---|
| 1 | 红 | 255, 0, 0 |
| 2 | 黄 | 255, 255, 0 |
| 3 | 绿 | 0, 255, 0 |
| 4 | 青 | 0, 255, 255 |
| 5 | 蓝 | 0, 0, 255 |
| 6 | 品红 | 255, 0, 255 |
| 7 | 前景(白底黑、黑底白) | 随主题 |
| 8 | 深灰 | 128, 128, 128 |
| 9 | 浅灰 | 192, 192, 192 |
索引 7 要特殊处理。它是「随背景反转」的意思,在浅色主题下应该是黑色,深色主题下应该是白色。写死成白色的话,用户在浅色界面上打开图纸会发现整张图不见了——这是最经典的血泪经验之一。
线宽取370,单位是百分之一毫米,0 表示「随图层」,负值表示「随图层默认」。直接映射到 WPF 的StrokeThickness时不能照抄,因为图纸的线宽语义是「打印宽度」,屏幕显示时按 0.5 到 1 个设备无关像素处理更合适。
线型取6(线型名),
本文还有配套的精品资源,点击获取