简介:面向需要把 AutoCAD 生成的 DXF 图纸嵌入桌面界面的 WPF 开发者,这份代码包围绕 DXF 文件读入与 Canvas 显示展开,涵盖 DXF 结构解析、实体到 WPF 图形对象的数据模型映射、在画布上动态绘制线条与多段线等元素,以及悬停高亮、点击选中、缩放平移等交互思路。资源共 50 个文件,以 13 个 C# 源码为核心,辅以 csproj 与 sln 工程文件、resx 界面资源、ico 图标和 2 个 DXF 示例图纸,压缩包约 143KB,可直接打开解决方案对照调试。已有 1856 人学习下载,适合初、中级开发者理解 DXF 数据组织并练习 WPF 矢量图形渲染与事件处理。包内分层代码和示例图纸可作为二次开发起点,便于快速搭建可运行的导入显示原型,也为后续性能优化、交互完善和功能扩展提供参考。
1. DXF 读进 WPF Canvas:先解决「坐标翻转」和「实体映射」这两件事
从上游专业拿到 DXF,多数人第一反应是丢进 CAD 截图,可一旦要做批量预览、图层开关、点击选中构件,截图就没用了。这份资源处理的就是这条链路:把 DXF 里的 LINE、LWPOLYLINE、ARC、INSERT 读出来,换算成 WPF Canvas 能直接绘制的图元,再挂上缩放、平移和命中测试。它适合已经在写 WPF 桌面工具、需要内嵌图纸预览的开发者,也适合想搞明白 DXF 组码到底怎么映射成屏幕像素的人。下面按解析、坐标、渲染、排错、进阶拆开写,每一步都能直接落到代码里。
2. 解析 DXF:组码流长什么样,以及该不该自己写解析器
2.1 组码就是「一行代码、一行值」,但坑全在重复组码上
DXF 的 ASCII 格式非常朴素:文件由一对一对的行组成,奇数行是组码(group code,整数),偶数行是值。组码决定这个值该怎么解释——0表示实体类型或段边界,2表示名字,8表示图层,10/20/30是一个点的 X/Y/Z,40是半径或高度,62是颜色索引。看一小段真实的 LWPOLYLINE:
0 LWPOLYLINE 5 2F 8 墙线 90 4 70 1 10 0.0 20 0.0 42 0.4142 10 3000.0 20 0.0 42 0.0 10 3000.0 20 2400.0 42 0.0 10 0.0 20 2400.0 42 0.090是顶点数,70是标志位(最低位为 1 表示闭合),然后10/20这一对会重复出现 N 次,每次后面可能跟一个42(bulge,凸度)。这就是手写解析器的第一个坎:组码不是唯一的,同一个组码在一段实体里可以出现很多次,你必须按「当前实体类型 + 已读到的状态机」来决定这个 10 是起点、是下一个顶点、还是 INSERT 的插入点。
第二个坎是分段。DXF 顶层是SECTION ... ENDSEC,常见的有 HEADER(全局变量,比如$EXTMIN/$EXTMAX/$INSUNITS/$DWGCODEPAGE)、TABLES(图层、线型、文字样式)、BLOCKS(块定义)、ENTITIES(模型空间实体)、OBJECTS。你真正要画的东西在 ENTITIES 里,但图层颜色在 TABLES 里,块里的几何在 BLOCKS 里,缺一个就画不对。
第三个坎是编码。老版本 DXF 的中文图层名、文字内容是 GBK 编码的,直接按 UTF-8 读会得到一串问号或者乱码。稳妥做法是读 HEADER 里的$DWGCODEPAGE(组码 3),按它选编码,ANSI_936对应 GB18030。血泪经验:这个坑在中文图纸上出现频率极高,而且症状是「图能画出来,但所有中文全是问号」,很容易被当成字体问题查半天。
2.2 自己写还是用库:netDxf 能省掉八成体力活
如果只是读几条 LINE 做个小工具,手写两百行状态机完全够用,还不用引依赖。但只要图纸里出现 SPLINE、ELLIPSE、HATCH、DIMENSION、带缩放的嵌套 INSERT,手写成本会指数上升——光是 SPLINE 的 NURBS 求值就够写一天。
我一般会直接用 .NET 生态里成熟的解析库,netDxf 是最常见的选择,它对 R12 到 2018 的各版本兼容性做得比较扎实,实体模型也是强类型的。加载一个文件只要几行:
using netDxf; using netDxf.Entities; // Load 内部会处理编码、段结构、块表;文件损坏或版本过新时返回 null var doc = DxfDocument.Load(filePath); if (doc == null) throw new InvalidOperationException($"DXF 解析失败:{filePath}"); // 模型空间的顶层实体 foreach (var entity in doc.Entities) { switch (entity) { case Line line: // 直线,StartPoint / EndPoint break; case LwPolyline lw: // 轻量多段线,Vertices 每项带 Bulge break; case Arc arc: // 圆弧,Center / Radius / StartAngle / EndAngle(角度制) break; case Circle circle: // 整圆 break; case Insert insert: // 块参照,Block / Position / Scale / Rotation break; case Text text: // 单行文字 break; case MText mtext: // 多行文字,Value 里带 \P 换行符 break; } }这段代码的关键点不在语法,在三个对象的语义:
doc.Entities只给模型空间的顶层实体,块里面的几何不在里面。要展开块,得拿insert.Block.Entities递归,同时把insert.Position、insert.Scale、insert.Rotation组合成一个变换矩阵往下传。Arc.StartAngle是角度制,不是弧度,而且是相对 OCS 坐标系从 +X 轴逆时针量的。直接喂给Math.Cos会得到完全错误的图形——这类错误不会抛异常,只会让你盯着屏幕怀疑人生。doc.Header里有$EXTMIN/$EXTMAX(图形范围)和$INSUNITS(单位)。图形范围经常是默认值或者干脆没写,所以第一次适配视图时不要盲信它,后面 3.1 会讲怎么兜底。
提示:解析这一步建议整体扔到
Task.Run里做。DXF 文件动辄几十兆,主线程解析会让窗口白屏好几秒,用户第一反应就是「这软件卡死了」。
2.3 中间图元模型:别让 DxfEntity 直接流进渲染层
新手最容易犯的错,是把Line、Arc这些库类型直接传到渲染方法里,然后switch一路写下去。等你要加图层开关、要按包围盒做视口裁剪、要缓存几何时,就会发现渲染层被库的 API 绑死了——换个解析库,整个渲染层重写。
正确的做法是在中间加一层自己的图元模型,只保留「画出这条线需要什么」,其余全丢掉:
// 坐标系统一用 DXF 世界坐标(Y 向上),渲染时再一次性翻转 public abstract class DxfPrimitive { public int LayerIndex; // 指向图层表的下标,方便做显隐 public uint Argb; // 已解析成具体颜色,BYLAYER 在这一步就消解掉 public double LineWeight; // 线宽,单位与图纸单位一致(一般是 mm) } public sealed class SegmentPrimitive : DxfPrimitive { public Point P0, P1; } public sealed class PolylinePrimitive : DxfPrimitive { public List<Point> Points = new(); // 已经按 bulge 展开过的折线 public bool Closed; } public sealed class ArcPrimitive : DxfPrimitive { public Point Center; public double Radius; public double StartDeg, EndDeg; // 角度制,保留原样,离散化时再转 } public sealed class TextPrimitive : DxfPrimitive { public Point Origin; public string Content = ""; public double Height; // 字高,图纸单位 public double RotationDeg; }这一层多做了一件事:把 BYLAYER 消解掉。DXF 里实体的颜色码62如果是 256 表示「随层」,0 表示「随块」,只有 1~255 才是固定索引色。同理线宽370的 -1 是随层、-2 是随块。这些「继承语义」如果在渲染时再判断,每画一条线都要往回查一次图层表,既慢又容易漏。在解析阶段一次算清楚,后面渲染只认具体值,逻辑会干净很多。
还有一个细节:图层本身要单独存一份LayerInfo(名字、颜色、可见性、冻结状态),渲染层和图元层都通过LayerIndex引用。这样「关掉某个图层」就变成渲染前过滤一遍列表的事,不用重新解析。
3. 坐标与几何:Y 轴翻转、bulge 展开、文字旋转
3.1 一个 Matrix 搞定 Y 翻转,别逐个点去改
DXF 是右手系,Y 轴向上;WPF 的 Canvas 和设备坐标 Y 轴向下。最直白的做法是遍历所有点做y = maxY - y,然后在缩放变化时再全部重算一遍——这条路走不通,因为缩放是交互行为,每滚一次滚轮就要改几万个点,帧率直接崩。
正确做法是把「世界坐标 → 屏幕坐标」收进一个Matrix,几何数据永远不动,只换矩阵:
/// <summary>把 DXF 世界坐标适配到视口,等比缩放并居中</summary> public static Matrix BuildFitMatrix(Rect worldBounds, Size viewport, double padding) { if (worldBounds.Width <= 0 || worldBounds.Height <= 0) return Matrix.Identity; double sx = (viewport.Width - padding * 2) / worldBounds.Width; double sy = (viewport.Height - padding * 2) / worldBounds.Height; double s = Math.Min(sx, sy); // 取小值,保证等比,圆不会被压成椭圆 var m = new Matrix(); // 1) 把图形左上角挪到原点附近;Bottom 就是 DXF 里的最大 Y m.Translate(-worldBounds.Left, -worldBounds.Bottom); // 2) 缩放并翻转 Y m.Scale(s, -s); // 3) 平移到视口内边距处 m.Translate(padding, padding); return m; }三个Translate/Scale调用的顺序不能乱,WPF 的Matrix每次调用都是「追加变换」,点的实际流程是:先减偏移 → 再缩放翻转 → 最后平移。推一下公式,X 方向得到(x - minX) * s + padding,Y 方向得到(maxY - y) * s + padding,正好把maxY映射到padding、把minY映射到padding + height * s,翻转和适配一次完成。
注意传进来的worldBounds要用new Rect(minX, minY, maxX - minX, maxY - minY)构造。WPF 的Rect语义里Top是上边、Bottom是Top + Height,在这里它们只是两个数值容器,别被名字带偏——Bottom取到的就是 DXF 的maxY,这正是翻转需要的基准。
至于worldBounds从哪来:先试$EXTMIN/$EXTMAX,如果两者相等、或者宽高为 0、或者范围大到离谱(比如±1e20,常见于从别的软件导出的空图纸),就退回遍历所有图元自己算包围盒。圆弧的包围盒不能只用Center ± Radius,那样会偏大;简单但够用的做法是取Center ± Radius再和两个端点取并集,宁可框大一点也不要框小了把图裁掉。
3.2 LWPOLYLINE 的 bulge 和 ARC 的角度:两个必须离散化的东西
bulge 是 DXF 里表示圆弧段的方式,定义是bulge = tan(θ/4),θ 是这段弧的包角,正值表示逆时针。它比存圆心和半径省空间,但对渲染很不友好。展开公式如下:
/// <summary>把一段带 bulge 的直线段展开成折线点集</summary> /// <param name="tolPx">允许的弦高误差(屏幕像素)</param> /// <param name="scale">当前世界→屏幕的缩放比,用于把像素误差折回世界单位</param> public static List<Point> TessellateBulge(Point p1, Point p2, double bulge, double tolPx, double scale) { var pts = new List<Point>(); if (Math.Abs(bulge) < 1e-9) { pts.Add(p1); pts.Add(p2); return pts; } double theta = 4 * Math.Atan(bulge); // 有符号包角,正=逆时针 double dx = p2.X - p1.X, dy = p2.Y - p1.Y; double d = Math.Sqrt(dx * dx + dy * dy); if (d < 1e-9) return pts; double r = d / (2 * Math.Sin(theta / 2)); // 有符号半径 double nx = -dy / d, ny = dx / d; // p1->p2 的左法线 double off = (d / 2) / Math.Tan(theta / 2); // 圆心沿法线的偏移量 var c = new Point((p1.X + p2.X) / 2 + nx * off, (p1.Y + p2.Y) / 2 + ny * off); double a0 = Math.Atan2(p1.Y - c.Y, p1.X - c.X); double sweep = theta; // 有符号扫角 double tol = Math.Max(tolPx / Math.Max(scale, 1e-6), 1e-9); double step = 2 * Math.Acos(Math.Clamp(1 - tol / Math.Abs(r), -1, 1)); if (step <= 1e-6) step = Math.PI / 18; // 半径过小时兜底 int n = Math.Clamp((int)Math.Ceiling(Math.Abs(sweep) / step), 2, 256); for (int i = 0; i <= n; i++) { double a = a0 + sweep * i / n; pts.Add(new Point(c.X + Math.Abs(r) * Math.Cos(a), c.Y + Math.Abs(r) * Math.Sin(a))); } return pts; }逻辑说明:先用theta和d反推半径,再用左法线把圆心推到正确的一侧——off的符号会自动跟着bulge的正负走,所以同一段代码既能处理顺时针弧也能处理逆时针弧。分段数不是写死的 32,而是按弦高误差反推:step = 2 * acos(1 - tol / r)是由「弦高 ≈ r(1 - cos(step/2))」解出来的单段最大张角,再拿总扫角除一下就是段数。这样在缩小视图时弧只画几段,放大时才加密,既省顶点又不会看出棱角。
参数上,tolPx一般取 0.5~1.0 像素,肉眼已经看不出来;scale必须是当前世界到屏幕的总缩放比(适配缩放 × 用户缩放),传错了就会出现「放大后圆弧变多边形」或者「顶点数量爆炸」两种极端。上限 256 段是保险丝,防止半径极小、扫角极大的畸形数据把内存吃穿。
ARC 实体走的是同一套逻辑,只是起点终点直接由StartDeg/EndDeg给:先把角度转弧度,算出两个端点,再把theta换成end - start(end < start时加 360)代进去即可。强烈建议圆弧一律离散成折线,不要用ArcSegment——Y 翻转之后SweepDirection和IsLargeArc的取值全部要反过来,边界条件还特别多,离散化反而更省心。
3.3 颜色、线宽、文字:三个最容易被忽略的属性
颜色这块,索引色(ACI)没法用一个公式算出来,1~9 是固定值,10~249 是分段色相环,250~255 是灰度。实际工程里出现最多的就是 1~7,先把这张小表硬编码进去能覆盖绝大多数图纸:
| ACI | RGB | 说明 |
|---|---|---|
| 1 | #FF0000 | 红 |
| 2 | #FFFF00 | 黄 |
| 3 | #00FF00 | 绿 |
| 4 | #00FFFF | 青 |
| 5 | #0000FF | 蓝 |
| 6 | #FF00FF | 品红 |
| 7 | 随背景 | 白底为黑 #000000,黑底为白 #FFFFFF |
7 号色是个经典坑:它在 CAD 里语义是「前景色」,白底图纸上是黑线,深色主题的 WPF 里如果直接映射成白色,整张图会瞬间消失。稳妥做法是把 7 号色绑到当前主题的前景色上动态求值。另外如果实体带真彩色(组码 420,一个 32 位整数),优先级高于 62,解析时判断一下。
线宽要区分两个概念:图纸线宽(370,单位 1/100 mm)和屏幕像素。渲染时如果想「不管怎么缩放,线看起来都是 1px」,就把Pen.Thickness设成1 / 当前总缩放比,让它在世界坐标里反向补偿;如果想保留线宽层次(粗线在放大后真的变粗),就直接把 mm 转成世界单位。两种做法都合理,但不能混着来,否则缩小视图时细线会整片消失——这是 WPF 抗锯齿的正常行为,不是 bug。
文字方面,Text的插入点是基线左端,Height是字高(不是字号),Rotation是角度制。用FormattedText画的时候要注意 Y 翻转会让文字上下颠倒,所以得在PushTransform里再套一次局部翻转,或者干脆把文字层单独放在一个不翻转的视觉层里,用矩阵反算坐标。多行文字MText的Value里带\P换行,还有一堆\fSimSun|b0|i0;之类的格式控制码,渲染前用正则清一遍,只留纯文本。
4. 渲染性能:上万实体为什么不能一条一个 Shape
4.1 Shape 是 FrameworkElement,这个代价你付不起
最直觉的写法是给每条实体new Line()、new Path()然后canvas.Children.Add()。两千条以内确实能跑,一旦上万就开始明显拖慢,拖动窗口或者滚动滚轮时能卡到十几帧。
原因在 WPF 的对象模型:Shape继承自FrameworkElement,意味着每个实例都要参与布局测量、样式解析、依赖属性系统、命中测试树的维护。一万个Line就是一万个要跑 Measure/Arrange 的元素。而我们知道这些图元的位置在解析阶段就定死了,根本不需要布局。
替代方案是用DrawingVisual承载DrawingContext,把整张图当作一个视觉对象画出来:
public sealed class DxfVisualHost : FrameworkElement { private readonly VisualCollection _children; private DrawingVisual? _visual; public DxfVisualHost() => _children = new VisualCollection(this); protected override int VisualChildrenCount => _children.Count; protected override Visual GetVisualChild(int index) => _children[index]; /// <param name="fit">世界坐标 → 画布坐标(含 Y 翻转)</param> /// <param name="view">画布坐标 → 屏幕坐标(用户缩放平移)</param> /// <param name="pxPerUnit">当前总缩放比,用于线宽补偿</param> public void Render(IReadOnlyList<DxfPrimitive> prims, Matrix fit, double pxPerUnit, bool[] layerVisible) { if (_visual != null) _children.Remove(_visual); _visual = new DrawingVisual(); using (var dc = _visual.RenderOpen()) { dc.PushTransform(new MatrixTransform(fit)); // Pen 缓存:同一 (颜色, 线宽) 只建一次,Freeze 后可跨线程复用 var penCache = new Dictionary<(uint, int), Pen>(); foreach (var p in prims) { if (!layerVisible[p.LayerIndex]) continue; var pen = GetPen(penCache, p.Argb, p.LineWeight, pxPerUnit); switch (p) { case SegmentPrimitive seg: dc.DrawLine(pen, seg.P0, seg.P1); break; case PolylinePrimitive poly when poly.Points.Count > 1: var geo = new StreamGeometry(); using (var ctx = geo.Open()) { ctx.BeginFigure(poly.Points[0], false, poly.Closed); ctx.PolyLineTo(poly.Points.GetRange(1, poly.Points.Count - 1), true, false); } geo.Freeze(); // 冻结后才能被多个渲染通道安全共享 dc.DrawGeometry(null, pen, geo); break; } } dc.Pop(); } _children.Add(_visual); } private static Pen GetPen(Dictionary<(uint, int), Pen> cache, uint argb, double weight, double pxPerUnit) { // 线宽按像素补偿:世界单位 = 像素 / 缩放比 int key = (int)Math.Round(Math.Max(weight, 1.0) * 10); if (cache.TryGetValue((argb, key), out var pen)) return pen; var brush = new SolidColorBrush(Color.FromArgb(255, (byte)(argb >> 16), (byte)(argb >> 8), (byte)argb)); brush.Freeze(); pen = new Pen(brush, Math.Max(key / 10.0, 0.5 / pxPerUnit)); pen.Freeze(); cache[(argb, key)] = pen; return pen; } }VisualCollection+GetVisualChild这套重写是DrawingVisual宿主的标准姿势,缺了VisualChildrenCount就什么也画不出来,这是新手最常漏的两行。RenderOpen()拿到的DrawingContext在Dispose时才真正提交,所以必须用using包住。
Freeze()的价值在于把Freezable从「可变的、有线程亲和性的对象」变成「不可变、可跨线程共享的对象」。如果解析在后台线程做、渲染在主线程做,未冻结的Brush/Pen/Geometry会直接抛跨线程访问异常。血泪经验:这个异常信息很不友好,只说「该对象归另一个线程所有」,指不到具体是哪个 Brush。
PushTransform把fit矩阵压进绘制上下文,意味着几何点始终保持 DXF 原始坐标(可能是几万、几十万量级),变形由 WPF 的合成层完成。这比在 C# 里逐点乘矩阵快一个数量级,因为后续的缩放平移只是改矩阵,不触发任何几何重算。
还有一个要注意的:StreamGeometry里BeginFigure的第三个参数是isClosed,闭合多段线传true,否则墙线、房间轮廓会缺最后一条边。
4.2 视口裁剪与分块:什么时候值得做
DrawingVisual之后,绘制本身已经很快了,剩下的大头是几何生成——每次重建DrawingVisual都要遍历所有图元、构造所有StreamGeometry。十万实体的图纸,这一步能吃掉几百毫秒,缩放时每滚一格都重建一次显然不可接受。
常规优化三档,按图元数量选:
第一档,几千到两万实体,直接全量重建就行,配上RenderTransform做缩放平移(见 4.3),只有「重新适配视图」时才重建一次。
第二档,两万到二十万,做包围盒级别的视口裁剪。给每个DxfPrimitive预算一个Rect,重建前用当前视图的世界坐标范围去筛,只需一次线性扫描,比构造几何便宜太多。落在视野外的直接跳过,大图纸在放大状态下通常只剩百分之几的图元需要画。
第三档,二十万以上,上分块。把世界坐标切成固定大小的网格(比如 5000×5000 单位一格),每个格子一个DrawingVisual,缩放平移时只重建进出视野的格子。代价是图层显隐要遍历所有格子的图元,需要额外维护一层索引。除非真的在做 CAD 级别的工具,一般用不到这一档。
4.3 缩放平移挂在 RenderTransform 上,不要改几何
视图交互有两层变换:fit(适配,图纸变了才重算)和view(用户缩放平移,每帧都变)。前者压在DrawingContext里,后者挂在宿主元素的RenderTransform上:
private Matrix _view = Matrix.Identity; // 滚轮缩放,以鼠标位置为锚点 protected override void OnMouseWheel(MouseWheelEventArgs e) { double factor = e.Delta > 0 ? 1.15 : 1 / 1.15; var p = e.GetPosition(this); // 已是元素本地坐标 _view.ScaleAtPrepend(factor, factor, p.X, p.Y); ApplyView(); e.Handled = true; } // 中键拖拽平移 private Point _last; protected override void OnMouseMove(MouseEventArgs e) { if (e.MiddleButton == MouseButtonState.Pressed) { var p = e.GetPosition(this); _view.TranslatePrepend(p.X - _last.X, p.Y - _last.Y); _last = p; ApplyView(); } base.OnMouseMove(e); } private void ApplyView() { RenderTransform = new MatrixTransform(_view); // 缩放超过 4 倍时开位图缓存,拖动明显更跟手 CacheMode = _view.M11 > 4 ? new BitmapCache(2.0) : null; }ScaleAtPrepend的语义是「在现有变换之前插入一个绕指定点的缩放」,效果就是以鼠标位置为中心缩放,符合直觉。TranslatePrepend同理,平移量是屏幕像素,不需要除以缩放比——这正是用 Prepend 而不是 Append 的原因,Prepend 作用在屏幕空间,Append 作用在世界空间,搞反了就会出现「放大后拖动速度变慢」的诡异手感。
RenderTransform不影响布局,只影响渲染,所以滚轮缩放不会触发Measure/Arrange,这是它比改Canvas.Left快得多的根本原因。BitmapCache在放大时把视觉对象缓存成位图,代价是文字会略微模糊,所以我一般设个 2.0 的缩放阈值再开。
5. 避坑与排查:五类最常见的翻车现场
5.1 打开一片空白,或者只有一个看不见的小点
现象:程序没报错,Canvas是空的;或者能看到一点点东西,但怎么缩放都不对。
原因:九成是适配矩阵算错了。要么$EXTMIN/$EXTMAX是(0,0,0)到(1,1,1)这种默认值,套上去之后整张图被缩到 1×1 的范围里;要么图纸单位是米而坐标值是毫米级,缩放比差 1000 倍;要么Rect构造参数传反了,宽高为负,Matrix直接变成退化矩阵。
解决:先看worldBounds,宽或高小于 1e-6、或者大于 1e9,一律判定为无效,改用遍历图元算出来的包围盒。同时在适配后打印一行日志:世界范围、缩放比、视口尺寸。这三个数字能定位 90% 的空白问题。另外记得读$INSUNITS,单位是米的话在显示时转成毫米,不然标注尺寸会差三个数量级。
5.2 中文图层名和文字全是问号
现象:图形位置完全正确,但所有中文标签显示成???,属性面板里的图层名也是乱码。
原因:文件是 GBK(或 GB18030)编码的,被按 UTF-8 解码了。DXF 2007 之前的版本没有统一的 Unicode 约定,跟随系统代码页,中文环境就是 CP936。
解决:读 HEADER 的$DWGCODEPAGE,值形如ANSI_936,取数字部分映射到Encoding.GetEncoding(936)。用第三方库的话确认一下它有没有读这个变量,有些版本会默认 UTF-8 导致中文乱码。最省事的根治办法是让上游另存为 DXF 2018,那个版本的规范要求 UTF-8,从此不用再纠结。
5.3 虚线全变成实线,或者细线整片消失
现象:图层明明设了DASHED线型,画出来是实线;或者缩小到一定比例后,某些线条突然不见了。
原因:WPF 的Pen.DashStyle是以线宽为单位的倍数,不是绝对长度。线宽设成 0.1 的时候,Dashes = {4, 2}意味着虚线段只有 0.4 个世界单位——屏幕上不到一个像素,自然看起来是实线。同理,线宽小于半个像素时,抗锯齿会把线渲染成近乎透明的浅灰,缩小时看着就「消失」了。
解决:虚线的Dashes数组要按LTSCALE和当前缩放比动态算,或者在渲染前把线宽统一抬到至少1 / pxPerUnit(1 个屏幕像素)。消失问题的根子是线宽补偿方向反了,检查一下是乘了缩放比还是除了缩放比。
5.4 块参照(INSERT)位置全错、方向镜像
现象:单独打开块定义看着没问题,插到图上之后位置偏移、旋转角度不对、甚至整个镜像了。
原因:变换顺序错了。DXF 里块的局部坐标要依次经过「块基点平移 → 缩放 → 旋转 → 插入点平移」才能到世界坐标,写成矩阵是T(insert) * R(rot) * S(scale) * T(-basePoint)。很多人顺手写成先旋转再缩放,或者忘了减去块基点,结果就是偏出去一大截。
解决:用Matrix按顺序组合,别手算:
var m = Matrix.Identity; m.Translate(-block.BasePoint.X, -block.BasePoint.Y); // 1. 移到块基点 m.Scale(insert.Scale.X, insert.Scale.Y); // 2. 缩放 m.Rotate(insert.Rotation); // 3. 旋转(角度制) m.Translate(insert.Position.X, insert.Position.Y); // 4. 平移到插入点嵌套块就把上一层的矩阵再乘进去,递归深度加个 8 层的保险丝防止循环引用。注意这里的 Y 翻转不能在中途做,要等到最后的fit矩阵统一处理,中间任何一层翻 Y 都会让旋转方向反过来。
5.5 解析时界面卡死,或者内存一路涨到几个 G
现象:打开大文件时窗口白屏,标题栏挂个「未响应」;或者连续打开几个文件之后内存不释放。
原因:一是解析在主线程跑;二是解析完没释放DxfDocument;三是每个图元都new Pen、new SolidColorBrush,几万个Freezable对象把托管堆和 WPF 的渲染资源都撑爆了。
解决:解析整个扔进Task.Run,await回来再更新 UI。跨线程传递的Freezable(Pen、Brush、Geometry)必须先Freeze()。解析完成后把DxfDocument置空、图元列表只保留必要字段。笔刷一定要走缓存,键用「颜色 + 线宽档位」,通常几百种组合就覆盖全图了。
6. 进阶:命中测试、图层开关,以及一份能自查的校验清单
图画出来只是第一步,能点中、能开关图层才算能用。命中测试不要直接用VisualTreeHelper.HitTest——DrawingVisual里所有几何都是一个视觉对象,点哪儿都命中同一个东西,拿不到「点中了哪条线」。
可行的做法是反向查:先把鼠标点用view和fit的逆矩阵变换回世界坐标,然后线性扫一遍图元列表,线段用「点到线段距离 < 容差」判、折线拆段判、圆和弧用「到圆心距离与半径之差 < 容差」判。容差取5 / pxPerUnit,也就是屏幕上 5 个像素。几万条实体线性扫一遍在毫秒级,够用;真嫌慢就先按包围盒粗筛一遍,再对候选做精确判定。
public DxfPrimitive? HitTest(Point screenPoint, Matrix view, Matrix fit, double tolPx) { var inv = view * fit; // 组合矩阵 if (!inv.HasInverse) return null; inv.Invert(); // 屏幕 → 世界 var world = inv.Transform(screenPoint); double tol = tolPx / fit.M11; // 像素容差折回世界单位 foreach (var p in _primitives) { if (!_layerVisible[p.LayerIndex]) continue; switch (p) { case SegmentPrimitive s when DistanceToSegment(world, s.P0, s.P1) <= tol: return p; case PolylinePrimitive poly: for (int i = 0; i < poly.Points.Count - 1; i++) if (DistanceToSegment(world, poly.Points[i], poly.Points[i + 1]) <= tol) return p; break; } } return null; }图层开关比想象中简单:维护一个bool[] _layerVisible,每次切换时只重建DrawingVisual,DxfDocument和图元列表都不动。整张图的图元数不变,重建耗时只跟遍历次数有关,用户点击图层复选框基本上是瞬间响应。如果图纸特别大、重建超过 100ms,可以把每个图层单独出一个DrawingVisual,切换时只改对应视觉对象的可见性,连重建都省了。
最后留一份我自己验收时必走一遍的清单:
| 校验项 | 判据 | 出问题先查哪 |
|---|---|---|
| 图形能完整显示 | 适配后四周留白均匀,无裁切 | worldBounds是否正确 |
| Y 方向不颠倒 | 文字正向可读,圆在矩形的上方 | Matrix中Scale(s, -s)是否生效 |
| 圆不失真 | 量取圆的宽高像素一致 | 缩放比是否取了min(sx, sy) |
| 闭合多段线闭合 | 房间、轮廓无缺口 | BeginFigure的isClosed |
| 中文正常 | 图层名、文字无乱码 | $DWGCODEPAGE与编码映射 |
| 图层显隐正确 | 关掉某层后对应图形消失 | BYLAYER 是否在解析阶段消解 |
| 大图不卡 | 滚轮缩放帧率不低于 30 | 是否误用了 Shape 逐条添加 |
这份清单看着啰嗦,但每一条我都真栽过。从那以后,我每次接一个新的 DXF 来源,都会先拿一份最简单的「一条直线 + 一段圆弧 + 一个中文标注 + 一个块参照」的最小样本跑一遍这套检查,确认通过再上真实图纸。最小样本排错比几十兆的图纸快十倍。
希望帮到你。
本文还有配套的精品资源,点击获取