1. 手工标管道标到怀疑人生,才决定写这个工具
做机电深化设计的朋友应该都有这个经历:图纸上十几根、几十根水管风管并排走,每根都要标注管径、标高、系统名称,放大、对齐、移动、改字,再放大、对齐、移动、改字,一个设备层几百根管道标下来,少说两三天。关键是这种活儿毫无技术含量,却极其熬人,标完眼睛都快瞎了。
当时我接手的是一个设备层的管线综合深化项目,冷冻水供回水、冷却水、生活给水、消防喷淋、压力排水,七七八八加起来四百多根管道,密密麻麻铺了好几排。手工标注的流程大概是:选中一根管道,查看它的尺寸和标高,手动创建文字,然后拖到合适的位置;第二根、第三根……重复同样的事情。标到一两百根的时候,视觉疲劳带来的错标漏标开始出现,有的管径写错了,有的标高没对齐,返工一遍反而更费时间。
后来我在想,既然Revit本身提供二次开发接口,为什么不能让程序替我读管道信息、算好位置、自动创建文字标注?于是就有了标题里这个“多排管道标注”工具。它做的事情说起来很简单:把当前视图里可见的管道收集起来,按照空间位置分成几排,然后自动提取每根管道的管径、标高、系统名称等信息,在管道旁边创建对应文字标注,并且自动避让,保证上下排文字不重叠、不自相交。
这个工具对应的场景非常明确:机电管线综合作业中的平面标注、剖面标注、局部大样标注。适合的对象包括做深化设计的技术人员、机电BIM工程师、以及刚接触Revit二次开发想找个真实练手项目的朋友。它不涉及复杂的几何算法,也不需要高深的图形学知识,核心就是Revit API的基本用法加上一点工程常识。但真要把标注排得整齐、不打架,里面的门道还是挺多的,这也是我把它写成一篇文章的原因。
顺便说一下,这篇文章涉及的技术栈是Revit二次开发,用C#语言,基于Revit API实现,开发环境没什么特殊要求,Visual Studio就能搞定。下面我会把整条开发链路拆开讲,从管道收集、分排逻辑、文字创建到避让处理,再复盘几个我实际踩过的坑。
2. 想清楚“多排”到底是什么,代码才不会白写
在动手之前,必须先把需求彻底搞明白。很多人第一次拿到这个需求,上来就先写“收集所有管道、创建标注”,结果做出来的工具只能处理一排管道的情况,稍微复杂点就全乱套。问题就出在没想清楚“多排”这个概念在物理空间里到底意味着什么。
2.1 先要把管道从模型里正确取出来
Revit里取管道,最基础的手段是FilteredElementCollector配合管道类型过滤。我知道不少人习惯用Category过滤,直接传BuiltInCategory.OST_PipeCurves,问题不大,但有一个更稳的做法,就是先过滤出MEPCurve,再在里面筛出具体是Pipe的还是FlexPipe的。
我用的代码大概是这样:
// 只取当前视图可见范围内的管道 FilteredElementCollector collector = new FilteredElementCollector(doc, viewId); ICollection<Element> pipeElements = collector .OfClass(typeof(MEPCurve)) .WherePasses(new ElementClassFilter(typeof(Pipe))) .ToList();这里有个细节值得说:管道在Revit API里是Pipe类,它继承自MEPCurve,而MEPCurve又继承自Element。如果直接OfClass(typeof(Pipe)),通常也能过滤出来,但我在某些Revit版本上遇到过过滤结果不稳定的情况,所以更稳妥的是先按MEPCurve过滤,再用ElementClassFilter做二次筛选。多一层过滤的开销可以忽略不计,但可靠性高不少。
还有一个很多人容易忽略的问题:FilteredElementCollector传入视图ID的时候,取出来的是“当前视图中可见”的元素,但如果这个视图有严重的裁剪框限制,或者管道所在的图层/子类别被隐藏,收集结果可能和你肉眼看到的不一致。这一点在后面“深坑复盘”里我会单独讲,这里先记住一个结论:收集管道时最好自己做一遍可见性校验,别完全信任视图过滤。
2.2 判断“第几排”,核心是拿管道中心线的高程
拿到管道列表之后,真正的问题来了:怎么知道哪根管道属于第一排、哪根属于第二排?
很多人的第一反应是直接比较管道的标高参数。比如读取RBS_PIPE_REFERENCE_OFFSET,这是管道相对于标高的偏移量,然后按偏移量大小分成几个组。这个方法在大部分场景下能跑通,但有个隐患:管道有坡度的时候,起点标高和终点标高不一样,偏一个值不能代表整根管道的空间位置。
我实际用的方案是取管道LocationCurve的起点和终点,算出中点坐标,再用中点的Z值作为这根管道的高度特征值:
private double GetPipeMiddleZ(Pipe pipe) { LocationCurve locationCurve = pipe.Location as LocationCurve; Curve curve = locationCurve.Curve; XYZ midPoint = curve.Evaluate(0.5, true); return midPoint.Z; }拿到每根管道的中点Z之后,按高度做聚类。注意这里不要简单地四舍五入到某个整数,因为如果两排管道之间的高差只有几十毫米,取整可能把两排合并成一排。正确做法是用容差聚类:先把所有管道按Z值排序,然后逐个比较,两个Z值之差小于容差就归为同一排,否则开启新的一排。
容差取多少?我实测的工程里,管道层间高差通常不小于100mm,而管道本身的管径从DN25到DN300不等,再加上保温层厚度,上下两排管道的中心高差一般在150mm以上。所以我用的容差是50mm,既能稳定分排,也不会把相邻排误合并。
2.3 排内排序:标注不能乱跳,要有顺序感
分完排之后,还要解决一个看起来很基础、实际很影响体验的问题:同一排内的管道,标注文字应该按什么顺序排列?
如果程序是随机顺序创建的,出来的标注就是东一个西一个,肉眼根本没法看。我的做法是:在每一排内部,把管道按中心点的水平坐标排序。具体用哪个方向作为“水平坐标轴”,取决于当前视图。
平面图上,我通常取视图的RightDirection作为排序投影方向;剖面图则取视图的UpDirection。然后用管道中心点在这个方向上做投影,按投影值从小到大排列:
XYZ right = view.RightDirection; var sortedPipes = groupPipes .OrderBy(p => { XYZ center = GetPipeCenter(p); return center.DotProduct(right); }) .ToList();投影值小的排在左边,大的排在右边,这样出来的标注顺序和图纸视觉顺序一致,后续微调也省心。这一步看着不起眼,但做完之后工具的专业感立刻就出来了:标注不是乱糟糟的一团,而是有节奏地从左到右排开。
3. 标注内容怎么定、文字怎么画上去、样式怎么统一
管道分好组、排好序之后,下一步就是把信息变成文字标注。这一节我重点讲三件事:标注内容怎么组装、文字用什么API创建、文字样式怎么管理。
3.1 标注内容组合:管径、标高、系统一个都不能少
我刚做第一版的时候,只标了管径,觉得“够用”。结果拿去给同事看,人家反问我:标高呢?系统呢?保温呢?光有管径我根本不知道这根管子是从哪来到哪去。所以标注内容至少要有这几项:
- 管径:读取
RBS_PIPE_DIAMETER_PARAM,换算成毫米。 - 中心标高:读取管道中点处的Z值,换算成相对标高的偏移值。
- 系统名称:通过连接件拿到管道所属的MEP System,再读系统名称。
- 管材信息:从管道类型参数里读出来,比如镀锌钢管、PPR管、衬塑钢管。
- 介质流向:如果视图方向允许,可以在标注里加个箭头示意,但这一步涉及符号绘制,复杂度高不少,我放到后面扩展里再提。
组装字符串的时候要注意单位的换算。Revit内部长度单位是英尺,而工程习惯用毫米,所以每次读参数都要用UnitUtils.ConvertFromInternalUnits:
double diameterInternal = pipe.GetParameters("直径").FirstOrDefault()?.AsDouble() ?? 0; double diameterMm = UnitUtils.ConvertFromInternalUnits(diameterInternal, UnitTypeId.Millimeters); string label = $"DN{diameterMm:0}";标高同理,转成毫米之后,我通常标成“相对标高+2.850”这种形式,具体格式可以根据项目标准调整。
3.2 文字创建:TextNote和它的那些参数
Revit里创建标注文字,用TextNote.Create。这个方法本身不难,难在配套的TextNoteOptions配置。我的代码是这样:
TextNoteOptions options = new TextNoteOptions(); options.HorizontalAlignment = HorizontalTextAlignment.Center; options.VerticalAlignment = VerticalTextAlignment.Middle; options.TextNoteTypeId = textNoteTypeId; XYZ insertionPoint = GetInsertionPoint(pipe, rowIndex); TextNote note = TextNote.Create(doc, view.Id, insertionPoint, label, options);有几个参数值得展开:
HorizontalTextAlignment和VerticalTextAlignment决定了文字锚点的相对位置。我一般用居中,这样后续避让计算时,位置坐标直接就是文字框的中心,算起来简单。
TextNoteTypeId是文字类型,不同的文字类型对应不同的字高、字体、颜色。项目里通常有“管道标注”“说明文字”等好几种文字样式,创建之前要么从当前视图拿到已有的文字类型,要么根据项目标准创建一个专用文字类型。千万别用默认类型,默认的图纸字体大小在打印出来后经常偏小或偏大,要反复调整。
3.3 文字高度的经验值:不要死用默认字高
这个点是纯经验:BIM模型里的管径信息,如果直接用2.5mm字高标在1:100的平面图上,打印出来勉强能看,但电子交付时经常觉得小气。项目规范不同,字高标准差异很大。
我更推荐做一步映射:根据视图比例自动调整字高。比如1:100视图用2.5mm字高,1:50视图用3.5mm字高,1:20大样用5mm字高。判断视图比例可以通过view.Scale拿到,然后查一张映射表。这样做出来的工具,不管切到什么视图,标出来的字都规规矩矩,不用人工再调。
创建完文字之后,还要顺手设一下是否显示边框。管道标注类文字通常不需要边框,把TextNote的“边框”显示关掉,图面会干净很多。
4. 文字避让是这套工具的命门:不做避让等于废品
标注工具做到这一步,已经能自动识别管道、自动生成文字了。但如果你直接拿第一版去用,会发现图上文字重叠得一塌糊涂,尤其是多排管道,第一排的标注文字和第二排的标注文字全部叠在一起,跟打了马赛克似的。
所以“避让”才是这套工具的灵魂。我从简单到复杂把它分成三个层次。
4.1 层间避让:第一排朝上放、第二排朝下放
第一个层次最基础,也最有效。既然管道在空间上分层排列,那么标注文字也应该分层:最上面一排管道的文字放在管道的上方,第二排管道的文字放在管道的下方,第三排再放上方……依此类推。
这里要注意“上”和“下”在视图里的含义。手工标注的时候,人是凭感觉判断的,程序则需要一个明确规则。我的规则是:先对管道排按照Z值从小到大排序,排好序后,第奇数排标注朝上放,第偶数排标注朝下放。这样做的好处是层间文字交错分布,不会和相邻排的文字撞在一起。
插入点计算的关键是法向偏移量。偏移量给多少?我给两个基准值:
- 垂直偏移:距管道中心至少250mm(模型单位),保证文字和管道线不重叠。
- 水平偏移:顺着管道方向再偏一点,让文字不会正好压在管道的立管符号上。
偏移量不是越大越好,太大文字飘到老远,看图的人找不到文字的归属;太小又和管道线纠缠不清。我实测下来,450mm的管线下方偏移量对绝大多数设备层场景都适用,但前提是排版间距够。如果图纸特别紧凑,可以把这个值做成工具的可调参数,而不是写死在代码里。
4.2 行内避让:同排多根管道标注不能互相挤
层间避让解决了上下排的问题,但同一排内如果管道密集,两根相邻管道的标注文字照样会撞在一起。
处理这个问题,我的第一版方案是“已知管距、估算文字宽、间距不够就错开”。具体做法是:每生成一个文字之前,先估算它的宽度,然后和左边已经生成的那个文字做重叠检测。如果重叠,就把当前文字沿着管道方向再往外侧移动一段距离,直到不重叠为止。
文字宽度怎么估?TextNote本身有BoundingBox可以取,但我实测在包含DN、+、下标、斜杠等特殊字符时,返回的Box宽度偶尔会偏大或偏小,不稳定。所以我用的是估算公式:
double estimatedWidth = label.Length * textHeight * 0.7;字高乘以一个系数得到大概的字宽,再乘以字符数。这个公式不精确,但用于避让判断足够了——宁可多留一点空,也不能撞上。
判断是否重叠时,还可以加一道保险:如果两根管道的中心距本身就很近(比如小于300mm),直接让标注错开标高,一根标注放在管道上方偏左,另一根放在管道上方偏右,用“左右岔开”代替“互相让位”。
4.3 与既有图面元素的冲突检测
这是避让逻辑里最容易被忽略的一块,也是最影响人体验的。你做的工具如果只避让管道自己的标注,却和旁边已有的尺寸标注、文字说明重叠,一样会被打回来重做。
我后来加了一步:生成标注之前,先收集视图范围内的所有TextNote、Dimension、IndependentTag等图元,把它们的位置和范围缓存下来。新标注的位置要生成之前,先和缓存的所有图元做包围盒相交检测,如果相交就微调位置,让出空间。
这里有个工程上的取舍:微调不能让出空间的时候怎么办?我的策略是放弃当前标注的避让,直接放到默认位置,但记录下来。等全部标注创建完之后,人工看一眼记录再手动处理那几个冲突项。自动化不能100%解决问题,但能把人工工作量从300个减到三五个,这已经是非常好的效果了。
4.4 一个特殊场景:同标高多排怎么办
排管道的时候,偶尔会遇到两根甚至三根管道完全同一个标高、并排贴在一起走。这种情况在管综里太常见了,尤其是桥架和风管并排,或者多根给水管共用一层支架的时候。
对这种“水平多排”,高度聚类根本分不出来,因为它们的高度相同。我的处理办法是加一个水平方向的聚类维度:同一高度内,再按水平坐标做一次聚类,水平坐标接近的归为同一组,然后把这组里的管道当作“一排骨架”上的若干根,标注位置在水平方向上均匀错开。
比如三根水平并排的管道,它们的标注插入点就在管道水平方向上的两侧依次展开,第一根标在左上方,第二根标在正上方,第三根标在右上方。这样至少能保证三根标注互相不压,直观上也能看出它们分别属于哪根管道。
5. 实测跑下来,我撞过的三个深坑和解决方案
工具从能跑到能用的过程,往往比开发过程更折磨人。我的这个多排管道标注工具,代码本身大概两天就写完了,But真正调通、敢拿给同事用的时间,我花了将近一周。下面这三个坑是其中最有代表性的。
5.1 深坑一:收集到的管道数量比实际多,标注数量直接翻倍
第一次在真实施工模型上跑,程序一执行,标注密密麻麻生成了将近900个,实际管道才四百多根。检查发现,问题出在管道连接件上:一根管道被过滤出来两次,因为FilteredElementCollector配合MEPCurve的过滤方式,在某些情况下会把同一根管道因为连接件子元素的原因重复返回。
排查链路是这样的:先在代码里给每根管道用ElementId做个Distinct,结果没有效果,说明返回的确实是同一个ElementId。后来又想到是不是视图的问题,换了几个视图测试,还是复现。最后把WherePasses的条件逐个去掉,找到问题在于ElementClassFilter(typeof(Pipe))这个过滤器在某些Revit版本里对族实例的匹配行为有Bug,父类和子类匹配可能会返回意外的结果。
解决方案很简单,把过滤方式改成更严格的条件,同时用一个HashSet<ElementId>再做一遍去重:
HashSet<ElementId> pipeIds = new HashSet<ElementId>(); foreach (Element e in pipeElements) { if (e is Pipe pipe) { if (pipeIds.Add(pipe.Id)) { pipes.Add(pipe); } } }这个坑告诉我的经验是:Revit API的过滤器看似简单,但版本兼容性是个隐形坑。凡是线上跑了会出问题的场景,都要在收集阶段主动加固,别把所有信任都交给过滤器。
5.2 深坑二:视图裁剪框导致的可见性判断失效
工具的第二个问题出现在剖面视图。我在某个剖面图里跑标注,程序报错说“找不到可用的插入点”,但实际视图里明明满满当当全是管道。
逐步排查后发现,问题是这个剖面视图带了自定义裁剪框,裁剪框范围之外的管道物理上存在,但在当前视图里不可见。FilteredElementCollector传入视图ID时,应该只返回可见元素,但在这个有裁剪框的视图里,它仍然返回了一部分裁剪框外的管道。这些管道的位置信息倒是能读出来,但我用来判断“是否在视图范围内”的算法没有考虑视图裁剪框的边界,导致插入点算到了视图范围之外,最终创建文字时被Revit拒绝。
修复的办法是在插入点的计算阶段,主动读取view.CropBox,把插入点限制在裁剪范围内,超出范围的直接跳过,不创建标注。代码如下:
BoundingBoxXYZ cropBox = view.CropBox; XYZ rangeMin = cropBox.Min; XYZ rangeMax = cropBox.Max; if (p.X < rangeMin.X - tolerance || p.X > rangeMax.X + tolerance || p.Y < rangeMin.Y - tolerance || p.Y > rangeMax.Y + tolerance) { continue; }顺带说一句,CropBox返回的是模型坐标系下的范围,但如果视图有旋转,要做一次变换。我一开始没做,结果旋转视图里标注全部错位,后来用一个Transform把裁剪框坐标转到视图坐标系才解决。
5.3 深坑三:管道类型名称不统一,匹配规则全崩
最后一个坑来自工程数据本身。一个管综模型往往是多人协作完成的,不同人建管道时用的族类型命名可能不一样。比如有人把塑料给水管命名为“给水-PPR-De25”,有人命名为“PPR给水管DN25”,还有人干脆用的是系统默认名。
我最早在做管材信息读取时,用pipe.Name直接判断材质,结果模型里同样一根De25的管子,被分成了好几种材质显示在标注里,工地上根本没法看。
解决方案是建立一套“模糊匹配规则”:把管道类型的显示名称全部提取出来,去掉数字、字母、标点,只保留中文字符,然后按关键词库去匹配“钢”“PPR”“衬塑”“铸铁”“铜”等材质词库。匹配不中的,统一显示为“未知材质”,并加入报告清单让人工处理。
string cleanName = Regex.Replace(typeName, "[0-9A-Za-z\\-]", ""); if (cleanName.Contains("PPR")) return "PPR"; if (cleanName.Contains("衬塑")) return "衬塑钢管"; if (cleanName.Contains("铸铁")) return "铸铁管";这个思路不仅适用于材质,管径参数名在不同项目里也可能不同,有的项目“公称直径”参数叫DN,有的叫“管径”,还有的叫Diameter。所以读取参数时,我优先用内置参数BuiltInParameter.RBS_PIPE_DIAMETER_PARAM,这个参数在管道类别下是标准化的,不会因为项目自定义而丢失。
5.4 一个小技巧:用“事务组”包住整段标注操作,错了还能一键回滚
标注操作一旦执行,就是几百个文字图元塞进模型里。如果中途代码Exception了,事务被回滚还好,最怕的是代码正常执行完,但生成出来的标注位置错乱,用户只能手动一个个删。
我的经验是创建标注之前,先开一个TransactionGroup,把所有标注放在一个事务组里。这样用户可以在外部一次性撤销整批标注,而不是一个个删:
using (TransactionGroup group = new TransactionGroup(doc, "批量管道标注")) { group.Start(); // 在这里面执行标注逻辑 group.Assimilate(); }这个细节有时候同比还能救命:如果标注结果不满意,直接把事务组回滚,模型恢复原状,重新调参再来一次就行,不用清理现场。
6. 做成可配置的外部命令之后,工具才算真的交付
工具开发完成只是第一步。真正让它产生价值的,是把参数做成可配置、让不懂代码的同事也能自己用。
6.1 参数面板设计:不硬编码任何阈值
我把之前提到的所有关键阈值全部提到外部,用一个简单的外部命令窗口承载:文字插入偏移量、容差值、字高映射表、是否显示材质、是否包含标高、是否跳过已标注的管道、避让的宽松度等。使用者打开工具,调好参数,点击运行,剩下的交给程序。
代码层面可以简单粗暴一点,用WinForm或者WPF都行,核心是参数的输入输出不要写死在代码里。最差也要读一个配置文件:
UserSettings settings = UserSettings.Load("PipeAnnotationSettings.json"); double offset = settings.VerticalOffset; int tolerance = settings.GroupTolerance;6.2 运行过程中的日志输出和结果报告
工具跑完几百根管道,用户最想知道的是:标注了多少根?有哪几根没标上?哪几根避让失败了?除了控制台,最好把结果写到模型旁边的文本文件或直接在模型里生成一个标注问题报告文字。我习惯是生成一个静态文字块,列出所有异常项,用户看完之后一键删除即可。
有了这个反馈机制,工具的抗抱怨能力大大增强。出问题时同事不会觉得“这工具坏了”,而是知道哪根管道的数据有问题,可以直接反馈给建模的人去修。
6.3 后续值得扩展的三个方向
这个工具目前做到“能用”的程度,但离“好用”还有距离。我个人觉得有三件值得继续做下去的事:
第一是支持风管和桥架的多排标注。目前逻辑针对管道,但管综图纸里风管、桥架同样需要标注,把它们纳入进来才是真正的管综标注工具。原理类似,只是对应的BuiltInCategory不同,读的尺寸参数不同(风管标注宽x高,桥架标注宽+桥架类型)。
第二是自适应标注旋转。现在的标注文字都是水平方向,但实际图纸中,管道经常是斜着走的,如果标注文字也跟着管道走向旋转,图面会自然很多。这需要计算管道的走向向量,然后把TextNote的旋转角度设置成对应角度。
第三是自动导出标注明细表。程序既然能读取每根管道的信息,自然可以把这些信息汇总成一份表:管径、标高、系统、材质、所在排号,导出成CSV,交到现场跟工人。这个功能做起来很简单,但实际带来的效率提升比自动标注本身还大——现场工人拿到表就能干活,不用在图纸上一条条找。
做这个工具的过程中,我自己的一个深刻体会是:Revit二次开发很多时候拼的不是算法多牛,而是你对工程场景理解得有多深。管道怎么排、标注怎么放、什么信息重要、什么信息多余,这些经验不在API文档里,而在一线做综合管线的人脑子里。把这两者结合起来,工具才有真正的使用价值,不然就算代码再好,拿到现场照样是废品。