@itwin/unified-selection
Copyright © Bentley Systems, Incorporated. All rights reserved. See LICENSE.md for license terms and full copyright notice.
The @itwin/unified-selection
package provides API for managing unified selection.
Basic concepts
The API consists of a few very basic concepts:
-
A Selectable
is something that can be selected and is associated with an EC instance. There are 2 types of selectables:
SelectableInstanceKey
uniquely identifies a single EC instance through a full class name and ECInstanceId.CustomSelectable
is identified by an arbitrary identifier
string and knows how to get any number of SelectableInstanceKey
associated with it.
-
Selectables
is a container for multiple Selectable
instances. The container is structured in a way that allows to quickly find specific selectables by their identifier.
-
SelectionStorage
is an interface that manages Selectables
for different iModels. It allows:
- Changing the selection (add, remove, replace, clear).
- Get active selection.
- Listen to selection changes.
The package delivers the createStorage()
function to create an instance of SelectionStorage
. Consumers are also expected to call SelectionStorage.clearStorage
whenever an iModel is closed to free up memory.
Basic usage
import { createStore } from "@itwin/unified-selection";
const unifiedSelection = createStore();
import { IModelConnection } from "@itwin/core-frontend";
IModelConnection.onClose.addListener((imodel) => {
unifiedSelection.clearStorage(imodel.key);
});
import { Selectables } from "@itwin/unified-selection";
unifiedSelection.selectionChangeEvent.addListener(({ imodelKey, source, changeType, selectables }) => {
const suffix = `in ${imodelKey} iModel from ${source} component`;
const numSelectables = Selectables.size(selectables);
switch (changeType) {
case "add":
console.log(`Added ${numSelectables} items to selection ${suffix}.`);
break;
case "remove":
console.log(`Removed ${numSelectables} items from selection ${suffix}.`);
break;
case "replace":
console.log(`Replaced selection with ${numSelectables} items ${suffix}.`);
break;
case "clear":
console.log(`Cleared selection ${suffix}.`);
break;
}
});
MyComponent.onECInstanceSelected((imodel: IModelConnection, key: { className: string; id: Id64String }) => {
unifiedSelection.addToSelection({ imodelKey: imodel.key, source: "MyComponent", selectables: [key] });
});
Details
Selection levels
By default, whenever a component changes unified selection, that happens at 0th (top) selection level. And similarly, whenever a component requests current selection from the storage, by default the top selection level is used. However, there are cases when we want to have multiple levels of selection.
For example, let's say there're 3 components: A, B and C:
- Component A shows a list of elements and allows selecting them.
- Component B shows a list of elements selected in Component A and allows selecting them individually. Selecting an individual element should not change selection in Component A or content in Component B itself.
- Component C shows properties of elements selected either in Component A or Component B.
The behavior described above can't be achieved using just one level of selection, because as soon as selection is made in Component B, that selection would get represented in Component A, and Component B would change what it's displaying to the individual element.
That can be fixed by introducing another selection level, but before the components can be configured, here are a few key facts about selection levels:
- Higher level selection has lower index. So top level selection is 0, lower level is 1, and so on.
- Changing higher level selection clears all lower level selections.
- Lower level selection doesn't have to be a sub-set of higher level selection.
With that in mind, the above components A, B and C can be configured as follows:
- Component A only cares about top level selection. Whenever something is selected in the component, unified selection is updated at the top level. Similarly, whenever unified selection changes, the component only reacts if that happened at the top level.
- Component B reloads its content if the selection changes at the top level. Row selection is handled using lower level, so selecting a row doesn't affect Component A's selection or Component B's content.
- Component C reloads its content no matter the selection level.
Hilite sets
Note: hilite = highlight
@itwin/core-frontend
contains a concept called the HiliteSet. This concept is tightly related to unified selection, because, generally, we want selected elements to be highlighted in the application's graphics views. The HiliteSet
object contains IDs of 3 types of elements: GeometricModel, SubCategory and GeometricElement. On the other hand, the unified selection API allows selecting other kinds of elements too, so IDs of these elements need to be mapped to the supported ones. The rules are as follows:
- for
BisCore.Subject
return IDs of all geometric models that are recursively under that Subject, - for
BisCore.Model
just return its ID, - for
BisCore.PhysicalPartition
just return ID of a model that models it, - for
BisCore.Category
return IDs of all its SubCategories, - for
BisCore.SubCategory
just return its ID, - for
BisCore.GeometricElement
return ID of its own and all its child elements recursively.
So for example when unified selection contains a Subject, the hilite set for it will contain all models under that Subject, it's child Subjects, their child Subjects, etc. Given such hilite set, the viewport component hilites all elements in those models.
The @itwin/unified-selection
package delivers APIs for creating a HiliteSet
or retrieving it for current selection in a SelectionStorage
:
import { IModelConnection } from "@itwin/core-frontend";
import { createECSqlQueryExecutor, createECSchemaProvider } from "@itwin/presentation-core-interop";
import { createHiliteSetProvider } from "@itwin/unified-selection";
const hiliteProvider = createHiliteSetProvider({
imodelAccess: {
...createECSchemaProvider(imodel),
...createECSqlQueryExecutor(imodel),
},
});
const hiliteSet = await hiliteProvider.getHiliteSet({ selectables });
import { createCachingHiliteSetProvider } from "@itwin/unified-selection";
const selectionHiliteProvider = createCachingHiliteSetProvider({
selectionStorage,
imodelProvider: (imodelKey: string) => getIModelByKey(imodelKey),
});
const selectionHiliteSet = await selectionHiliteProvider.getHiliteSet({ imodel.key });
selectionHiliteProvider.dispose();
Selection scopes
Selection scopes allow decoupling of what gets picked and what gets selected. Without selection scopes, whenever a user picks an element in the viewport, its ID goes straight into unified selection storage. With selection scopes we can modify that and add something different. The input to the selection scopes' processor is a query executor, element IDs, and the scope to apply, and the output is an iterator of SelectableInstanceKey
. We get the input when the user picks some elements in the viewport, run that through the selection scope processor and put the output into unified selection storage.
Here are the scopes we support at the moment:
element
- return key of selected element.category
- return key of geometric element's category.model
- return key of element's model.functional
- return key of element's related functional element. For BisCore.GeometricElement3d
the related functional element is found using the Functional.PhysicalElementFulfillsFunction
relationship. For BisCore.GeometricElement2d
the nearest functional element is searched for using the Functional.DrawingGraphicRepresentsFunctionalElement
relationship - if the given element has a related functional element, it will be returned, otherwise the element's parent will be checked and if it also does not have a related functional, then the parent of the parent will be checked until no more ancestors can be traversed or a functional element is found. Regardless whether it is an BisCore.GeometricElement2d
or BisCore.GeometricElement3d
if no functional element is found then the element itself will be returned.
The @itwin/unified-selection
package delivers a computeSelection
function for computing which elements should be added into unified selection storage based on the given element ID's and a specified selection scope:
import { computeSelection } from "@itwin/unified-selection";
import { IModelConnection } from "@itwin/core-frontend";
import { createECSqlQueryExecutor } from "@itwin/presentation-core-interop";
const queryExecutor = createECSqlQueryExecutor(imodel);
const selection = computeSelection({ queryExecutor, elementIds, scope: "element" });
element
and functional
scopes additionally allow selecting assembly elements by specifying the ancestorLevel
property in the selection scope argument of computeSelection
function. The ancestorLevel
property specifies how far "up" we should walk to find the target element. When not specified or 0
, the target element matches the request element. When set to 1
, the target element matches the direct parent element. When 2
, the target element is the parent of the parent element, and so on. In all situations when this is > 0
, we're not walking further than the last existing element, for example, when ancestorLevel = 1
(direct parent element is requested), but the request element doesn't have a parent, the request element is returned as the result. A negative value would result in the top-most element to be returned.
For the functional
scope, the ancestorLevel
property is used as follows: if an element is a BisCore.GeometricElement3d
element, its ancestor is selected based on the given ancestorLevel
the same as with non-functional elements, and then the resulting element's related functional element will be returned (using the Functional.PhysicalElementFulfillsFunction
relationship), or if it does not have one, then the resulting element will be returned. For BisCore.GeometricElement2d
elements, the nearest related functional element is found in the same way it is done when the ancestorLevel
property is not provided, and then the ancestor of that element is returned (based on the provided value of ancestorLevel
).
import { computeSelection } from "@itwin/unified-selection";
import { IModelConnection } from "@itwin/core-frontend";
import { createECSqlQueryExecutor } from "@itwin/presentation-core-interop";
const queryExecutor = createECSqlQueryExecutor(imodel);
const selection = computeSelection({ queryExecutor, elementIds, scope: { id: "element", ancestorLevel: 1 } });
iModel selection synchronization with unified selection
The @itwin/unified-selection
package delivers a enableUnifiedSelectionSyncWithIModel
function to enable selection synchronization between an iModel and a SelectionStorage
. When called, it returns a cleanup function that should be used to disable the synchronization. There should only be one active synchronization between a single iModel and a SelectionStorage
at a given time. For example, this function could be used inside a useEffect
hook in a component that holds an iModel:
import { createECSqlQueryExecutor, createECSchemaProvider } from "@itwin/presentation-core-interop";
useEffect(() => {
return enableUnifiedSelectionSyncWithIModel({
imodelAccess: {
...createECSqlQueryExecutor(imodel),
...createECSchemaProvider(imodel),
key: imodel.key,
hiliteSet: imodel.hilited,
selectionSet: imodel.selectionSet,
},
selectionStorage,
activeScopeProvider: () => "element",
});
}, [imodel]);