简介:一套基于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 List<MapNode> Nodes { get; set; } = new(); public List<MapEdge> Edges { get; set; } = new(); public List<ObstaclePolygon> Obstacles { get; set; } = new(); }几个参数值得说明。坐标单位一律用毫米而不是米:调度系统下发运动指令时不用乘 1000,UI 上输入 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 MapName="WH-A3" UnitPerMeter="1000"> <Nodes> <Node Id="N001" X="1520.5" Y="3400" Heading="1.5708" Type="Normal"/> <Node Id="N002" X="3000" Y="3400" Heading="0" Type="Station" StationName="P1"/> </Nodes> <Edges> <Edge Id="E001" StartNodeId="N001" EndNodeId="N002" IsBidirectional="true" MaxSpeed="1200"/> </Edges> <Obstacles> <Obstacle Id="OBJ001" Height="1800"> <Point X="0" Y="0"/><Point X="1200" Y="0"/> <Point X="1200" Y="800"/><Point X="0" Y="800"/> </Obstacle> </Obstacles> </AgvMap>两种格式的取舍整理如下:
| 对比项 | XML | JSON |
|---|---|---|
| 可读性 | 标签自带语义,层级清晰 | 紧凑,长行在 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 + GDI+ | WPF 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;最大缩放取 5~10 像素/毫米,足够看清站点朝向箭头。
滚轮缩放的关键是"缩放中心跟随鼠标":缩放前用 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 Dictionary<string, 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 和 Warning,Error 阻断导出,Warning 允许导出但提示。
public sealed record ValidationIssue( string Code, string Severity, string EntityId, string Message); public List<ValidationIssue> Validate(AgvMap map) { var issues = new List<ValidationIssue>(); 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 两条单向边同时存在,额外又标一条 IsBidirectional=true 的边,调度端避让逻辑会重复计算,直接 Error 阻断。
再进一步,调度系统要求所有站点都能回到充电位,意味着路网的有向图必须强连通。用 Tarjan 求强连通分量,分量数大于 1 时把孤立分量里的节点列出来,这个检查能拦住一大批"换了地方充电就回不来"的现场事故。
4.3 用 A* 路径预览验证路网可达性
路径预览是编辑器里最实用的功能:选中起点和终点站点,立刻画出 A* 求出的最短路径,规划人员当场确认是否绕路、转弯是否过急。这个功能同时验证了拓扑正确性——路径求不出来,地图一定有问题。
public static List<string> AStar( Dictionary<string, List<(string To, double Cost)>> graph, Dictionary<string, (double X, double Y)> coords, string start, string goal) { var dist = new Dictionary<string, double> { [start] = 0 }; var prev = new Dictionary<string, 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 List<string>(); for (var n = goal; n != null; n = prev.GetValueOrDefault(n)) path.Add(n); path.Reverse(); return path; } static double Heuristic( Dictionary<string, (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 和 Undo:
public 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 倍分辨率的高清 PNG:
var 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 或现场运维脚本,地图文件进仓库之前先过一遍检查。
本文还有配套的精品资源,点击获取