第一次写这系列笔记的时候,我就想过一个问题:Revit 二次开发里,大家问得最多的一个点是什么?不是创建图元,也不是布置族实例,而是“我怎么才能拿到我想要的图元”。而这个问题的一切答案,基本都绕不开过滤器。过滤器之于 Revit 二次开发,就相当于 SQL 的 WHERE 条件之于数据库查询,你不会它,等于还没入门。
这篇是“Revit二次开发”系列的第一篇,专门聊聊过滤器。我尽量把 FilteredElementCollector、ElementFilter 这一大家子讲透,配合 C# 代码示例和实际项目里的坑,给刚开始写 Revit 插件的朋友一份能直接抄作业的笔记。适合刚接触 Revit API、被图元遍历搞到崩溃的开发者,也适合打算系统梳理过滤器的朋友当目录参考。
1. 为什么 Revit 二次开发绕不开过滤器
1.1 一个初学者的灵魂拷问
你第一次写 Revit 插件,十有八九是这么开始的:新建一个外部命令,然后想把这个项目里所有的墙体捞出来。于是你打开文档,看到 FilteredElementCollector,复制一行示例代码,跑通了,很兴奋。但接下来你要按用途筛选、按参数筛选、把某个区域内的图元剔除,马上就卡住了。
我刚开始学的时候,习惯用一个特别笨的办法:拿到所有 Element,然后 foreach 一个个判断,用 if 去筛。数据量小的时候还行,一旦项目上了几百兆,楼上楼下一堆机电管线,那个遍历速度直接劝退用户。后来我认真把过滤器体系过了一遍,才发现这玩意儿的设计比我最初的印象要讲究得多。
这里先说个结论:Revit 的过滤器,本质上是一套为“高效筛选图元”设计的决策器。你告诉它“要什么”“不要什么”,它用内部优化过的规则去匹配,而不是把所有图元加载到内存里,再靠你一个个跑业务判断。
1.2 过滤器是什么,不是什么
先理清概念。在 Revit API 里,有两个容易混淆的名字:
FilteredElementCollector:这是“收集器”,你可以理解为一个大筛子入口。它负责定义“从哪里筛”,可选当前文档、某个视图,或者某些链接模型的元素集合。ElementFilter类及其子类:这是“过滤器”,决定“筛什么”。比如ElementClassFilter筛类型,ElementCategoryFilter筛类别,ElementParameterFilter筛参数规则。
实际使用顺序永远是:Collector 决定数据源,Filter 决定筛选条件,最后用.WherePasses()把条件挂上去,或者用OfClass()这类快捷方法直接筛。
还有一个很多人都忽略的点:FilteredElementCollector不一定只能拿全文档元素。你可以让你收集器只作用在当前视图上,直接过滤掉不可见图元,这种场景在构件统计、报验批次管理里特别常见。类似“只看得到的”“只在这个房间里的”,都可以用 collector 作用域来实现。
2. 过滤器的核心分类与设计逻辑
2.1 三大家族:快速、慢速、逻辑
刚开始看 API 文档,ElementFilter 下面一堆类很容易晕。我自己的分类习惯是把它分成三组:
快速过滤器:不打开 Element,直接判断类、类别、是否匹配某个唯一 Id。比如ElementClassFilter、ElementCategoryFilter、ElementIdFilter。它们快是因为 Revit 内部本来就有这些索引信息。
慢速过滤器:需要读取元素的某些数据,比如ElementParameterFilter要遍历图元并读取参数值,BoundingBoxIntersectsFilter要判断几何包围盒。不是不能用,而是要明白它们相对耗时。
逻辑过滤器:用来组合上面两类,形成“与、或、非”的判断。比如LogicalAndFilter、LogicalOrFilter、LogicalNotFilter。这个在你需要复合条件时,比你以为的把元素取出来再用 if 判断要高效得多。
这个结构其实很像认识一个人:快速过滤器就像先看外表,慢速过滤器就像再看履历,逻辑过滤器则决定你到底是“要 A 又要 B”“A 或 B 都行”还是“只要不要 A 的”。三者组合起来,才是你在项目里真正干活的样子。
2.2 命中还是排除:过滤器都在做判断题
过滤器所有子类,最终只回答一个问题:这个图元要不要?
所以当你自己定义一个过滤器类,继承ElementFilter,其实只需要重写一个方法bool Passes(Element element),然后加上一个可选的判断条件。但在实际项目里,绝大多数场景直接用现成子类就够了,不需要造轮子。
这里有个反直觉的细节:WherePasses()并不直接在 C# 里返回一个 List,而是返回一个 FilteredElementCollector。这个收集器是“惰性”的,它到你真正开始遍历(比如.ToList()、.FirstOrDefault())的时候才真正执行筛选。所以不要以为随便调用 WherePasses 就会立刻把全项目跑一遍,它是可以被继续链式调用的。等你确定要取结果了,再来一次迭代,它才真正工作。
提示:能快速过滤器解决的就不要上慢速过滤器。这不仅是性能问题,更关系到插件在大项目里的稳定性。快速过滤器失败不会导致 Revit 崩溃,但慢速过滤器在大数据量下可能让你等得怀疑人生。
3. 常用过滤器实操代码解析
3.1 按类别的三件套
Revit 二次开发里最常用的一个快速筛选操作是“按类别找图元”。比如我想拿到所有墙,直接:
FilteredElementCollector collector = new FilteredElementCollector(doc); ICollection<Element> walls = collector .OfClass(typeof(Wall)) .Cast<Wall>() .ToList();这个方法本质是内部创建了ElementClassFilter(typeof(Wall)),你用起来简单,但要知道它在干什么。OfClass(typeof(Wall))会找出所有 wall 类型的实例,包括基于系统族、基于载入族创建的墙。
但有时候你要的是“某个类别”,而不是“某个类”。比如你要拿所有“门”,门的 API 类型是FamilyInstance,同时还想拿门所在的族类型信息。用 OfClass 的话拿到的只是 FamilyInstance,你还得自己再去取类型和族。这时候用类别过滤器会更直接:
ElementCategoryFilter doorCategoryFilter = new ElementCategoryFilter(BuiltInCategory.OST_Doors); FilteredElementCollector doorCollector = new FilteredElementCollector(doc); ICollection<Element> doors = doorCollector .WherePasses(doorCategoryFilter) .ToList();这段代码筛出所有类别为“门”的图元。它不关心门具体是哪个类实现的,FamilyInstance、IndependentTag等等只要类别匹配都会进来。所以做“族实例统计”类的工具时,用ElementCategoryFilter往往比OfClass更省心。
还有一个经常被提的组合:先按类拿基础对象,再按类别排除一些干扰项。比如我要项目里所有柱子,但我不想要“建筑柱”里面的某些结构连接件,那就可以:
FilteredElementCollector columnCollector = new FilteredElementCollector(doc); ElementCategoryFilter columnCatFilter = new ElementCategoryFilter(BuiltInCategory.OST_StructuralColumns); ICollection<Element> columns = columnCollector .WherePasses(columnCatFilter) .WherePasses(new ElementClassFilter(typeof(FamilyInstance))) .ToList();你可能会奇怪:ElementCategoryFilter不是已经能筛出结构柱了吗?为什么还要再按 FamilyInstance 筛一次?因为有些类别下,Revit 内部会包含一些“类”不同的辅助图元。再叠加一个类过滤器,等于把范围逼近到“结构柱类别 + FamilyInstance”,两者同时满足,而不是 shift 或 或的关系。这是我在做钢筋统计工具时踩过坑后总结出来的纪律:能同时用快速过滤器缩窄范围,绝对不要相信自己后续的 if 兜底。
3.2 参数过滤器
很多时候,按类别拿完还远远不够。例如我们要统计“所有宽度为 900 的门”。传统思路是把门全取出来,逐个 read 参数,再判断。慢,而且代码丑。Revit 提供的正式方式是用ElementParameterFilter。
// 定义参数过滤器:宽度 = 900mm ParameterValueProvider provider = new ParameterValueProvider(new ElementId((int)BuiltInParameter.DOOR_WIDTH)); FilterNumericEquals rule = new FilterNumericEquals(provider, 900 / 304.8, 1e-6); ElementParameterFilter widthFilter = new ElementParameterFilter(rule); FilteredElementCollector doorCollector = new FilteredElementCollector(doc); ICollection<Element> wideDoors = doorCollector .WherePasses(new ElementCategoryFilter(BuiltInCategory.OST_Doors)) .WherePasses(widthFilter) .ToList();这段要注意三个点:
第一,DOOR_WIDTH返回的参数值单位是英尺(feet),而用户界面上看到的是毫米。你必须手动除以 304.8,否则你的 900 拿不到任何结果,而且运行时不报错。这种 bug 特别恶心,因为看起来逻辑完全没问题。如果懒得记,直接用UnitUtils.ConvertToInternalUnits(900, UnitTypeId.Millimeters)转换。
第二,FilterNumericEquals构造函数里有个精度参数。Revit 内部是 double 计算,用 1e-6 就是用来容忍浮点误差。等你实际用的时候,如果发现该筛出来的没筛出来,首先检查是不是精度设得太严格。
第三,ElementParameterFilter是慢速过滤器。它不是等参数已经加载好再判断,而是运行时去读每个元素的参数值。这意味着,如果你在第一步已经用 OfClass 拿到很小范围了,还勉强能接受;如果你直接全文档用参数过滤器去筛所有图元,那个性能损耗是非常明显的。
实操心得:做参数过滤前,先用快速过滤器缩小范围到必要的类别上,再挂参数过滤器。比如“先筛门,再按参数筛宽度”,这比“直接按宽度参数筛所有元素”快很多。别把参数过滤器当万能钥匙用。
3.3 逻辑组合与排除
实际业务里,很少只有单一条件。比如你要找“所有的幕墙嵌板,但不包含门窗嵌板”。这种场景用逻辑过滤器最优雅。
// 要幕墙嵌板 ElementCategoryFilter curtainPanelFilter = new ElementCategoryFilter(BuiltInCategory.OST_CurtainWallPanels); // 不要门嵌板 ElementCategoryFilter doorPanelFilter = new ElementCategoryFilter(BuiltInCategory.OST_Doors); LogicalNotFilter notDoorFilter = new LogicalNotFilter(doorPanelFilter); LogicalAndFilter finalFilter = new LogicalAndFilter(curtainPanelFilter, notDoorFilter); FilteredElementCollector collector = new FilteredElementCollector(doc); ICollection<Element> finalPanels = collector .WherePasses(finalFilter) .ToList();LogicalAndFilter支持把多个过滤器合成一个,但这里如果你直接给它传数组,数组里每个过滤器会被 AND 连接。如果你想要“A 或 B”,就用LogicalOrFilter。
再补充一个细节:ExclusionFilter专门用来做“排除某组 Id”。比如你前面收集一堆元素,想排除其中一些手选出来的重复项:
ICollection<ElementId> excludedIds = new List<ElementId> { id1, id2 }; ExclusionFilter excludingFilter = new ExclusionFilter(excludedIds); FilteredElementCollector collector = new FilteredElementCollector(doc); ICollection<Element> filteredElements = collector .WherePasses(new ElementCategoryFilter(BuiltInCategory.OST_Walls)) .WherePasses(excludingFilter) .ToList();这个在高亮选中、批量操作、差异对比场景里很好用,它避免了你写很多“if (excludedIds.Contains(element.Id))”这种代码。
3.4 空间范围过滤
还有一个经常遇到的需求:找个区域内的图元。比如框选一个范围、按某个房间范围统计电器设备。这时候就要用BoundingBoxIntersectsFilter或BoundingBoxIsInsideFilter。
// 定义范围,假设是一个 10m x 10m x 3m 的立方体 XYZ min = new XYZ(0, 0, 0); XYZ max = new XYZ(10, 10, 3); // 注意单位也是英尺 Outline outline = new Outline(min, max); BoundingBoxIntersectsFilter bboxFilter = new BoundingBoxIntersectsFilter(outline); FilteredElementCollector collector = new FilteredElementCollector(doc); ICollection<Element> intersectedElements = collector .WherePasses(bboxFilter) .ToList();这里要搞清楚两个过滤器的区别:
BoundingBoxIntersectsFilter:只要元素的包围盒和给定范围有交集,就通过。适合“范围框选”的场景,碰到边缘也算。BoundingBoxIsInsideFilter:要求元素整个包围盒都在给定范围内,才算通过。适合统计严格位于盒子内的设备。
两者的边界情况差异非常明显,实际项目中你用方框圈一批构件时,发现多了或少了,多半是选错了这两个过滤器。
还有一个注意点:Revit 的BoundingBox是轴对齐包围盒,不考虑图元朝向。旋转过的墙或者斜梁,包围盒会大于实际几何体。如果你的筛选要求很精确,需要配合几何相交检测二次确认。
4. 组装一个真实场景:批量提取项目中的门信息
4.1 需求拆解与流程设计
讲了这么多单个过滤器,来一个综合场景:小工具“门表速查”。需求很简单:把当前项目中所有门,按“楼层、门编号、宽度、高度、类型名称”输出到一个文本或 Excel 表格。听起来不难,但实现过程中会用到好几类过滤器,顺序很关键。
我的流程是这样的:
- 用类别过滤器
OST_Doors拿到所有门。 - 用类过滤器
FamilyInstance再缩一档,排除掉类别里的其他杂项元素。 - 对拿到的门集合,按某个范围(比如只看当前楼层平面视图)再过滤,避免输出一堆别的楼层的门。
- 逐个读取参数:门编号、宽度、高度、类型名称。
- 输出并排序。
第二步和第三步的顺序很重要:先类别,再类,再空间。每一步都把候选集缩小,后面的参数读取就快。
4.2 代码实现
public void ExportDoors(Document doc, string outputPath) { // 第一步:类别过滤器,拿到所有门 ElementCategoryFilter doorCategory = new ElementCategoryFilter(BuiltInCategory.OST_Doors); // 第二步:类过滤器,限定 FamilyInstance ElementClassFilter familyInstanceClass = new ElementClassFilter(typeof(FamilyInstance)); LogicalAndFilter doorScopeFilter = new LogicalAndFilter(doorCategory, familyInstanceClass); FilteredElementCollector collector = new FilteredElementCollector(doc); // 第三步:先拿到初步的门的集合 ICollection<Element> doors = collector .WherePasses(doorScopeFilter) .ToList(); List<Dictionary<string, string>> rows = new List<Dictionary<string, string>>(); foreach (Element door in doors) { FamilyInstance instance = door as FamilyInstance; if (instance == null) continue; string mark = instance.get_Parameter(BuiltInParameter.ALL_MODEL_MARK)?.AsString() ?? ""; double width = instance.get_Parameter(BuiltInParameter.DOOR_WIDTH)?.AsDouble() ?? 0; double height = instance.get_Parameter(BuiltInParameter.DOOR_HEIGHT)?.AsDouble() ?? 0; double widthMM = UnitUtils.ConvertFromInternalUnits(width, UnitTypeId.Millimeters); double heightMM = UnitUtils.ConvertFromInternalUnits(height, UnitTypeId.Millimeters); ElementType type = instance.Document.GetElement(instance.GetTypeId()) as ElementType; string typeName = type?.Name ?? ""; rows.Add(new Dictionary<string, string> { { "mark", mark }, { "width", widthMM.ToString("F0") }, { "height", heightMM.ToString("F0") }, { "type", typeName } }); } // 假定 rows 后续写入文件,这里略 }这里值得注意的有几个:
ALL_MODEL_MARK就是构件“标记”参数,一般用来存门编号。它不是所有项目都填了,所以做工具时最好留个空值兜底。- 读取宽度高度时,我显式用
ConvertFromInternalUnits转成毫米,否则输出一堆英尺小数,表格没法看。 GetTypeId()是FamilyInstance的方法,可以拿到实例对应的类型 Id,然后通过doc.GetElement拿到ElementType。这个套路在处理族实例时非常通用。- 我在循环里没有直接做二次空间过滤,因为实际工具里通常会把“楼层”作为参数条件,而不是用包围盒。但如果你的目标视图本身是激活的工作平面视图,也可以用视图作用域的 Collector 一步到位。
4.3 一个性能体感对比
曾经在某项目上,模型三百多兆,几十个楼层,总门数不多,也就两千。我一开始偷懒,想用一次性new FilteredElementCollector(doc).WherePasses(doorScopeFilter)拿所有门,后面再逐层处理。实测结果:一次遍历几毫秒就回来了,非常快。
真正慢的反而是后面的参数读取:每扇门要读三个参数、取一次类型。两千扇门,加起来也就 200-300 毫秒,这个速度完全可接受。但如果你想全文档遍历所有图元,再逐个判断 Element.Name 或者判断参数是否存在,那基本就是几秒甚至十几秒的体验差距。这就是过滤器的价值所在——它把“查询”和“数据加工”两个阶段的成本分得非常清楚。
5. 避坑实录:我踩过的过滤器相关坑
5.1 快速过滤器判断不了参数
我记得有一次写个工具,想筛出所有“含有某个扩展数据的墙”。我用的第一个版本是:
FilteredElementCollector collector = new FilteredElementCollector(doc); ICollection<Element> walls = collector .OfClass(typeof(Wall)) .WherePasses(new ElementParameterFilter(rule)) .ToList();问题出在rule里我用了一个共享参数,这个参数在某些墙上根本没被添加过。结果是我期望“没有该参数的墙被排除”,实际上 Revit 对“参数不存在”和“参数值为空”的处理方式会让人意外。ElementParameterFilter 对不存在的参数,它不会直接返回 false,而是可能根据规则类型走“无参数也算一种状态”的逻辑。
后来我的解决方案很土:先快速过滤,再在循环里手动判断该参数是否存在。
foreach (Wall wall in walls) { Parameter targetParam = wall.get_Parameter(sharedParamGuid); if (targetParam == null) continue; // 处理 }这不是过滤器不好,而是我理解错了它的语义。过滤器的职责是“按规则筛选”,不是“按业务兜底”。你需要兜底的行为,老老实实在循环里做。
5.2 当心过滤器和事务的配合
如果你在事务里改了某些参数,然后用 FilteredElementCollector 再去取元素,注意:过滤器的结果可能不会实时反映未提交的事务修改。很多时候需要做完修改后,再 new 一个新的 collector,或者调用doc.Regenerate()后重新收集。
我记得有一次在批量替换门的编号,代码流程是:收集所有门 → 循环修改 mark → 再收集所有门去验证。结果验证那步拿到的还是旧数据,害得我排查了半天。最后先doc.Regenerate(),再新建 Collector,就正常了。
注意:把“收集图元”和“修改图元”分开两个阶段写,修改完如果需要同批数据再操作,一定重新收集,别复用旧集合。
5.3 空间过滤器的边界情况
前面提到BoundingBoxIntersectsFilter和BoundingBoxIsInsideFilter的边界差异。我在写某项目的机电管线统计时,用包围盒过滤风管,结果发现有些明明在盒子内的风管没被筛进来。后来一查,风管的系统族实例的 BoundingBox 可能是按管线连接件的外包围盒来算的,而不是按实际的几何中线。斜向管路尤其严重。
解决办法倒也不复杂:空间过滤只作为第一轮粗筛,第二轮用元素的get_Geometry或 Solid 相交判断再来一次精确验证。这笔账很划算:第一轮快速缩小范围,第二轮精确处理,总时间比直接全量几何碰撞小得多。
5.4 类别与类的混用会把自己坑哭
有人写了这么多代码:
collector.OfClass(typeof(Wall)) .OfCategory(BuiltInCategory.OST_Walls) .ToList();这看起来是双重保证,实际不会出问题,但很容易让人误以为OfCategory比WherePasses(ElementCategoryFilter)有什么额外效果。两者行为基本一致。同样地,有些人用WherePasses(new ElementClassFilter(typeof(FamilyInstance)))来筛门,但把“没有门类别”也一起筛出来了。因为 FamilyInstance 类涵盖墙上的嵌板、柱、独立标记等一大堆东西,你后面必须再用类别过滤收窄。把类过滤和类别过滤的顺序、含义都搞明白,就不会犯这种错。
我自己写项目的固定写法是:
- 先按类别快速过滤。
- 再按类过滤往里收一层。
- 如果条件需要参数、范围等,再挂慢速过滤。
- 索引级别的筛选全部在 collector 上做,循环里只做数据加工。
这样做下来,插件稳定,不容易漏,也方便半年后自己回来看懂。
5.5 版本差异带来的小心思
不同的 Revit 版本里,某些 BuiltInParameter 枚举有过变更,比如DOOR_WIDTH、DOOR_HEIGHT这类参数在老版本存在,新版本里可能废弃,但还能用。你在新版本编译时的 warning 别忽略,最好打开 API 文档看一眼替代参数。
实际工作中,我见过有人在一台机器上把插件编译通过,换到项目方的 Revit 版本上一运行就崩,原因之一就是用了当前环境不存在的 BuiltInParameter。所以多版本兼容的插件,建议专门做一层“参数映射表”,把旧枚举映射到新枚举,运行时根据产品版本动态取。
6. 一些顺手的小建议
关于过滤器,最后再补充几个不是过滤器本身、但直接影响你使用体验的小经验:
先确认要操作的文档和视图。如果你只想处理当前可见图元,直接在 FilteredElementCollector 构造里传 view,相比先收集全文档再逐个判断 IsHidden 要高效太多。我自己写“当前视图标记尺寸检查”工具时,就是这么干的,速度快得让甲方以为是假插件。
元素 Id 的过滤很有用但容易被忽略。做联动选择、联动高亮时,经常需要“拿一批 Id 对应的元素”。直接用ElementIdFilter传一个ICollection<ElementId>,比循环取 Id 再 GetElement 快不少,也省代码。
养成看过滤结果计数的习惯。调试插件的时候,每次 WherePasses 之后先看一下集合数量。数量在预期范围内,再继续往下写逻辑。如果第一层就筛出离谱的数量,趁早回头检查条件,别等后面调试得焦头烂额才发现第一步就错了。
我在实际项目里用过很多次过滤器组合以后,最大的体会是:Revit 二次开发的问题,百分之六七十不是“代码怎么写”,而是“你希望捞到什么、不应该捞到什么”定义清楚。过滤器恰好是逼着你思考这个问题的工具。你越能精确描述筛选目标,插件的表现就越稳、越可控。下一篇笔记我会接着写图元几何操作,到时候会大量复用今天讲的过滤器。先把这一步踩实了,后面就顺了。