Working with the Asset Elements API
When you tile a design file such as RVT, IFC, or DWG with the BIM/CAD Tiler with Database, or add a model from Bentley Infrastructure Cloud, Cesium ion keeps the full property set of every element. In CesiumJS, Cesium.IonAssetElementService reads those properties, queries elements by property value, and locates them in the 3D view.
You'll learn how to:
- Read the properties of an element you click, with
getElementIdandgetElement. - Query elements with JSON filters and page through the results.
- Highlight query results with 3D Tiles styling and fly to any result.

The query Sandcastle with doors and windows highlighted green and the result list on the left.
- Familiarity with CesiumJS basics (Viewer, 3D Tiles, entities)
- Completion of Working with BIM/CAD Tileset Metadata is recommended but not required
- Familiarity with Cesium Sandcastle
- This tutorial uses the Snowdon Towers sample models: the Structural RVT model in the inspect Sandcastle, and the Architectural IFC model in the query Sandcastle. The Sandcastles use the default Cesium ion access token, so you don't need a Cesium ion account to follow along.
Two Sandcastles, one for inspecting elements and one for queries, that together:
- Highlight an element when you click it and show its full property set
- Highlight elements as you hover over them
- Run JSON filter queries and highlight the matching elements
- List results as each page loads
- Fly the camera to a result when you click it in the list
The Asset Elements API returns detailed properties for individual elements in your design model. Working with BIM/CAD Tileset Metadata describes the model as a whole. The Elements API looks up any single element and returns its full property set from the source file.
In CesiumJS, Cesium.IonAssetElementService calls the API for you. Create one service per asset:
const elementService = Cesium.IonAssetElementService.fromAssetId(ASSET_ID);The service uses Cesium.Ion.defaultAccessToken and Cesium.Ion.defaultServer unless you pass the accessToken or server options. This tutorial uses these methods:
IonAssetElementService.getElementId(feature)reads the element id from a picked feature.getElement(id)returns one element with its properties.- Query with
listElements, one page at a time. IonAssetElementService.getBoundingSphere(element)returns a bounding sphere to fly the camera to.
See the IonAssetElementService reference and the Asset Elements REST API documentation.
IonAssetElementService is experimental, so its API may change without the standard CesiumJS deprecation policy.
What is an "element"? An element represents a single object from your source design file, such as a wall, door, or column. While a tileset may split an element across multiple rendered features for efficient streaming, the element ID links them all back to the same source object and its complete property set.
The Elements API makes engineering data easier to explore, query, and use in AI-powered workflows. The more consistently your data is structured, the more meaningful and reliable the results you can unlock.
Not seeing the results you expected? We'd love to know more. Tell us more on the Cesium Community Forum.
Each step shows one concept and its code. The snippets use the same names as the Sandcastles, so a function from one step may call a function from another. Part 1 (Steps 1 to 3) follows the aec-elements-api-inspect Sandcastle. Part 2 (Steps 4 and 5) follows the aec-elements-api-query Sandcastle, and its HTML tab holds the markup for the result panel. Each part ends with a link to its complete Sandcastle.
Load the tileset and create the element service. The service is bound to one asset, and Steps 2 and 3 call it. The query Sandcastle in Step 4 creates its own viewer, service, and tileset for its model.
const ASSET_ID = 6093105; // Snowdon Towers Sample Structural.rvt
// A service bound to the asset that talks to the ion Asset Elements API.
const elementService = Cesium.IonAssetElementService.fromAssetId(ASSET_ID);Then create the viewer:
const viewer = new Cesium.Viewer("cesiumContainer", {
timeline: false,
animation: false,
baseLayerPicker: false,
});The Sandcastle also sets the time of day for the lighting. Then load the tileset:
const tileset = viewer.scene.primitives.add(
await Cesium.Cesium3DTileset.fromIonAssetId(ASSET_ID),
);
await viewer.zoomTo(tileset);To use your own asset, set Cesium.Ion.defaultAccessToken to a token with the assets:read scope and access to the appropriate assets in your Cesium ion account. Set it before you call fromAssetId, because the service reads the default token when you create it.
BIM/CAD Tiler with Database required: The Asset Elements API works with models tiled with the BIM/CAD Tiler with Database, or added from Bentley Infrastructure Cloud. If tileset.metadata is undefined, or getElement rejects with a 404 for a picked element, re-upload your model with the BIM/CAD Tiler with Database.
The BIM/CAD Tiler with Database is currently in Technology Preview. APIs for working with this tiler and the data it produces might change prior to General Availability.
Each rendered feature carries an element property that identifies its source element. IonAssetElementService.getElementId(feature) reads it, and returns undefined for anything without one, such as the globe. Pass the id to getElement to get the element's label, location, and full property set.
getElement returns an object like this one, trimmed to three properties:
{
id: "3298534883926",
label: 'Concrete 10" [3-GM]',
location: {
center: { longitude: -79.887, latitude: 40.0223, height: 213.98 },
radius: 11.8,
},
properties: {
Category: { value: "3298534883472", displayValue: "Walls", units: null },
Height: { value: 23.3934, displayValue: "23.3934 m", units: "Units:M" },
"Net Volume": {
value: 18.2696,
displayValue: "18.2696 m³",
units: "Units:CUB_M",
},
},
}Each property has a raw value, a displayValue formatted for people with its units included, and units. For a reference such as Category, value is an id and displayValue is the name. location.center is in degrees and meters above the ellipsoid, and location.radius is in meters. Elements without a location have no location field.
Element ID typing
getElementId returns the element id as a decimal string, such as "3298534883926", and getElement takes that string. Elements, tree nodes, and tree path entries from the service also have decimal string ids, so ids from the tileset and from the service compare with === and match in a Set.
Click handler
Add a hidden entity to hold the InfoBox content, and a function that turns the properties into a table with each property's displayValue. The function sets each name and value with textContent, so the InfoBox shows them as text and never runs them as HTML.
// An entity used only to display the fetched properties in the InfoBox.
const infoEntity = viewer.entities.add(new Cesium.Entity({ show: false }));
function propertiesTable(properties) {
const rows = Object.entries(properties)
.map(([key, property]) => {
// displayValue already includes formatted units.
const text = property?.displayValue ?? property?.value ?? property;
const row = document.createElement("tr");
const nameCell = row.insertCell();
const name = document.createElement("b");
name.textContent = key;
nameCell.appendChild(name);
row.insertCell().textContent = String(text);
return row.outerHTML;
})
.join("");
return `<table class="cesium-infoBox-defaultTable"><tbody>${rows}</tbody></table>`;
}The click handler reads the element id, highlights the element with the style from Step 3, and shows "Loading..." in the InfoBox while it fetches the element. Clicking anything without an element id, such as the globe, clears the selection. If the user clicks again before the response arrives, the activeSelection counter drops the older response. If the request fails, the InfoBox shows the error. Setting the action on viewer.screenSpaceEventHandler replaces the viewer's default left click behavior.
// On click, read the element id off the picked feature and fetch its metadata.
viewer.screenSpaceEventHandler.setInputAction(async function (movement) {
const selection = ++activeSelection;
const feature = viewer.scene.pick(movement.position);
// getElementId reads the element id a rendered feature belongs to.
const elementId = Cesium.IonAssetElementService.getElementId(feature);
if (!Cesium.defined(elementId)) {
selectedElementId = undefined;
tileset.makeStyleDirty();
infoEntity.show = false;
viewer.selectedEntity = undefined;
return;
}
selectedElementId = elementId;
tileset.makeStyleDirty();
infoEntity.name = `Element ${elementId}`;
infoEntity.description = "Loading...";
infoEntity.show = true;
viewer.selectedEntity = infoEntity;
try {
const element = await elementService.getElement(elementId);
if (selection !== activeSelection) {
return;
}
infoEntity.description = propertiesTable(element?.properties ?? {});
} catch (error) {
if (selection === activeSelection) {
infoEntity.description = `<p style="color:#ff6b6b;">${error.message}</p>`;
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);Feature properties vs API properties: The properties stored in tiles, which you read with feature.getProperty(), are a small subset that keeps tiles small for streaming. The API returns the full property set from the source model on demand.
The click handler in Step 2 stores the clicked element's id in selectedElementId. A style with an evaluateColor callback reads each feature's id with getElementId and colors the selected element green. After selectedElementId changes, the handler calls tileset.makeStyleDirty() so CesiumJS evaluates the style again.
let selectedElementId;
let activeSelection = 0;
tileset.style = new Cesium.Cesium3DTileStyle({
color: {
evaluateColor(feature, result) {
const id = Cesium.IonAssetElementService.getElementId(feature);
const color =
Cesium.defined(selectedElementId) && id === selectedElementId
? Cesium.Color.LIME
: Cesium.Color.WHITE;
return Cesium.Color.clone(color, result);
},
},
});Open the complete inspect Sandcastle and click any element to see its properties.

The inspect Sandcastle with a selected wall highlighted green and its properties in the InfoBox.
Dependency: This step calls appendResultItems() from Step 5.
Pass a JSON filter to listElements to get the elements whose properties match. Filter keys are the property names that getElement returns, such as IFC Class Name, and they are case sensitive. The special key @label matches the element's label.
The query Sandcastle uses the Snowdon Towers Sample Architectural IFC model, so its filters use IFC property names:
const ASSET_ID = 6093184; // Snowdon Towers Sample Architectural.ifcBIM content in the API: The elements returned by the API correspond to BIM objects from your source file. For IFC models, this includes classes like ifcwall, ifccolumn, ifcspace, ifcdoor, ifcwindow, ifcslab, and many more. Each element carries its source properties (dimensions, materials, type marks, etc.). Not all source data may be preserved; coverage depends on the source format and the ingestion pipeline's support for that format's object types.
JSON filter examples
| Filter | Matches |
|---|---|
{ "IFC Class Name": "ifcwall" } | Elements whose IFC class is ifcwall |
{ "IFC Class Name": "ifcwall", "Pset_WallCommon.IsExternal": true } | Exterior walls. Keys in one object must all match |
{ "$and": [{ "IFC Class Name": "ifcspace" }, { "FootprintArea": { "$gt": 41.8 } }] } | Spaces with a footprint area above 41.8 (square meters in the sample model) |
{ "IFC Class Name": { "$in": ["ifcdoor", "ifcwindow"] } } | Doors or windows |
{ "$or": [{ "@label": { "$like": "%Retail%" } }, { "@label": { "$like": "%Office%" } }] } | Elements whose label contains "Retail" or "Office" |
The filters are MongoDB style, but not every MongoDB operator is supported. Use $like for text patterns. $like ignores case, while keys and plain values are case sensitive.
Numeric filters compare raw numeric values, so $gt works on numbers. Reference properties such as Category hold an id in value, so filter on a text property such as IFC Class Name instead.
For aggregate statistics using the same JSON filters, see Aggregate BIM/CAD element properties.
Page through results
Define the queries the toolbar offers. Each one has a label and a JSON filter.
// JSON filters match element property names using operators such as $in and $like.
const QUERIES = [
{
label: "All Walls",
filter: { "IFC Class Name": "ifcwall" },
},
{
// Multiple keys in one object are combined with AND.
label: "Exterior Walls",
filter: { "IFC Class Name": "ifcwall", "Pset_WallCommon.IsExternal": true },
},
{
label: "Large Spaces > 450 sq ft",
filter: {
// FootprintArea is in m²; 450 sq ft ≈ 41.8 m².
$and: [
{ "IFC Class Name": "ifcspace" },
{ FootprintArea: { $gt: 41.8 } },
],
},
},
{
label: "Doors and Windows",
filter: { "IFC Class Name": { $in: ["ifcdoor", "ifcwindow"] } },
},
{
label: "Leasable Spaces (Retail, Office, or Commercial)",
filter: {
"IFC Class Name": "ifcspace",
$or: [
{ "@label": { $like: "%Retail%" } },
{ "@label": { $like: "%Office%" } },
{ "@label": { $like: "%Commercial%" } },
],
},
},
];listElements returns one page with items, total, and a continuationToken when more results remain. total counts matches across all pages, or is null when the total is unavailable. top sets the page size and defaults to 100. The Sandcastle requests 1000 per page. To get the next page, pass the token back with the same filter. queryElements loops until there is no continuationToken, and passes each page to an onPage callback so results appear while the query loads:
async function queryElements(filter, options = {}) {
const { signal, onPage } = options;
let totalFetched = 0;
let total;
let continuationToken;
do {
const page = await elementService.listElements({
filter,
top: 1000,
continuationToken,
});
// listElements is not cancelable, so drop pages for superseded queries.
if (signal?.aborted) {
return { aborted: true };
}
totalFetched += page.items.length;
total = page.total;
if (onPage) {
onPage(page.items, totalFetched, total);
}
continuationToken = page.continuationToken;
} while (Cesium.defined(continuationToken));
return { total: total ?? totalFetched };
}On the sample model, the presets match 1078, 590, 128, 200, and 18 elements, so All Walls takes two pages.
Running a query
Get the result panel elements from the Sandcastle's HTML tab:
const resultPanel = document.getElementById("resultPanel");
const resultHeader = document.getElementById("resultHeader");
const resultsList = document.getElementById("resultsList");Keep the selected element, the hovered element, and the ids of the query matches. The query Sandcastle is separate from the inspect Sandcastle, so it declares its own selection state and style. Ids from getElementId and from the service are both decimal strings, so the comparisons and Set lookups match.
// Element ids are decimal strings, as returned by IonAssetElementService.
let selectedElementId = null;
let hoveredElementId = null;
const matchedElementIds = new Set(); // query results
let activeQueryController = null;The style colors the selected element yellow, the hovered element cyan, and query matches green. While a selection or query is active, everything else fades. A MOUSE_MOVE handler in the Sandcastle sets hoveredElementId.
function applyStyle() {
const style = new Cesium.Cesium3DTileStyle();
style.color = {
evaluateColor: function (feature, result) {
const id = Cesium.IonAssetElementService.getElementId(feature);
// Selected: yellow
if (id === selectedElementId) {
return Cesium.Color.clone(Cesium.Color.YELLOW, result);
}
// Hovered: cyan
if (id === hoveredElementId) {
return Cesium.Color.clone(Cesium.Color.CYAN.withAlpha(0.7), result);
}
// Query match: green
if (matchedElementIds.size > 0 && matchedElementIds.has(id)) {
return Cesium.Color.clone(Cesium.Color.LIME, result);
}
// Faded when selection or query is active
if (selectedElementId || matchedElementIds.size > 0) {
return Cesium.Color.clone(Cesium.Color.WHITE.withAlpha(0.02), result);
}
// Default
return Cesium.Color.clone(Cesium.Color.WHITE, result);
},
};
tileset.style = style;
}After the state changes, call tileset.makeStyleDirty() so CesiumJS evaluates the style again. In the Sandcastle, initialize loads the tileset, zooms to it, and then calls applyStyle.
Why evaluateColor instead of style conditions? Declarative style conditions work for a few elements. A query can match thousands, and checking a long list of conditions for every feature gets slow. A Set lookup in evaluateColor stays fast. tileset.makeStyleDirty() re-evaluates the existing style without parsing a new one.
The query Sandcastle loads world terrain and turns on globe translucency, so runQuery can fade the globe while results show:
viewer.scene.setTerrain(Cesium.Terrain.fromWorldTerrain());
viewer.scene.globe.depthTestAgainstTerrain = true;
viewer.scene.globe.translucency.enabled = true;runQuery stops any running query with an AbortController, clears the previous results, and calls queryElements. Each page adds its ids to the highlight set and its items to the result list, and the header shows the count so far. If a request fails, the header shows the error.
async function runQuery(query) {
// Cancel any in-flight query
if (activeQueryController) {
activeQueryController.abort();
}
activeQueryController = new AbortController();
const { signal } = activeQueryController;
// Clear previous state
selectedElementId = null;
matchedElementIds.clear();
if (tileset) {
tileset.makeStyleDirty();
}
viewer.selectedEntity = undefined;
resultsList.innerHTML = "";
resultHeader.textContent = `${query.label} — loading...`;
resultPanel.style.display = "block";
viewer.scene.globe.translucency.frontFaceAlpha = 0.25;
try {
const result = await queryElements(query.filter, {
signal,
onPage(pageItems, totalSoFar, totalElements) {
for (const el of pageItems) {
matchedElementIds.add(el.id);
}
tileset.makeStyleDirty();
appendResultItems(pageItems);
const totalLabel =
totalElements !== undefined ? ` of ${totalElements}` : "";
resultHeader.textContent = `${query.label} — ${totalSoFar}${totalLabel} results`;
},
});
if (result.aborted) {
return;
}
resultHeader.textContent = `${query.label} — ${result.total} result${result.total !== 1 ? "s" : ""}`;
} catch (err) {
if (signal.aborted) {
return;
}
resultHeader.textContent = `Error: ${err.message}`;
}
}Clearing results
clearResults stops any running query, clears the selection and highlights, hides the result panel, and makes the globe opaque again.
function clearResults() {
if (activeQueryController) {
activeQueryController.abort();
}
selectedElementId = null;
matchedElementIds.clear();
resultsList.innerHTML = "";
resultPanel.style.display = "none";
viewer.scene.globe.translucency.frontFaceAlpha = 1.0;
viewer.selectedEntity = undefined;
if (tileset) {
tileset.makeStyleDirty();
}
}The toolbar menu runs a query, or clears the results with its first entry. The Clear button clears the results and resets the menu.
Sandcastle.addToolbarMenu(
[
{ text: "Select a query...", onselect: clearResults },
...QUERIES.map((query) => ({
text: query.label,
onselect: () => runQuery(query),
})),
],
"toolbar",
);
// Get a reference to the dropdown so the Clear button can reset it
const queryDropdown = document.querySelector("#toolbar select");
Sandcastle.addToolbarButton("Clear", function () {
clearResults();
if (queryDropdown) {
queryDropdown.selectedIndex = 0;
}
});Show each result in a list. Clicking an item highlights it yellow, flies the camera to it, and shows its properties in the InfoBox.
Building the result list
Each list item has an id and may include a label and location. The list shows the label, or the id when there is no label, with the element id underneath.
function appendResultItems(items) {
for (const el of items) {
const li = document.createElement("li");
const label = document.createElement("div");
label.className = "result-label";
label.textContent = el.label ?? el.id;
li.appendChild(label);
const detail = document.createElement("div");
detail.className = "result-detail";
detail.textContent = `Element ${el.id}`;
li.appendChild(detail);
li.addEventListener("click", () => selectElement(el));
resultsList.appendChild(li);
}
}List items may omit properties. To show properties in a table, you need one getElement call per row, so keep such tables to a few rows.
Selecting and flying to an element
selectElement highlights the element, flies to it, and shows its properties in the InfoBox. It fetches the full element with getElement, and drops the response if the user has selected another element in the meantime. In the query Sandcastle, clicking an element in the 3D view also calls selectElement, with flyTo: false.
The query Sandcastle has its own InfoBox entity and table function, named differently from Step 2:
function buildDescription(properties) {
const rows = Object.entries(properties)
.map(([key, property]) => {
// displayValue already includes formatted units.
const text = property.displayValue ?? property.value;
return `<tr><td><b>${key}</b></td><td>${text}</td></tr>`;
})
.join("");
return `<table class="cesium-infoBox-defaultTable"><tbody>${rows}</tbody></table>`;
}const elementPropertiesEntity = new Cesium.Entity({
name: "Element",
show: false,
});
viewer.entities.add(elementPropertiesEntity);async function selectElement(element, options = {}) {
const { flyTo = true } = options;
const elementId = element.id;
selectedElementId = elementId;
tileset.makeStyleDirty();
viewer.scene.globe.translucency.frontFaceAlpha = 0.25;
if (flyTo) {
flyToElement(element);
}
// Fetch element properties and show in InfoBox
elementPropertiesEntity.name = element.label ?? `Element ${elementId}`;
elementPropertiesEntity.description = "Loading...";
elementPropertiesEntity.show = true;
viewer.selectedEntity = elementPropertiesEntity;
try {
const details = await elementService.getElement(elementId);
if (elementId !== selectedElementId) {
return;
}
elementPropertiesEntity.name = details.label ?? `Element ${elementId}`;
elementPropertiesEntity.description = buildDescription(
details.properties ?? {},
);
} catch (err) {
elementPropertiesEntity.description = `<p style="color:#ff6b6b;">Error: ${err.message}</p>`;
}
viewer.selectedEntity = undefined;
viewer.selectedEntity = elementPropertiesEntity;
}flyToElement builds a bounding sphere with IonAssetElementService.getBoundingSphere(element). It returns undefined when the element has no valid location, and the Sandcastle then zooms to the whole tileset. Small elements get a minimum radius so the camera does not end up inside them.
function flyToElement(element) {
const boundingSphere =
Cesium.IonAssetElementService.getBoundingSphere(element);
if (!Cesium.defined(boundingSphere)) {
viewer.zoomTo(tileset);
return;
}
boundingSphere.radius = Math.max(boundingSphere.radius, 5) * 1.5;
viewer.camera.flyToBoundingSphere(boundingSphere, {
offset: new Cesium.HeadingPitchRange(
0,
Cesium.Math.toRadians(-30),
0, // auto-compute range to fit sphere
),
duration: 2,
});
}Open the complete query Sandcastle to pick a query from the toolbar menu, see the matches highlighted, and click a result to fly to it.
- Bring your own data: You need a Cesium ion account, an access token with the
assets:readscope, and a model tiled with the BIM/CAD Tiler with Database, or added from Bentley Infrastructure Cloud. If you upload a file, georeference it with an EPSG code and vertical datum so elements get a location. Then setCesium.Ion.defaultAccessTokento your token and replaceASSET_IDwith your asset ID. - Aggregate BIM/CAD element properties covers aggregate statistics using the same JSON filters, such as totals for a dashboard.
- Navigate a BIM/CAD model tree shows how to browse an asset's elements as a tree and find where a picked element sits in it.
- Cross-model queries: Create one service per asset to run the same filter against several models from one project, for example structural columns and architectural spaces.
Open the query Sandcastle in a new tab.
Open the inspect Sandcastle to click any element and see its properties. Open the query Sandcastle to pick a query from the toolbar menu, see the matches highlighted, and click a result to fly to it.
| Symptom | Cause | Fix |
|---|---|---|
getElement rejects with a 404 | The asset has no element with that ID, or the model was not tiled with the BIM/CAD Tiler with Database | Check the asset ID, or re-upload with the BIM/CAD Tiler with Database |
| Requests fail with 401 | Invalid or expired access token | Create a new token with the assets:read scope |
tileset.metadata is undefined | Legacy pipeline upload | Re-upload with the BIM/CAD Tiler with Database |
getBoundingSphere returns undefined | The element's location data is missing or invalid | Skip the fly-to for that element, and check that the source model is georeferenced |
| Element appears far from the model | The source model has an incorrect coordinate reference system | Check the EPSG code and vertical datum, then re-upload |
getElementId returns undefined on a model feature | The model has no element ID mapping | Re-upload with the BIM/CAD Tiler with Database |
| A query returns no results | Filter keys must match getElement property names exactly. Filter keys and string values are case sensitive. An unknown key returns no results, not an error | Call getElement on one element and copy its property names |
The Elements API makes engineering data easier to explore, query, and use in AI-powered workflows. The more consistently your data is structured, the more meaningful and reliable the results you can unlock.
Not seeing the results you expected? We'd love to know more. Tell us more on the Cesium Community Forum.
| Task | How |
|---|---|
| Read a picked feature's element ID | IonAssetElementService.getElementId(feature) |
| Fetch one element's properties | elementService.getElement(id) |
| Highlight elements by ID | evaluateColor with a Set of element IDs |
| Query by property | listElements({ filter }) with a JSON filter |
| Page through results | Pass continuationToken back to listElements |
| Fly to an element | IonAssetElementService.getBoundingSphere(element) with camera.flyToBoundingSphere |