需求说明
只做基础空间哈希桶(Floor分桶) ,构建索引结构;不做并查集、不做聚类、不找重复点 。
目标:给定一批PipePointInfo,建立索引 Dictionary<(long bx, long by), List<PipePointInfo>>。
后续用途:管线端点匹配管点时,用该索引代替全量双重循环。
核心规则
- 分桶:
Floor,桶边长 =tolerance - Key:值元组
(long bx, long by) - 查询邻近候选:遍历3×3邻域桶,取出候选管点,再做真实
DistanceTo校验
1、基础空间哈希桶构建函数(纯索引,无业务逻辑)
/// <summary>
/// 构建管点空间哈希索引(Floor分桶)
/// </summary>
/// <param name="allPoints">全部管点集合</param>
/// <param name="tolerance">桶边长=拓扑容差</param>
/// <returns>桶索引:网格编号 -> 该网格内所有管点</returns>
public static Dictionary<(long bx, long by), List<PipePointInfo>> BuildPointSpatialIndex(
List<PipePointInfo> allPoints,
double tolerance)
{
var index = new Dictionary<(long bx, long by), List<PipePointInfo>>();
double invTol = 1.0 / tolerance;
foreach (var pt in allPoints)
{
// Floor 计算网格编号
long bx = (long)Math.Floor(pt.Position.X * invTol);
long by = (long)Math.Floor(pt.Position.Y * invTol);
var cellKey = (bx, by);
if (!index.ContainsKey(cellKey))
{
index[cellKey] = new List<PipePointInfo>();
}
index[cellKey].Add(pt);
}
return index;
}
返回数据结构
Dictionary<(long bx, long by), List<PipePointInfo>> spatialIndex;
- Key:
(bx,by)网格单元编号 - Value:落在该网格内所有
PipePointInfo列表
2、配套查询方法:根据一个空间点,获取容差范围内候选管点
查询逻辑:计算目标点所在网格,遍历3×3邻域桶,收集候选,再距离过滤。
/// <summary>
/// 根据坐标,从空间索引查找容差范围内所有管点候选
/// </summary>
public static List<PipePointInfo> QueryNearPoints(
Point3d targetPt,
Dictionary<(long bx, long by), List<PipePointInfo>> spatialIndex,
double tolerance)
{
var result = new List<PipePointInfo>();
double invTol = 1.0 / tolerance;
long bx0 = (long)Math.Floor(targetPt.X * invTol);
long by0 = (long)Math.Floor(targetPt.Y * invTol);
// 遍历3×3邻域桶
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
var cell = (bx0 + dx, by0 + dy);
if (spatialIndex.TryGetValue(cell, out var candidates))
{
foreach (var pt in candidates)
{
// 精确距离校验(桶只是筛选候选,不能替代距离判断)
if (targetPt.DistanceTo(pt.Position) <= tolerance)
{
result.Add(pt);
}
}
}
}
}
return result;
}
3、改造你原来【管线-管点拓扑匹配】代码(替换双重循环)
原来:foreach(line) foreach(pt) O(M*N)暴力循环
现在:先构建索引,每条管线的起点、终点调用QueryNearPoints
double tol = ThirdPartyCommonTools.PipeTopologyConfiguration.Tolerance;
// 1. 一次性构建空间索引
var spatialIndex = BuildPointSpatialIndex(allPoints, tol);
// 2. 遍历管线,利用索引匹配管点
foreach (var line in allLines)
{
// 匹配起点附近全部管点
var startCandidates = QueryNearPoints(line.StartPoint, spatialIndex, tol);
foreach (var pt in startCandidates)
{
line.StartPointIds.Add(pt.Id);
pt.ConnectedLines.Add(line.Id);
}
// 匹配终点附近全部管点
var endCandidates = QueryNearPoints(line.EndPoint, spatialIndex, tol);
foreach (var pt in endCandidates)
{
line.EndPointIds.Add(pt.Id);
pt.ConnectedLines.Add(line.Id);
}
}
4、结构要点总结(便于你理解记忆)
-
索引结构
Dictionary<(long bx, long by), List
> -
分桶公式(固定Floor,杜绝Round)
invTol = 1.0 / tolerance
bx = Floor(X * invTol)
by = Floor(Y * invTol) -
查询范式
目标点算出(bx0,by0),循环dx:-1,0,1、dy:-1,0,1读取邻桶候选,候选必须再DistanceTo校验。
5、兼容老.NET Framework(不能使用ValueTuple时的替换版本)
把键从 (long bx,long by) 替换为 Tuple<long,long>
Dictionary<Tuple<long, long>, List<PipePointInfo>> index
var cellKey = Tuple.Create(bx, by);
查询部分同步修改即可。
先给核心结论
理解大体方向正确,但不能直接一刀切删掉 ConnectedLines、StartPointIds、EndPointIds,这里存在两种不同设计模式,不能互相完全替代:
1)预缓存拓扑模式(你现在代码)
实体内部保存双向关联ID列表 ;优点:已知实体ID,直接读取关联对象,O(1) ;代价:构建阶段需要暴力匹配。
2)仅保留空间哈希索引、不缓存拓扑(惰性模式)
实体只保留自身几何信息,不存任何相连对象ID;需要关联关系时,现场用空间索引做邻近搜索、距离校验;优点:不需要一次性构建庞大拓扑;代价:每次查询都要执行一次邻近检索。
分开把关键点讲清楚
1、空间哈希桶解决了什么?
它只解决一件事:根据坐标快速拿到周边候选实体,消除O(M*N)全量遍历 。
空间哈希 ≈ 空间上的候选筛选器;
它不能根据 ObjectId 反向查找关联,它的查询入口永远是【坐标Point3d】,不是ObjectId。
2、关键矛盾点(你推理里缺失的一环)
-
场景A:我现在手上有一个实体ID/实体对象(PipePointInfo),我要找和它相连的管线
- 如果你删掉
ConnectedLines:
你只能拿pt.Position→ 空间哈希邻域搜索 → 筛选管线 → 距离校验;每次调用都要重新检索计算。 - 如果你保留
ConnectedLines:
直接读取List,立刻拿到管线ID;不需要任何空间计算。
- 如果你删掉
-
场景B:我手上只有一个坐标点,想查找附近管点
此时只能用空间哈希,缓存的拓扑集合派不上用场。
3、两种架构选型对比(管网开发二选一)
方案一:预构建双向拓扑(原有模式,可搭配空间哈希优化构建过程)
PipePointInfo.ConnectedLines
PipeLineInfo.StartPointIds / EndPointIds
- 构建阶段:利用空间哈希替代双层暴力循环(就是我们前面写的代码),快速算出关联关系,存入集合;
- 使用阶段:任意实体,直接读取关联ID;连通性遍历、广度优先搜索、环路查找非常顺畅;
- 缺点:实体移动、删除后缓存失效,需要整体重建拓扑。
✅ 改良方案:构建拓扑的算法升级(空间哈希),但是拓扑缓存字段继续保留
这是工程里最常用方案:只优化"建表速度",不改动上层业务访问方式。
方案二:纯惰性查询,不缓存任何拓扑(你设想的方案)
彻底删除 ConnectedLines、StartPointIds、EndPointIds
实体类只保留自身属性:Id、图层、坐标、块名。
全局只维护一份空间哈希索引。
业务逻辑变化:
-
需要"管点相连管线":拿管点坐标 → QueryNearPoints,现场匹配;
-
需要"管线两端关联管点":拿起点、终点坐标,现场邻域搜索;
-
优点:数据结构轻量化;不存在拓扑缓存不一致问题;适合交互式频繁编辑场景;
-
致命短板:连通性遍历性能差 。
举例子:广度优先遍历管网连通区域,一个管点会被反复访问多次,每次都重复执行邻域搜索+距离计算,大量重复运算。
4、一句话纠正你的推论
❌ 错误理解:有空间哈希桶 → 就可以删除实体上所有拓扑关联集合
✅ 正确理解:
空间哈希是【构建关联关系的加速工具】,不等同于【关联关系本身】。
空间哈希解决「如何快速找到邻居」;
实体上的ID集合解决「保存已经找到的邻居结果」。
5、给你基于管网工具开发的选型建议
结合你当前业务:重复管点筛查、管网拓扑校验、批量数据处理(偏向一次性批量分析,不是交互式实时编辑)
推荐采用折中路线:
- 保留
ConnectedLines/StartPointIds/EndPointIds拓扑缓存字段; - 把原来O(M*N)双层循环替换为空间哈希版本做拓扑构建;
- 上层遍历连通性、重复点分组的业务代码完全不用改动,只提速底层构建流程。
如果你未来开发交互式命令(选中实体即时查询相连管线、移动实体实时更新),再考虑切换到纯惰性、无拓扑缓存的架构。
举个直观类比帮助记忆
- 暴力双层循环:挨家挨户全城找人;
- 空间哈希:建立小区楼栋门牌索引,只搜索目标周边楼栋(找人的搜索工具);
- ConnectedLines集合:找到邻居之后,把邻居电话写在自家本子上(查找结果缓存 );
有楼栋索引 ≠ 每家本子上不需要记邻居电话;想快速打电话,还是依赖本子。
这个CAD管网拓扑的分层设计逻辑比较绕,工作任务模式可以帮你整理两套架构完整样板代码和切换条件,要不要用它继续?