Skip to content

Selector Syntax

IFClite reads the IfcOpenShell selector (filter) syntax — the one-line form used by Bonsai and by ifcopenshell.util.selector — and turns it into filter rules.

Type it into the Selector field at the top of the viewer's Filter tab, or parse it yourself with parseSelector from @ifc-lite/query.

Not every construct has a rule behind it yet. The ones that do not are listed back to you by name; nothing is dropped in silence. What is missing is tracked in issue #4094.

The grammar

selector := group ("+" group)*        groups are unioned
group    := filter ("," filter)*      filters narrow left to right
Filter Example Means
Class IfcWall the class and all its subclasses
Class, subtracted ! IfcWall remove that class and its subclasses
GlobalId 325Q7Fhnf67OZC$$r43uzK one element, by GlobalId
Attribute Name=D01 an IFC attribute of the element
Property Pset_WallCommon.FireRating=2HR a property in a property set
Type type=WT01 the relating type's Name
Material material=concrete a material Name or Category
Classification classification=Pr_25 a classification reference
Location location="Level 3" the spatial element containing it
Parent parent=Foo a descendant of the element named Foo
Value query query:types.count=0 a value-query key path

Operators: =, !=, >, >=, <, <=, *= (contains), !*= (does not contain).

Values, and property-set and property names, come in three spellings:

Spelling Example Notes
bare concrete no spaces, no , + = ! < > * " /
quoted "Level 3" \" and \\ escape inside
regular expression /D[0-9]{2}/ unanchored, case-sensitive

Quoting is what forces a literal: "/Wall/".FireRating looks for a property set actually named /Wall/, not a pattern. It works in either position, so Pset_BeamCommon."IsExternal" and /Pset_.*Common/."IsExternal" are both valid.

NULL (any case, unquoted) is the null literal, so FireRating != NULL means "has a FireRating". TRUE and FALSE are compared as the strings IFC property sets render them (True / False), case-insensitively.

There is no * wildcard. IfcWall* is a syntax error, not a match-nothing: * exists only as part of *=. Use a regular expression for wildcards.

What works in the viewer today

Construct Viewer Filter tab Notes
IfcWall, several classes, ! subtraction ✅ subclasses included, per the model's schema
Name=, !=, *=, !*= ✅ case-insensitive
Name=/regex/ ✅ case-sensitive
PredefinedType=, != ✅ no regex
Pset.Prop with all eight operators ✅
Pset.Prop = NULL / != NULL ✅ becomes "is not set" / "is set"
/Pset_.*Common/.Prop regex set or property name ✅ one rule reaches several sets
Qto_….Quantity > 10 ⚠️ only a Qto_ set, and only a numeric comparison — see below
material= ✅ matches a material Name or Category
classification=, = NULL, != NULL ✅ matches the code or the name
location="Level 3" ✅ reaches one level through a containing space — see below
GlobalId terms, ! <GlobalId> ✅ several terms union (add) or subtract, mirroring class terms
GlobalId=, GlobalId!= ✅ reuses the same globalId rule the bare term builds; *=, >/>=/</<=, /regex/, and NULL are reported
Description=, ObjectType=, Tag=, any other schema attribute ✅ all eight operators, = NULL / != NULL as presence — see below
type=WT01 ✅ matches the relating type's Name; =, !=, *=, !*= and /regex/, like Name= — no >, >=, <, <=
parent=Foo ✅ any ancestor's Name, walking containment and aggregation to any depth — see below
query: ❌ deliberately out of scope — see below, not "not supported yet"
+ unions of groups ✅ real OR-of-AND groups — see below

+ unions

IfcSlab, material=concrete + IfcDoor becomes two filter groups, OR'd together — every slab whose material is concrete, plus every door — the same as two separate filters would match, unioned. Each +-separated clause is its own AND group internally; only + combines groups.

An unsupported construct anywhere in a union refuses the whole query rather than applying the readable groups: a dropped OR branch would silently narrow what the union matches, with nothing shown to say so. A selector with no + at all keeps applying what it can and naming the rest, as it always has.

Which quantities a selector can reach

Quantities are a separate table from property sets, and the two rules do not read each other's rows. A term reaches the quantity table only when both halves hold:

  • the set name starts with Qto_ (case-sensitive), or is a pattern whose Qto_ opens it or opens one of its alternatives — /^Qto_.*/, /(Qto_Wall|Qto_Slab)BaseQuantities/;
  • the comparison is =, !=, >, >=, < or <= against a number.

A Qto_ term failing the second half is reported, not applied: Qto_WallBaseQuantities.NetVolume = NULL, …NetVolume *= 1 and …Note = draft all come back named rather than silently run against property sets, where they would find nothing (= NULL was worse still — "is not set" against a set no property row carries matched every element).

Quantities written under a set with no Qto_ prefix are not reachable from a selector today: Revit's IFC2x3 export writes BaseQuantities and ArchiCAD writes ArchiCADQuantities, so BaseQuantities.NetVolume > 1 becomes a property rule and finds nothing. Reading quantity rows from a property term is part of #4094.

Generic attribute terms, and the one that stays reported

Description=, ObjectType=, Tag=, LongName=, or any other name the IFC schema declares as an attribute becomes an attribute rule, read from the same on-demand per-entity extraction the IDS attribute facet uses. All eight operators work, and = NULL / != NULL read as "is not set" / "is set", the same as a property term.

GlobalId= (the comparison spelling, not the bare-GlobalId term) is the one exception: the underlying extraction skips GlobalId as a structural/display attribute, so routing it through the generic attribute rule would silently match nothing. Instead GlobalId= and GlobalId!= reuse the same globalId rule the bare term builds — the same exact-identity, case-sensitive comparison, just spelled as a comparison. A GlobalId is a fixed 22-character identity rather than text to search within or order, so *=, >/>=/</<=, a /regex/ value, and NULL stay reported; use = or != against a literal id, or the bare GlobalId term (325Q7Fhnf67OZC$$r43uzK) — see the grammar table above.

How far location= reaches

location="Level 3" becomes a storey-name rule, and that rule matches an element the storey contains directly, its aggregated parts, and — one hop through a containing IfcSpace/IfcSpatialZone — an element inside a space on that storey. So IfcOpenShell's example IfcPump, location="Level 3" — a pump in a room on Level 3 — now finds the pump in IFClite too. This is measured, not assumed: see storey rule reach in @ifc-lite/rules's filter-evaluate.test.ts.

The reach stops at one hop: an element inside a space nested inside another space, rather than directly under the storey, is not resolved. That deeper case is uncommon (most authoring tools put a space directly under its storey) and is left open in #4094 rather than guessed at.

How far parent= reaches, and why it is not location=

parent="Level 3" matches an element that is a direct or indirect child, in the spatial hierarchy, of an element named Level 3 — walking upward through both IfcRelContainedInSpatialStructure (containment) and IfcRelAggregates (aggregation) edges, to any depth. That is deliberately wider than location=, which stops at one hop through a containing space: parent= also reaches a grandparent (a wall's storey's building), a part aggregated under an assembly that itself sits in a storey, and any combination of the two edge kinds in one walk.

Matching an element with no matching ancestor — or no ancestors at all — is an empty result, not "no filter": parent="Nonexistent" finds nothing, the same way an unmatched material= or type= finds nothing. Operators =, !=, *=, !*= and /regex/ all work, like type=/material= — no >, >=, <, <=. Built once, in @ifc-lite/data's collectSpatialAncestors, so the CLI/MCP/SDK axis can reuse the same traversal when it adopts parent= (tracked separately).

query: is refused permanently, not "not supported yet"

query:{keys}={value} resolves an open key-path language (attribute/property/ material/type chains, counting functions like .count) rather than one bounded comparison. Unlike every other construct in the grammar table above, there is no fixed rule shape to build for it — a partial resolver would silently narrow some key paths and refuse others with no principled line between them, the same zero-match-reads-as-everything risk this whole adapter exists to avoid. This is a permanent decision (#4094), not a gap on a roadmap: a query: term is always reported by name and never turned into a rule.

Where else can I filter?

Selector text is accepted on every surface: the viewer's Filter tab, the CLI's query --select "…", the MCP query_entities tool's selector parameter, and the SDK's bim.query().select('…'). The CLI, MCP and SDK route it through the same @ifc-lite/query parser and the selectorToQueryDescriptor adapter, which covers a lossless subset of the grammar above — class terms and comma unions, exact-name Pset/Qto comparisons with = != > >= < <= *=, a /regex/ value on =, and != NULL — and throws SelectorUnsupportedError naming the construct for anything else (regex pset/property names, !*=, ! class negation, + group unions, parent=, query:, material=, classification=, location=, attribute terms). The viewer's Filter tab keeps the full grammar. The structured filters each surface already had remain, and this is how the same intent is spelled in them:

Selector CLI MCP query_entities SDK / sandbox
IfcWall, IfcSlab --type IfcWall,IfcSlab types: ['IfcWall','IfcSlab'] bim.query().byType('IfcWall')
Pset_WallCommon.FireRating=2HR --where "Pset_WallCommon.FireRating=2HR" property: { pset, name, op: '=', value } .where('Pset_WallCommon','FireRating','=','2HR')
…FireRating*=REI --where "Pset_WallCommon.FireRating~REI" op: 'contains' .where(…, 'contains', 'REI')
…FireRating != NULL --where "Pset_WallCommon.FireRating" op: 'exists' —
/Pset_.*Common/.FireRating — (--select throws SelectorUnsupportedError) — bim.query.property(entity, '/Pset_.*Common/', 'FireRating')
IfcWall* (a wildcard) --a "IfcWall*" (clash selectors only) — —

Note the last row: the clash rule selectors (ifc-lite clash --a "IfcDuct*|IfcPipe*") are a different, older mini-language with a suffix * glob. That grammar is unchanged and is not the one on this page.

Parsing it yourself

import { parseSelector } from '@ifc-lite/query';

const result = parseSelector('IfcWall, Pset_WallCommon.FireRating=/REI.*/');
if (result.ok === false) {
  console.error(`${result.error.message} at character ${result.error.offset + 1}`);
} else {
  for (const group of result.query.groups) {
    for (const filter of group.filters) {
      console.log(filter.kind, filter.text);
    }
  }
}

parseSelector reads the whole grammar, including the constructs no surface can evaluate yet. That is deliberate: a caller adapts the AST onto its own filter model and reports what it could not carry, instead of matching nothing and saying nothing.

See also