C#实战:自研AGV地图编辑器,搞定路网绘制与拓扑校验
简介一套基于C#开发的AGV地图编辑软件完整源码面向自动化仓储与智能物流领域的软件工程师与AGV系统集成开发者。软件支持自由添加和删除站点、停车位、小车及运行路径编辑好的地图以XML文件格式保存也可直接导入已有XML地图文件进行修改。全部程序均为源码底层架构包含ArchitectureAll工程、DataModelHelper数据模型、LogHelper日志模块、XmlHelper等并有对应解决方案与可执行文件适合在此基础之上根据实际项目需求进行二次开发降低AGV调度地图模块的起步难度。资源共763个文件约49.33MB其中包含80余个C#源码文件、60余个DLL依赖库、64张界面与图标相关的PNG图片同时附有大量log日志与XML配置样例便于对照运行环境与地图数据结构。目前已有1032人学习下载是一份兼具学习与工程落地价值的AGV地图编辑软件参考项目。1. 从现场痛点说起为什么 C# 项目要自己写 AGV 地图编辑器做 AGV 调度系统的人迟早会撞上同一个问题地图从哪来。商业编辑器绑定品牌开源方案通常只适配某一种导航协议于是项目组只剩两条路拿记事本手写路网 XML或者让 C# 上位机工程师从零画一个。自己开发 AGV 地图编辑软件本质不是在做绘图工具而是在做调度系统与物理现场之间的对齐层——把厂房的墙、货架、充电桩、路径节点变成一套可编辑、可校验、可增量下发的数据。下面按「数据结构 → 画布渲染 → 交互编辑 → 拓扑校验 → 现场技巧」的顺序把一线工程师自己动手时最常用的一版方案讲透包括坐标单位怎么定、序列化选 XML 还是 JSON、缩放平移怎么不卡、A* 预览怎么接进编辑器。适合正在写 AGV 上位机或调度系统的 C# 开发者也适合想评估自研成本的技术负责人。读完应该能搭出一个能存文件、能画路网、能给调度系统交差的最小可用编辑器。2. 先定数据模型用 C# 类把地图拆成节点、边、站点和障碍物2.1 AGV 地图编辑器不是画图软件很多人上手先画界面在画布上拖出几个矩形再想办法把像素转成导航数据。这个路子做演示没问题厂房一改就得返工。AGV 地图编辑器的正确起点是数据界面上每一个图形都是某个实体的投影画布只是渲染这些实体的场景。场景层大致分四类障碍物层墙、货架、料箱供激光或轮廓导航避障使用路网层节点和边构成 AGV 可行驶的拓扑图站点层上料点、下料点、充电位本质是带语义的节点辅助层量测标注、文字、箭头不参与调度逻辑。这样划分之后编辑就不是移动像素而是修改模型保存也不是截屏而是把模型落盘。后续所有功能——校验、A* 预览、下发调度系统——都建立在模型上而不是画布状态上。2.2 用 C# 定义核心实体类最小可用的数据模型只需要四个类型AgvMap、MapNode、MapEdge、ObstaclePolygon。下面是常见做法public enum NodeType { Normal, Station, Charger, StopPoint } public sealed class MapNode { public string Id { get; set; } // 全局唯一如 N001 public double X { get; set; } // 世界坐标单位 mm public double Y { get; set; } public double Heading { get; set; } // 停靠朝向单位弧度 public NodeType Type { get; set; } public string StationName { get; set; } // Type Station 时使用 } public sealed class MapEdge { public string Id { get; set; } public string StartNodeId { get; set; } public string EndNodeId { get; set; } public bool IsBidirectional { get; set; } // 是否允许双向行驶 public double MaxSpeed { get; set; } // 限速mm/s } public sealed class ObstaclePolygon { public string Id { get; set; } public List(double X, double Y) Points { get; set; } // 闭合多边形 public double Height { get; set; } // 障碍物高度mm } public sealed class AgvMap { public string MapName { get; set; } public double UnitPerMeter { get; set; } 1000; // 1 米 1000 单位 public ListMapNode Nodes { get; set; } new(); public ListMapEdge Edges { get; set; } new(); public ListObstaclePolygon Obstacles { get; set; } new(); }几个参数值得说明。坐标单位一律用毫米而不是米调度系统下发运动指令时不用乘 1000UI 上输入 1520.5 比输入 1.5205 直观得多现场工程师量厂房也习惯看毫米。Heading 用弧度存储、UI 层显示度数因为绝大多数 AGV 控制器的报文协议用弧度存弧度可以少一次转换就少一处精度损耗。UnitPerMeter 不是摆设——有些 SLAM 建图导出的栅格图是 0.05 米/像素编辑器加载底图时要靠它把像素坐标换算成世界毫米坐标。2.3 地图文件的序列化XML 还是 JSON地图文件本质是给调度系统读的协议格式取舍要看调度端技术栈。常见选法调度系统是纯 C# 或团队用 Git 管版本用 XML标签语义清晰、合并冲突肉眼可解调度端还要接 Web 前端或第三方系统用 JSON跨语言解析零成本。XML 序列化用 XmlSerializer 最省事但它要求类型有无参构造函数实体类上不要放只读集合属性。下面是 C# 里用 XmlSerializer 反序列化的完整写法using System.Xml.Serialization; var serializer new XmlSerializer(typeof(AgvMap)); using var reader new StreamReader(wh-a3.map.xml); var map (AgvMap)serializer.Deserialize(reader);序列化后的地图文件大致长这样AgvMap MapNameWH-A3 UnitPerMeter1000 Nodes Node IdN001 X1520.5 Y3400 Heading1.5708 TypeNormal/ Node IdN002 X3000 Y3400 Heading0 TypeStation StationNameP1/ /Nodes Edges Edge IdE001 StartNodeIdN001 EndNodeIdN002 IsBidirectionaltrue MaxSpeed1200/ /Edges Obstacles Obstacle IdOBJ001 Height1800 Point X0 Y0/Point X1200 Y0/ Point X1200 Y800/Point X0 Y800/ /Obstacle /Obstacles /AgvMap两种格式的取舍整理如下对比项XMLJSON可读性标签自带语义层级清晰紧凑长行在 Git diff 里难读版本迁移加字段不破坏老文件同样友好C# 序列化XmlSerializer需无参构造函数System.Text.Json无需额外特性跨语言消费一般好无论选哪个文件头都要带版本号。调度系统启动先读版本再解析地图从 2.0 升到 2.1 时走迁移逻辑而不是解析到一半报错。2.4 地图文件就是调度系统的协议编辑器输出的地图文件不是归档材料而是调度系统启动时加载的路网数据。格式一旦定下来就要当协议管理加字段可以改字段类型不行删字段前先确认没有调度端还在用。常见做法是把保存前校验做成编辑器的强制关卡保存、导出、下发三个动作都过同一套校验器校验不通过就禁止导出。现场迭代时还会遇到一个实际问题调度系统在跑、地图要改。不要用同一个文件名覆盖保存留一个 SaveAsVersion 按钮每次保存生成带时间戳的新版本文件调度端支持热加载时随时可回退。文件名里带 AGV 车型或区域名比如 wh-a3_v20250412_1530.map.xml比手写 version.txt 可靠得多。3. 绘制与交互用 WPF 搭一个不卡顿的地图画布3.1 WinForms 还是 WPF编辑器的场景特性决定选型地图编辑器是典型的事件密集型应用鼠标按下、拖动、弹起每个动作都要命中检测和局部刷新。WinForms 加 GDI 能写两千节点以内性能也够但所有绘制都要自己管理 Invalidate 区域缩放时整幅重绘的卡顿感很难消除。WPF 的保留模式渲染retained mode把绘图指令缓存成可视对象平移缩放由 WPF 自己决定重绘范围代码更少效果更稳。如果已有 WinForms 上位机基础不建议因此重写框架。把渲染层封装成接口WinForms 用双缓冲 PictureBox 实现WPF 用 DrawingVisual 实现编辑器业务逻辑不依赖具体渲染器。选型参考对比项WinForms GDIWPF DrawingVisual节点超过 3000 的缩放卡顿明显流畅首次构建视觉树有开销命中检测手写几何计算可用 VisualTreeHelper.HitTest与现有上位机集成直接需要 ElementHost 桥接学习门槛低中3.2 坐标变换世界坐标、屏幕坐标与缩放平移画布核心是一个屏幕坐标到世界坐标的变换。地图数据存毫米世界坐标渲染时乘以缩放系数并加上平移量得到屏幕像素坐标。不要在设计时把节点坐标直接写成画布像素图一放大数据就废了。public readonly struct ViewTransform { public double Scale { get; init; } // 像素 / 毫米 public double OffsetX { get; init; } // 世界原点到画布原点的像素偏移 public double OffsetY { get; init; } public Point WorldToScreen(double wx, double wy) new(wx * Scale OffsetX, wy * Scale OffsetY); public (double X, double Y) ScreenToWorld(double sx, double sy) ((sx - OffsetX) / Scale, (sy - OffsetY) / Scale); }Scale 取值要和地图尺寸联动。一张 200 米乘 100 米的厂房在 1920 宽的屏幕上全览Scale 约 1920 / 200000 ≈ 0.0096 像素/毫米所以最小缩放要留到 0.005最大缩放取 510 像素/毫米足够看清站点朝向箭头。滚轮缩放的关键是缩放中心跟随鼠标缩放前用 ScreenToWorld 算出鼠标下的世界点缩放后重新计算 Offset让该世界点仍落在鼠标位置。private void OnMouseWheel(double mouseX, double mouseY, double zoomStep) { var (wx, wy) _view.ScreenToWorld(mouseX, mouseY); var newScale Math.Clamp(_view.Scale * zoomStep, 0.005, 10.0); _view new ViewTransform { Scale newScale, OffsetX mouseX - wx * newScale, // 使鼠标下的世界点保持不动 OffsetY mouseY - wy * newScale }; InvalidateVisual(); }zoomStep 滚轮向上取 1.2向下取 1/1.2觉得缩得生硬就改成 1.1。平移用中键或空格加左键每帧根据鼠标位移更新 OffsetX/OffsetY不用重算任何业务数据。3.3 鼠标交互点击选中、拖拽移动、拉线连边编辑器的交互模型就三件事选中、拖拽、连边。连边最容易出错设计上遵循先点起点节点再点终点节点的两段式流程private MapNode _pendingStart; private MapNode _hoverEnd; protected override void OnMouseMove(MouseEventArgs e) { var (wx, wy) _view.ScreenToWorld(e.GetPosition(this).X, e.GetPosition(this).Y); _hoverEnd HitTestNode(wx, wy)?.Node; // 命中容差 6 像素 InvalidateVisual(); // 橡皮筋线跟随鼠标刷新 } protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) { var (wx, wy) _view.ScreenToWorld(e.GetPosition(this).X, e.GetPosition(this).Y); var hit HitTestNode(wx, wy); if (_pendingStart null) { _pendingStart hit?.Node; return; } if (hit ! null hit.Node ! _pendingStart) { if (!EdgeExists(_pendingStart.Id, hit.Node.Id)) AddEdge(_pendingStart, hit.Node); else StatusBar.Text 该边已存在; } _pendingStart null; }命中检测的容差用像素而不是世界毫米6 像素在 0.01 比例下对应 600 毫米在 5 比例下对应 1.2 毫米只有用屏幕像素做容差缩放后点选行为才一致。节点几千以内用线性遍历足够几万个再上四叉树不要提前优化。拖拽节点时移动的是世界坐标刷新的是画布渲染。每次 mouse move 只更新该节点和邻接边的视觉对象不要整幅 Invalidate否则节点一多立刻感受到 UI 刷新卡顿。3.4 绘制刷新策略别让整幅地图陪葬UI 刷新卡顿最常见的根源就是一次拖动触发整幅图重绘。WPF 里的常见做法是自绘容器配合 DrawingVisual 按需更新private readonly Dictionarystring, DrawingVisual _nodeVisuals new(); private void UpdateNodeVisual(MapNode node) { var visual _nodeVisuals[node.Id]; using var dc visual.RenderOpen(); var p _view.WorldToScreen(node.X, node.Y); dc.DrawEllipse(Brushes.Orange, nodePen, p, 4, 4); // 半径 4 像素 if (node.Type NodeType.Station) dc.DrawText(stationLabel, new Point(p.X 6, p.Y - 8)); }这段只重绘一个节点的视觉对象WPF 合成器只重新合成这一小块区域3000 个节点的地图拖动也能保持流畅。背景网格单独画在 DrawingBrush 平铺的 Rectangle 上图层从底到顶依次是网格、SLAM 底图、障碍物、路网、站点标注、橡皮筋线。还有两个环境相关的坑。工业现场工控机的显卡驱动老旧WPF 硬件加速偶发花屏启动时加一句RenderOptions.ProcessRenderMode RenderMode.SoftwareOnly兜底触摸屏的拖动事件和鼠标事件在 WPF 里互相干扰手势判定阈值放 4 像素低于 4 像素的移动不触发平移。4. 让地图能跑吸附、拓扑校验与 A* 路径预览4.1 栅格吸附与角度约束工厂布局大多是正交货架阵列手绘坐标容易带小数点堆积。常见做法是提供可开关的栅格吸附按住 Ctrl 拖拽时节点坐标吸附到 50 或 100 毫米的栅格上。实现就是在鼠标移动事件里对世界坐标取整private (double X, double Y) SnapToGrid(double wx, double wy, double gridMm) { var g gridMm; return (Math.Round(wx / g) * g, Math.Round(wy / g) * g); }gridMm 设 50 还是 100取决于 AGV 定位精度激光导航的定位噪声正负 20 毫米50 毫米栅格不冲突磁条或二维码导航按 100 毫米短边铺设栅格取 100 更贴合现场。角度约束同样走吸附绘制站点时按住 Shift把 Heading 按 15 度步进避免出现 37.2 度这种既难调又容易让 AGV 转弯困难的朝向。4.2 拓扑完整性校验保存前的强制关卡导航图最怕看起来连上了调度系统加载就报错。编辑器内要内置一套和调度端完全一致的校验器导出前必须通过。校验按严重级别分 Error 和 WarningError 阻断导出Warning 允许导出但提示。public sealed record ValidationIssue( string Code, string Severity, string EntityId, string Message); public ListValidationIssue Validate(AgvMap map) { var issues new ListValidationIssue(); var nodeIds map.Nodes.Select(n n.Id).ToHashSet(); foreach (var edge in map.Edges) { if (!nodeIds.Contains(edge.StartNodeId) || !nodeIds.Contains(edge.EndNodeId)) { issues.Add(new(EDGE_ORPHAN, Error, edge.Id, $边 {edge.Id} 引用不存在的节点)); } } foreach (var node in map.Nodes) { var degree map.Edges.Count(e e.StartNodeId node.Id || e.EndNodeId node.Id); if (degree 0) issues.Add(new(NODE_ISOLATED, Warning, node.Id, 节点没有任何连边)); } return issues; }除孤点外有两类校验现场经常踩中。一是最小边长边长短于 AGV 车体回转直径一半时转弯来不及Warning 阈值建议取车体宽度的 1.5 倍写进配置而不是硬编码。二是双向边语义冲突A→B 和 B→A 两条单向边同时存在额外又标一条 IsBidirectionaltrue 的边调度端避让逻辑会重复计算直接 Error 阻断。再进一步调度系统要求所有站点都能回到充电位意味着路网的有向图必须强连通。用 Tarjan 求强连通分量分量数大于 1 时把孤立分量里的节点列出来这个检查能拦住一大批换了地方充电就回不来的现场事故。4.3 用 A* 路径预览验证路网可达性路径预览是编辑器里最实用的功能选中起点和终点站点立刻画出 A* 求出的最短路径规划人员当场确认是否绕路、转弯是否过急。这个功能同时验证了拓扑正确性——路径求不出来地图一定有问题。public static Liststring AStar( Dictionarystring, List(string To, double Cost) graph, Dictionarystring, (double X, double Y) coords, string start, string goal) { var dist new Dictionarystring, double { [start] 0 }; var prev new Dictionarystring, string(); var open new SortedSet(double F, string Id)(); open.Add((Heuristic(coords, start, goal), start)); while (open.Count 0) { var (_, current) open.Min; open.Remove(open.Min); if (current goal) break; foreach (var (next, edgeCost) in graph[current]) { var tentative dist[current] edgeCost; if (tentative dist.GetValueOrDefault(next, double.MaxValue)) { dist[next] tentative; prev[next] current; open.Add((tentative Heuristic(coords, next, goal), next)); } } } if (!dist.ContainsKey(goal)) return new(); // 不可达返回空路径 var path new Liststring(); for (var n goal; n ! null; n prev.GetValueOrDefault(n)) path.Add(n); path.Reverse(); return path; } static double Heuristic( Dictionarystring, (double X, double Y) coords, string a, string b) { var (ax, ay) coords[a]; var (bx, by) coords[b]; return Math.Sqrt((ax - bx) * (ax - bx) (ay - by) * (ay - by)); }启发函数直接用欧氏距离这是可采纳启发式路网最短路径永远不小于两点直线距离不会把次优路径误判成最短。提示欧氏距离启发式要求边的代价不小于直线距离。如果边代价叠加上正的转弯惩罚整体依然可采纳只是搜索的扩展节点数会略多。边代价不要只算边长建议叠加转弯惩罚路径总代价 路程 转弯次数 × 单次惩罚。转弯惩罚在回溯时计算三点夹角超过 30 度算一次转弯。默认参数表参数默认值说明距离代价1 / 毫米基础路程90° 转弯惩罚500相当于多走 0.5 米180° 掉头惩罚1500现场应尽量避免站点资源惩罚100避免路径穿过繁忙站点上面的代码用 SortedSet 当优先队列代价相同的节点按 Id 字典序弹出不影响正确性但能让同样输入得到稳定输出方便做回归对比。4.4 撤销重做与增量保存撤销重做不要用整图快照。两千节点深拷贝一次要几十毫秒拖拽中连续入栈会卡。常见做法是把每个编辑动作封装成命令对象实现 ICommand 接口的 Do 和 Undopublic interface IMapCommand { void Do(AgvMap map); void Undo(AgvMap map); string Description { get; } } public sealed class MoveNodeCommand : IMapCommand { private readonly string _nodeId; private readonly double _oldX, _oldY, _newX, _newY; public MoveNodeCommand(string nodeId, double oldX, double oldY, double newX, double newY) { _nodeId nodeId; _oldX oldX; _oldY oldY; _newX newX; _newY newY; } public void Do(AgvMap map) Set(map, _newX, _newY); public void Undo(AgvMap map) Set(map, _oldX, _oldY); private void Set(AgvMap map, double x, double y) { var n map.Nodes.First(n n.Id _nodeId); n.X x; n.Y y; } }撤销栈最多保留 50 步超过弹最底的命令如果栈底是加载文件这类大操作清空整个栈。文件保存走两段式先写临时文件再替换正式文件配合带时间戳的版本文件名机器断电也不会留下半截地图文件。5. 收尾三招SLAM 底图对齐、离线渲染导出与强连通自检5.1 SLAM 栅格底图对齐让坐标不再靠猜现场画图最常见的场景是建图机器人已经跑出一张栅格图但它的原点和世界原点对不上。编辑器要支持把 .pgm/.png 底图按 YAML 里的分辨率加载进来再做三点对齐得到底图到世界坐标的仿射变换。先在底图上点三个已知世界坐标的参考点厂房立柱、充电桩基座再解 2×2 线性方程组算出尺度、旋转和平移点多时用最小二乘。对齐完成后把变换矩阵存进地图元数据下次打开直接套用。5.2 离线渲染导出给现场和调度端一张对得上的图调度联调时现场工程师手里得有一张标了节点 ID 的地图别让他们对着屏幕截图。加一个 ExportPng 命令把当前视图渲染到 RenderTargetBitmap输出 4 倍分辨率的高清 PNGvar rtb new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); rtb.Render(canvas); var encoder new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(rtb)); using var fs File.Create(wh-a3_export.png); encoder.Save(fs);渲染前把 canvas 的 RenderTransform 设成 4 倍缩放矩阵得到的 PNG 放大看也不糊。画面上保留坐标网格、节点 ID 和站点名障碍物用半透明灰色填充。导出的 PNG 同时保留坐标网格和节点 ID现场对图时能直接指认N032 这个点是传送带拐角。5.3 强连通自检调度端启动时的最后一道闸门编辑器里查过一遍还不够调度系统启动加载地图时应当再跑同样的校验特别是 Tarjan 强连通检查。把校验器编译成独立类库编辑器、调度端、命令行工具三个入口引用同一份代码避免两边规则悄悄分叉。命令行版本做成agvmapcheck wh-a3_v20250412.map.xml这样的工具接 CI 或现场运维脚本地图文件进仓库之前先过一遍检查。本文还有配套的精品资源点击获取