简介:面向WPF/.NET桌面开发者的DXF图纸可视化示例工程,聚焦将AutoCAD导出的DXF矢量文件解析后绘制到Canvas画布这一需求,适合具备C#与XAML基础、希望打通CAD数据到界面渲染链路的开发者参考。完整压缩包共50个文件、约143KB,以13个C#源码文件为核心,另含resx资源、ico图标及csproj/sln工程配置,并附带两个dxf示例图纸,可直接编译运行对照调试。已有1856人学习。代码按解析、模型映射与绘制分层组织,DrawingObject、Shapes等类承担几何实体到WPF形状对象的转换,MainGUI提供界面入口,便于理解图层、颜色、线型等属性如何映射为画布元素,并在此基础上扩展缩放、平移、选中高亮等交互。
1. 打开图纸一片空白,问题不在 Canvas
某开发者接手了一个老图纸预览需求:客户手里全是历史遗留的DXF 文件,要求在自家 WPF 桌面端里直接打开看,能缩放、能平移、能关图层。他第一版做的事很直觉——把文件读成字符串,正则扒出坐标,new Line(...)一个个往Canvas上塞。结果几百兆的图打开就是白板,鼠标滚轮滚到冒烟才在角落看见一个小点;换成小文件倒是能出图了,可十几个圆角弧线全画成了直角折线,客户当场就皱了眉。
问题的根源有两层:一是 DXF 里那套「Y 轴向上、单位为图纸单位」的坐标系,和 WPF「Y 轴向下、单位为像素」的渲染坐标是两种语言;二是 DXF 的实体远比「线段」复杂,圆弧藏在凸度里、圆角藏在多段线里。这个项目要落地的就是把这两层打通:解析 DXF 拿到几何数据,做包围盒归一化,再按图层用 GDI 风格的绘制接口一次性刷到 Canvas 相关的视觉层上。适合手上有存量图纸、又必须做纯桌面查看器的开发者,也适合要把图纸预览嵌进自己业务系统的人。下面按我实际拆过的顺序讲,从文件结构一路讲到二次开发怎么改。
2. 技术选型与工程结构:解析层和渲染层怎么切
动手之前先把边界划清楚。一套能长期维护的 DXF 查看器,一定是「解析层产出纯数据、渲染层只管画」的两段式:解析层不引用任何 WPF 类型,输出的是一堆double坐标和实体描述;渲染层拿到描述后,负责坐标变换、画笔样式和视觉树。这么切的直接好处是解析部分可以脱离界面写单元测试,图纸解析错了不用开着窗口调。
2.1 DXF 里到底存了什么:组码才是全篇的钥匙
ASCII 格式的 DXF 本质是一个纯文本文件,内容按「两行一组」排列:第一行是组码(整数),第二行是值。组码决定了这个值的语义——是坐标、是半径、还是图层名。整个文件被SECTION/ENDSEC切成若干段,跟显示最相关的是三段:HEADER段存全局变量,其中$EXTMIN和$EXTMAX给出了整张图的包围盒,这是自动缩放的现成钥匙;TABLES段里的LAYER表定义了所有图层及颜色;ENTITIES段则是实体的主战场。
截一段真实文件里的LINE实体,读起来是这样的:
0 LINE 8 轮廓线 10 1250.0 20 3400.0 30 0.0 11 1890.5 21 3400.0 31 0.0组码0表示「新实体的类型」,值LINE说明这是个线段;8是图层名,这里落在「轮廓线」图层;10/20/30是起点 XYZ,11/21/31是终点 XYZ。注意 Y 坐标是 3400 这种量级,而画布高度可能只有 800 像素——不做缩放直接画,当然什么都看不见。更关键的是,DXF 的 Y 轴向上为正,和数学坐标系一致,而屏幕坐标 Y 轴向下为正,两者差一个符号。
LWPOLYLINE(轻量多段线)是另一个必须吃透的类型,也是踩坑最多的地方。它的顶点不是一次性给的,而是10/20这一对组码反复出现,有 N 个顶点就出现 N 次。组码90声明顶点总数,组码70是标志位(1表示闭合),而可选的组码42是凸度(bulge)——它决定了这一小段是直线还是圆弧,值等于tan(θ/4),θ 是圆弧包含角。很多图纸里那些圆角、倒角,全藏在这个42里,只按直线处理的话,圆角就变成了尖角。
提示:
HEADER段的$EXTMIN/$EXTMAX不一定可信,有些二次编辑过的图纸会写错。稳妥做法是解析完实体后自己遍历一遍求包围盒,把$EXTMIN只当作兜底值。
2.2 两条解析路线:现成库还是自己写读取器
摆在新项目面前的路其实只有两条,选错了后面会很被动。
| 维度 | 现成解析库 | 手写组码读取器 |
|---|---|---|
| 开发成本 | 低,几行代码能拿到实体集合 | 中,需要处理几百行解析逻辑 |
| 实体覆盖面 | 全,样条、填充、标注都支持 | 看你自己补到哪,通常只做常用的几种 |
| 可控性 | 中,遇到库的 bug 只能绕 | 高,任何字段都能自己决定怎么解释 |
| 依赖风险 | 有,第三方库版本升级可能破坏 API | 无 |
| 适合场景 | 需要快速看到效果、图纸种类杂 | 只关心线框、追求零依赖、要深度定制 |
我一般的判断标准是:如果图纸来源五花八门、什么实体都有,先上现成库把路子跑通,社区里那个能通过 NuGet 装到的 DXF 库就是常见选择,入口是DxfDocument.Load(path),拿到Entities集合按类型筛选;如果只是自家系统导出的线框图,或者是嵌入式场景不想引第三方依赖,那就手写读取器,代码量其实也就几百行,而且调试时能精确知道每个数字从哪来。这个项目我选的是手写路线,原因很实际——渲染层需要的是「已经摊平的、带图层信息的、坐标统一的图元列表」,现成库给的是带层级关系的对象树,中间还要再转一层,不如直接一步到位。
2.3 工程目录:四个工程各管一段
目录结构按「解析不依赖 UI」的原则来切,一层层往下推:
DxfViewer/ ├── DxfViewer.Core/ # 纯 .NET 类库,零 WPF 引用 │ ├── IO/ │ │ ├── DxfCodeReader.cs # 组码流式读取器 │ │ └── DxfParser.cs # 段调度 + 实体分发 │ ├── Entities/ │ │ ├── DxfEntity.cs # 抽象基类,持图层名、颜色 │ │ ├── LineEntity.cs │ │ ├── CircleEntity.cs │ │ ├── ArcEntity.cs │ │ └── PolylineEntity.cs # 含 bulge 段 │ └── Model/ │ └── DxfDocumentModel.cs # 实体集合 + 包围盒 + 图层表 ├── DxfViewer.Rendering/ # WPF 类库 │ ├── DxfView.cs # 核心控件,OnRender 绘制 │ ├── ViewportController.cs # 缩放平移状态机 │ └── LayerFilter.cs # 图层显隐 ├── DxfViewer.App/ # WPF 主程序 │ ├── MainWindow.xaml │ └── ViewModels/ └── DxfViewer.Tests/ # 对 Core 的单元测试这个划分里最容易搞错的是DxfViewer.Rendering的定位。它是个类库,不是用户控件包,所以不要往里塞对话框、不要写 MessageBox,只对外暴露Load(DxfDocumentModel)和几个依赖属性(背景色、线宽、图层过滤集合)。视图模型层负责调文件对话框、做异步加载、把结果喂给控件。这样以后要把查看器嵌到别的系统里,直接引用这个类库就行,改动面很小。测试工程只测 Core,用几张几十行的手工 DXF 当夹具,验证解析出的顶点数、半径、闭合标志对不对。
3. 解析层落地:从组码到可绘制的几何对象
解析层的活儿可以拆成三段:先把文件按「组码-值」对读出来,再按当前所处的段和实体类型分派,最后把结果装进统一的中间模型。中间模型的设计决定了渲染层有多好写,所以不能偷懒直接把解析器的原始输出透传上去。
3.1 最小可用的组码读取器
读取器是整个解析的地基,写法上要避开一个致命的直觉陷阱:不要用字典按组码存值。原因在 2.1 里提过,LWPOLYLINE的10/20会重复出现几十次,用字典存的话后一个顶点直接覆盖前一个,最后你只拿到最后一个点,画出来就是一根孤零零的线。正确姿势是流式读取,读到什么处理什么。
public sealed class DxfCodeReader : IDisposable { private readonly StreamReader _reader; public DxfCodeReader(string path) { // DXF 常见 ANSI 编码,用带 BOM 探测的读取器兜底 _reader = new StreamReader(path, Encoding.Default, detectEncodingFromByteOrderMarks: true); } /// <summary> /// 读一对「组码 + 值」。返回值 false 表示文件读完。 /// code 为组码,value 为原始字符串(不做类型转换,交给上层决定) /// </summary> public bool TryReadPair(out int code, out string value) { code = -1; value = string.Empty; var codeLine = _reader.ReadLine(); if (codeLine is null) return false; // 文件结束 codeLine = codeLine.Trim(); if (codeLine.Length == 0) return TryReadPair(out code, out value); // 跳过空行 var valueLine = _reader.ReadLine(); if (valueLine is null) return false; // 组码行可能带前导空格,int.Parse 之前必须 Trim if (!int.TryParse(codeLine, NumberStyles.Integer, CultureInfo.InvariantCulture, out code)) throw new DxfFormatException($"非法的组码行:{codeLine}"); value = valueLine.Trim(); return true; } public void Dispose() => _reader.Dispose(); }这段逻辑里有三个参数值得说明。Encoding.Default是为了兼容国内老图纸常见的 GBK 编码,如果图层名读出来是乱码,八成就是这里的问题,改成Encoding.GetEncoding(936)通常能救回来。detectEncodingFromByteOrderMarks: true让 UTF-8 BOM 的文件也能正确识别。int.TryParse里指定InvariantCulture是个小习惯——组码是纯整数,用本地化解析在某些区域设置下会出幺蛾子。
递归跳过空行那段看着不起眼,实际很有用。不同的 CAD 导出工具在段与段之间留的空行数量不一样,有的工具还会在文件末尾补几行空白,不处理的话读到最后会抛异常。至于为什么返回string而不是直接转double:同一个组码在不同上下文里类型不同,组码70有时是标志位、有时是颜色索引,统一转double会丢信息,让上层按需解析更稳妥。
3.2 实体映射:LINE、CIRCLE、ARC、LWPOLYLINE 怎么拆
有了读取器,上层就是一个状态机。第一个循环负责找SECTION并记下段名,进入ENTITIES段后,看到组码0就开一个新实体,后面的组码全部喂给这个实体的解析方法,直到下一个0出现。
private List<DxfEntity> ParseEntities(DxfCodeReader reader) { var list = new List<DxfEntity>(); DxfEntity? current = null; while (reader.TryReadPair(out var code, out var value)) { if (code == 0) { // 遇到新的类型码,先把上一个实体收尾 if (current is not null && current is not EndOfSectionEntity) list.Add(current); if (value == "ENDSEC") break; // ENTITIES 段结束 current = CreateEntity(value); // 工厂按类型 new continue; } current?.ReadProperty(code, value); // 各实体自己消化组码 } return list; }这里的设计要点是ReadProperty交给每个实体子类实现,而不是在一个巨型switch里堆几百行。LineEntity.ReadProperty只认10/20/30/11/21/31/8这几个码,其他一律忽略;PolylineEntity.ReadProperty里则要维护「当前正在收第几个顶点」的游标——读到10说明新顶点开始,把上一个顶点的X定下来,读到20补上Y,读到42给这个顶点打上凸度标记。
圆弧的解析值得单独拎出来说,它是坐标翻转最容易翻车的地方。DXF 里ARC的圆心在10/20/30、半径在40、起始角在50、终止角在51,角度单位是度,方向约定是从起始角逆时针扫到终止角。把它换成顺时针就画反了。
// DXF 角度是「度」且逆时针,这里统一转成弧度存储 public override void ReadProperty(int code, string value) { switch (code) { case 10: CenterX = ToDouble(value); break; case 20: CenterY = ToDouble(value); break; case 40: Radius = ToDouble(value); break; case 50: StartAngle = ToDouble(value) * Math.PI / 180.0; break; case 51: EndAngle = ToDouble(value) * Math.PI / 180.0; break; case 8: LayerName = value; break; } } /// <summary>DXF 约定:逆时针从 StartAngle 扫到 EndAngle</summary> public double SweepAngle { get { var sweep = EndAngle - StartAngle; if (sweep <= 0) sweep += Math.PI * 2; // 跨 0 度的情况 return sweep; } }SweepAngle这个属性处理了跨 0 度的问题:如果起始角 350 度、终止角 10 度,直接相减是负数,加一圈2π才是对的。这个坑不处理,图纸里那些小圆角会变成反向的大圆弧,视觉上就是「明明是个圆角却画了个缺口」。
多段线的凸度转换则更绕一层。凸度b = tan(θ/4),反推包含角θ = 4 * atan(b)。知道包含角、弦的两个端点,就能算出这段圆弧的圆心:先求弦的中点和弦长,再用弦长 / 2 / sin(θ/2)得到半径,圆心在中垂线上按方向偏移。这部分我建议单独抽一个BulgeArc辅助类,里面就两个方法——ToSegments(p1, p2, bulge, tolerance)把凸度段离散成折线。离散化是性价比最高的选择:后面所有实体统一当折线处理,渲染层不用为圆弧写第二套逻辑,缩放时的精度问题也一并用「按当前缩放级别重新离散」兜住。
注意:离散化的容差要跟当前缩放比例挂钩,固定容差在大图上会看到明显的棱角。我一般按「一个像素对应的世界单位」来定弧高误差。
3.3 中间模型:一个 Rect 加一个列表就够了
解析完的产物不该直接暴露给渲染层,中间要有一层归一化模型:
public sealed class DxfDocumentModel { public IReadOnlyList<DxfEntity> Entities { get; } public IReadOnlyList<string> Layers { get; } // 世界坐标下的包围盒,Left=MinX, Top=MinY, Bottom=MaxY // 注意 Top 是「数学上的小值」,不是屏幕的上边 public Rect Bounds { get; } public DxfDocumentModel(IReadOnlyList<DxfEntity> entities) { Entities = entities; Layers = entities.Select(e => e.LayerName).Distinct().OrderBy(x => x).ToList(); double minX = double.MaxValue, minY = double.MaxValue; double maxX = double.MinValue, maxY = double.MinValue; foreach (var e in entities) { e.AccumulateBounds(ref minX, ref minY, ref maxX, ref maxY); } // 纯直线构成的图可能宽高为 0,给个最小尺寸防除零 var w = Math.Max(maxX - minX, 1e-6); var h = Math.Max(maxY - minY, 1e-6); Bounds = new Rect(minX, minY, w, h); } }Bounds用Rect存,但字段语义要提前约定死:Left = MinX、Top = MinY、Bottom = MaxY。这里Top容易被误读成「屏幕上的顶部」,实际它在数值上是 Y 的最小值——毕竟 DXF 的 Y 向上。第 4 章的坐标变换公式直接依赖这个约定,写错一个符号图形就会上下颠倒。
AccumulateBounds放在实体基类里,各子类按自己的几何特征实现:线段取两端点;圆取圆心加减半径;圆弧如果求精确包围盒比较麻烦,直接用整个圆的包围盒兜底,稍微偏大一点点不影响视觉效果,还省了一堆三角函数。
4. 渲染层落地:Canvas 上的坐标变换和几千条线的画法
渲染层要解决三件事:怎么从世界坐标算出屏幕坐标、怎么画才不卡、怎么让鼠标滚轮和拖拽用起来顺手。这三件事各自独立,可以分头做。
4.1 坐标对齐:Y 轴翻转加包围盒缩放
坐标系变换是整篇里最容易写成玄学的一段。我的写法很朴素,不玩矩阵,直接给两个映射函数:
public sealed class Viewport { private Rect _world; // 世界坐标包围盒 private double _scale = 1.0; // 世界单位 -> 像素 private Point _origin; // 世界包围盒左上角在屏幕上的落点 /// <summary>按控件大小自适应缩放,留 8% 边距</summary> public void Fit(Rect world, double viewW, double viewH) { _world = world; var sx = viewW / world.Width; var sy = viewH / world.Height; _scale = Math.Min(sx, sy) * 0.92; // 0.92 是留白系数 _origin = new Point( (viewW - world.Width * _scale) / 2, // 水平居中 (viewH - world.Height * _scale) / 2); // 垂直居中 } public Point ToScreen(Point p) => new( _origin.X + (p.X - _world.Left) * _scale, _origin.Y + (_world.Bottom - p.Y) * _scale); // 关键:Bottom - y 实现 Y 翻转 }Fit里的Math.Min(sx, sy)保证整张图都能塞进视口不变形——用Max的话图会被裁掉一截。0.92这个留白系数是我试出来的,取 1.0 时图形正好贴边,视觉上很挤;取 0.85 又浪费太多空间。0.9 到 0.95 之间都行,凭手感调。
ToScreen里_world.Bottom - p.Y是整段代码的灵魂。因为Bounds.Top = MinY、Bounds.Bottom = MaxY,所以MaxY - y恰好把「Y 越大越靠上」翻成「Y 越大越靠下」。新手最常见的错法是写成p.Y - _world.Top,画出来的图整体上下镜像,文字全反,看着很诡异,但要盯很久才反应过来。
缩放平移交互时不建议重算这个映射,直接在控件外层套RenderTransform:
public void ZoomAt(Point screenCenter, double factor) { // 以鼠标位置为中心缩放:先反算出该点的世界坐标,缩放后重新对齐 var worldAtCursor = new Point( _world.Left + (screenCenter.X - _origin.X) / _scale, _world.Bottom - (screenCenter.Y - _origin.Y) / _scale); _scale = Math.Clamp(_scale * factor, _minScale, _maxScale); _origin = new Point( screenCenter.X - (worldAtCursor.X - _world.Left) * _scale, screenCenter.Y - (_world.Bottom - worldAtCursor.Y) * _scale); InvalidateVisual(); // 触发重绘 }factor一般给1.1或0.9,滚轮一格缩放 10% 手感比较跟手。Math.Clamp卡住上下限很重要,_minScale我取「整图刚好填满视口」的值再乘 0.5,_maxScale取初始值的 50 倍左右,不然用户能一直滚到无穷大,最后界面假死不响应。
注意这里是以鼠标位置为中心缩放,而不是以视口中心。区别在于用户体验——以鼠标为中心时,用户想把某个角落放大,鼠标指哪儿哪儿就变大,符合直觉;以视口中心缩放的话,每一次缩放都要重新找目标,用起来很别扭。
4.2 用 OnRender 一次性绘制,别往 Canvas 里塞 Shape
第一版之所以卡成幻灯片,是因为Canvas.Children.Add(new Line(...))这种方式。每一个Shape都是一个独立的UIElement,进视觉树后要参与布局、命中测试、渲染批次划分,几千个就是几千次开销,上万条线直接卡到没法交互。
正确做法是让整个控件变成一个绘制面,重写OnRender,用一个DrawingContext把几何一次性刷出去:
protected override void OnRender(DrawingContext dc) { base.OnRender(dc); if (_doc is null) return; dc.DrawRectangle(_background, null, new Rect(RenderSize)); // 先铺底色 foreach (var entity in _doc.Entities) { // 图层被关掉的直接跳过,省下的是一次完整的几何构建 if (!_layerFilter.IsVisible(entity.LayerName)) continue; var pen = _penCache.GetPen(entity); // 按图层色缓存画笔,别在循环里 new switch (entity) { case LineEntity line: dc.DrawLine(pen, _viewport.ToScreen(line.Start), _viewport.ToScreen(line.End)); break; case CircleEntity circle: dc.DrawEllipse(null, pen, _viewport.ToScreen(circle.Center), circle.Radius * _viewport.Scale, circle.Radius * _viewport.Scale); break; case PolylineEntity pl: // 多段线在 3.2 里已离散成折线,这里直接构建 StreamGeometry dc.DrawGeometry(null, pen, BuildPolylineGeometry(pl)); break; } } }这里有三个性能关键点。第一,Pen对象一定要缓存。同一个图层里可能有两万条线,循环里new Pen(Brushes.Black, 1)两万次,光是对象分配就够 GC 忙一阵了,按图层名做 key 缓存,命中率接近 100%。第二,_viewport.Scale是直接读属性,不要在循环里反复调用ToScreen去反推。第三,BuildPolylineGeometry用的是StreamGeometry,这是 WPF 里最轻的几何容器,比PathGeometry省内存,适合一次性构建、多次绘制、不需要再修改的场景。
OnRender什么时候被调用由框架决定,你只需要在数据变化时调InvalidateVisual()。缩放时如果走的是RenderTransform路线,其实连重绘都不用,框架直接用变换矩阵缩放已有的渲染数据,帧率能稳在 60。只有切图层、换图纸这种内容变化才需要真正重绘。
提示:
OnRender里千万别做 IO、别解析文件、别调MessageBox。要给用户「正在加载」的反馈,在加载前先把状态切到 Loading,异步解析完再InvalidateVisual。
4.3 图层开关和鼠标拾取
图层控制本身很简单,就是一个HashSet<string>加一个IsVisible判断。但有几处细节影响体验:一是过滤后要重新计算可见实体的包围盒,否则关掉大半个图层后,用户点「适应窗口」,图还是缩在角落里;二是图层列表要按 DXF 里的原始顺序排,不要按字母序——图纸作者排的顺序往往有语义,比如「先画墙体再画标注」,打乱了用户找不到想要的那层。
鼠标拾取(点一下选中最近的图元)不用上VisualTreeHelper.HitTest,因为我们的图元压根不在视觉树里。常规做法是自己在世界坐标里做距离判断:把鼠标屏幕坐标反算成世界坐标,然后对每个可见实体算「点到实体」的最短距离,阈值取3 / _scale(屏幕上 3 个像素对应的世界距离),取最近的那个。圆和线段都有闭式解,多段线就逐段遍历。图元数量过万时可以做一次空间索引,比如按包围盒切成网格,但一般图纸几万条以内,暴力遍历加提前用包围盒剪枝就够快了。
public DxfEntity? HitTest(Point screenPoint) { var world = _viewport.ToWorld(screenPoint); // ToScreen 的逆运算 var tol = 3.0 / _viewport.Scale; // 屏幕上 3px 的容差 DxfEntity? best = null; double bestDist = double.MaxValue; foreach (var e in _doc.Entities) { if (!_layerFilter.IsVisible(e.LayerName)) continue; if (!e.Bounds.Inflate(tol).Contains(world)) continue; // 包围盒快速剪枝 var d = e.DistanceTo(world); if (d < tol && d < bestDist) { bestDist = d; best = e; } } return best; }Inflate(tol)这一步是做膨胀包围盒,意义在于鼠标点到线宽边缘外一两个像素也该算命中。DistanceTo让每个实体自己实现:线段用点到线段距离公式(注意是线段不是直线,投影点要Clamp到端点之间);圆是|dist(center, p) - radius|;多段线取所有子段的最小值。
5. 避坑与排查:这几个坑我基本每次都会踩
解析器和渲染器分开写,出问题时定位会快很多:图能出来但形状不对,问题在解析;形状对但看不见,问题在变换。下面这五条是我在实际项目里反复踩到的,按通用性从高到低排。
5.1 解析阶段的坑
现象一:多段线只画出一个点或一根线段,圆角全部消失。
原因是用了字典按组码存值。LWPOLYLINE的10/20会重复出现 N 次,字典存完只剩最后一对,前面的顶点全被覆盖掉了。这个问题很阴险——小文件里的直线段看着正常,只有带圆角的复杂多段线才暴露。
解决方式是回到 3.1 的流式读取,PolylineEntity.ReadProperty里维护顶点游标:读到10就_vertices.Add(new Vertex { X = parse(value) }),读到20就把值赋给_vertices[^1].Y,读到42就赋给_vertices[^1].Bulge。另外一定要读组码90(声明顶点数)做一次校验,数量对不上就说明解析逻辑有问题,早发现早改。
现象二:打开某个文件直接抛异常,或者读出一堆乱码图层名。
八成是编码问题,也可能是碰到了二进制 DXF。ASCII DXF 和 BINARY DXF 的文件头不一样,二进制格式的起始哨兵是AutoCAD Binary DXF,后面紧跟一个控制字符。国内老工具导出的文件还常是 GBK 编码,用 UTF-8 读图层名就会出来一串问号。
解决方案是打开文件后先读前 22 个字节判断是不是二进制,是就直接给用户一个明确提示「暂不支持二进制 DXF,请另存为 ASCII 格式」,不要硬解。编码方面,先试Encoding.Default,如果读出来的图层名里出现连续的替换字符\uFFFD,降级到Encoding.GetEncoding(936)再读一遍。
现象三:图纸打开有内容,但少了整整一层,或者某些图元凭空消失。
两种可能。一种是实体落在了BLOCKS段定义的块里,图纸里通过INSERT引用,而INSERT自带平移、旋转、缩放三个变换参数(组码10/20/30是插入点,41/42/43是 XYZ 缩放,50是旋转角)。如果只解析ENTITIES段不处理INSERT,那些被块包裹的图元就全丢了。另一种是图层被标记为「关闭」或「冻结」,组码70的值带对应标志位。
解决方式分两步:先判断是否真的需要支持块,如果图纸里全是散图元就不用管;如果需要,就在遇到INSERT时找到同名BLOCK定义,把里面的实体递归展开,每个点按INSERT的变换参数做一次仿射变换再并入结果。图层状态则解析LAYER表里的70组码,负数表示关闭,默认不渲染但列表里保留,给用户手动打开的机会。
5.2 渲染与交互的坑
现象四:图形整体上下颠倒,或者文字注释看着是镜像的。
这是 Y 轴翻转写错方向的经典表现。常见错法是screenY = (p.Y - _world.Top) * scale,看起来好像没问题,但 DXF 的Top是MinY,这样算出来 Y 越大越靠下,本来在上方的图形跑到了下方,整体镜像。
解决方式就是 4.1 里的公式:_world.Bottom - p.Y。如果一时判断不了是不是反了,有个笨办法很好用——在图纸原点画一个小十字标记,加载后看这个十字在左上角还是左下角。DXF 里原点一般在左下,屏幕上应该出现在左下,偏了就是翻转错了方向。
现象五:图纸能显示,但鼠标滚轮缩放一顿一顿的,拖拽时画面撕裂。
第一版常犯的错误是在OnRender里做几何构建,缩放时每次InvalidateVisual都把上万个Geometry重建一遍,65 毫秒的预算根本不够。还有一种情况是没做缩放层级差异,无论放大到多少倍都用同一个离散精度画圆弧,放大到 100 倍时那点折线段数完全不够。
解决方案是把「内容重绘」和「视图变换」彻底分开。缩放平移只改_scale和_origin之后套RenderTransform,不调InvalidateVisual;等鼠标停了 200 毫秒(用DispatcherTimer做防抖),再按当前缩放级别重新离散并真正重绘一次,把精度补上去。这样连续滚动时画面是平滑的(只是用旧精度),停下来之后自动变清晰,视觉上完全感觉不到延迟。
6. 二次开发:加实体、加图层控制、加导出
图纸能正确显示之后,真正的改造才刚刚开始。大多数业务系统要的不是一个纯查看器,而是「在图上看数据、从图里拿数据」。这里说三个最常见的方向,改起来都不难,但顺序有讲究。
第一个方向是补实体类型。3.2 里的switch少了几种常用实体,按优先级补的话,顺序建议是TEXT(文字标注,组码1是内容、40是字高、50是旋转角)、HATCH(填充,比较复杂,可以先跳过只记录包围盒)、SPLINE(样条曲线,用70判断是否闭合,控制点走10/20/30,权重走41,需要自己写 B 样条求值)。补的时候遵守一条原则:新实体不要污染渲染层的 switch,而是让实体自己实现一个BuildGeometry(Viewport)接口,渲染层只调这一个方法。这样加实体就只改 Core 工程,DxfView一行都不用动。
第二个方向是图层控制面板。给每个图层配一个可见性开关加一个颜色覆盖,数据存在LayerFilter里。要注意的是颜色覆盖别直接改实体的Color属性——用户在面板里改一下颜色,原始数据就被污染了,下次导出还是这个色。正确做法是在LayerFilter里维护一个Dictionary<string, Color>的覆盖表,渲染时优先查这个表。另外图层开关切换后记得重算可见包围盒并调Fit,不然用户点「适应窗口」会得到一张缩在角落的图。
第三个方向是导出图片,用于把当前视图塞进报表。这里用RenderTargetBitmap就行,但有个细节:默认的RenderTargetBitmap按 96 DPI 生成,导出到 A4 报告里会糊。指定 DPI 和像素尺寸时要按物理尺寸换算:
public void ExportPng(string path, double dpi = 300) { // ViewW/ViewH 是当前视口的实际尺寸(设备无关单位) var pxW = (int)(ViewW * dpi / 96.0); var pxH = (int)(ViewH * dpi / 96.0); var rtb = new RenderTargetBitmap(pxW, pxH, dpi, dpi, PixelFormats.Pbgra32); rtb.Render(this); // this 是 DxfView,别忘了先 Fit 到合适比例再渲染 var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(rtb)); using var fs = File.Create(path); encoder.Save(fs); }dpi / 96.0这个系数是关键,96 是 WPF 的逻辑 DPI,300 是印刷常用的物理 DPI,换算完才能保证导出的图片在纸面上尺寸正确。还有个坑是导出的背景——OnRender里画的是深色背景,直接导出去打印一片黑。做法是在导出前临时把背景刷改成白色,渲染完再改回来,或者干脆给DxfView加一个ExportMode属性,OnRender里判断一下换配色。
| 改造方向 | 改动位置 | 工作量 | 备注 |
|---|---|---|---|
| 补实体类型 | Core/Entities 新增子类 | 小 | 每种实体约 60 行 |
| 图层控制面板 | Rendering/LayerFilter | 中 | 注意别污染原始数据 |
| 导出图片 | Rendering/DxfView | 小 | 先 Fit 再导出 |
| 坐标拾取精度 | Viewport.ToWorld | 小 | 依赖反向变换正确 |
改造时的验证方法我有个固定套路:每加一种实体,先在测试工程里造一个只含该实体的 20 行 DXF 手工文件,跑一遍解析,断言顶点数、包围盒和预期一致;渲染部分则用一张「全实体总览图」做人工比对,一眼就能看出哪种实体画歪了。这套流程跑熟了,一次能省掉半天翻车的调试。
最后说个教训。早年我做这类图纸查看器,习惯性先把渲染跑通再回头抠解析,结果渲染层为了迁就错误的解析结果打了一堆补丁,等发现是凸度没处理的时候,那些补丁已经和渲染逻辑缠在一起,只能整体重写。从那以后我每次接 DXF 相关的活儿,都强制先让解析层输出一份纯文本的图元清单——每条线是哪个图层、起终点是啥、圆弧的凸度是多少——人工核对没问题了,再动渲染那部分代码。这个习惯帮我省下的时间,比任何优化技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取