简介:为C#与WPF开发者提供的DXF文件解析与绘图二次开发实用资源,解决在工业软件、图纸管理类项目中读取CAD几何数据并在界面中重新绘制的核心需求。资源实现了对DXF文件中直线、圆形、圆弧以及Polyline2D图元数据集的有效读取,并基于Canvas控件完成图形重绘与界面展示,同时封装自定义Canvas控件便于重复引用。压缩包共包含140个文件,以C#源码(34个cs)、XAML界面(5个xaml)与编译输出(16个dll、16个pdb)为主,另有配置文件、示例DXF文档等辅助内容,整体体积仅2.28MB。已有497人学习使用。开发人员可直接参考图元解析流程、坐标换算与Canvas绘制思路,也可复用自定义控件快速嵌入自身项目,尤其适合需要了解DXF格式解析原理或开展CAD相关功能开发的C#工程师。
1. C#、WPF实现读取CAD的DXF文件,并绘图:从一个文本文件到可交互的CAD看图方案
很多人一听到“读取CAD文件”就觉得要先装几百兆的CAD软件、走一堆二次开发接口,其实完全不用。C#、WPF实现读取CAD的DXF文件,并绘图这件事,本质上只需要两样东西:一个能按行读文本的StreamReader,一个能画线的DrawingContext。DXF 是 CAD 软件公开的中性交换格式,它不是二进制黑匣子,而是一个纯文本协议;解析它不需要任何 CAD 软件授权,也不需要调用 COM 接口。你只需要把组码配对规则理解透,再用 WPF 的DrawingVisual把实体画到屏幕上,就能做出一个可平移、可缩放、可查询图元属性的轻量级 CAD 看图器。这个方案特别适合做图纸预览、工程数据提取、参数化图元生成这类场景。接下来我会按“格式拆解 → 解析实现 → WPF绘图 → 踩坑记录 → 性能进阶”这条路把整套做法讲透。
2. 认识DXF的组码与三段结构:为什么它是轻量级图纸方案的首选
2.1 组码是DXF的语法核心:先看懂一对“两行”的读写规则
DXF 的原始形态是一个文本文件,每一行都是独立数据。文件里最基本的读写单元叫“组码对”,一个组码对包含连续的两行:第一行是整数组码,第二行是值。组码决定“这一行数据是什么”,值就是数据本身。例如下面这两个组码对:
10 250.0 20 150.0组码10表示点的 X 坐标,值250.0;组码20表示点的 Y 坐标,值150.0。你写解析器时,必须始终保持“先读组码行,再读值行”的节奏,一旦顺序错乱,整个文件都会解析错位。这也解释了为什么 DXF 解析不能用正则表达式一次性匹配整段——它的信息是分散在行与行之间的,只能逐行顺序读。
我在解析器里维护一个currentCode和currentValue的配对循环,每次读两行,处理一组。核心逻辑用一个while循环包住,遇到组码0时,说明一个新的实体、段或标记开始了,这时需要根据currentValue的内容做状态切换。组码0是 DXF 里最重要的分界符,它的值可以是SECTION、ENTITY名称(如LINE、CIRCLE)、EOF等。解析到EOF就结束。
2.2 只做读取与绘图,你只需关心三个段:HEADER、TABLES、ENTITIES
一个完整 DXF 文件包含 HEADER、CLASSES、TABLES、BLOCKS、ENTITIES、OBJECTS 等多个段,但如果是做“读取并绘图”,真正需要处理的只有三个段。
HEADER 段提供图纸级参数。最常用的是$ACADVER(CAD 版本)、$INSUNITS(单位制,0 为无单位,1 为英寸,4 为毫米)、$EXTMIN和$EXTMAX(图纸范围的最小/最大角点)。这些值在后续坐标变换时非常关键,尤其是单位制,决定了你画出来的图是 254 毫米还是 10 英寸。
TABLES 段里最重要的是 LAYER 表。每个图层记录包含名称(组码2)、颜色号(组码62)、线型名(组码6)等。图元的实际颜色通常继承自图层,所以解析 LAYER 表是后面绘图时确定画笔颜色的前置条件。
ENTITIES 段是核心,它包含所有可见图元:LINE、CIRCLE、ARC、LWPOLYLINE、SPLINE、INSERT(块引用)、TEXT、MTEXT、DIMENSION 等。每种图元的几何数据都通过一组固定的组码来表达。例如最简单的 LINE 实体:
0 LINE 8 0 10 0.0 20 0.0 30 0.0 11 100.0 21 50.0 31 0.0组码8是图层名,10/20/30是起点 XYZ,11/21/31是终点 XYZ。你只需要把这些数值取出来,构造一个内部图元对象,后面在 WPF 里就可以按类型画出对应的形状。
提示:不要一开始就想支持所有实体类型。先从 LINE、CIRCLE、ARC、LWPOLYLINE、TEXT 这五种最常见的实体起步,能覆盖绝大多数机械和建筑图纸的 80% 以上可见内容。
BLOCKS 段和 INSERT 实体是配套出现的。如果你的图纸里有很多 INSERT 引用,说明图中有大量复用块。处理路径是:先解析 BLOCKS 里的块定义,再在渲染 INSERT 时做块内图元的坐标平移与旋转。这一步建议放在基础实体跑通之后再做,否则调试成本会很高。
2.3 坐标系与单位约定:把 CAD 世界坐标映射到 WPF 屏幕坐标
DXF 使用世界坐标系(WCS),坐标原点在图纸左下角,X 轴向右,Y 轴向上,Z 轴遵守右手定则。而 WPF 的屏幕坐标系是 X 轴向右,Y 轴向下的。这意味着你绘图时必须做一步 Y 轴翻转,否则画出来的图纸是上下镜像的。
翻转公式很简单:screenY = (boundsMaxY - entityY) * scale。其中boundsMaxY是整张图纸在所有实体中的最大 Y 值,scale是缩放系数。这样把 CAD 里的高坐标映射到屏幕上方,符合看图习惯。
单位问题同样影响坐标系。同一张图,$INSUNITS为 4(毫米)时,一条 100 长度的线表示 100 毫米;为 1(英寸)时表示 100 英寸。如果忽略单位换算,在毫米图纸上画出来的视觉效果会偏大 25.4 倍,导致平移缩放时边界框范围异常。我一般在解析 HEADER 后就把所有坐标统一换算成毫米,后续逻辑只认一种单位。
3. 用C#写一个够用的DXF解析器:组码配对、段分发与实体映射
3.1 解析器的整体骨架:按行读取组码对,并跟踪段状态
下面这段代码是一个最小可用的 DXF 解析骨架。它只负责按组码对读取文件,并把不同段分发到对应处理方法里。为了控制篇幅,我把所有实体解析都放在ParseEntity一个方法里做分派。
public class DxfDocument { public Dictionary<string, DxfLayer> Layers { get; } = new(); public List<DxfEntity> Entities { get; } = new(); public double Units { get; private set; } = 4; // 默认按毫米 public DxfPoint ExtMin { get; private set; } public DxfPoint ExtMax { get; private set; } public void Load(string filePath) { using var reader = new StreamReader(filePath, Encoding.UTF8); string currentSection = string.Empty; string currentEntityType = string.Empty; var pendingGroups = new Dictionary<int, string>(); bool inHeader = false; while (!reader.EndOfStream) { string codeLine = reader.ReadLine()?.Trim() ?? string.Empty; string valueLine = reader.ReadLine() ?? string.Empty; if (!int.TryParse(codeLine, out int code)) continue; if (code == 0) { // 上一次积累的图元数据,在这里统一提交 if (currentEntityType.Length > 0 && pendingGroups.Count > 0) { ParseEntity(currentEntityType, pendingGroups); pendingGroups.Clear(); } string marker = valueLine; if (marker == "SECTION") { currentSection = string.Empty; } else if (marker == "HEADER") { currentSection = "HEADER"; inHeader = true; } else if (marker == "TABLES") { currentSection = "TABLES"; } else if (marker == "ENTITIES") { currentSection = "ENTITIES"; } else if (marker == "EOF") { break; } else if (currentSection == "ENTITIES") { currentEntityType = marker; pendingGroups.Clear(); } else if (currentSection == "TABLES" && marker == "LAYER") { // 图层记录开始,单独处理 } continue; } if (currentSection == "HEADER") { HandleHeaderGroup(code, valueLine); } else if (currentSection == "TABLES") { HandleTableGroup(code, valueLine); } else if (currentSection == "ENTITIES") { pendingGroups[code] = valueLine; } } if (currentEntityType.Length > 0 && pendingGroups.Count > 0) { ParseEntity(currentEntityType, pendingGroups); } } }我这里用pendingGroups这个字典把组码到值的映射临时缓存起来,遇到下一个组码0时,说明当前实体已经结束,就把整组数据交给ParseEntity去实例化具体图元。代码里最需要注意的一点是:组码0的valueLine可能是SECTION,也可能是LINE,还可能是EOF,必须先判断当前所处段,再决定它的语义。否则会把实体名误判成段名。
参数说明:HandleHeaderGroup里你应该关注$INSUNITS、$EXTMIN、$EXTMAX这三个变量。$EXTMIN/$EXTMAX的 X/Y/Z 分别用组码10/20/30和11/21/31表示。用于提前计算图纸包围盒,可以避免渲染阶段遍历所有实体时再重复计算一次。
3.2 实体映射:把组码字典转成可绘制的图元对象
拿到pendingGroups字典后,就是纯数据映射工作。不同实体类型的组码含义不同,我在ParseEntity里用switch分派。下面以 LINE、CIRCLE、ARC 和 LWPOLYLINE 为例说明。
private void ParseEntity(string type, Dictionary<int, string> g) { double Get(int code, double defaultValue = 0.0) => g.TryGetValue(code, out var s) && double.TryParse(s, out var v) ? v : defaultValue; switch (type) { case "LINE": Entities.Add(new DxfLineEntity { Layer = g.GetValueOrDefault("8", "0"), Start = new DxfPoint(Get(10), Get(20), Get(30)), End = new DxfPoint(Get(11), Get(21), Get(31)) }); break; case "CIRCLE": Entities.Add(new DxfCircleEntity { Layer = g.GetValueOrDefault("8", "0"), Center = new DxfPoint(Get(10), Get(20), Get(30)), Radius = Get(40) }); break; case "ARC": Entities.Add(new DxfArcEntity { Layer = g.GetValueOrDefault("8", "0"), Center = new DxfPoint(Get(10), Get(20), Get(30)), Radius = Get(40), StartAngle = Get(50), EndAngle = Get(51) }); break; case "LWPOLYLINE": var poly = new DxfPolylineEntity { Layer = g.GetValueOrDefault("8", "0"), Closed = Get(70, 0) == 1 }; // 顶点成组出现:X为10,Y为20,凸度为42 for (int i = 0; i < 10000; i++) { if (!g.TryGetValue(10 + i * 100, out var xStr)) break; double x = double.Parse(xStr); double y = double.Parse(g[20 + i * 100]); double bulge = g.TryGetValue(42 + i * 100, out var bStr) ? double.Parse(bStr) : 0.0; poly.Vertices.Add(new DxfVertex(x, y, bulge)); } Entities.Add(poly); break; } }这段代码的关键在于 LWPOLYLINE 的顶点组码规律:第 1 个顶点的 X 是组码10,Y 是组码20;第 2 个顶点的 X 是组码110,Y 是组码120;第 3 个顶点的 X 是210,Y 是220。以此类推,顶点索引每增加 1,组码值加 100。
注意:这种“组码 + 索引偏移”是 DXF 里最容易踩坑的地方。很多新人只读取组码 10/20 就以为拿到了所有顶点,结果多顶点多段线画出来只有第一个点。
Get这个局部函数的默认值机制,保证了即使某个图元缺少某些组码,解析器也不会直接抛异常。这在面对真实工程图纸时非常重要——很多 CAD 软件导出的 DXF 并不完全规范,缺组码、多组码是常态。解析器的健壮性,本质上是“收窄输入,放宽容错”。
3.3 文件的编码问题:GBK 还是 UTF-8,直接影响文本和图层名的正确性
DXF 规范按版本区分编码:R12 及更早版本多为 ANSI 编码,R2000 及以后通常为 UTF-8,但很多国产 CAD 软件导出的文件标注是 UTF-8,实际写的是 GBK。直接用Encoding.UTF8读,TEXT 实体里的中文会乱码,图层名如果含中文,也会出现匹配不到颜色的情况。
我建议按下面的策略读取:先尝试 UTF-8 严格解码,如果出现DecoderFallbackException,就回退到 GBK。实现上可以封装一个DetectEncoding方法:
public static Encoding DetectDxfEncoding(string filePath) { // 先读前 2KB 看是否有非法 UTF-8 序列 using var fs = File.OpenRead(filePath); byte[] buffer = new byte[2048]; int read = fs.Read(buffer, 0, buffer.Length); string utf8Candidate = Encoding.UTF8.GetString(buffer, 0, read); // 简单判断:UTF-8 解码后是否含常见中文替换符 if (utf8Candidate.Contains('\uFFFD')) { return Encoding.GetEncoding("GBK"); } return Encoding.UTF8; }这个方案仍然有误判可能,但应对绝大多数由国内 CAD 软件导出的 DXF 文件是够用的。如果你处理的图纸来源固定,最稳妥的办法是直接问对方用的是哪个 CAD 版本导出的,或者打开文件看前几行有没有$ACADVER字段,AC1015(R2000)以上版本按 UTF-8 读,AC1009(R12)则优先 GBK。我把这个判断逻辑封装进Load方法里,用户就不需要手动指定编码了。
4. 在WPF里把图元画出来:DrawingVisual、坐标翻转与图层颜色映射
4.1 为什么选择 DrawingVisual 而不是 Canvas + Shape 控件
第一反应是用Canvas加Line、Ellipse控件是正常想法,但真实图纸动辄上万图元,每个Line控件都是一个独立的FrameworkElement,会触发布局、样式、事件等大量开销,绘制万级图元时 UI 线程会卡到无法拖动。我的实际做法是用FrameworkElement子类挂一个DrawingVisual,利用 WPF 的渲染层直接画图形。DrawingVisual不是控件,它没有布局参与,也不会产生独立的可视树节点开销。
public class DxfDrawingVisualHost : FrameworkElement { private readonly DrawingVisual _visual = new DrawingVisual(); public DxfDrawingVisualHost() { AddVisualChild(_visual); } protected override int VisualChildrenCount => 1; protected override Visual GetVisualChild(int index) => _visual; public void RenderAllEntities(DxfDocument doc, double scale, DxfPoint origin) { using (DrawingContext dc = _visual.RenderOpen()) { foreach (var entity in doc.Entities) { RenderEntity(dc, entity, doc.Layers, scale, origin); } } } }核心机制是RenderOpen()拿到一个DrawingContext,在这个上下文里调DrawLine、DrawEllipse、DrawGeometry等绘制方法,最后调用Close()一次性提交渲染指令。RenderOpen里的绘制命令不会立刻触发每帧布局,而是被 WPF 的渲染线程批量执行,性能非常好。
4.2 把世界坐标转换为屏幕坐标:一个ToScreen方法搞定
坐标转换是整个绘图正确性的基础。前面说过 DXF 是左下角原点、Y 向上,WPF 是左上角原点、Y 向下。要做两件事:原点平移和 Y 轴翻转。我封装一个Transform类统一管理,避免在绘图逻辑里散落坐标计算。
public class DxfViewTransform { public double Scale { get; set; } = 1.0; public double OffsetX { get; set; } public double OffsetY { get; set; } public Point ToScreen(DxfPoint world) { // OffsetY 里已经包含了包围盒的 MaxY,翻转在此完成 double sx = (world.X - OffsetX) * Scale; double sy = (OffsetY - world.Y) * Scale; return new Point(sx, sy); } }使用方式:在RenderAllEntities之前,先从DxfDocument遍历所有实体求出ExtMin/ExtMax。OffsetX设为ExtMin.X,OffsetY设为ExtMax.Y,Scale根据控件可用尺寸和图纸宽度计算,公式是:
double availableWidth = ActualWidth - margin * 2; double availableHeight = ActualHeight - margin * 2; double scale = Math.Min( availableWidth / (doc.ExtMax.X - doc.ExtMin.X), availableHeight / (doc.ExtMax.Y - doc.ExtMin.Y));这个scale取的是 X/Y 两个方向缩放比的较小值,防止图纸变形。如果你不需要保持纵横比,可以分别算sx和sy,适合那种只填满控件、不关心比例的预览场景。
4.3 图层颜色映射:颜色号与 WPF Brush 的对应
DXF 的图层颜色号是 1 到 255 的整数索引,其中 1 是红色、2 是黄色、3 是绿色、4 是青色、5 是蓝色、6 是洋红、7 是白色(黑底上显示为白色),其余颜色是调色板索引色。WPF 里没有原生调色板,需要自己维护一张索引到Color的映射表。
另一个重要细节:图元默认继承图层颜色,但如果实体自身的组码62存在,则该实体覆盖图层颜色。我在构建画笔时按优先级取色:
private Brush ResolveEntityBrush(DxfEntity entity, Dictionary<string, DxfLayer> layers) { int colorIndex = 7; // 默认白色 if (entity.ColorIndex != null) { colorIndex = entity.ColorIndex.Value; } else if (layers.TryGetValue(entity.Layer, out var layer)) { colorIndex = layer.ColorIndex; } int rgb = DxfColorPalette.IndexToRgb(colorIndex); var brush = new SolidColorBrush(Color.FromRgb( (byte)(rgb >> 16), (byte)(rgb >> 8), (byte)rgb)); brush.Freeze(); // 冻结,提升渲染性能 return brush; }一笔画线时,Pen也应该Freeze()。WPF 中冻结的Brush和Pen可以在渲染线程跨线程使用,不被冻结则需要 Dispatcher 封送,性能差异在万级图元时非常明显。每次画之前先查Brush缓存字典,避免为每个实体都 new 一个SolidColorBrush。这种微优化在 5 万实体以上的图纸上,能差出 300 毫秒左右的绘制耗时。
5. 读取DXF绘图避坑:单位、镜像、样条拟合与乱码问题
5.1 单位制没看就画,整张图小 25.4 倍是常态
现象:画出来的图所有尺寸都比原图缩水,比如一条标注 254 毫米的线在屏幕上只有 10 像素宽,怎么缩放都对不上。
原因:$INSUNITS组码值为 1(英寸)时,你仍按毫米逻辑设定缩放比。DXF 中的坐标数值是“无单位数字”,必须配合单位制理解才能换算成真实物理尺寸。
解决:在HandleHeaderGroup里读取$INSUNITS,建立单位换算系数。毫米为 4,系数是 1;英寸为 1,系数是 25.4;英尺为 2,系数是 304.8。所有实体坐标在写入模型前统一乘以系数,解析层只保留“图纸单位”,渲染层只认“毫米”。换算发生在Load方法的末尾,遍历所有实体统一处理。
5.2 圆弧和椭圆的方向反了:Y 轴翻转导致镜像的“玄学”
现象:直线、多段线都对,但 ARC 实体永远画成镜像弧,椭圆方向也反。
原因:DXF 的 ARC 角度是逆时针为正(在 Y 向上的坐标系里),WPF 的DrawGeometry里的角度是顺时针为正(Y 向下的屏幕坐标)。当你做了 Y 翻转,等于把坐标系镜像了,角度方向自然反。
解决:转换 ARC 时,不仅要翻转 Y 坐标,还要把角度取反。比如 DXF 中起点角 30 度、终点角 120 度,翻转后应画成起点角 -30 度、终点角 -120 度。代码里加一步:
double startRad = -entity.StartAngle * Math.PI / 180.0; double endRad = -entity.EndAngle * Math.PI / 180.0;同时注意 WPF 的ArcSegment需要IsLargeArc和SweepDirection两个参数。判断角度差是否大于 180 度来设置IsLargeArc,SweepDirection在翻转后一般用Clockwise。这个改动是我认为整个 DXF 绘图里最容易翻车的点,没有之一。
5.3 SPLINE 只有控制点,必须自己拟合折线
现象:SPLINE 实体画出来只是一条连接控制点的折线,圆角处明显不光滑,甚至有尖角。
原因:DXF 的 SPLINE 存储的是控制点(组码10/20/30)、节点向量(组码40)和阶数(组码71),它不直接给曲线上的采样点。直接连控制点等于画了控制多边形,不是样条本身。
解决:用 de Boor 算法把样条在参数区间上采样,生成足够密的折线点。实现上我简化成均匀采样而不求精确节点区间:
private Point[] SampleSpline(DxfSplineEntity spline, int sampleCount = 64) { var points = new List<Point>(); double tMin = spline.Knots[0]; double tMax = spline.Knots[^1]; for (int i = 0; i <= sampleCount; i++) { double t = tMin + (tMax - tMin) * i / sampleCount; var pt = DeBoor(spline.ControlPoints, spline.Knots, spline.Order, t); points.Add(new Point(pt.X, pt.Y)); } return points.ToArray(); }采样密度是精度和性能的折中。对于机械图纸,64 个采样点足够;对于曲线很长的车身造型或地形等高线,建议按曲线长度动态决定采样数,每 2 像素一个采样点。单纯加密度到 1000 只会浪费 CPU,肉眼根本分不出差别。
5.4 TEXT 与 MTEXT 中文乱码:图层颜色对了,字是问号
现象:中文注记全部显示为“?”或乱码,英文正常。
原因:TEXT 实体的字符串值本身编码不对,或者 WPF 渲染时选择的字体不支持中文。
解决:分两层处理。解析层按 3.3 节的编码探测读取;渲染层把FormattedText的FontFamily指定为“Microsoft YaHei”或“SimSun”,而不是默认的 Segoe UI。同时设置TextOptions.TextFormattingMode为Display,中文边缘会更清晰。MTEXT 比 TEXT 更麻烦,它有内嵌格式代码\fSimSun;这类控制序列,需要先剥离控制符再显示。
5.5 多段线的 bulge 凸度:顶点之间藏着弧段
现象:一条 LWPOLYLINE 画出来是直的折线,但原图里顶点间明显是圆弧过渡。
原因:DXF 多段线每个顶点的组码42是凸度值,含义是“该顶点到下一顶点之间弧段的弯曲程度”,正值为逆时针,负值为顺时针。忽略它,所有弧段都被画成直线。
解决:对每个带非零 bulge 的顶点段,用公式还原弧段。凸度b与包含角θ的关系是b = tan(θ/4)。已知起点P1、终点P2、凸度b,可以算圆心和半径。我封装了一个AppendBulgeArc方法,用来在构建StreamGeometry时把弧段追加进去。核心计算如下:
private void AppendBulgeArc(StreamGeometryContext ctx, Point p1, Point p2, double bulge) { double theta = 4 * Math.Atan(bulge); double midX = (p1.X + p2.X) / 2; double midY = (p1.Y + p2.Y) / 2; double dist = Math.Sqrt(Math.Pow(p2.X - p1.X, 2) + Math.Pow(p2.Y - p1.Y, 2)); if (Math.Abs(bulge) < 1e-6 || dist < 1e-6) { ctx.LineTo(p2, true, false); return; } double radius = dist / (2 * Math.Sin(theta / 2)); // 圆心在垂直平分线上偏移 double offset = radius * Math.Cos(theta / 2); double dx = (p2.X - p1.X) / dist; double dy = (p2.Y - p1.Y) / dist; // 根据 bulge 符号决定偏移方向 double sign = bulge > 0 ? -1 : 1; Point center = new Point( midX - dx * offset * sign, midY - dy * offset * sign); bool isLargeArc = Math.Abs(theta) > Math.PI; bool isClockwise = bulge < 0; ctx.ArcTo(p2, new Size(radius, radius), 0, isLargeArc, isClockwise ? SweepDirection.Clockwise : SweepDirection.Counterclockwise, true, false); }这里最容易被忽略的是圆心位置与bulge符号的关系。bulge为正表示弧在起点到终点连线的左侧,圆心在连线右侧;为负则相反。我最早实现时把符号写反了,结果所有圆弧都变成另一侧的镜弧,调试了很久才发现是这个正负号的问题。
6. 进阶:让看图器支持平移缩放、增量加载与命中测试
基础绘图跑通之后,下一步是做交互:鼠标拖拽平移、滚轮缩放、点击图元弹出属性。这三件事如果在渲染阶段不做设计,后面改起来会非常痛苦。
我的做法是:不重新解析文件,也不重新遍历实体,而是用一个RenderTransform作用于整个DrawingVisualHost。平移缩放只改MatrixTransform的数值,不触发任何重绘。WPF 的渲染层会拿这个变换矩阵重新投射已有几何,整个过程不跑托管代码,性能开销极小。代码如下:
private void ApplyViewTransform(double scale, double offsetX, double offsetY) { var matrix = new Matrix(); matrix.ScaleAt(scale, scale, 0, 0); matrix.Translate(offsetX, offsetY); host.RenderTransform = new MatrixTransform(matrix); }注意这里ScaleAt的缩放中心要结合实际视口中心计算,否则每次缩放都会朝左上角跑,滚轮缩放的手感很差。我一般在MouseWheel事件里取鼠标当前位置作为缩放中心:
var pos = e.GetPosition(host); double oldScale = _scale; _scale *= (e.Delta > 0 ? 1.2 : 1.0 / 1.2); // 保持鼠标下的图纸点不动 _offsetX = pos.X / oldScale - (pos.X / _scale - _offsetX); _offsetY = pos.Y / oldScale - (pos.Y / _scale - _offsetY); ApplyViewTransform(_scale, _offsetX, _offsetY);增量加载方面,大图纸(超过 5 万实体)不适合一次性全部渲染。我通常做两阶段:先渲染所有 LINE、LWPOLYLINE、ARC 这类基础线性图元,大约能覆盖轮廓;TEXT、MTEXT、SPLINE、HATCH 这些昂贵图元等用户缩放到合适比例再补画。判断依据是视口包围盒与图元包围盒是否相交,相交才画。
命中测试可以用VisualTreeHelper.HitTest做,但DrawingVisual默认不参与命中,需要为每个实体建立一个包围盒缓存,命中时先粗筛包围盒,再对命中的候选实体做精确几何判断。这里我习惯用一种更快的方式:在实体模型上保存渲染时的Geometry对象,命中测试直接调geometry.FillContains(point)。前提是你在渲染阶段已经把实体转换成了持久化的StreamGeometry,而不是用完就丢。
多图层过滤也是一个实用技巧。图纸中经常有“辅助线层”和“标注层”,看图时希望临时隐藏。我在渲染前构建一个visibleLayers集合,遍历实体时检查entity.Layer是否在集合里,不在就跳过。这个逻辑放在RenderAllEntities里,每次新设置可见层后调用一次RenderOpen重绘,视觉上干净利落。
TEXT 注记的性能是最后一根稻草。几千个 TEXT 实体如果都用FormattedText逐个画,绘制时间可能比所有直线加起来还长。我后来把所有 TEXT 实体按缩放阈值分批渲染:当整体缩放比大于 0.5 时才显示注记,小于这个比例时只显示图元轮廓。这个策略被我用在模拟项目X的图纸预览模块里,渲染时间从 1.8 秒降到了 0.4 秒。你看图时,缩小到整张图纸时根本看不清文字内容,何必白白浪费这一秒多。
最后说一个我自己的习惯:每次写完解析器改动,我都会用一个包含 5 张以上真实图纸的测试集回归,至少覆盖 R12、R2000、R2018 三个版本的 DXF,外加一份手工构造的“畸形文件”——缺组码、多空格、乱序行都有。解析器不怕原版文件,怕的是各种 CAD 软件自己导出的“方言版”。这种血泪经验多了之后,你再看到网上的“DXF 解析很简单”的说法,就只会笑笑:格式确实简单,但从格式到能稳定看图,中间全是坑。希望这篇笔记能帮你把最常见的坑先填平,少走我走过的弯路,祝顺利。
本文还有配套的精品资源,点击获取