Skip to content

Querying Data

Guide to querying IFC data with IFClite.

Overview

IFClite provides multiple query interfaces:

IfcQuery's fluent and SQL bulk queries read the parsed IfcDataStore passed to its constructor. They do not include entities created or deleted in a session's mutation view. Use the SDK query backend for live edited models.

flowchart TB subgraph Sources["Data Sources"] Entities["Entity Table"] Props["Property Table"] Graph["Relationship Graph"] end subgraph Query["Query Interfaces"] Fluent["Fluent API"] SQL["SQL (DuckDB)"] Direct["Direct Access"] end subgraph Output["Output"] Arrays["Entity Arrays"] Values["Property Values"] Trees["Hierarchy Trees"] end Sources --> Query Query --> Output

Fluent Query API

Basic Queries

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

const query = new IfcQuery(store); // store from parseColumnar()

// Get all walls
const walls = query.walls().execute();

// Get all doors
const doors = query.doors().execute();

// Get all windows
const windows = query.windows().execute();

// Get specific entity types
const beams = query.ofType('IFCBEAM').execute();
const columns = query.ofType('IFCCOLUMN').execute();

Type Shortcuts

Method Entity Type
.walls() IFCWALL, IFCWALLSTANDARDCASE
.doors() IFCDOOR
.windows() IFCWINDOW
.slabs() IFCSLAB
.columns() IFCCOLUMN
.beams() IFCBEAM
.spaces() IFCSPACE

Property Filters

// Filter by property value
const externalWalls = query
  .walls()
  .whereProperty('Pset_WallCommon', 'IsExternal', '=', true)
  .execute();

// Filter by numeric comparison
const wellInsulated = query
  .walls()
  .whereProperty('Pset_WallCommon', 'ThermalTransmittance', '<=', 0.25)
  .execute();

// Filter by boolean
const loadBearing = query
  .walls()
  .whereProperty('Pset_WallCommon', 'LoadBearing', '=', true)
  .execute();

// Filter by string pattern
const fireRated = query
  .walls()
  .whereProperty('Pset_WallCommon', 'FireRating', 'startsWith', 'REI')
  .execute();

// Quantity sets work through the same call — name the Qto_ set as the
// first argument
const largeWalls = query
  .walls()
  .whereProperty('Qto_WallBaseQuantities', 'NetSideArea', '>', 10)
  .execute();

Comparisons are same-type only: '60' (a string FireRating) does not match the number 60, and a null on either side never matches — not even with !=. contains and startsWith are string-only. A filter matches when any property of that name, in any set of that name, satisfies it — which can differ from EntityNode.property(), a single-value getter that returns the first match, for an entity that carries the same property twice.

Scope the query before filtering on a property

On a STEP (.ifc) model the property sets are read lazily from the source buffer rather than from a pre-built index, so whereProperty resolves each candidate entity and the cost grows with how many entities reach the filter. Call .walls() / .ofType(...) / .onStorey(...) first: query.all().whereProperty(...) resolves every entity in the model, which on a large model can cost many times the type-scoped form.

This applies to a cache-restored .ifc model too: the cache stores the property table as it was built, and a STEP parse leaves it empty, so a restored model resolves per candidate exactly like a fresh parse.

The rule is the store, not the file format: a query answers from the property index whenever the store carries table rows, and resolves per candidate when it does not. An indexed store's cost scales with the number of rows carrying the name rather than with the candidate count. Both paths return the same entities.

Chained Queries

flowchart LR Start["query"] Type["walls()"] Filter1["whereProperty()"] Filter2["whereProperty()"] Output["execute()"] Start --> Type --> Filter1 --> Filter2 --> Output
// Complex query chain
const walls = query
  .walls()
  .whereProperty('Pset_WallCommon', 'IsExternal', '=', true)
  .whereProperty('Pset_WallCommon', 'FireRating', '=', 'REI60')
  .whereProperty('Qto_WallBaseQuantities', 'NetSideArea', '>', 10)
  .execute();

// execute() returns QueryResultEntity[]; project the fields you need
const results = walls.map(w => ({ expressId: w.expressId, name: w.name, type: w.type }));

console.log(results);
// [
//   { expressId: 123, name: 'Wall-001', type: 'IfcWall' },
//   { expressId: 456, name: 'Wall-002', type: 'IfcWallStandardCase' },
//   ...
// ]

Spatial Queries

Hierarchy Navigation

graph TD Project["IfcProject"] Site["IfcSite"] Building["IfcBuilding"] Storey1["Storey: Ground Floor"] Storey2["Storey: First Floor"] Wall1["Wall-001"] Wall2["Wall-002"] Door["Door-001"] Project --> Site Site --> Building Building --> Storey1 Building --> Storey2 Storey1 --> Wall1 Storey1 --> Wall2 Wall1 --> Door
// Get all storeys (getter, returns EntityNode[])
const storeys = query.storeys;

// Get elements on a specific storey
const groundFloor = query.storeys.find(s => s.name === 'Ground Floor');
const groundFloorElements = groundFloor
  ? query.onStorey(groundFloor.expressId).execute()
  : [];

// Get the direct elements of every storey (contains() is one hop, not recursive;
// use traverse() or decomposes() to walk nested aggregation)
const buildingElements = query.storeys.flatMap(storey => storey.contains());

// Navigate up the hierarchy
const wall = query.entity(123);
const storey = wall.containedIn(); // EntityNode | null
const building = wall.building();  // walks up to the containing IfcBuilding

Spatial Relationships

// Get entities contained by a spatial container (returns EntityNode[])
const contained = query.entity(storeyId).contains();

// Get the container of an element (EntityNode | null)
const container = query.entity(wallId).containedIn();

Relationship Queries

// Get property sets for an entity (returns PropertySet[])
const psets = query.entity(wallId).properties();

// Get quantity sets for an entity (returns QuantitySet[])
const qtos = query.entity(wallId).quantities();

// Get openings in a wall (VoidsElement, returns EntityNode[])
const openings = query.entity(wallId).voids();

// Get filling elements (doors/windows in openings; FillsElement)
const fillings = query.entity(openingId).filledBy();

Relationship Types

related(ref, relationshipType, direction) accepts the exact EXPRESS name of every schema-resolvable concrete IfcRelationship subtype available in the model's IFC schema. IFC2X3 IfcRelAssociates is excluded because it has no Relating* attribute. Names are never shortened aliases. For example, use IfcRelContainedInSpatialStructure, IfcRelAggregates, IfcRelNests, IfcRelDefinesByObject, IfcRelAssociatesMaterial, IfcRelConnectsPorts, IfcRelSequence, or IfcRelPositions (IFC4X3). forward follows the EXPRESS relating-to-related slots; inverse walks them in reverse.

The SDK's bim.relationships(ref) also returns relations, alongside the convenience voids, fills, groups, and connections arrays. Each entry identifies one relationship record and its opposite endpoint:

const { relations = [] } = bim.relationships({ modelId, expressId: wallId });
for (const edge of relations) {
  console.log(
    edge.relationshipId,   // express id of the IfcRel* record
    edge.relationshipType, // exact name, such as IfcRelVoidsElement
    edge.direction,        // forward or inverse relative to wallRef
    edge.entity,           // { id, type, name? } at the other end
  );
}

relationshipId distinguishes separate IFC relationship records even when they connect the same pair of entities. It is 0 only for payloads from an older server that did not provide relationship record ids.

SQL Queries

For complex analytics, use SQL via DuckDB. SQL support is optional: install the @duckdb/duckdb-wasm package in your app (it is lazy-loaded on the first sql() call, so it adds nothing to your bundle until used; sql() throws if the package is not installed):

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

const query = new IfcQuery(store); // store from parseColumnar()

// sql() lazily initializes DuckDB-WASM on first call
const result = await query.sql(`
  SELECT
    e.type,
    COUNT(*) as count,
    AVG(p.value_int) as avg_fire_rating
  FROM entities e
  JOIN properties p ON e.express_id = p.entity_id
  WHERE e.type LIKE 'IfcWall%'
    AND p.pset_name = 'Pset_WallCommon'
    AND p.prop_name = 'FireRating'
  GROUP BY e.type
  ORDER BY count DESC
`);

console.table(result);
// ┌──────────────────────┬───────┬─────────────────┐
// │ type                 │ count │ avg_fire_rating │
// ├──────────────────────┼───────┼─────────────────┤
// │ IfcWallStandardCase  │ 42    │ 45              │
// │ IfcWall              │ 18    │ 30              │
// └──────────────────────┴───────┴─────────────────┘

SQL Table Schema

// Entities table
interface EntitiesTable {
  express_id: number;
  global_id: string;
  name: string | null;
  description: string | null;
  type: string;
  object_type: string | null;
  has_geometry: boolean;
  is_type: boolean;
  contained_in_storey: number | null;
  defined_by_type: number | null;
}

// Properties table
interface PropertiesTable {
  entity_id: number;
  pset_name: string;
  pset_global_id: string;
  prop_name: string;
  prop_type: string;
  value_string: string | null;
  value_real: number | null;
  value_int: number | null;
  value_bool: boolean | null;
}

// Quantities table
interface QuantitiesTable {
  entity_id: number;
  qset_name: string;
  quantity_name: string;
  quantity_type: string;
  value: number;
  formula: string | null;
}

// Relationships table
interface RelationshipsTable {
  source_id: number;
  target_id: number;
  rel_type: string;
  rel_id: number;
}

Complex SQL Examples

-- Find walls with their storey names
-- IfcRelContainedInSpatialStructure edges run storey (source_id) -> element (target_id)
SELECT
  e.express_id,
  e.name as wall_name,
  s.name as storey_name
FROM entities e
JOIN relationships r ON e.express_id = r.target_id
JOIN entities s ON r.source_id = s.express_id
WHERE e.type LIKE 'IfcWall%'
  AND r.rel_type = 'IfcRelContainedInSpatialStructure'
  AND s.type = 'IfcBuildingStorey';

-- Calculate total area by entity type
SELECT
  e.type,
  SUM(q.value) as total_area
FROM entities e
JOIN quantities q ON e.express_id = q.entity_id
WHERE q.quantity_name = 'NetArea'
GROUP BY e.type
ORDER BY total_area DESC;

-- Find walls with missing fire ratings
SELECT e.express_id, e.name, e.type
FROM entities e
WHERE e.type LIKE 'IfcWall%'
  AND NOT EXISTS (
    SELECT 1 FROM properties p
    WHERE p.entity_id = e.express_id
      AND p.pset_name = 'Pset_WallCommon'
      AND p.prop_name = 'FireRating'
  );

Direct Data Access

For performance-critical operations, access columnar data directly:

import { IfcTypeEnumFromString } from '@ifc-lite/data';

// store is the IfcDataStore from parseColumnar()

// Access the entity table
console.log(`Total entities: ${store.entities.count}`);

// Iterate efficiently over the columnar express-id array
for (let i = 0; i < store.entities.count; i++) {
  const expressId = store.entities.expressId[i];
  const name = store.entities.getName(expressId);
  const type = store.entities.getTypeName(expressId);
}

// Fetch every entity of a given type
const wallIds = store.entities.getByType(IfcTypeEnumFromString('IfcWall'));

// Match entities by property value (prop, operator, value, psetName)
const externalWallIds = store.properties.findByProperty(
  'IsExternal', '=', true, 'Pset_WallCommon',
);

findByProperty reads the columnar property table, which a STEP parse leaves empty on purpose (store.properties.count === 0) — on a .ifc model it returns [], and it still returns [] after that model is restored from cache, because the empty table is what was cached. Use query.walls().whereProperty(...), which picks the right source for the store it is given, unless you know the table is materialised.

Query Performance

graph LR subgraph Performance["Query Performance"] Fluent["Fluent API<br/>fast, type-indexed"] Direct["Direct Access<br/>fastest, no wrappers"] SQL["SQL Query<br/>heavier: tables are loaded into DuckDB first"] end Simple["Simple Queries"] --> Fluent Bulk["Bulk Operations"] --> Direct Complex["Complex Analytics"] --> SQL

Performance Tips

  1. Use type shortcuts for common entity types
  2. Filter early to reduce result set size
  3. Use direct access for performance-critical loops
  4. Use SQL for complex aggregations
  5. Cache query results when reusing
// Efficient: filter by type first
const externalWalls = query
  .walls()
  .whereProperty('Pset_WallCommon', 'IsExternal', '=', true)
  .execute();

// Inefficient: scan all entities, then narrow by type in JS
const alsoExternalWalls = query
  .all()
  .whereProperty('Pset_WallCommon', 'IsExternal', '=', true)
  .execute()
  .filter(e => e.type === 'IfcWall');

Captured populations

A capture supports up to 20,000 members across 1,024 source files. Larger or malformed saved populations refuse explicitly; capture a smaller population instead.

For a pinned selected or visible Rules population, persist a CapturedEntityScope and resolve it with resolveCapturedEntityScope(scope, models). Models supply their filterIdentity, full-content sourceContentHash, parsed store, and live mutation view. The resulting per-model candidate map includes explicit empty sets for all other loaded models; pass it as candidateExpressIdsByModel to the federated filter evaluator. Scope resolution refuses missing, ambiguous, replaced, or deleted members before a run. Source identity must cover every source byte, not a sampled hash or filename. Authored members additionally store their original CREATE_ENTITY mutation ID and refuse recovery that loses that provenance.

Next Steps