<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>Query 3D models | Sample | ArcGIS Maps SDK for JavaScript</title>
<!-- Load the ArcGIS Maps SDK for JavaScript from CDN -->
<script type="module" src="https://js.arcgis.com/5.1/"></script>
--query-models-view-bottom-offset: 45px;
max-block-size: calc(100vh - var(--query-models-view-bottom-offset));
--calcite-panel-space: 0;
gap: var(--calcite-space-md);
grid-template-columns: repeat(3, 1fr);
gap: var(--calcite-space-xs);
gap: var(--calcite-space-2xs);
color: var(--calcite-color-text-2);
font-size: var(--calcite-font-size--2);
justify-content: space-between;
gap: var(--calcite-space-sm);
font-size: var(--calcite-font-size--1);
padding: var(--calcite-space-md) var(--calcite-space-md) var(--calcite-space-2xs);
color: var(--calcite-color-text-2);
font-size: var(--calcite-font-size--1);
padding: 0 var(--calcite-space-md) var(--calcite-space-md);
padding-block-start: var(--calcite-space-md);
gap: var(--calcite-space-xs);
margin-block-start: var(--calcite-space-md);
justify-content: space-between;
gap: var(--calcite-space-xs);
font-size: var(--calcite-font-size--1);
color: var(--calcite-color-text-2);
font-size: var(--calcite-font-size--1);
gap: var(--calcite-space-xs);
padding: var(--calcite-space-sm) var(--calcite-space-md);
border: 1px solid var(--calcite-color-border-3);
background: var(--calcite-color-foreground-1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
justify-content: space-between;
gap: var(--calcite-space-sm);
font-size: var(--calcite-font-size--1);
color: var(--calcite-color-text-2);
font-size: var(--calcite-font-size--1);
<arcgis-scene id="scene" item-id="e5bc4a47e27c4e8c925dbf164082e7f4" popup-disabled>
heading="Query and download model files"
<!-- content-top is pinned above the scrolling default slot. -->
<div slot="content-top" class="panel-controls">
<calcite-button id="pick-button" appearance="outline" icon-start="cursor" width="full">Pick</calcite-button>
<calcite-button id="select-button" appearance="outline" icon-start="polygon" width="full"
<calcite-button id="clear-button" appearance="outline" icon-start="x" width="full" disabled
<div class="panel-layer">
<span class="panel-layer-label">3D object layer</span>
<div class="panel-layer-row">
<span id="layer-chip" class="panel-layer-name">Layer</span>
<calcite-chip id="count-chip" scale="s">No results</calcite-chip>
<calcite-label scale="s" layout="inline-space-between">
<calcite-select id="format-select" scale="s">
<calcite-option value="source">Source models</calcite-option>
<calcite-option value="all">All models</calcite-option>
<div id="status-text" class="status-text">Pick a feature or draw a polygon to query model files.</div>
<div id="results-list" class="results-list"></div>
const [GraphicsLayer, SketchViewModel] = await $arcgis.import([
"@arcgis/core/layers/GraphicsLayer.js",
"@arcgis/core/widgets/Sketch/SketchViewModel.js",
const FILTER_SOURCE = "source";
const FILTER_ALL = "all";
const FILTER_FORMAT_PREFIX = "format:";
const HIGHLIGHT_HOVER = "hover";
// === DOM REFERENCES ===
const viewElement = document.querySelector("arcgis-scene");
const resultsPanel = document.getElementById("results-panel");
const pickButton = document.getElementById("pick-button");
const selectButton = document.getElementById("select-button");
const clearButton = document.getElementById("clear-button");
const formatSelect = document.getElementById("format-select");
const statusText = document.getElementById("status-text");
const resultsList = document.getElementById("results-list");
const layerChip = document.getElementById("layer-chip");
const countChip = document.getElementById("count-chip");
entries: [], // Flattened model rows currently shown in the panel.
message: "Loading buildings...",
let sketchViewModel = null;
let pointerMoveHandle = null;
let hoverHighlightHandle = null;
let selectionHighlightHandle = null;
let hoverObjectId = null;
// Ignore stale async results after switching modes or clearing the selection.
// === INITIALIZATION ===
async function initialize() {
await viewElement.viewOnReady();
// Selected features use the default highlight; hover gets a lighter, temporary style.
sceneLayer = findLayerByTitle("Zurich Buildings City Center");
sceneLayer.outFields = ["*"];
layerView = await view.whenLayerView(sceneLayer);
sketchLayer = new GraphicsLayer({ listMode: "hide" });
view.map.add(sketchLayer);
sketchViewModel = new SketchViewModel({
updateOnGraphicClick: false, // Draw a fresh polygon instead of editing the old one.
defaultCreateOptions: { hasZ: false },
sketchViewModel.on("create", onSketchCreate);
state.layerTitle = sceneLayer.title || "Scene layer";
state.message = "Pick a feature or draw a polygon to query model files.";
state.controlsDisabled = false;
// === EVENT LISTENERS ===
pickButton.addEventListener("click", togglePickMode);
selectButton.addEventListener("click", startPolygonSelection);
clearButton.addEventListener("click", clearSelection);
formatSelect.addEventListener("calciteSelectChange", updateFilter);
function togglePickMode() {
const wasPickMode = state.mode === "pick";
state.message = "Click a feature in the scene to query its model files.";
clickHandle = view.on("click", (event) => void onPick(event));
pointerMoveHandle = view.on("pointer-move", (event) => void onPointerMove(event));
async function onPick(event) {
// Ignore this result if another pick starts before hitTest() resolves.
const currentHitTestId = ++pickHitTestId;
const { results } = await view.hitTest(event, { include: [sceneLayer] });
if (currentHitTestId !== pickHitTestId || state.mode !== "pick") {
const objectId = getObjectIdFromHitTest(results);
await loadModels([objectId], `Querying model files for feature ${objectId}...`);
async function onPointerMove(event) {
const currentHitTestId = ++hoverHitTestId;
const { results } = await view.hitTest(event, { include: [sceneLayer] });
if (currentHitTestId !== hoverHitTestId || state.mode !== "pick") {
const objectId = getObjectIdFromHitTest(results);
if (objectId === hoverObjectId) {
hoverObjectId = objectId;
hoverHighlightHandle = layerView.highlight(toObjectId(objectId), { name: HIGHLIGHT_HOVER });
// === SELECT FEATURES BY POLYGON ===
function startPolygonSelection() {
state.message = "Draw a polygon on the scene to select features.";
sketchViewModel.create("polygon");
function onSketchCreate(event) {
if (event.state === "complete") {
void selectByGeometry(event.graphic.geometry);
} else if (event.state === "cancel") {
async function selectByGeometry(geometry) {
const currentQueryId = ++selectQueryId;
showLoading("Finding features in the drawn area...");
// Query the SceneLayer service so the polygon uses feature geometry, not view bounding boxes.
const query = sceneLayer.createQuery();
query.geometry = geometry;
query.spatialRelationship = "intersects";
const objectIds = await sceneLayer.queryObjectIds(query);
if (currentQueryId !== selectQueryId) {
state.selectedObjectIds = objectIds;
state.filter = FILTER_SOURCE;
state.message = "No features found in the drawn area.";
await loadModels(objectIds, `Querying model files for ${objectIds.length} features...`);
async function loadModels(objectIds, loadingMessage) {
const currentQueryId = ++modelsQueryId;
state.selectedObjectIds = objectIds;
showLoading(loadingMessage);
// queryModels() returns a Map of objectId -> ModelInfo[] for the selected features.
const models = await sceneLayer.queryModels({ objectIds });
if (currentQueryId !== modelsQueryId) {
function setEntries(modelsByObjectId) {
state.entries = normalizeEntries(modelsByObjectId);
state.filter = FILTER_SOURCE;
state.message = summaryMessage();
function summaryMessage() {
if (!state.entries.length) {
return "No model files for the selection.";
const featureCount = new Set(state.entries.map((entry) => entry.objectId)).size;
return `${featureCount} feature${featureCount === 1 ? "" : "s"} selected.`;
// Flatten the objectId -> models map into the row list rendered by the panel.
function normalizeEntries(modelsByObjectId) {
const usedNames = new Map();
for (const [objectId, models] of modelsByObjectId) {
for (const model of models) {
const files = model.files;
const format = model.format.toUpperCase();
const extension = format.toLowerCase();
const baseName = uniqueName(getModelBaseName(model, objectId), extension, usedNames);
id: `${objectId}-${index++}`,
displayName: `${baseName}.${extension}`,
isSource: model.isSource === true,
size: model.size ?? files.reduce((sum, file) => sum + file.size, 0),
// In this scene the source names are placeholders, so feature ids make clearer downloads.
function getModelBaseName(model, objectId) {
const stem = String(model.name || "")
.replace(/\.[^.]+$/, "");
return stem && stem.toLowerCase() !== "esrigeometrymultipatch" ? stem : `Feature ${objectId}`;
// Keep names distinct when a feature has several models of the same format.
function uniqueName(baseName, extension, usedNames) {
const key = `${baseName}.${extension}`;
const count = (usedNames.get(key) || 0) + 1;
usedNames.set(key, count);
return count === 1 ? baseName : `${baseName} (${count})`;
function getFilterOptions() {
const sourceCount = state.entries.filter((entry) => entry.isSource).length;
const counts = new Map();
for (const entry of state.entries) {
counts.set(entry.format, (counts.get(entry.format) || 0) + 1);
createOption(FILTER_SOURCE, `Source models (${sourceCount})`),
createOption(FILTER_ALL, `All models (${state.entries.length})`),
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([format, count]) => createOption(`${FILTER_FORMAT_PREFIX}${format}`, `${format} (${count})`)),
function updateFilter() {
state.filter = formatSelect.value || FILTER_SOURCE;
function getVisibleEntries() {
if (state.filter === FILTER_ALL) {
if (state.filter.startsWith(FILTER_FORMAT_PREFIX)) {
const format = state.filter.slice(FILTER_FORMAT_PREFIX.length);
return state.entries.filter((entry) => entry.format === format);
return state.entries.filter((entry) => entry.isSource);
function clearSelection() {
clearSelectionHighlight();
state.selectedObjectIds = [];
state.filter = FILTER_SOURCE;
state.message = "Pick a feature or draw a polygon to query model files.";
if (state.mode === "pick") {
pointerMoveHandle?.remove();
pointerMoveHandle = null;
} else if (state.mode === "polygon") {
sketchViewModel.cancel();
sketchViewModel?.cancel();
sketchLayer?.removeAll();
async function downloadModel(entry) {
// queryModel() returns one model's files: source as uploaded, derived by format.
const options = entry.isSource ? undefined : { format: entry.model.format };
const model = await sceneLayer.queryModel(entry.objectId, options);
const files = model.files;
for (const [index, file] of files.entries()) {
const blob = new Blob([await file.arrayBuffer()], { type: "application/octet-stream" });
triggerDownload(blob, downloadName(entry, index, files.length));
? `Downloaded ${entry.displayName}.`
: `Downloaded ${files.length} files for ${entry.displayName}.`;
// Identifiable file name, e.g. "Feature 12.glb"; multi-file models get a suffix.
function downloadName(entry, index, total) {
return total === 1 ? entry.displayName : `${entry.baseName}-${index + 1}.${entry.extension}`;
function triggerDownload(blob, fileName) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.download = fileName;
// Keep the object URL alive long enough for the browser to start the download.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
function highlightSelection() {
clearSelectionHighlight();
if (state.selectedObjectIds.length) {
selectionHighlightHandle = layerView.highlight(state.selectedObjectIds.map(toObjectId));
function clearSelectionHighlight() {
selectionHighlightHandle?.remove();
selectionHighlightHandle = null;
function clearHoverHighlight() {
hoverHighlightHandle?.remove();
hoverHighlightHandle = null;
const visible = getVisibleEntries();
const filterOptions = getFilterOptions();
const hasSelectedFilter = filterOptions.some((option) => option.value === state.filter);
if (!hasSelectedFilter) {
state.filter = FILTER_SOURCE;
pickButton.disabled = state.controlsDisabled;
selectButton.disabled = state.controlsDisabled;
pickButton.appearance = state.mode === "pick" ? "solid" : "outline";
selectButton.appearance = state.mode === "polygon" ? "solid" : "outline";
viewElement.style.cursor = state.mode === "pick" ? "crosshair" : "";
layerChip.textContent = state.layerTitle;
countChip.textContent = state.entries.length
? `${visible.length} model${visible.length === 1 ? "" : "s"}`
state.controlsDisabled ||
(state.entries.length === 0 && state.mode === null && state.selectedObjectIds.length === 0);
resultsPanel.loading = state.loading;
formatSelect.replaceChildren(...filterOptions);
resultsList.classList.toggle("is-empty", state.loading || visible.length === 0);
statusText.hidden = true;
resultsList.replaceChildren(createNotice(state.message));
statusText.hidden = true;
resultsList.replaceChildren(
createNotice(state.entries.length ? "No model files match the selected view." : state.message),
statusText.hidden = false;
statusText.textContent = state.message;
resultsList.replaceChildren(
...[...groupByObjectId(visible)].map(([objectId, entries]) => createFeatureBlock(objectId, entries)),
function createFeatureBlock(objectId, entries) {
const head = createElement("div", "feature-head");
head.append(createElement("span", "feature-title", `Feature ${objectId}`));
// Show the count only when it adds information.
if (entries.length > 1) {
head.append(createElement("span", "feature-count", `${entries.length} models`));
const block = createElement("section", "result-block");
block.append(head, ...entries.map(createModelRow));
function createModelRow(entry) {
// The feature id is in the block header, so each row shows only the format.
const nameLine = createElement("div", "model-line");
nameLine.append(createElement("span", "model-name", entry.format));
nameLine.append(createChip("Source", "outline"));
const downloadButton = document.createElement("calcite-button");
downloadButton.scale = "s";
downloadButton.appearance = "outline";
downloadButton.iconStart = "download";
downloadButton.textContent = "Download";
downloadButton.addEventListener("click", () => void downloadModel(entry));
const metaLine = createElement("div", "model-line");
`${entry.files.length} file${entry.files.length === 1 ? "" : "s"} · ${bytesLabel(entry.size)}`,
const row = createElement("div", "model-row");
row.append(nameLine, metaLine);
function createElement(tag, className, text) {
const node = document.createElement(tag);
node.className = className;
function createChip(text, appearance) {
const chip = document.createElement("calcite-chip");
chip.appearance = appearance;
function createOption(value, label) {
const option = document.createElement("calcite-option");
option.textContent = label;
option.selected = value === state.filter;
function createNotice(message) {
const text = createElement("div", null, message);
const notice = document.createElement("calcite-notice");
// === STATUS HELPERS ===
function showLoading(message) {
function findLayerByTitle(title) {
return view.map.allLayers.find((layer) => layer.title === title);
function getObjectIdFromHitTest(results) {
return results.find((result) => result.graphic?.layer === sceneLayer)?.graphic.getObjectId();
function groupByObjectId(entries) {
const groups = new Map();
for (const entry of entries) {
if (!groups.has(entry.objectId)) {
groups.set(entry.objectId, []);
groups.get(entry.objectId).push(entry);
// highlight() accepts numeric objectIds; coerce values from attributes when possible.
function toObjectId(objectId) {
const numberValue = Number(objectId);
return Number.isNaN(numberValue) ? objectId : numberValue;
function bytesLabel(bytes) {
return kb < 1024 ? `${kb.toFixed(1)} KB` : `${(kb / 1024).toFixed(1)} MB`;