diff --git a/packages/itwin/tree-widget/.pnpmfile.cjs b/packages/itwin/tree-widget/.pnpmfile.cjs index bec4fc44c8..d4eff3d8b9 100644 --- a/packages/itwin/tree-widget/.pnpmfile.cjs +++ b/packages/itwin/tree-widget/.pnpmfile.cjs @@ -1,13 +1,12 @@ //@ts-check - const itwinUiV3dependencies = [ "@itwin/presentation-components", "@itwin/components-react", "@itwin/core-react", "@itwin/imodel-components-react", - "@itwin/appui-react" -] + "@itwin/appui-react", +]; module.exports = { hooks: { diff --git a/packages/itwin/tree-widget/.stylelintrc.json b/packages/itwin/tree-widget/.stylelintrc.json index 66bc1fb38e..fc32b06e4a 100644 --- a/packages/itwin/tree-widget/.stylelintrc.json +++ b/packages/itwin/tree-widget/.stylelintrc.json @@ -1,9 +1,7 @@ { "overrides": [ { - "files": [ - "./src/**/*.css" - ], + "files": ["./src/**/*.css"], "extends": "stylelint-config-standard", "rules": { "comment-whitespace-inside": null diff --git a/packages/itwin/tree-widget/api/tree-widget-react.api.md b/packages/itwin/tree-widget/api/tree-widget-react.api.md index 99d9c32063..e87157c47e 100644 --- a/packages/itwin/tree-widget/api/tree-widget-react.api.md +++ b/packages/itwin/tree-widget/api/tree-widget-react.api.md @@ -194,9 +194,11 @@ type FunctionProps any> = Parameters[0]; // @beta (undocumented) interface GetCategoryVisibilityStatusProps { // (undocumented) - categoryId: Id64String; + categoryIds: Id64Arg; // (undocumented) modelId: Id64String; + // (undocumented) + parentElementIds?: Id64Arg; } // @beta (undocumented) @@ -351,7 +353,7 @@ export interface ModelsTreeVisibilityHandlerOverrides { }) => Promise>; // (undocumented) getModelDisplayStatus?: HierarchyVisibilityHandlerOverridableMethod<(props: { - id: Id64String; + ids: Id64Arg; }) => Promise>; // (undocumented) getSubjectNodeVisibility?: HierarchyVisibilityHandlerOverridableMethod<(props: { @@ -560,7 +562,7 @@ export interface VisibilityStatus { } // @beta -export function VisibilityTree({ visibilityHandlerFactory, treeRenderer, hierarchyLevelSizeLimit, ...props }: VisibilityTreeProps): JSX.Element; +export function VisibilityTree({ visibilityHandlerFactory, treeRenderer, hierarchyLevelSizeLimit, ...props }: VisibilityTreeProps): JSX_2.Element; // @beta (undocumented) type VisibilityTreeProps = Omit & { diff --git a/packages/itwin/tree-widget/pnpm-lock.yaml b/packages/itwin/tree-widget/pnpm-lock.yaml index c434a96436..b6a8191513 100644 --- a/packages/itwin/tree-widget/pnpm-lock.yaml +++ b/packages/itwin/tree-widget/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: '@itwin/oidc-signin-tool>@itwin/core-common': 4.11.1 '@itwin/service-authorization>@itwin/core-common': 4.11.1 -pnpmfileChecksum: sha256-gLZHbCOFXj/2+r7dw7eLBIUi87NOnNHlWkkd1uiptZg= +pnpmfileChecksum: sha256-E2jNlu7vvNbcOUYIOxolUAA8EHZ437t7v9NMvw89a6o= importers: diff --git a/packages/itwin/tree-widget/src/e2e-tests/ModelsTree.test.ts-snapshots/Models-tree-instances-focus-1-chromium-linux.png b/packages/itwin/tree-widget/src/e2e-tests/ModelsTree.test.ts-snapshots/Models-tree-instances-focus-1-chromium-linux.png index 308418a47e..a24ce6986f 100644 Binary files a/packages/itwin/tree-widget/src/e2e-tests/ModelsTree.test.ts-snapshots/Models-tree-instances-focus-1-chromium-linux.png and b/packages/itwin/tree-widget/src/e2e-tests/ModelsTree.test.ts-snapshots/Models-tree-instances-focus-1-chromium-linux.png differ diff --git a/packages/itwin/tree-widget/src/e2e-tests/ModelsTree.test.ts-snapshots/Models-tree-search-1-chromium-linux.png b/packages/itwin/tree-widget/src/e2e-tests/ModelsTree.test.ts-snapshots/Models-tree-search-1-chromium-linux.png index e57b1e8005..95eb7661fc 100644 Binary files a/packages/itwin/tree-widget/src/e2e-tests/ModelsTree.test.ts-snapshots/Models-tree-search-1-chromium-linux.png and b/packages/itwin/tree-widget/src/e2e-tests/ModelsTree.test.ts-snapshots/Models-tree-search-1-chromium-linux.png differ diff --git a/packages/itwin/tree-widget/src/test/trees/Common.ts b/packages/itwin/tree-widget/src/test/trees/Common.ts index 5054c136ca..b386fac303 100644 --- a/packages/itwin/tree-widget/src/test/trees/Common.ts +++ b/packages/itwin/tree-widget/src/test/trees/Common.ts @@ -28,7 +28,7 @@ export function createIModelMock(queryHandler?: (query: string, params?: QueryBi export function createFakeSinonViewport( props?: Partial> & { - view?: Partial> & { isSpatialView?: () => boolean; }; + view?: Partial> & { isSpatialView?: () => boolean }; perModelCategoryVisibility?: Partial; queryHandler?: Parameters[0]; }, diff --git a/packages/itwin/tree-widget/src/test/trees/common/internal/AlwaysAndNeverDrawnElementInfo.test.ts b/packages/itwin/tree-widget/src/test/trees/common/internal/AlwaysAndNeverDrawnElementInfo.test.ts index 6e050e684f..c31599125f 100644 --- a/packages/itwin/tree-widget/src/test/trees/common/internal/AlwaysAndNeverDrawnElementInfo.test.ts +++ b/packages/itwin/tree-widget/src/test/trees/common/internal/AlwaysAndNeverDrawnElementInfo.test.ts @@ -9,7 +9,7 @@ import sinon from "sinon"; import { AlwaysAndNeverDrawnElementInfo, SET_CHANGE_DEBOUNCE_TIME, -} from "../../../../tree-widget-react/components/trees/common/internal/AlwaysAndNeverDrawnElementInfo.js"; +} from "../../../../tree-widget-react/components/trees/common/internal/withoutParents/AlwaysAndNeverDrawnElementInfo.js"; import { createResolvablePromise } from "../../../TestUtils.js"; import { createFakeSinonViewport } from "../../Common.js"; diff --git a/packages/itwin/tree-widget/src/test/trees/common/internal/VisibilityChangeEventListener.test.ts b/packages/itwin/tree-widget/src/test/trees/common/internal/VisibilityChangeEventListener.test.ts index 0d5fe94139..e74ad8f195 100644 --- a/packages/itwin/tree-widget/src/test/trees/common/internal/VisibilityChangeEventListener.test.ts +++ b/packages/itwin/tree-widget/src/test/trees/common/internal/VisibilityChangeEventListener.test.ts @@ -12,11 +12,14 @@ import { createFakeSinonViewport } from "../../Common.js"; describe("VisibilityChangeEventListener", () => { it("raises event on `onAlwaysDrawnChanged` event", async () => { const vpMock = createFakeSinonViewport(); - using handler = createVisibilityChangeEventListener({ viewport: vpMock, listeners: { - elements: true, - categories: true, - models: true - }}); + using handler = createVisibilityChangeEventListener({ + viewport: vpMock, + listeners: { + elements: true, + categories: true, + models: true, + }, + }); const spy = sinon.spy(); handler.onVisibilityChange.addListener(spy); vpMock.onAlwaysDrawnChanged.raiseEvent(vpMock); @@ -25,11 +28,14 @@ describe("VisibilityChangeEventListener", () => { it("raises event on `onNeverDrawnChanged` event", async () => { const vpMock = createFakeSinonViewport(); - using handler = createVisibilityChangeEventListener({ viewport: vpMock, listeners: { - elements: true, - categories: true, - models: true - }}); + using handler = createVisibilityChangeEventListener({ + viewport: vpMock, + listeners: { + elements: true, + categories: true, + models: true, + }, + }); const spy = sinon.spy(); handler.onVisibilityChange.addListener(spy); vpMock.onNeverDrawnChanged.raiseEvent(vpMock); @@ -38,58 +44,70 @@ describe("VisibilityChangeEventListener", () => { it("raises event on `onViewedCategoriesChanged` event", async () => { const vpMock = createFakeSinonViewport(); - using handler = createVisibilityChangeEventListener({ viewport: vpMock, listeners: { - elements: true, - categories: true, - models: true - }}); - const spy = sinon.spy(); - handler.onVisibilityChange.addListener(spy); - vpMock.onViewedCategoriesChanged.raiseEvent(vpMock); - await waitFor(() => expect(spy).to.be.calledOnce); + using handler = createVisibilityChangeEventListener({ + viewport: vpMock, + listeners: { + elements: true, + categories: true, + models: true, + }, + }); + const spy = sinon.spy(); + handler.onVisibilityChange.addListener(spy); + vpMock.onViewedCategoriesChanged.raiseEvent(vpMock); + await waitFor(() => expect(spy).to.be.calledOnce); }); it("raises event on `onViewedModelsChanged` event", async () => { const vpMock = createFakeSinonViewport(); - using handler = createVisibilityChangeEventListener({ viewport: vpMock, listeners: { - elements: true, - categories: true, - models: true - }}); - const spy = sinon.spy(); - handler.onVisibilityChange.addListener(spy); - vpMock.onViewedModelsChanged.raiseEvent(vpMock); - await waitFor(() => expect(spy).to.be.calledOnce); + using handler = createVisibilityChangeEventListener({ + viewport: vpMock, + listeners: { + elements: true, + categories: true, + models: true, + }, + }); + const spy = sinon.spy(); + handler.onVisibilityChange.addListener(spy); + vpMock.onViewedModelsChanged.raiseEvent(vpMock); + await waitFor(() => expect(spy).to.be.calledOnce); }); it("raises event on `onViewedCategoriesPerModelChanged` event", async () => { const vpMock = createFakeSinonViewport(); - using handler = createVisibilityChangeEventListener({ viewport: vpMock, listeners: { - elements: true, - categories: true, - models: true - }}); - const spy = sinon.spy(); - handler.onVisibilityChange.addListener(spy); - vpMock.onViewedCategoriesPerModelChanged.raiseEvent(vpMock); - await waitFor(() => expect(spy).to.be.calledOnce); + using handler = createVisibilityChangeEventListener({ + viewport: vpMock, + listeners: { + elements: true, + categories: true, + models: true, + }, + }); + const spy = sinon.spy(); + handler.onVisibilityChange.addListener(spy); + vpMock.onViewedCategoriesPerModelChanged.raiseEvent(vpMock); + await waitFor(() => expect(spy).to.be.calledOnce); }); it("raises event once when multiple affecting events are fired", async () => { const vpMock = createFakeSinonViewport(); const { onViewedCategoriesPerModelChanged, onViewedCategoriesChanged, onViewedModelsChanged, onAlwaysDrawnChanged, onNeverDrawnChanged } = vpMock; - using handler = createVisibilityChangeEventListener({ viewport: vpMock, listeners: { - elements: true, - categories: true, - models: true - }}); - const spy = sinon.spy(); - handler.onVisibilityChange.addListener(spy); - onViewedCategoriesPerModelChanged.raiseEvent(vpMock); - onViewedCategoriesChanged.raiseEvent(vpMock); - onViewedModelsChanged.raiseEvent(vpMock); - onAlwaysDrawnChanged.raiseEvent(vpMock); - onNeverDrawnChanged.raiseEvent(vpMock); - await waitFor(() => expect(spy).to.be.calledOnce); + using handler = createVisibilityChangeEventListener({ + viewport: vpMock, + listeners: { + elements: true, + categories: true, + models: true, + }, + }); + const spy = sinon.spy(); + handler.onVisibilityChange.addListener(spy); + onViewedCategoriesPerModelChanged.raiseEvent(vpMock); + onViewedCategoriesChanged.raiseEvent(vpMock); + onViewedModelsChanged.raiseEvent(vpMock); + onAlwaysDrawnChanged.raiseEvent(vpMock); + onNeverDrawnChanged.raiseEvent(vpMock); + await waitFor(() => expect(spy).to.be.calledOnce); }); }); diff --git a/packages/itwin/tree-widget/src/test/trees/models-tree/ModelsTreeDefinition.test.ts b/packages/itwin/tree-widget/src/test/trees/models-tree/ModelsTreeDefinition.test.ts index af479824f5..504f969c6b 100644 --- a/packages/itwin/tree-widget/src/test/trees/models-tree/ModelsTreeDefinition.test.ts +++ b/packages/itwin/tree-widget/src/test/trees/models-tree/ModelsTreeDefinition.test.ts @@ -957,6 +957,119 @@ describe("Models tree", () => { ], }); }); + + it("shows element's children category when it differs from parent element's category", async function () { + await using buildIModelResult = await buildIModel(this, async (builder) => { + const rootSubject: InstanceKey = { className: SUBJECT_CLASS_NAME, id: IModel.rootSubjectId }; + const model = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel", partitionParentId: rootSubject.id }); + const parentCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + const childCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory2" }); + const parentElement = insertPhysicalElement({ builder, modelId: model.id, categoryId: parentCategory.id }); + const childElement = insertPhysicalElement({ builder, modelId: model.id, categoryId: childCategory.id, parentId: parentElement.id }); + return { rootSubject, model, parentCategory, childCategory, parentElement, childElement }; + }); + const { imodel, ...keys } = buildIModelResult; + using provider = createModelsTreeProvider({ imodel }); + await validateHierarchy({ + provider, + expect: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.model], + supportsFiltering: true, + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.parentCategory], + supportsFiltering: true, + children: [ + NodeValidators.createForClassGroupingNode({ + className: keys.parentElement.className, + label: "Physical Object", + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.parentElement], + supportsFiltering: true, + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.childCategory], + supportsFiltering: true, + children: [ + NodeValidators.createForClassGroupingNode({ + className: keys.childElement.className, + label: "Physical Object", + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.childElement], + supportsFiltering: true, + children: false, + }), + ], + }), + ], + }), + ], + }), + ], + }), + ], + }), + ], + }), + ], + }); + }); + + it("hides element's children category when it is the same as parent element's category", async function () { + await using buildIModelResult = await buildIModel(this, async (builder) => { + const rootSubject: InstanceKey = { className: SUBJECT_CLASS_NAME, id: IModel.rootSubjectId }; + const model = insertPhysicalModelWithPartition({ builder, codeValue: "TestPhysicalModel", partitionParentId: rootSubject.id }); + const parentCategory = insertSpatialCategory({ builder, codeValue: "SpatialCategory" }); + const parentElement = insertPhysicalElement({ builder, modelId: model.id, categoryId: parentCategory.id }); + const childElement = insertPhysicalElement({ builder, modelId: model.id, categoryId: parentCategory.id, parentId: parentElement.id }); + return { rootSubject, model, parentCategory, parentElement, childElement }; + }); + const { imodel, ...keys } = buildIModelResult; + using provider = createModelsTreeProvider({ imodel }); + await validateHierarchy({ + provider, + expect: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.model], + supportsFiltering: true, + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.parentCategory], + supportsFiltering: true, + children: [ + NodeValidators.createForClassGroupingNode({ + className: keys.parentElement.className, + label: "Physical Object", + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.parentElement], + supportsFiltering: true, + children: [ + NodeValidators.createForClassGroupingNode({ + className: keys.childElement.className, + label: "Physical Object", + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [keys.childElement], + supportsFiltering: true, + children: false, + }), + ], + }), + ], + }), + ], + }), + ], + }), + ], + }), + ], + }); + }); }); describe("Hierarchy customization", () => { diff --git a/packages/itwin/tree-widget/src/test/trees/models-tree/ModelsTreeFiltering.test.ts b/packages/itwin/tree-widget/src/test/trees/models-tree/ModelsTreeFiltering.test.ts index bc04f22930..311d6f3d7f 100644 --- a/packages/itwin/tree-widget/src/test/trees/models-tree/ModelsTreeFiltering.test.ts +++ b/packages/itwin/tree-widget/src/test/trees/models-tree/ModelsTreeFiltering.test.ts @@ -612,12 +612,124 @@ describe("Models tree", () => { }), ], ), + TreeFilteringTestCaseDefinition.create( + "child element's category", + async (builder) => { + const rootSubject: InstanceKey = { className: SUBJECT_CLASS_NAME, id: IModel.rootSubjectId }; + const model = insertPhysicalModelWithPartition({ builder, codeValue: `model`, partitionParentId: rootSubject.id }); + + const parentCategory = insertSpatialCategory({ builder, codeValue: "matching parentCategory" }); + + const parentElement = insertPhysicalElement({ builder, userLabel: `parentElement`, modelId: model.id, categoryId: parentCategory.id }); + + const childrenCategory = insertSpatialCategory({ builder, codeValue: "matching childrenCategory" }); + + const childElementWithSameCategory = insertPhysicalElement({ + builder, + userLabel: `element child1`, + modelId: model.id, + categoryId: parentCategory.id, + parentId: parentElement.id, + }); + const childElementWithDifferentCategory = insertPhysicalElement({ + builder, + userLabel: `element child2`, + modelId: model.id, + categoryId: childrenCategory.id, + parentId: parentElement.id, + }); + const nestedChildElementWithSameCategory = insertPhysicalElement({ + builder, + userLabel: `element nestedChild`, + modelId: model.id, + categoryId: parentCategory.id, + parentId: childElementWithDifferentCategory.id, + }); + + return { + rootSubject, + model, + parentCategory, + parentElement, + childrenCategory, + childElementWithDifferentCategory, + childElementWithSameCategory, + nestedChildElementWithSameCategory, + }; + }, + (x) => [ + [adjustedModelKey(x.model), x.parentCategory], + [adjustedModelKey(x.model), x.parentCategory, adjustedElementKey(x.parentElement), x.childrenCategory], + [ + adjustedModelKey(x.model), + x.parentCategory, + adjustedElementKey(x.parentElement), + x.childrenCategory, + adjustedElementKey(x.childElementWithDifferentCategory), + x.parentCategory, + ], + ], + (x) => [x.childrenCategory, x.parentCategory], + (_x) => "matching", + (x) => [ + NodeValidators.createForInstanceNode({ + instanceKeys: [x.model], + autoExpand: true, + children: [ + NodeValidators.createForInstanceNode({ + label: "matching parentCategory", + autoExpand: true, + children: [ + NodeValidators.createForClassGroupingNode({ + label: "Physical Object", + autoExpand: true, + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [x.parentElement], + autoExpand: true, + children: [ + NodeValidators.createForInstanceNode({ + label: "matching childrenCategory", + autoExpand: true, + children: [ + NodeValidators.createForClassGroupingNode({ + label: "Physical Object", + autoExpand: true, + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [x.childElementWithDifferentCategory], + autoExpand: true, + children: [ + NodeValidators.createForInstanceNode({ + label: "matching parentCategory", + }), + ], + }), + ], + }), + ], + }), + NodeValidators.createForClassGroupingNode({ + label: "Physical Object", + autoExpand: false, + }), + ], + }), + ], + }), + ], + }), + ], + }), + ], + ), TreeFilteringTestCaseDefinition.create( "child Element nodes", async (builder) => { const rootSubject: InstanceKey = { className: SUBJECT_CLASS_NAME, id: IModel.rootSubjectId }; const model = insertPhysicalModelWithPartition({ builder, codeValue: `model-x`, partitionParentId: rootSubject.id }); const category = insertSpatialCategory({ builder, codeValue: "category-x" }); + const childCategory = insertSpatialCategory({ builder, codeValue: "childCategory" }); const rootElement = insertPhysicalElement({ builder, userLabel: `root element 0`, modelId: model.id, categoryId: category.id }); const childElement1 = insertPhysicalElement({ builder, @@ -637,14 +749,14 @@ describe("Models tree", () => { builder, userLabel: `matching element 3`, modelId: model.id, - categoryId: category.id, + categoryId: childCategory.id, parentId: rootElement.id, }); - return { rootSubject, model, category, rootElement, childElement1, childElement2, childElement3 }; + return { rootSubject, model, category, childCategory, rootElement, childElement1, childElement2, childElement3 }; }, (x) => [ [adjustedModelKey(x.model), x.category, adjustedElementKey(x.rootElement), adjustedElementKey(x.childElement1)], - [adjustedModelKey(x.model), x.category, adjustedElementKey(x.rootElement), adjustedElementKey(x.childElement3)], + [adjustedModelKey(x.model), x.category, adjustedElementKey(x.rootElement), x.childCategory, adjustedElementKey(x.childElement3)], ], (x) => [x.childElement1, x.childElement3], (_x) => "matching", @@ -668,6 +780,24 @@ describe("Models tree", () => { label: /^root element/, autoExpand: true, children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [x.childCategory], + autoExpand: true, + children: [ + NodeValidators.createForClassGroupingNode({ + label: "Physical Object", + autoExpand: true, + children: [ + NodeValidators.createForInstanceNode({ + instanceKeys: [x.childElement3], + label: /^matching element 3/, + autoExpand: false, + children: false, + }), + ], + }), + ], + }), NodeValidators.createForClassGroupingNode({ label: "Physical Object", autoExpand: true, @@ -678,12 +808,6 @@ describe("Models tree", () => { autoExpand: false, children: false, }), - NodeValidators.createForInstanceNode({ - instanceKeys: [x.childElement3], - label: /^matching element 3/, - autoExpand: false, - children: false, - }), ], }), ], @@ -1507,7 +1631,6 @@ describe("Models tree", () => { let hierarchyConfig: ModelsTreeHierarchyConfiguration; before(async function () { - // eslint-disable-next-line deprecation/deprecation imodel = ( await buildIModel(this, async (...args) => { const imodelSetupResult = await testCase.setupIModel(...args); diff --git a/packages/itwin/tree-widget/src/test/trees/models-tree/ModelsTreeHierarchyLevelFiltering.test.ts b/packages/itwin/tree-widget/src/test/trees/models-tree/ModelsTreeHierarchyLevelFiltering.test.ts index 8f71d84572..029c879e56 100644 --- a/packages/itwin/tree-widget/src/test/trees/models-tree/ModelsTreeHierarchyLevelFiltering.test.ts +++ b/packages/itwin/tree-widget/src/test/trees/models-tree/ModelsTreeHierarchyLevelFiltering.test.ts @@ -427,6 +427,9 @@ describe("Models tree", () => { type: "instances" as const, instanceKeys: [keys.parentElement], }, + extendedData: { + categoryId: keys.category.id, + }, parentKeys: [ { type: "instances" as const, @@ -525,6 +528,9 @@ describe("Models tree", () => { type: "instances" as const, instanceKeys: [keys.modeledElement], }, + extendedData: { + categoryId: keys.category.id, + }, parentKeys: [ { type: "instances" as const, diff --git a/packages/itwin/tree-widget/src/test/trees/models-tree/Utils.ts b/packages/itwin/tree-widget/src/test/trees/models-tree/Utils.ts index 5d1c7f0161..e7b9e7ce12 100644 --- a/packages/itwin/tree-widget/src/test/trees/models-tree/Utils.ts +++ b/packages/itwin/tree-widget/src/test/trees/models-tree/Utils.ts @@ -3,7 +3,7 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { concatMap, count, EMPTY, expand, firstValueFrom, from, toArray } from "rxjs"; +import { concatMap, EMPTY, expand, firstValueFrom, from, toArray } from "rxjs"; import sinon from "sinon"; import { createIModelHierarchyProvider } from "@itwin/presentation-hierarchies"; import { @@ -17,7 +17,8 @@ import { ModelsTreeIdsCache } from "../../../tree-widget-react/components/trees/ import { defaultHierarchyConfiguration, ModelsTreeDefinition } from "../../../tree-widget-react/components/trees/models-tree/ModelsTreeDefinition.js"; import { createIModelAccess } from "../Common.js"; -import type { Id64Array, Id64String } from "@itwin/core-bentley"; +import type { ParentElementMap } from "../../../tree-widget-react/components/trees/models-tree/internal/ModelsTreeIdsCache.js"; +import type { Id64Arg, Id64Array, Id64Set, Id64String } from "@itwin/core-bentley"; import type { IModelConnection } from "@itwin/core-frontend"; import type { ClassGroupingNodeKey, @@ -27,6 +28,7 @@ import type { HierarchyProvider, NonGroupingHierarchyNode, } from "@itwin/presentation-hierarchies"; +import type { CategoryId, ElementId, ModelId, ParentId, SubjectId } from "../../../tree-widget-react/components/trees/common/internal/Types.js"; type ModelsTreeHierarchyConfiguration = ConstructorParameters[0]["hierarchyConfig"]; @@ -73,10 +75,11 @@ export function createModelsTreeProvider({ } interface IdsCacheMockProps { - subjectsHierarchy?: Map; - subjectModels?: Map; - modelCategories?: Map; - categoryElements?: Map; + subjectsHierarchy?: Map>; + subjectModels?: Map>; + modelCategories?: Map>; + categoryElements?: Map>; + childrenInfo?: Map; } export function createFakeIdsCache(props?: IdsCacheMockProps): ModelsTreeIdsCache { @@ -98,21 +101,34 @@ export function createFakeIdsCache(props?: IdsCacheMockProps): ModelsTreeIdsCach ); return firstValueFrom(obs); }), - getModelCategoryIds: sinon.stub<[Id64String], Promise>().callsFake(async (modelId) => { - return props?.modelCategories?.get(modelId) ?? []; - }), - getModelElementCount: sinon.stub<[Id64String], Promise>().callsFake(async (modelId) => { - const obs = from(props?.modelCategories?.get(modelId) ?? EMPTY).pipe( - concatMap((categoryId) => props?.categoryElements?.get(categoryId) ?? EMPTY), - count(), + getModelCategoryIds: sinon.stub<[ModelId], Promise>>().callsFake(async (modelId) => { + return ( + props?.modelCategories + ?.get(modelId) + ?.filter(({ isAtRoot }) => isAtRoot) + .map(({ categoryId }) => categoryId) ?? [] ); - return firstValueFrom(obs); }), - getCategoryElementsCount: sinon.stub<[Id64String, Id64String], Promise>().callsFake(async (_, categoryId) => { + getAllModelCategoryIds: sinon.stub<[ModelId], Promise>>().callsFake(async (modelId) => { + return props?.modelCategories?.get(modelId)?.map(({ categoryId }) => categoryId) ?? []; + }), + getCategoryChildCategories: sinon + .stub<[{ modelId: Id64String; categoryIds: Id64Arg; parentElementIds?: Id64Arg }], Promise>>>() + .callsFake(async () => new Map()), + getCategoryElementsCount: sinon.stub<[ModelId, CategoryId, Id64Arg | undefined], Promise>().callsFake(async (_, categoryId) => { return props?.categoryElements?.get(categoryId)?.length ?? 0; }), + getElementsAllChildren: sinon.stub<[{ modelId: Id64String; elementIds: Id64Array }]>().callsFake(async () => new Map()), + getCategoryAllIndirectChildren: sinon + .stub<[{ modelId: Id64String; categoryId: Id64String; parentElementIds?: Id64Arg }], Promise>>>() + .callsFake(async () => new Map()), + getElementsChildCategories: sinon + .stub<[{ modelId: Id64String; elementIds: Id64Set }], Promise>>>() + .callsFake(async () => new Map()), hasSubModel: sinon.stub<[Id64String], Promise>().callsFake(async () => false), - getCategoriesModeledElements: sinon.stub<[Id64String, Id64Array], Promise>().callsFake(async () => []), + getCategoriesModeledElements: sinon + .stub<[{ modelId: Id64String; categoryIds: Id64Arg; parentIds?: Id64Arg; includeNested: boolean }], Promise>() + .callsFake(async () => []), }); } @@ -145,7 +161,12 @@ export function createModelHierarchyNode(modelId?: Id64String, hasChildren?: boo }, }; } -export function createCategoryHierarchyNode(modelId?: Id64String, categoryId?: Id64String, hasChildren?: boolean): NonGroupingHierarchyNode { +export function createCategoryHierarchyNode( + modelId?: Id64String, + categoryId?: Id64String, + hasChildren?: boolean, + parentId?: ElementId, +): NonGroupingHierarchyNode { return { key: { type: "instances", @@ -153,7 +174,7 @@ export function createCategoryHierarchyNode(modelId?: Id64String, categoryId?: I }, children: !!hasChildren, label: "", - parentKeys: [], + parentKeys: parentId ? [{ type: "instances", instanceKeys: [{ className: GEOMETRIC_ELEMENT_3D_CLASS_NAME, id: parentId }] }] : [], extendedData: { isCategory: true, modelId: modelId ?? "0x1", @@ -166,6 +187,7 @@ export function createElementHierarchyNode(props: { categoryId: Id64String | undefined; hasChildren?: boolean; elementId?: Id64String; + parentId?: ElementId; }): NonGroupingHierarchyNode { return { key: { @@ -174,7 +196,7 @@ export function createElementHierarchyNode(props: { }, children: !!props.hasChildren, label: "", - parentKeys: [], + parentKeys: props.parentId ? [{ type: "instances", instanceKeys: [{ className: GEOMETRIC_ELEMENT_3D_CLASS_NAME, id: props.parentId }] }] : [], extendedData: { modelId: props.modelId, categoryId: props.categoryId, diff --git a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeIdsCache.test.ts b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeIdsCache.test.ts index b7ffec4fbc..275670e54f 100644 --- a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeIdsCache.test.ts +++ b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeIdsCache.test.ts @@ -17,33 +17,12 @@ describe("ModelsTreeIdsCache", () => { return new ModelsTreeIdsCache(createLimitingECSqlQueryExecutor(createECSqlQueryExecutor(iModel), "unbounded"), defaultHierarchyConfiguration); } - it("caches model element count", async () => { - const modelId = "0x1"; - const categoryId = "0x2"; - const elementIds = ["0x10", "0x20", "0x30"]; - const stub = sinon.fake((query: string) => { - if (query.includes("GROUP BY modelId, categoryId")) { - return elementIds.map((elementId) => ({ elementId, modelId, categoryId })); - } - if (query.includes("COUNT(*)")) { - return [{ modelId, elementCount: elementIds.length }]; - } - - return []; - }); - using cache = createIdsCache(stub); - await expect(cache.getModelElementCount(modelId)).to.eventually.eq(elementIds.length); - expect(stub).to.have.callCount(2); - await expect(cache.getModelElementCount(modelId)).to.eventually.eq(elementIds.length); - expect(stub).to.have.callCount(2); - }); - it("caches category element count", async () => { const modelId = "0x1"; const categoryId = "0x2"; const elementIds = ["0x10", "0x20", "0x30"]; const stub = sinon.fake((query: string) => { - if (query.includes(`WHERE Parent.Id IS NULL AND (Model.Id = ${modelId} AND Category.Id = ${categoryId})`)) { + if (query.includes(`WHERE (e.Parent.Id IS NULL AND e.Model.Id = ${modelId} AND e.Category.Id = ${categoryId})`)) { return [{ modelId, categoryId, elementsCount: elementIds.length }]; } throw new Error(`Unexpected query: ${query}`); diff --git a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeVisibilityHandler.test.ts b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeVisibilityHandler.test.ts index 02c2e26804..6dd1416b69 100644 --- a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeVisibilityHandler.test.ts +++ b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeVisibilityHandler.test.ts @@ -5,7 +5,7 @@ import { assert, expect } from "chai"; import sinon from "sinon"; -import { CompressedId64Set } from "@itwin/core-bentley"; +import { CompressedId64Set, Id64 } from "@itwin/core-bentley"; import { Code, ColorDef, IModel, IModelReadRpcInterface, RenderMode, SnapshotIModelRpcInterface } from "@itwin/core-common"; import { IModelApp, NoRenderApp, OffScreenViewport, PerModelCategoryVisibility, SpatialViewState, ViewRect } from "@itwin/core-frontend"; import { ECSchemaRpcInterface } from "@itwin/ecschema-rpcinterface-common"; @@ -60,13 +60,6 @@ interface VisibilityOverrides { type ModelsTreeHierarchyConfiguration = Partial[0]["hierarchyConfig"]>; describe("ModelsTreeVisibilityHandler", () => { - function createIdsCache(iModel: IModelConnection, hierarchyConfig?: ModelsTreeHierarchyConfiguration) { - return new ModelsTreeIdsCache(createLimitingECSqlQueryExecutor(createECSqlQueryExecutor(iModel), "unbounded"), { - ...defaultHierarchyConfiguration, - ...hierarchyConfig, - }); - } - before(async () => { await NoRenderApp.startup(); await TestUtils.initialize(); @@ -89,6 +82,13 @@ describe("ModelsTreeVisibilityHandler", () => { createdHandlers = []; }); + function createIdsCache(iModel: IModelConnection, hierarchyConfig?: ModelsTreeHierarchyConfiguration) { + return new ModelsTreeIdsCache(createLimitingECSqlQueryExecutor(createECSqlQueryExecutor(iModel), "unbounded"), { + ...defaultHierarchyConfiguration, + ...hierarchyConfig, + }); + } + function createFakeIModelAccess(): ModelsTreeVisibilityHandlerProps["imodelAccess"] { return { classDerivesFrom: sinon.fake.returns(false), @@ -99,15 +99,35 @@ describe("ModelsTreeVisibilityHandler", () => { const overrides: ModelsTreeVisibilityHandlerProps["overrides"] = { getModelDisplayStatus: props?.overrides?.models && - (async ({ id, originalImplementation }) => { - const res = props.overrides!.models!.get(id); - return res ? createVisibilityStatus(res) : originalImplementation(); + (async ({ ids, originalImplementation }) => { + let visibility: Visibility | "unknown" = "unknown"; + for (const modelId of Id64.iterable(ids)) { + const res = props.overrides!.models!.get(modelId); + if (!res) { + continue; + } + if (visibility !== "unknown" && res !== visibility) { + return createVisibilityStatus("partial"); + } + visibility = res; + } + return visibility !== "unknown" ? createVisibilityStatus(visibility) : originalImplementation(); }), getCategoryDisplayStatus: props?.overrides?.categories && - (async ({ categoryId, originalImplementation }) => { - const res = props.overrides!.categories!.get(categoryId); - return res ? createVisibilityStatus(res) : originalImplementation(); + (async ({ categoryIds, originalImplementation }) => { + let visibility: Visibility | "unknown" = "unknown"; + for (const id of Id64.iterable(categoryIds)) { + const res = props.overrides!.categories!.get(id); + if (!res) { + continue; + } + if (visibility !== "unknown" && res !== visibility) { + return createVisibilityStatus("partial"); + } + visibility = res; + } + return visibility !== "unknown" ? createVisibilityStatus(visibility) : originalImplementation(); }), getElementDisplayStatus: props?.overrides?.elements && @@ -310,25 +330,6 @@ describe("ModelsTreeVisibilityHandler", () => { expect(result).to.include({ state: "hidden", isDisabled: true }); }); - it("doesn't query model element count if always/never drawn sets are empty and exclusive mode is off", async () => { - const modelId = "0x1"; - const categories = ["0x10", "0x20"]; - const node = createModelHierarchyNode(modelId); - const idsCache = createFakeIdsCache({ - modelCategories: new Map([[modelId, categories]]), - categoryElements: new Map([ - ["0x10", ["0x100", "0x200"]], - ["0x20", ["0x300", "0x400"]], - ]), - }); - using handlerResult = createHandler({ idsCache }); - const { handler } = handlerResult; - - const result = await handler.getVisibilityStatus(node); - expect(result).to.include({ state: "visible" }); - expect(idsCache.getModelElementCount).not.to.be.called; - }); - describe("visible", () => { it("when enabled and has no categories", async () => { const modelId = "0x1"; @@ -341,7 +342,10 @@ describe("ModelsTreeVisibilityHandler", () => { it("when enabled and all categories are displayed", async () => { const modelId = "0x1"; - const categories = ["0x10", "0x20"]; + const categories = [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ]; const node = createModelHierarchyNode(modelId); const idsCache = createFakeIdsCache({ modelCategories: new Map([[modelId, categories]]), @@ -354,7 +358,10 @@ describe("ModelsTreeVisibilityHandler", () => { it("when all elements are in the exclusive always drawn list", async () => { const modelId = "0x1"; - const categories = ["0x10", "0x20"]; + const categories = [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ]; const node = createModelHierarchyNode(modelId); const modelCategories = new Map([[modelId, categories]]); const categoryElements = new Map([ @@ -383,7 +390,10 @@ describe("ModelsTreeVisibilityHandler", () => { it("when always drawn list is empty", async () => { const modelId = "0x1"; - const categories = ["0x10", "0x20"]; + const categories = [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ]; const node = createModelHierarchyNode(modelId); const idsCache = createFakeIdsCache({ modelCategories: new Map([[modelId, categories]]), @@ -405,7 +415,10 @@ describe("ModelsTreeVisibilityHandler", () => { it("when all categories are displayed and always/never drawn lists contain no elements", async () => { const modelId = "0x1"; - const categories = ["0x10", "0x20"]; + const categories = [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ]; const node = createModelHierarchyNode(modelId); const idsCache = createFakeIdsCache({ modelCategories: new Map([[modelId, categories]]), @@ -445,7 +458,10 @@ describe("ModelsTreeVisibilityHandler", () => { it("all categories are hidden", async () => { const modelId = "0x1"; - const categories = ["0x10", "0x20"]; + const categories = [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ]; const node = createModelHierarchyNode(modelId); const idsCache = createFakeIdsCache({ modelCategories: new Map([[modelId, categories]]), @@ -466,7 +482,15 @@ describe("ModelsTreeVisibilityHandler", () => { it("when all elements are in never drawn list", async () => { const modelId = "0x1"; const node = createModelHierarchyNode(modelId); - const modelCategories = new Map([[modelId, ["0x10", "0x20"]]]); + const modelCategories = new Map([ + [ + modelId, + [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ], + ], + ]); const categoryElements = new Map([ ["0x10", ["0x100", "0x200"]], ["0x20", ["0x300", "0x400"]], @@ -493,7 +517,15 @@ describe("ModelsTreeVisibilityHandler", () => { it("when none of the elements are in exclusive always drawn list", async () => { const modelId = "0x1"; const node = createModelHierarchyNode(modelId); - const modelCategories = new Map([[modelId, ["0x10", "0x20"]]]); + const modelCategories = new Map([ + [ + modelId, + [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ], + ], + ]); const categoryElements = new Map([ ["0x10", ["0x100", "0x200"]], ["0x20", ["0x300", "0x400"]], @@ -517,7 +549,15 @@ describe("ModelsTreeVisibilityHandler", () => { it("when in exclusive always drawn list is empty", async () => { const modelId = "0x1"; const node = createModelHierarchyNode(modelId); - const modelCategories = new Map([[modelId, ["0x10", "0x20"]]]); + const modelCategories = new Map([ + [ + modelId, + [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ], + ], + ]); const categoryElements = new Map([ ["0x10", ["0x100", "0x200"]], ["0x20", ["0x300", "0x400"]], @@ -539,7 +579,10 @@ describe("ModelsTreeVisibilityHandler", () => { it("when all categories are hidden and always/never drawn lists contain no children", async () => { const modelId = "0x1"; - const categories = ["0x10", "0x20"]; + const categories = [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ]; const node = createModelHierarchyNode(modelId); const idsCache = createFakeIdsCache({ modelCategories: new Map([[modelId, categories]]), @@ -565,7 +608,10 @@ describe("ModelsTreeVisibilityHandler", () => { describe("partially visible", () => { it("when at least one category is hidden", async () => { const modelId = "0x1"; - const categories = ["0x10", "0x20"]; + const categories = [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ]; const node = createModelHierarchyNode(modelId); const idsCache = createFakeIdsCache({ modelCategories: new Map([[modelId, categories]]), @@ -574,7 +620,7 @@ describe("ModelsTreeVisibilityHandler", () => { idsCache, viewport: createFakeSinonViewport({ view: { - viewsCategory: sinon.fake((id) => id === categories[0]), + viewsCategory: sinon.fake((id) => id === categories[0].categoryId), }, }), }); @@ -587,7 +633,15 @@ describe("ModelsTreeVisibilityHandler", () => { const modelId = "0x1"; const node = createModelHierarchyNode(modelId); const idsCache = createFakeIdsCache({ - modelCategories: new Map([[modelId, ["0x10", "0x20"]]]), + modelCategories: new Map([ + [ + modelId, + [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ], + ], + ]), categoryElements: new Map([ ["0x10", ["0x100", "0x200"]], ["0x20", ["0x300", "0x400"]], @@ -609,7 +663,15 @@ describe("ModelsTreeVisibilityHandler", () => { const modelId = "0x1"; const node = createModelHierarchyNode(modelId); const idsCache = createFakeIdsCache({ - modelCategories: new Map([[modelId, ["0x10", "0x20"]]]), + modelCategories: new Map([ + [ + modelId, + [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ], + ], + ]), categoryElements: new Map([ ["0x10", ["0x100", "0x200"]], ["0x20", ["0x300", "0x400"]], @@ -630,7 +692,10 @@ describe("ModelsTreeVisibilityHandler", () => { it("when some categories are visible, some hidden and always/never drawn lists contain no children", async () => { const modelId = "0x1"; - const categories = ["0x10", "0x20"]; + const categories = [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ]; const node = createModelHierarchyNode(modelId); const idsCache = createFakeIdsCache({ modelCategories: new Map([[modelId, categories]]), @@ -642,7 +707,7 @@ describe("ModelsTreeVisibilityHandler", () => { using handlerResult = createHandler({ idsCache, viewport: createFakeSinonViewport({ - view: { viewsCategory: sinon.fake((id) => id === categories[0]) }, + view: { viewsCategory: sinon.fake((id) => id === categories[0].categoryId) }, alwaysDrawn: new Set(["0xfff"]), neverDrawn: new Set(["0xeee"]), }), @@ -673,30 +738,6 @@ describe("ModelsTreeVisibilityHandler", () => { expect(status.state).to.eq("visible"); }); - it("doesn't query elements if model is hidden", async () => { - const modelId = "0x1"; - const categoryId = "0x2"; - const node = createCategoryHierarchyNode(modelId, categoryId); - const idsCache = createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), - categoryElements: new Map([[categoryId, ["0x100", "0x200"]]]), - }); - using handlerResult = createHandler({ - idsCache, - viewport: createFakeSinonViewport({ - alwaysDrawn: new Set(["0x400"]), - view: { - viewsModel: sinon.fake.returns(false), - }, - }), - }); - const { handler } = handlerResult; - - const result = await handler.getVisibilityStatus(node); - expect(result).to.include({ state: "hidden" }); - expect(idsCache.getModelElementCount).not.to.be.called; - }); - describe("is visible", () => { it("when `viewport.view.viewsCategory` returns TRUE and there are NO elements in the NEVER drawn list", async () => { const categoryId = "0x2"; @@ -807,7 +848,7 @@ describe("ModelsTreeVisibilityHandler", () => { const node = createCategoryHierarchyNode(modelId, categoryId); using handlerResult = createHandler({ idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elements]]), }), viewport: createFakeSinonViewport({ @@ -875,7 +916,7 @@ describe("ModelsTreeVisibilityHandler", () => { const node = createCategoryHierarchyNode(modelId, categoryId); using handlerResult = createHandler({ idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elements]]), }), viewport: createFakeSinonViewport({ @@ -898,7 +939,7 @@ describe("ModelsTreeVisibilityHandler", () => { const node = createCategoryHierarchyNode(modelId, categoryId); using handlerResult = createHandler({ idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elements]]), }), viewport: createFakeSinonViewport({ @@ -921,7 +962,7 @@ describe("ModelsTreeVisibilityHandler", () => { const node = createCategoryHierarchyNode(modelId, categoryId); using handlerResult = createHandler({ idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elements]]), }), viewport: createFakeSinonViewport({ @@ -944,7 +985,7 @@ describe("ModelsTreeVisibilityHandler", () => { const node = createCategoryHierarchyNode(modelId, categoryId); using handlerResult = createHandler({ idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elements]]), }), viewport: createFakeSinonViewport({ @@ -1144,7 +1185,7 @@ describe("ModelsTreeVisibilityHandler", () => { }); using handlerResult = createHandler({ idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elementIds]]), }), overrides: { @@ -1165,7 +1206,7 @@ describe("ModelsTreeVisibilityHandler", () => { }); using handlerResult = createHandler({ idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elementIds]]), }), viewport: createFakeSinonViewport({ @@ -1186,7 +1227,7 @@ describe("ModelsTreeVisibilityHandler", () => { }); using handlerResult = createHandler({ idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elementIds]]), }), viewport: createFakeSinonViewport({ @@ -1208,7 +1249,7 @@ describe("ModelsTreeVisibilityHandler", () => { }); using handlerResult = createHandler({ idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elementIds]]), }), }); @@ -1226,7 +1267,7 @@ describe("ModelsTreeVisibilityHandler", () => { }); using handlerResult = createHandler({ idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elementIds]]), }), viewport: createFakeSinonViewport({ @@ -1247,7 +1288,7 @@ describe("ModelsTreeVisibilityHandler", () => { }); using handlerResult = createHandler({ idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elementIds]]), }), viewport: createFakeSinonViewport({ @@ -1273,7 +1314,7 @@ describe("ModelsTreeVisibilityHandler", () => { view: { viewsCategory: sinon.fake.returns(categoryOn) }, }), idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elementIds]]), }), }); @@ -1369,7 +1410,15 @@ describe("ModelsTreeVisibilityHandler", () => { using handlerResult = createHandler({ viewport, idsCache: createFakeIdsCache({ - modelCategories: new Map([[modelId, ["0x10", "0x20"]]]), + modelCategories: new Map([ + [ + modelId, + [ + { categoryId: "0x10", isAtRoot: true }, + { categoryId: "0x20", isAtRoot: true }, + ], + ], + ]), categoryElements: new Map([ ["0x10", ["0x100", "0x200"]], ["0x20", ["0x300", "0x400"]], @@ -1419,8 +1468,8 @@ describe("ModelsTreeVisibilityHandler", () => { const idsCache = createFakeIdsCache({ modelCategories: new Map([ - [modelId, [categoryId]], - [otherModelId, [otherCategoryId]], + [modelId, [{ categoryId, isAtRoot: true }]], + [otherModelId, [{ categoryId: otherCategoryId, isAtRoot: true }]], ]), categoryElements: new Map([ [categoryId, [...alwaysDrawnElements, ...neverDrawnElements]], @@ -1436,7 +1485,11 @@ describe("ModelsTreeVisibilityHandler", () => { it(`removes per model category overrides`, async () => { const modelId = "0x1"; - const categoryIds = ["0x2", "0x3", "0x4"]; + const categoryIds = [ + { categoryId: "0x2", isAtRoot: true }, + { categoryId: "0x3", isAtRoot: true }, + { categoryId: "0x4", isAtRoot: true }, + ]; const node = createModelHierarchyNode(modelId); const viewport = createFakeSinonViewport(); using handlerResult = createHandler({ @@ -1745,7 +1798,7 @@ describe("ModelsTreeVisibilityHandler", () => { elements, }); const idsCache = createFakeIdsCache({ - modelCategories: new Map([[modelId, [categoryId]]]), + modelCategories: new Map([[modelId, [{ categoryId, isAtRoot: true }]]]), categoryElements: new Map([[categoryId, elements]]), }); const viewport = createFakeSinonViewport({ @@ -1843,16 +1896,55 @@ describe("ModelsTreeVisibilityHandler", () => { subModelElementId?: Id64String; } - const testCases: Array<{ - describeName: string; - createIModel: (context: Mocha.Context) => Promise<{ imodel: IModelConnection } & IModelWithSubModelIds>; - cases: Array<{ - only?: boolean; - name: string; - getTargetNode: (ids: IModelWithSubModelIds) => NonGroupingHierarchyNode | GroupingHierarchyNode; - expectations: (ids: IModelWithSubModelIds) => ReturnType; - }>; - }> = [ + interface IModelWithParentIds { + subjectId: Id64String; + parentElementId: Id64String; + modelId: Id64String; + categoryId: Id64String; + childCategoryId: Id64String; + childElementId: Id64String; + } + + interface IModelWithoutParentAndSubmodelIds { + subjectId: Id64String; + modelId: Id64String; + categoryId: Id64String; + firstElementId: Id64String; + secondElementId: Id64String; + } + + const testCases: Array< + | { + describeName: string; + createIModel: (context: Mocha.Context) => Promise<{ imodel: IModelConnection } & IModelWithSubModelIds>; + cases: Array<{ + only?: boolean; + name: string; + getTargetNode: (ids: IModelWithSubModelIds) => NonGroupingHierarchyNode | GroupingHierarchyNode; + expectations: (ids: IModelWithSubModelIds) => ReturnType; + }>; + } + | { + describeName: string; + createIModel: (context: Mocha.Context) => Promise<{ imodel: IModelConnection } & IModelWithParentIds>; + cases: Array<{ + only?: boolean; + name: string; + getTargetNode: (ids: IModelWithParentIds) => NonGroupingHierarchyNode | GroupingHierarchyNode; + expectations: (ids: IModelWithParentIds) => ReturnType; + }>; + } + | { + describeName: string; + createIModel: (context: Mocha.Context) => Promise<{ imodel: IModelConnection } & IModelWithoutParentAndSubmodelIds>; + cases: Array<{ + only?: boolean; + name: string; + getTargetNode: (ids: IModelWithoutParentAndSubmodelIds) => NonGroupingHierarchyNode | GroupingHierarchyNode; + expectations: (ids: IModelWithoutParentAndSubmodelIds) => ReturnType; + }>; + } + > = [ { describeName: "with modeled elements", createIModel: async function createIModel(context: Mocha.Context): Promise<{ imodel: IModelConnection } & IModelWithSubModelIds> { @@ -2121,12 +2213,316 @@ describe("ModelsTreeVisibilityHandler", () => { }, ], }, + { + describeName: "with child elements who have the same category as parent", + createIModel: async function createIModel(context: Mocha.Context): Promise<{ imodel: IModelConnection } & IModelWithParentIds> { + return buildIModel(context, async (builder, testSchema) => { + const rootSubject: InstanceKey = { className: SUBJECT_CLASS_NAME, id: IModel.rootSubjectId }; + const partition = insertPhysicalPartition({ builder, codeValue: "model", parentId: rootSubject.id }); + const model = insertPhysicalSubModel({ builder, modeledElementId: partition.id }); + const category = insertSpatialCategory({ builder, codeValue: "category" }); + const parentElement = insertPhysicalElement({ + builder, + userLabel: `element`, + modelId: model.id, + categoryId: category.id, + classFullName: testSchema.items.SubModelablePhysicalObject.fullName, + }); + const childElement = insertPhysicalElement({ + builder, + userLabel: `element2`, + modelId: model.id, + categoryId: category.id, + parentId: parentElement.id, + }); + return { + subjectId: rootSubject.id, + parentElementId: parentElement.id, + modelId: model.id, + categoryId: category.id, + childCategoryId: category.id, + childElementId: childElement.id, + }; + }); + }, + cases: [ + { + name: "everything is visible when subject display is turned on", + getTargetNode: (ids: IModelWithParentIds) => createSubjectHierarchyNode(ids.subjectId), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "everything is visible when model display is turned on", + getTargetNode: (ids: IModelWithParentIds) => createModelHierarchyNode(ids.modelId, true), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "everything is visible when parent element's category display is turned on", + getTargetNode: (ids: IModelWithParentIds) => createCategoryHierarchyNode(ids.modelId, ids.categoryId, true), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "everything is visible when parent's class grouping node display is turned on", + getTargetNode: (ids: IModelWithParentIds) => + createClassGroupingHierarchyNode({ modelId: ids.modelId, categoryId: ids.categoryId, elements: [ids.parentElementId] }), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "everything is visible when parent element's display is turned on", + getTargetNode: (ids: IModelWithParentIds) => + createElementHierarchyNode({ + modelId: ids.modelId, + categoryId: ids.categoryId, + elementId: ids.parentElementId, + hasChildren: true, + }), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "everything is visible when child element's category display is turned on", + getTargetNode: (ids: IModelWithParentIds) => createCategoryHierarchyNode(ids.modelId, ids.childCategoryId, true, ids.parentElementId), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "subject, model, category and parent element have partial visibility when child element's display is turned on", + getTargetNode: (ids: IModelWithParentIds) => + createElementHierarchyNode({ + modelId: ids.modelId, + categoryId: ids.categoryId, + elementId: ids.childElementId, + parentId: ids.parentElementId, + }), + expectations: (ids: IModelWithParentIds): ReturnType => ({ + subject: () => "partial", + model: () => "partial", + category: ({ parentElementId }) => { + if (parentElementId !== undefined) { + return "visible"; + } + return "partial"; + }, + groupingNode: ({ elementIds }) => { + if (elementIds.includes(ids.parentElementId)) { + return "partial"; + } + return "visible"; + }, + element: ({ elementId }) => { + if (elementId === ids.childElementId) { + return "visible"; + } + return "partial"; + }, + }), + }, + ], + }, + { + describeName: "with child elements who have diffrent category from parent", + createIModel: async function createIModel(context: Mocha.Context): Promise<{ imodel: IModelConnection } & IModelWithParentIds> { + return buildIModel(context, async (builder, testSchema) => { + const rootSubject: InstanceKey = { className: SUBJECT_CLASS_NAME, id: IModel.rootSubjectId }; + const partition = insertPhysicalPartition({ builder, codeValue: "model", parentId: rootSubject.id }); + const model = insertPhysicalSubModel({ builder, modeledElementId: partition.id }); + const category = insertSpatialCategory({ builder, codeValue: "category" }); + const parentElement = insertPhysicalElement({ + builder, + userLabel: `element`, + modelId: model.id, + categoryId: category.id, + classFullName: testSchema.items.SubModelablePhysicalObject.fullName, + }); + const childCategory = insertSpatialCategory({ builder, codeValue: "category2" }); + const childElement = insertPhysicalElement({ + builder, + userLabel: `element2`, + modelId: model.id, + categoryId: childCategory.id, + parentId: parentElement.id, + }); + return { + subjectId: rootSubject.id, + parentElementId: parentElement.id, + modelId: model.id, + categoryId: category.id, + childCategoryId: childCategory.id, + childElementId: childElement.id, + }; + }); + }, + cases: [ + { + name: "everything is visible when subject display is turned on", + getTargetNode: (ids: IModelWithParentIds) => createSubjectHierarchyNode(ids.subjectId), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "everything is visible when model display is turned on", + getTargetNode: (ids: IModelWithParentIds) => createModelHierarchyNode(ids.modelId, true), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "everything is visible when parent element's category display is turned on", + getTargetNode: (ids: IModelWithParentIds) => createCategoryHierarchyNode(ids.modelId, ids.categoryId, true), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "everything is visible when parent element's class grouping node display is turned on", + getTargetNode: (ids: IModelWithParentIds) => + createClassGroupingHierarchyNode({ modelId: ids.modelId, categoryId: ids.categoryId, elements: [ids.parentElementId] }), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "everything is visible when parent element's display is turned on", + getTargetNode: (ids: IModelWithParentIds) => + createElementHierarchyNode({ + modelId: ids.modelId, + categoryId: ids.categoryId, + elementId: ids.parentElementId, + hasChildren: true, + }), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "subject, model, category and parent element have partial visibility when child element's category display is turned on", + getTargetNode: (ids: IModelWithParentIds) => createCategoryHierarchyNode(ids.modelId, ids.childCategoryId, true, ids.parentElementId), + expectations: (ids: IModelWithParentIds): ReturnType => ({ + subject: () => "partial", + model: () => "partial", + category: ({ categoryId }) => { + if (categoryId === ids.childCategoryId) { + return "visible"; + } + return "partial"; + }, + groupingNode: ({ elementIds }) => { + if (elementIds.includes(ids.parentElementId)) { + return "partial"; + } + return "visible"; + }, + element: ({ elementId }) => { + if (elementId === ids.childElementId) { + return "visible"; + } + return "partial"; + }, + }), + }, + { + name: "subject, model, category and parent element have partial visibility when child element's display is turned on", + getTargetNode: (ids: IModelWithParentIds) => + createElementHierarchyNode({ + modelId: ids.modelId, + categoryId: ids.childCategoryId, + elementId: ids.childElementId, + parentId: ids.parentElementId, + }), + expectations: (ids: IModelWithParentIds): ReturnType => ({ + subject: () => "partial", + model: () => "partial", + category: ({ categoryId }) => { + if (categoryId === ids.childCategoryId) { + return "visible"; + } + return "partial"; + }, + groupingNode: ({ elementIds }) => { + if (elementIds.includes(ids.parentElementId)) { + return "partial"; + } + return "visible"; + }, + element: ({ elementId }) => { + if (elementId === ids.childElementId) { + return "visible"; + } + return "partial"; + }, + }), + }, + ], + }, + { + describeName: "without modeled or child elements", + createIModel: async function createIModel(context: Mocha.Context): Promise<{ imodel: IModelConnection } & IModelWithoutParentAndSubmodelIds> { + return buildIModel(context, async (builder) => { + const rootSubject: InstanceKey = { className: SUBJECT_CLASS_NAME, id: IModel.rootSubjectId }; + const partition = insertPhysicalPartition({ builder, codeValue: "model", parentId: rootSubject.id }); + const model = insertPhysicalSubModel({ builder, modeledElementId: partition.id }); + const category = insertSpatialCategory({ builder, codeValue: "category" }); + const firstElement = insertPhysicalElement({ + builder, + userLabel: `element1`, + modelId: model.id, + categoryId: category.id, + }); + const secondElement = insertPhysicalElement({ + builder, + userLabel: `element2`, + modelId: model.id, + categoryId: category.id, + }); + return { + subjectId: rootSubject.id, + firstElementId: firstElement.id, + modelId: model.id, + categoryId: category.id, + secondElementId: secondElement.id, + }; + }); + }, + cases: [ + { + name: "everything is visible when subject display is turned on", + getTargetNode: (ids: IModelWithoutParentAndSubmodelIds) => createSubjectHierarchyNode(ids.subjectId), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "everything is visible when model display is turned on", + getTargetNode: (ids: IModelWithoutParentAndSubmodelIds) => createModelHierarchyNode(ids.modelId, true), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "everything is visible when category display is turned on", + getTargetNode: (ids: IModelWithoutParentAndSubmodelIds) => createCategoryHierarchyNode(ids.modelId, ids.categoryId, true), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "everything is visible when class grouping node display is turned on", + getTargetNode: (ids: IModelWithoutParentAndSubmodelIds) => + createClassGroupingHierarchyNode({ modelId: ids.modelId, categoryId: ids.categoryId, elements: [ids.firstElementId, ids.secondElementId] }), + expectations: () => VisibilityExpectations.all("visible"), + }, + { + name: "subject, model, category and class grouping node have partial visibility when one element has its display turned on", + getTargetNode: (ids: IModelWithoutParentAndSubmodelIds) => + createElementHierarchyNode({ + modelId: ids.modelId, + categoryId: ids.categoryId, + elementId: ids.firstElementId, + }), + expectations: (ids: IModelWithoutParentAndSubmodelIds): ReturnType => ({ + subject: () => "partial", + model: () => "partial", + category: () => "partial", + groupingNode: () => "partial", + element: ({ elementId }) => { + if (elementId === ids.firstElementId) { + return "visible"; + } + return "hidden"; + }, + }), + }, + ], + }, ]; testCases.forEach(({ describeName, createIModel, cases }) => { describe(describeName, () => { let iModel: IModelConnection; - let createdIds: IModelWithSubModelIds; + let createdIds: IModelWithSubModelIds | IModelWithParentIds | IModelWithoutParentAndSubmodelIds; before(async function () { const { imodel, ...ids } = await createIModel(this); @@ -2143,7 +2539,7 @@ describe("ModelsTreeVisibilityHandler", () => { using visibilityTestData = createVisibilityTestData({ imodel: iModel }); const { handler, provider, viewport } = visibilityTestData; - const nodeToChangeVisibility = getTargetNode(createdIds); + const nodeToChangeVisibility = getTargetNode(createdIds as any); await validateHierarchyVisibility({ provider, handler, @@ -2156,7 +2552,7 @@ describe("ModelsTreeVisibilityHandler", () => { provider, handler, viewport, - visibilityExpectations: expectations(createdIds), + visibilityExpectations: expectations(createdIds as any), }); await handler.changeVisibility(nodeToChangeVisibility, false); viewport.renderFrame(); @@ -2195,31 +2591,6 @@ describe("ModelsTreeVisibilityHandler", () => { }); }); - it("showing subject makes it, all its models, categories and elements visible", async function () { - await using buildIModelResult = await buildIModel(this, async (builder) => { - const categoryId = insertSpatialCategory({ builder, codeValue: "category" }).id; - const modelId = insertPhysicalModelWithPartition({ builder, partitionParentId: IModel.rootSubjectId, codeValue: "1" }).id; - insertPhysicalElement({ builder, modelId, categoryId }); - }); - - const { imodel } = buildIModelResult; - using visibilityTestData = createVisibilityTestData({ imodel }); - const { handler, provider, viewport } = visibilityTestData; - await handler.changeVisibility(createSubjectHierarchyNode(IModel.rootSubjectId), true); - await validateHierarchyVisibility({ - provider, - handler, - viewport, - visibilityExpectations: { - subject: () => "visible", - model: () => ({ tree: "visible", modelSelector: true }), - category: () => ({ tree: "visible", categorySelector: false, perModelCategoryOverride: "show" }), - groupingNode: () => "visible", - element: () => "visible", - }, - }); - }); - it("showing model makes it, all its categories and elements visible and doesn't affect other models", async function () { await using buildIModelResult = await buildIModel(this, async (builder) => { const categoryId = insertSpatialCategory({ builder, codeValue: "category" }).id; @@ -2289,38 +2660,6 @@ describe("ModelsTreeVisibilityHandler", () => { }); }); - it("hiding parent element makes it hidden, model and category partially visible, while children remain visible", async function () { - await using buildIModelResult = await buildIModel(this, async (builder) => { - const category = insertSpatialCategory({ builder, codeValue: "category" }).id; - const model = insertPhysicalModelWithPartition({ builder, partitionParentId: IModel.rootSubjectId, codeValue: "1" }).id; - const parentElement = insertPhysicalElement({ builder, modelId: model, categoryId: category }).id; - const child = insertPhysicalElement({ builder, modelId: model, categoryId: category, parentId: parentElement }).id; - insertPhysicalElement({ builder, modelId: model, categoryId: category, parentId: child }); - return { model, category, parentElement }; - }); - - const { imodel, ...ids } = buildIModelResult; - using visibilityTestData = createVisibilityTestData({ imodel }); - const { handler, provider, viewport } = visibilityTestData; - await handler.changeVisibility(createModelHierarchyNode(ids.model), true); - viewport.renderFrame(); - await handler.changeVisibility(createElementHierarchyNode({ modelId: ids.model, categoryId: ids.category, elementId: ids.parentElement }), false); - viewport.renderFrame(); - - await validateHierarchyVisibility({ - provider, - handler, - viewport, - visibilityExpectations: { - subject: () => "partial", - model: () => ({ tree: "partial", modelSelector: true }), - category: () => ({ tree: "partial", categorySelector: false, perModelCategoryOverride: "show" }), - groupingNode: ({ elementIds }) => (elementIds.includes(ids.parentElement) ? "hidden" : "visible"), - element: ({ elementId }) => (elementId === ids.parentElement ? "hidden" : "visible"), - }, - }); - }); - it("if model is hidden, showing element adds it to always drawn set and makes model and category visible in the viewport", async function () { await using buildIModelResult = await buildIModel(this, async (builder) => { const category = insertSpatialCategory({ builder, codeValue: "category" }).id; @@ -2520,166 +2859,6 @@ describe("ModelsTreeVisibilityHandler", () => { }); }); - it("showing grouping node makes it and its grouped elements visible", async function () { - await using buildIModelResult = await buildIModel(this, async (builder) => { - const category = insertSpatialCategory({ builder, codeValue: "category" }).id; - const model = insertPhysicalModelWithPartition({ builder, partitionParentId: IModel.rootSubjectId, codeValue: "1" }).id; - const parentElement = insertPhysicalElement({ builder, modelId: model, categoryId: category }).id; - const child = insertPhysicalElement({ builder, modelId: model, categoryId: category, parentId: parentElement }).id; - insertPhysicalElement({ builder, modelId: model, categoryId: category, parentId: child }); - - const otherCategory = insertSpatialCategory({ builder, codeValue: "otherCategory" }).id; - insertPhysicalElement({ builder, modelId: model, categoryId: otherCategory }); - - return { model, category, parentElement }; - }); - - const { imodel, ...ids } = buildIModelResult; - using visibilityTestData = createVisibilityTestData({ imodel }); - const { handler, provider, viewport } = visibilityTestData; - await handler.changeVisibility( - createClassGroupingHierarchyNode({ - modelId: ids.model, - categoryId: ids.category, - elements: [ids.parentElement], - }), - true, - ); - viewport.renderFrame(); - - await validateHierarchyVisibility({ - provider, - handler, - viewport, - visibilityExpectations: { - subject: () => "partial", - model: () => ({ tree: "partial", modelSelector: true }), - category: ({ categoryId }) => - categoryId === ids.category - ? { tree: "partial", categorySelector: false, perModelCategoryOverride: "none" } - : { tree: "hidden", categorySelector: false, perModelCategoryOverride: "none" }, - groupingNode: ({ elementIds }) => (elementIds.includes(ids.parentElement) ? "visible" : "hidden"), - element: ({ elementId }) => (elementId === ids.parentElement ? "visible" : "hidden"), - }, - }); - }); - - it("hiding grouping node makes it and its grouped elements hidden", async function () { - await using buildIModelResult = await buildIModel(this, async (builder) => { - const category = insertSpatialCategory({ builder, codeValue: "category" }).id; - const model = insertPhysicalModelWithPartition({ builder, partitionParentId: IModel.rootSubjectId, codeValue: "1" }).id; - const parentElement = insertPhysicalElement({ builder, modelId: model, categoryId: category }).id; - const child = insertPhysicalElement({ builder, modelId: model, categoryId: category, parentId: parentElement }).id; - insertPhysicalElement({ builder, modelId: model, categoryId: category, parentId: child }); - - const otherCategory = insertSpatialCategory({ builder, codeValue: "otherCategory" }).id; - insertPhysicalElement({ builder, modelId: model, categoryId: otherCategory }); - - return { model, category, parentElement }; - }); - - const { imodel, ...ids } = buildIModelResult; - using visibilityTestData = createVisibilityTestData({ imodel }); - const { handler, provider, viewport } = visibilityTestData; - await handler.changeVisibility(createSubjectHierarchyNode(IModel.rootSubjectId), true); - viewport.renderFrame(); - await handler.changeVisibility( - createClassGroupingHierarchyNode({ - modelId: ids.model, - categoryId: ids.category, - elements: [ids.parentElement], - }), - false, - ); - viewport.renderFrame(); - - await validateHierarchyVisibility({ - provider, - handler, - viewport, - visibilityExpectations: { - subject: () => "partial", - model: () => ({ tree: "partial", modelSelector: true }), - category: ({ categoryId }) => ({ - tree: categoryId === ids.category ? "partial" : "visible", - categorySelector: false, - perModelCategoryOverride: "show", - }), - groupingNode: ({ elementIds }) => (elementIds.includes(ids.parentElement) ? "hidden" : "visible"), - element: ({ elementId }) => (elementId === ids.parentElement ? "hidden" : "visible"), - }, - }); - }); - - describe("child element category is different than parent's", () => { - it("model visibility only takes into account parent element categories", async function () { - await using buildIModelResult = await buildIModel(this, async (builder) => { - const parentCategory = insertSpatialCategory({ builder, codeValue: "parentCategory" }); - const childCategory = insertSpatialCategory({ builder, codeValue: "childCategory" }); - const model = insertPhysicalModelWithPartition({ builder, codeValue: "model" }); - - const parentElement = insertPhysicalElement({ builder, modelId: model.id, categoryId: parentCategory.id }); - insertPhysicalElement({ builder, modelId: model.id, categoryId: childCategory.id, parentId: parentElement.id }); - return { modelId: model.id, parentCategoryId: parentCategory.id, parentElementId: parentElement.id }; - }); - const { imodel, modelId, parentCategoryId, parentElementId } = buildIModelResult; - using visibilityTestData = createVisibilityTestData({ imodel }); - const { handler, viewport, ...props } = visibilityTestData; - const parentCategoryNode = createCategoryHierarchyNode(modelId, parentCategoryId); - - await handler.changeVisibility(parentCategoryNode, true); - viewport.renderFrame(); - await validateHierarchyVisibility({ - ...props, - handler, - viewport, - visibilityExpectations: { - ...VisibilityExpectations.all("visible"), - // FIXME: This is strange from the UX perspective - groupingNode: ({ elementIds }) => (elementIds.includes(parentElementId) ? "visible" : "hidden"), - element: ({ elementId }) => (elementId === parentElementId ? "visible" : "hidden"), - }, - }); - }); - - it("category visibility only takes into account element trees that start with those that have no parents", async function () { - await using buildIModelResult = await buildIModel(this, async (builder) => { - const category = insertSpatialCategory({ builder, codeValue: "parentCategory" }); - const model = insertPhysicalModelWithPartition({ builder, codeValue: "model" }); - const element = insertPhysicalElement({ builder, modelId: model.id, categoryId: category.id }); - - const unrelatedParentCategory = insertSpatialCategory({ builder, codeValue: "differentParentCategory" }); - const unrelatedParentElement = insertPhysicalElement({ builder, modelId: model.id, categoryId: unrelatedParentCategory.id }); - insertPhysicalElement({ builder, modelId: model.id, categoryId: category.id, parentId: unrelatedParentElement.id }); - - return { modelId: model.id, categoryId: category.id, elementId: element.id, unrelatedCategoryId: unrelatedParentCategory.id }; - }); - const { imodel, modelId, categoryId, elementId } = buildIModelResult; - using visibilityTestData = createVisibilityTestData({ imodel }); - const { handler, viewport, ...testProps } = visibilityTestData; - const elementNode = createElementHierarchyNode({ modelId, categoryId, elementId }); - - await handler.changeVisibility(elementNode, true); - viewport.renderFrame(); - await validateHierarchyVisibility({ - ...testProps, - handler, - viewport, - visibilityExpectations: { - subject: () => "partial", - model: () => ({ tree: "partial", modelSelector: true }), - category: (props) => ({ - tree: props.categoryId === categoryId ? "visible" : "hidden", - categorySelector: false, - perModelCategoryOverride: "none", - }), - groupingNode: ({ elementIds }) => (elementIds.includes(elementId) ? "visible" : "hidden"), - element: (props) => (props.elementId === elementId ? "visible" : "hidden"), - }, - }); - }); - }); - describe("reacting to category selector", () => { async function createIModel(context: Mocha.Context) { return buildIModel(context, async (builder) => { diff --git a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/VisibilityValidation.ts b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/VisibilityValidation.ts index ba87bc7cda..26664066af 100644 --- a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/VisibilityValidation.ts +++ b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/VisibilityValidation.ts @@ -7,9 +7,10 @@ import { assert, expect } from "chai"; import { expand, from, mergeMap } from "rxjs"; import { PerModelCategoryVisibility } from "@itwin/core-frontend"; import { HierarchyNode } from "@itwin/presentation-hierarchies"; -import { waitFor } from "@testing-library/react"; import { toVoidPromise } from "../../../../tree-widget-react/components/trees/common/internal/Rxjs.js"; import { ModelsTreeNode } from "../../../../tree-widget-react/components/trees/models-tree/internal/ModelsTreeNode.js"; +import { getParentElementIds } from "../../../../tree-widget-react/components/trees/models-tree/internal/ModelsTreeVisibilityHandler.js"; +import { waitFor } from "../../../TestUtils.js"; import type { Visibility } from "../../../../tree-widget-react/components/trees/common/internal/Tooltip.js"; import type { Id64Array, Id64String } from "@itwin/core-bentley"; @@ -21,7 +22,7 @@ interface VisibilityExpectations { subject(id: string): Visibility; element(props: { modelId: Id64String; categoryId: Id64String; elementId: Id64String }): Visibility; groupingNode(props: { modelId: Id64String; categoryId: Id64String; elementIds: Id64Array }): Visibility; - category(props: { modelId: Id64String; categoryId: Id64String }): + category(props: { modelId: Id64String; categoryId: Id64String; parentElementId: Id64String | undefined }): | Visibility | { tree: Visibility; @@ -93,7 +94,12 @@ export async function validateNodeVisibility({ node, handler, visibilityExpectat if (ModelsTreeNode.isCategoryNode(node)) { const modelId = ModelsTreeNode.getModelId(node)!; - const expected = visibilityExpectations.category({ modelId, categoryId: id }); + const parentElementIds = getParentElementIds(node.parentKeys, modelId); + const expected = visibilityExpectations.category({ + modelId, + categoryId: id, + parentElementId: parentElementIds?.[0], + }); if (typeof expected === "string") { expect(actualVisibility.state).to.eq(expected, JSON.stringify({ modelId, categoryId: id })); return; diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/TreeWidgetUiItemsProvider.tsx b/packages/itwin/tree-widget/src/tree-widget-react/components/TreeWidgetUiItemsProvider.tsx index be01bb8a52..8a7995fea9 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/TreeWidgetUiItemsProvider.tsx +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/TreeWidgetUiItemsProvider.tsx @@ -35,7 +35,6 @@ interface TreeWidgetProps { onFeatureUsed?: (feature: string) => void; } - /** * Creates a tree widget definition that should be returned from `UiItemsProvider.getWidgets()`. * @public diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/tree-header/WidgetHeader.css b/packages/itwin/tree-widget/src/tree-widget-react/components/tree-header/WidgetHeader.css index d4778e70b4..491bb5038f 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/tree-header/WidgetHeader.css +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/tree-header/WidgetHeader.css @@ -13,7 +13,7 @@ display: flex; vertical-align: middle; padding: var(--iui-size-s) var(--iui-size-s) var(--iui-size-xs) var(--iui-size-s); - gap: var(--iui-size-s); + gap: var(--iui-size-s); .tw-content-header-selector { width: 100%; diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeDefinition.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeDefinition.ts index c7e11bad8b..3427b175c8 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeDefinition.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/CategoriesTreeDefinition.ts @@ -9,6 +9,7 @@ import { createNodesQueryClauseFactory, createPredicateBasedHierarchyDefinition, import { createBisInstanceLabelSelectClauseFactory, ECSql } from "@itwin/presentation-shared"; import { DEFINITION_CONTAINER_CLASS_NAME, + ELEMENT_CLASS_NAME, INFORMATION_PARTITION_ELEMENT_CLASS_NAME, MODEL_CLASS_NAME, SUB_CATEGORY_CLASS_NAME, @@ -511,8 +512,10 @@ export class CategoriesTreeDefinition implements HierarchyDefinition { 1, IFNULL(( SELECT 1 - FROM ${this._categoryElementClass} ce - WHERE ce.Parent.Id = this.ECInstanceId + FROM ${ELEMENT_CLASS_NAME} ce + WHERE + ce.Parent.Id = this.ECInstanceId + AND ce.ECClassId IS (${this._categoryElementClass}) LIMIT 1 ), 0) ) @@ -570,9 +573,14 @@ export class CategoriesTreeDefinition implements HierarchyDefinition { selector: ` IFNULL(( SELECT 1 - FROM ${this._categoryElementClass} ce + FROM ${ELEMENT_CLASS_NAME} ce JOIN ${MODEL_CLASS_NAME} m ON ce.Model.Id = m.ECInstanceId - WHERE ce.Parent.Id = this.ECInstanceId OR (ce.Model.Id = this.ECInstanceId AND m.IsPrivate = false) + WHERE + ce.ECClassId IS (${this._categoryElementClass}) + AND ( + ce.Parent.Id = this.ECInstanceId + OR (ce.Model.Id = this.ECInstanceId AND m.IsPrivate = false) + ) LIMIT 1 ), 0) `, diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeIdsCache.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeIdsCache.ts index fb458f0535..f0a0a381e4 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeIdsCache.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeIdsCache.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { DEFINITION_CONTAINER_CLASS_NAME, MODEL_CLASS_NAME, SUB_CATEGORY_CLASS_NAME } from "../../common/internal/ClassNameDefinitions.js"; -import { ModelCategoryElementsCountCache } from "../../common/internal/ModelCategoryElementsCountCache.js"; import { getClassesByView, getDistinctMapValues } from "../../common/internal/Utils.js"; +import { ModelCategoryElementsCountCache } from "../../common/internal/withoutParents/ModelCategoryElementsCountCache.js"; import type { CategoryId, DefinitionContainerId, ElementId, ModelId, SubCategoryId } from "../../common/internal/Types.js"; import type { Id64Array, Id64Set, Id64String } from "@itwin/core-bentley"; @@ -263,7 +263,7 @@ export class CategoriesTreeIdsCache implements Disposable { this._elementModelsCategories ??= (async () => { const [modelCategories, modelWithCategoryModeledElements] = await Promise.all([ (async () => { - const elementModelsCategories = new Map(); + const elementModelsCategories = new Map(); for await (const queriedCategory of this.queryElementModelCategories()) { let modelEntry = elementModelsCategories.get(queriedCategory.modelId); if (modelEntry === undefined) { @@ -276,7 +276,7 @@ export class CategoriesTreeIdsCache implements Disposable { })(), this.getModelWithCategoryModeledElements(), ]); - const result = new Map; isSubModel: boolean; }>(); + const result = new Map; isSubModel: boolean }>(); const subModels = getDistinctMapValues(modelWithCategoryModeledElements); for (const [modelId, modelEntry] of modelCategories) { const isSubModel = subModels.has(modelId); diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeVisibilityHandler.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeVisibilityHandler.ts index 4f8fe50176..24007bbcc0 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeVisibilityHandler.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/categories-tree/internal/CategoriesTreeVisibilityHandler.ts @@ -31,7 +31,6 @@ import { import { assert, Id64 } from "@itwin/core-bentley"; import { PerModelCategoryVisibility } from "@itwin/core-frontend"; import { HierarchyNode, HierarchyNodeKey } from "@itwin/presentation-hierarchies"; -import { AlwaysAndNeverDrawnElementInfo } from "../../common/internal/AlwaysAndNeverDrawnElementInfo.js"; import { toVoidPromise } from "../../common/internal/Rxjs.js"; import { createVisibilityStatus } from "../../common/internal/Tooltip.js"; import { getClassesByView, releaseMainThreadOnItemsCount, setDifference, setIntersection } from "../../common/internal/Utils.js"; @@ -47,11 +46,12 @@ import { getVisibilityFromAlwaysAndNeverDrawnElementsImpl, mergeVisibilityStatuses, } from "../../common/internal/VisibilityUtils.js"; +import { AlwaysAndNeverDrawnElementInfo } from "../../common/internal/withoutParents/AlwaysAndNeverDrawnElementInfo.js"; import { createVisibilityHandlerResult } from "../../common/UseHierarchyVisibility.js"; import { CategoriesTreeNode } from "./CategoriesTreeNode.js"; import { createFilteredTree, parseCategoryKey, parseSubCategoryKey } from "./FilteredTree.js"; -import type { CategoryAlwaysOrNeverDrawnElementsQueryProps } from "../../common/internal/AlwaysAndNeverDrawnElementInfo.js"; +import type { CategoryAlwaysOrNeverDrawnElementsQueryProps } from "../../common/internal/withoutParents/AlwaysAndNeverDrawnElementInfo.js"; import type { GetVisibilityFromAlwaysAndNeverDrawnElementsProps } from "../../common/internal/VisibilityUtils.js"; import type { Observable, Subscription } from "rxjs"; import type { Id64Arg, Id64Array, Id64Set, Id64String } from "@itwin/core-bentley"; @@ -242,7 +242,7 @@ class CategoriesTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler if (CategoriesTreeNode.isModelNode(node)) { return this.getModelVisibilityStatus({ - modelId: node.key.instanceKeys[0].id, + modelIds: node.key.instanceKeys.map((instanceKey) => instanceKey.id), }); } @@ -290,7 +290,7 @@ class CategoriesTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler } if (models?.size) { - observables.push(from(models).pipe(mergeMap((modelId) => this.getModelVisibilityStatus({ modelId })))); + observables.push(this.getModelVisibilityStatus({ modelIds: models })); } if (categories?.size) { @@ -337,27 +337,31 @@ class CategoriesTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler ); } - private getModelVisibilityStatus({ modelId }: { modelId: Id64String }): Observable { + private getModelVisibilityStatus({ modelIds }: { modelIds: Id64Arg }): Observable { const result = defer(() => { const viewport = this._props.viewport; + return from(Id64.iterable(modelIds)).pipe( + mergeMap((modelId) => { + if (!viewport.view.viewsModel(modelId)) { + return from(this._idsCache.getModelCategoryIds(modelId)).pipe( + mergeMap((categoryIds) => from(this._idsCache.getCategoriesModeledElements(modelId, categoryIds))), + getSubModeledElementsVisibilityStatus({ + parentNodeVisibilityStatus: createVisibilityStatus("hidden"), + getModelVisibilityStatus: (modelProps) => this.getModelVisibilityStatus(modelProps), + }), + ); + } - if (!viewport.view.viewsModel(modelId)) { - return from(this._idsCache.getModelCategoryIds(modelId)).pipe( - mergeMap((categoryIds) => from(this._idsCache.getCategoriesModeledElements(modelId, categoryIds))), - getSubModeledElementsVisibilityStatus({ - parentNodeVisibilityStatus: createVisibilityStatus("hidden"), - getModelVisibilityStatus: (modelProps) => this.getModelVisibilityStatus(modelProps), - }), - ); - } - - return from(this._idsCache.getModelCategoryIds(modelId)).pipe( - mergeAll(), - mergeMap((categoryId) => this.getCategoryDisplayStatus({ modelId, categoryIds: [categoryId], ignoreSubCategories: true })), + return from(this._idsCache.getModelCategoryIds(modelId)).pipe( + mergeAll(), + mergeMap((categoryId) => this.getCategoryDisplayStatus({ modelId, categoryIds: [categoryId], ignoreSubCategories: true })), + mergeVisibilityStatuses, + ); + }), mergeVisibilityStatuses, ); }); - return createVisibilityHandlerResult(this, { id: modelId }, result, undefined); + return createVisibilityHandlerResult(this, { id: modelIds }, result, undefined); } private getDefinitionContainerDisplayStatus(props: { definitionContainerIds: Id64Array }): Observable { @@ -652,7 +656,7 @@ class CategoriesTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler }); return from(this._idsCache.hasSubModel(elementId)).pipe( - mergeMap((hasSubModel) => (hasSubModel ? this.getModelVisibilityStatus({ modelId: elementId }) : of(undefined))), + mergeMap((hasSubModel) => (hasSubModel ? this.getModelVisibilityStatus({ modelIds: elementId }) : of(undefined))), map((subModelVisibilityStatus) => getElementVisibility( viewsModel, diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/components/EmptyTree.tsx b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/components/EmptyTree.tsx index 0aee5c9814..186c88f581 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/components/EmptyTree.tsx +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/components/EmptyTree.tsx @@ -79,8 +79,6 @@ export function UnknownInstanceFocusError({ base }: FilterEmptyTreeProps) { ); } - - interface EmptyTreeContentProps { icon?: string; } diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/Types.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/Types.ts index a1da6997a0..f673ed49df 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/Types.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/Types.ts @@ -22,3 +22,6 @@ export type DefinitionContainerId = Id64String; /** @internal */ export type ElementId = Id64String; + +/** @internal */ +export type ParentId = Id64String; diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/UseIModelAccess.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/UseIModelAccess.ts index 7a11b9764f..0830695d42 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/UseIModelAccess.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/UseIModelAccess.ts @@ -10,7 +10,7 @@ import { createCachingECClassHierarchyInspector } from "@itwin/presentation-shar import { TreeWidget } from "../../../../TreeWidget.js"; import { LOGGING_NAMESPACE } from "../Utils.js"; -import type { FunctionProps} from "../Utils.js"; +import type { FunctionProps } from "../Utils.js"; import type { IModelConnection } from "@itwin/core-frontend"; import type { SchemaContext } from "@itwin/ecschema-metadata"; import type { useIModelTree } from "@itwin/presentation-hierarchies-react"; @@ -18,31 +18,31 @@ import type { useIModelTree } from "@itwin/presentation-hierarchies-react"; type IModelAccess = FunctionProps["imodelAccess"]; export interface UseIModelAccessProps { - imodel: IModelConnection; - getSchemaContext: (imodel: IModelConnection) => SchemaContext; - treeName: string; - imodelAccess?: IModelAccess; - hierarchyLevelSizeLimit?: number; + imodel: IModelConnection; + getSchemaContext: (imodel: IModelConnection) => SchemaContext; + treeName: string; + imodelAccess?: IModelAccess; + hierarchyLevelSizeLimit?: number; } /** @internal */ -export function useIModelAccess({imodel, getSchemaContext, treeName, imodelAccess: providedIModelAccess, hierarchyLevelSizeLimit}: UseIModelAccessProps): { +export function useIModelAccess({ imodel, getSchemaContext, treeName, imodelAccess: providedIModelAccess, hierarchyLevelSizeLimit }: UseIModelAccessProps): { imodelAccess: IModelAccess; currentHierarchyLevelSizeLimit: number; } { - const defaultHierarchyLevelSizeLimit = hierarchyLevelSizeLimit ?? 1000; - const imodelAccess = useMemo(() => { - TreeWidget.logger.logInfo( - `${LOGGING_NAMESPACE}.${treeName}`, - `iModel changed, now using ${providedIModelAccess ? "provided imodel access" : `"${imodel.name}"`}`, - ); - return providedIModelAccess ?? createIModelAccess({ getSchemaContext, imodel, hierarchyLevelSizeLimit: defaultHierarchyLevelSizeLimit }); - }, [providedIModelAccess, getSchemaContext, imodel, treeName, defaultHierarchyLevelSizeLimit]); + const defaultHierarchyLevelSizeLimit = hierarchyLevelSizeLimit ?? 1000; + const imodelAccess = useMemo(() => { + TreeWidget.logger.logInfo( + `${LOGGING_NAMESPACE}.${treeName}`, + `iModel changed, now using ${providedIModelAccess ? "provided imodel access" : `"${imodel.name}"`}`, + ); + return providedIModelAccess ?? createIModelAccess({ getSchemaContext, imodel, hierarchyLevelSizeLimit: defaultHierarchyLevelSizeLimit }); + }, [providedIModelAccess, getSchemaContext, imodel, treeName, defaultHierarchyLevelSizeLimit]); - return { - imodelAccess, - currentHierarchyLevelSizeLimit: defaultHierarchyLevelSizeLimit, - } + return { + imodelAccess, + currentHierarchyLevelSizeLimit: defaultHierarchyLevelSizeLimit, + }; } function createIModelAccess({ diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/VisibilityUtils.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/VisibilityUtils.ts index 9937a79a4c..a9d6dff40e 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/VisibilityUtils.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/VisibilityUtils.ts @@ -3,7 +3,7 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { filter, from, map, mergeAll, mergeMap, of, reduce, startWith, toArray } from "rxjs"; +import { concat, EMPTY, filter, from, map, mergeAll, mergeMap, of, reduce, startWith, toArray } from "rxjs"; import { QueryRowFormat } from "@itwin/core-common"; import { PerModelCategoryVisibility } from "@itwin/core-frontend"; import { reduceWhile } from "./Rxjs.js"; @@ -12,10 +12,10 @@ import { getClassesByView, releaseMainThreadOnItemsCount } from "./Utils.js"; import type { Viewport } from "@itwin/core-frontend"; import type { Observable, OperatorFunction } from "rxjs"; -import type { Id64Array, Id64String } from "@itwin/core-bentley"; +import type { Id64Arg, Id64Array, Id64Set, Id64String } from "@itwin/core-bentley"; import type { NonPartialVisibilityStatus, Visibility } from "./Tooltip.js"; import type { VisibilityStatus } from "../UseHierarchyVisibility.js"; -import type { ElementId, ModelId } from "./Types.js"; +import type { CategoryId, ElementId, ModelId, ParentId } from "./Types.js"; import type { CategoryInfo } from "../CategoriesVisibilityUtils.js"; function mergeVisibilities(obs: Observable): Observable { @@ -52,13 +52,79 @@ export function mergeVisibilityStatuses(obs: Observable): Obse ); } +/** @internal */ +export function getChildrenDisplayStatus({ + parentNodeVisibilityStatus, + getCategoryDisplayStatus, +}: { + parentNodeVisibilityStatus: VisibilityStatus; + getCategoryDisplayStatus: ({ categoryIds, parentElementIds }: { categoryIds: Id64Set; parentElementIds?: Id64Arg }) => Observable; +}): OperatorFunction>, VisibilityStatus> { + return (obs) => { + return obs.pipe( + mergeMap((childCategoriesMap) => { + if (childCategoriesMap.size === 0) { + return of(parentNodeVisibilityStatus); + } + + return from(childCategoriesMap).pipe( + mergeMap(([elementId, categoryIds]) => getCategoryDisplayStatus({ categoryIds, parentElementIds: elementId })), + startWith(parentNodeVisibilityStatus), + mergeVisibilityStatuses, + ); + }), + ); + }; +} + +/** @internal */ +export function changeChildrenDisplayStatus({ + queueElementsVisibilityChange, + getDefaultCategoryVisibilityStatus, + createChangeSubModelsObservable, + parentsInfo, +}: { + queueElementsVisibilityChange: (elementIds: Id64Set, isDisplayedByDefault: boolean) => Observable; + getDefaultCategoryVisibilityStatus: (categoryId: Id64String) => NonPartialVisibilityStatus; + createChangeSubModelsObservable: (elementIds: Id64Array) => Observable; + parentsInfo?: { elementIds: Id64Set; parentsCategoryVisibility: "visible" | "hidden" }; +}): OperatorFunction>, void> { + return (obs) => { + return obs.pipe( + mergeMap((categoryElementsMap) => from(categoryElementsMap)), + reduce( + (acc, [categoryId, elementIds]) => { + const defaultCategoryVisibility = getDefaultCategoryVisibilityStatus(categoryId); + if (defaultCategoryVisibility.state === "visible") { + acc.alwaysDrawn.push(...elementIds); + } else { + acc.neverDrawn.push(...elementIds); + } + return acc; + }, + { + alwaysDrawn: parentsInfo && parentsInfo.parentsCategoryVisibility === "visible" ? [...parentsInfo.elementIds] : new Array(), + neverDrawn: parentsInfo && parentsInfo.parentsCategoryVisibility === "hidden" ? [...parentsInfo.elementIds] : new Array(), + }, + ), + mergeMap((state) => + concat( + state.alwaysDrawn.length > 0 ? queueElementsVisibilityChange(new Set(state.alwaysDrawn), true) : EMPTY, + state.neverDrawn.length > 0 ? queueElementsVisibilityChange(new Set(state.neverDrawn), false) : EMPTY, + createChangeSubModelsObservable([...state.alwaysDrawn, ...state.neverDrawn]), + ), + ), + ); + }; +} + /** @internal */ export function getSubModeledElementsVisibilityStatus({ parentNodeVisibilityStatus, getModelVisibilityStatus, }: { parentNodeVisibilityStatus: VisibilityStatus; - getModelVisibilityStatus: ({ modelId }: { modelId: Id64String }) => Observable; + getModelVisibilityStatus: ({ modelIds }: { modelIds: Id64Array }) => Observable; }): OperatorFunction { return (obs) => { return obs.pipe( @@ -67,11 +133,7 @@ export function getSubModeledElementsVisibilityStatus({ if (modeledElementIds.length === 0) { return of(parentNodeVisibilityStatus); } - return from(modeledElementIds).pipe( - mergeMap((modeledElementId) => getModelVisibilityStatus({ modelId: modeledElementId })), - startWith(parentNodeVisibilityStatus), - mergeVisibilityStatuses, - ); + return getModelVisibilityStatus({ modelIds: modeledElementIds }).pipe(startWith(parentNodeVisibilityStatus), mergeVisibilityStatuses); }), ); }; @@ -82,7 +144,7 @@ export function filterSubModeledElementIds({ doesSubModelExist, }: { doesSubModelExist: (elementId: Id64String) => Promise; -}): OperatorFunction, Array> { +}): OperatorFunction | Set, Array> { return (obs) => { return obs.pipe( mergeAll(), @@ -168,7 +230,7 @@ export function getVisibilityFromAlwaysAndNeverDrawnElementsImpl( } if (viewport.isAlwaysDrawnExclusive) { - return createVisibilityStatus(alwaysDrawn?.size ? "partial" : "hidden") + return createVisibilityStatus(alwaysDrawn?.size ? "partial" : "hidden"); } const status = props.defaultStatus(); @@ -198,14 +260,14 @@ export function getElementOverriddenVisibility(props: { elementId: Id64String; v /** @internal */ export interface GetVisibilityFromAlwaysAndNeverDrawnElementsProps { /** Status when always/never lists are empty and exclusive mode is off */ - defaultStatus: () => VisibilityStatus; + defaultStatus: (categoryId?: Id64String) => VisibilityStatus; } /** @internal */ export function getElementVisibility( viewsModel: boolean, overridenVisibility: NonPartialVisibilityStatus | undefined, - categoryVisibility: NonPartialVisibilityStatus, + categoryVisibility: VisibilityStatus, subModelVisibilityStatus?: VisibilityStatus, ): VisibilityStatus { if (subModelVisibilityStatus?.state === "partial") { diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/withParents/AlwaysAndNeverDrawnElementInfo.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/withParents/AlwaysAndNeverDrawnElementInfo.ts new file mode 100644 index 0000000000..e154f5e1ac --- /dev/null +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/withParents/AlwaysAndNeverDrawnElementInfo.ts @@ -0,0 +1,276 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { + BehaviorSubject, + debounceTime, + EMPTY, + filter, + first, + forkJoin, + from, + fromEventPattern, + map, + merge, + reduce, + scan, + share, + shareReplay, + startWith, + Subject, + switchMap, + take, + takeUntil, + tap, +} from "rxjs"; +import type { BeEvent, Id64Arg, Id64String } from "@itwin/core-bentley"; +import { Id64 } from "@itwin/core-bentley"; +import { createECSqlQueryExecutor } from "@itwin/presentation-core-interop"; +import { GEOMETRIC_ELEMENT_3D_CLASS_NAME } from "../ClassNameDefinitions.js"; +import { pushToMap, setDifference } from "../Utils.js"; + +import type { Observable, Subscription } from "rxjs"; +import type { Viewport } from "@itwin/core-frontend"; +import type { CategoryId, ElementId, ModelId, ParentId } from "../Types.js"; + +interface ElementInfo { + elementId: Id64String; + modelId: Id64String; + categoryId: Id64String; + parentElementId?: Id64String; +} + +type CacheEntry = Map>>>; + +/** @internal */ +export interface ModelAlwaysOrNeverDrawnElementsQueryProps { + modelId: Id64String; +} + +/** @internal */ +export interface CategoryAlwaysOrNeverDrawnElementsQueryProps { + modelId?: Id64String; + categoryIds: Id64Arg; + parentElementIds?: Id64Arg; +} + +/** @internal */ +export type AlwaysOrNeverDrawnElementsQueryProps = ModelAlwaysOrNeverDrawnElementsQueryProps | CategoryAlwaysOrNeverDrawnElementsQueryProps; + +/** @internal */ +export const SET_CHANGE_DEBOUNCE_TIME = 20; + +/** @internal */ +export class AlwaysAndNeverDrawnElementInfo implements Disposable { + private _subscriptions: Subscription[]; + private _alwaysDrawn: Observable; + private _neverDrawn: Observable; + private _disposeSubject = new Subject(); + + private _suppressors: Observable; + private _suppress = new Subject(); + private _forceUpdate = new Subject(); + + constructor( + private readonly _viewport: Viewport, + private readonly _elementClassName?: string, + ) { + this._alwaysDrawn = this.createCacheEntryObservable({ + event: this._viewport.onAlwaysDrawnChanged, + getSet: () => this._viewport.alwaysDrawn, + id: "alwaysDrawn", + }); + this._neverDrawn = this.createCacheEntryObservable({ + event: this._viewport.onNeverDrawnChanged, + getSet: () => this._viewport.neverDrawn, + id: "neverDrawn", + }); + this._suppressors = this._suppress.pipe( + scan((acc, suppress) => acc + (suppress ? 1 : -1), 0), + startWith(0), + shareReplay(1), + ); + this._subscriptions = [ + this._alwaysDrawn.subscribe(), + this._neverDrawn.subscribe(), + this._suppressors.pipe(filter((suppressors) => suppressors === 0)).subscribe({ + next: () => this._forceUpdate.next(), + }), + ]; + } + + public suppressChangeEvents() { + this._suppress.next(true); + } + + public resumeChangeEvents() { + this._suppress.next(false); + } + + public getElements(props: { setType: "always" | "never" } & AlwaysOrNeverDrawnElementsQueryProps): Observable> { + const cache = props.setType === "always" ? this._alwaysDrawn : this._neverDrawn; + const getElements = + "categoryIds" in props + ? (entry: CacheEntry | undefined): Set => { + if (!entry) { + return new Set(); + } + const result = new Set(); + const { modelId, parentElementIds, categoryIds } = props; + + const parentElementMaps = modelId ? [entry.get(modelId)] : entry.values(); + for (const parentElementMap of parentElementMaps) { + if (!parentElementMap) { + continue; + } + for (const parentElementId of parentElementIds ? Id64.iterable(parentElementIds) : [undefined]) { + const parentEntry = parentElementMap.get(parentElementId); + if (!parentEntry) { + continue; + } + for (const categoryId of Id64.iterable(categoryIds)) { + const categoryElements = parentEntry.get(categoryId); + categoryElements?.forEach((element) => result.add(element)); + } + } + } + + return result; + } + : (entry: CacheEntry | undefined) => { + const parentElementMap = entry?.get(props.modelId); + const elements = new Set(); + parentElementMap?.forEach((categoriesMap) => { + categoriesMap.forEach((elementIds) => elementIds.forEach((id) => elements.add(id))); + }); + return elements; + }; + + return cache.pipe(map(getElements)); + } + + private createCacheEntryObservable(props: { event: BeEvent<() => void>; getSet(): Set | undefined; id: string }) { + const event = props.event; + const resultSubject = new BehaviorSubject(undefined); + + const obs = merge( + fromEventPattern( + (handler) => event.addListener(handler), + (handler) => event.removeListener(handler), + ), + this._forceUpdate, + ).pipe( + // Fire the observable once at the beginning + startWith(undefined), + // Stop listening to events when dispose() is called + takeUntil(this._disposeSubject), + // Reset result subject as soon as a new event is emitted. + // This will make newly subscribed observers wait for the debounce period to pass + // instead of consuming the cached value which at this point becomes invalid. + tap(() => resultSubject.next(undefined)), + // Check if cache updates are not suppressed. + switchMap(() => + this._suppressors.pipe( + filter((suppressors) => suppressors === 0), + take(1), + ), + ), + debounceTime(SET_CHANGE_DEBOUNCE_TIME), + // Cancel pending request if dispose() is called. + takeUntil(this._disposeSubject), + // If multiple requests are sent at once, preserve only the result of the newest. + switchMap(() => this.queryAlwaysOrNeverDrawnElementInfo(props.getSet(), props.id)), + // Share the result by using a subject which always emits the saved result. + share({ + connector: () => resultSubject, + resetOnRefCountZero: false, + }), + // Wait until the result is available. + first((x): x is CacheEntry => !!x), + ); + return obs; + } + + public [Symbol.dispose]() { + this._subscriptions.forEach((x) => x.unsubscribe()); + this._subscriptions = []; + this._disposeSubject.next(); + } + + private queryAlwaysOrNeverDrawnElementInfo(set: Set | undefined, requestId: string): Observable { + const elementInfo = set?.size ? this.queryElementInfo([...set], requestId) : EMPTY; + return elementInfo.pipe( + reduce((state, { categoryId, modelId, elementId, parentElementId }) => { + let parentElementMap = state.get(modelId); + if (!parentElementMap) { + parentElementMap = new Map(); + state.set(modelId, parentElementMap); + } + let categoryMap = parentElementMap.get(parentElementId); + if (!categoryMap) { + categoryMap = new Map(); + parentElementMap.set(parentElementId, categoryMap); + } + + pushToMap(categoryMap, categoryId, elementId); + return state; + }, new Map>>>()), + ); + } + + private queryElementInfo(elementIds: Array, requestId: string): Observable { + const executor = createECSqlQueryExecutor(this._viewport.iModel); + const reader = executor.createQueryReader( + { + ecsql: ` + SELECT + ECInstanceId elementId, + Model.Id modelId, + Category.Id categoryId, + Parent.Id parentElementId + FROM ${GEOMETRIC_ELEMENT_3D_CLASS_NAME} + WHERE InVirtualSet(?, ECInstanceId) + `, + bindings: [{ type: "idset", value: elementIds }], + }, + { + restartToken: `ModelsTreeVisibilityHandler/${requestId}`, + }, + ); + + return from(reader).pipe( + map((row) => ({ + elementId: row.elementId, + modelId: row.modelId, + categoryId: row.categoryId, + parentElementId: row.parentElementId ? row.parentElementId : undefined, + })), + ); + } + public getAlwaysDrawnElements(props: AlwaysOrNeverDrawnElementsQueryProps) { + return this.getElements({ ...props, setType: "always" }); + } + + public getNeverDrawnElements(props: AlwaysOrNeverDrawnElementsQueryProps) { + return this.getElements({ ...props, setType: "never" }); + } + + public clearAlwaysAndNeverDrawnElements(props: AlwaysOrNeverDrawnElementsQueryProps) { + return forkJoin({ + alwaysDrawn: this.getAlwaysDrawnElements(props), + neverDrawn: this.getNeverDrawnElements(props), + }).pipe( + map(({ alwaysDrawn, neverDrawn }) => { + const viewport = this._viewport; + if (viewport.alwaysDrawn?.size && alwaysDrawn.size) { + viewport.setAlwaysDrawn(setDifference(viewport.alwaysDrawn, alwaysDrawn)); + } + if (viewport.neverDrawn?.size && neverDrawn.size) { + viewport.setNeverDrawn(setDifference(viewport.neverDrawn, neverDrawn)); + } + }), + ); + } +} diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/withParents/ModelCategoryElementsCountCache.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/withParents/ModelCategoryElementsCountCache.ts new file mode 100644 index 0000000000..ef7f60a9ec --- /dev/null +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/withParents/ModelCategoryElementsCountCache.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { bufferTime, filter, firstValueFrom, mergeAll, mergeMap, ReplaySubject, Subject } from "rxjs"; +import { assert } from "@itwin/core-bentley"; + +import type { Id64Array, Id64String } from "@itwin/core-bentley"; +import type { Subscription } from "rxjs"; +import type { LimitingECSqlQueryExecutor } from "@itwin/presentation-hierarchies"; +import type { CategoryId, ModelId, ParentId } from "../Types.js"; + +type ModelParentCategoryKey = `${ModelId}-${ParentId}-${CategoryId}`; + +/** @internal */ +export class ModelCategoryElementsCountCache implements Disposable { + private _cache = new Map>(); + private _requestsStream = new Subject<{ modelId: Id64String; categoryId: Id64String; parentElementIds?: Id64Array }>(); + private _subscription: Subscription; + + public constructor( + private _queryExecutor: LimitingECSqlQueryExecutor, + private _elementsClassName: string, + ) { + this._subscription = this._requestsStream + .pipe( + bufferTime(20), + filter((requests) => requests.length > 0), + mergeMap(async (requests) => this.queryCategoryElementCounts(requests)), + mergeAll(), + ) + .subscribe({ + next: ([key, elementsCount]) => { + const subject = this._cache.get(key); + assert(!!subject); + subject.next(elementsCount); + }, + }); + } + + private async queryCategoryElementCounts( + input: Array<{ modelId: Id64String; categoryId: Id64String; parentElementIds?: Id64Array }>, + ): Promise> { + const result = new Map(); + if (input.length === 0) { + return result; + } + const reader = this._queryExecutor.createQueryReader( + { + ecsql: ` + SELECT COUNT(*) elementsCount, e.Category.Id categoryId, e.Model.Id modelId, e.Parent.Id parentId + FROM ${this._elementsClassName} e + WHERE + ${input + .map( + ({ modelId, categoryId, parentElementIds }) => `( + e.Parent.Id ${parentElementIds ? `IN (${parentElementIds.join(", ")})` : "IS NULL"} + AND e.Model.Id = ${modelId} + AND e.Category.Id = ${categoryId} + )`, + ) + .join(" OR ")} + GROUP BY e.Model.Id, e.Category.Id, e.Parent.Id + `, + }, + { rowFormat: "ECSqlPropertyNames", limit: "unbounded" }, + ); + + for await (const row of reader) { + const key: ModelParentCategoryKey = `${row.modelId}-${row.parentId ?? ""}-${row.categoryId}`; + result.set(key, row.elementsCount); + } + return result; + } + + public [Symbol.dispose]() { + this._subscription.unsubscribe(); + } + + public async getCategoryElementsCount(modelId: Id64String, categoryId: Id64String, parentElementIds?: Id64Array): Promise { + let cacheKey: ModelParentCategoryKey = `${modelId}--${categoryId}`; + let result: Subject | undefined; + for (const parentElementId of parentElementIds ?? [undefined]) { + cacheKey = `${modelId}-${parentElementId ?? ""}-${categoryId}`; + result = this._cache.get(cacheKey); + if (result !== undefined) { + return firstValueFrom(result); + } + } + + result = new ReplaySubject(1); + this._cache.set(cacheKey, result); + this._requestsStream.next({ modelId, categoryId, parentElementIds }); + return firstValueFrom(result); + } +} diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/AlwaysAndNeverDrawnElementInfo.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/withoutParents/AlwaysAndNeverDrawnElementInfo.ts similarity index 98% rename from packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/AlwaysAndNeverDrawnElementInfo.ts rename to packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/withoutParents/AlwaysAndNeverDrawnElementInfo.ts index 79deebf6c1..06a4e5040d 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/AlwaysAndNeverDrawnElementInfo.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/withoutParents/AlwaysAndNeverDrawnElementInfo.ts @@ -26,12 +26,12 @@ import { tap, } from "rxjs"; import { createECSqlQueryExecutor } from "@itwin/presentation-core-interop"; -import { getClassesByView, pushToMap, setDifference } from "./Utils.js"; +import { getClassesByView, pushToMap, setDifference } from "../Utils.js"; import type { Observable, Subscription } from "rxjs"; import type { BeEvent, Id64Array, Id64String } from "@itwin/core-bentley"; import type { Viewport } from "@itwin/core-frontend"; -import type { CategoryId, ElementId, ModelId } from "./Types.js"; +import type { CategoryId, ElementId, ModelId } from "../Types.js"; interface ElementInfo { elementId: Id64String; diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/ModelCategoryElementsCountCache.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/withoutParents/ModelCategoryElementsCountCache.ts similarity index 98% rename from packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/ModelCategoryElementsCountCache.ts rename to packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/withoutParents/ModelCategoryElementsCountCache.ts index e51192cd20..64e2245f87 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/ModelCategoryElementsCountCache.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/common/internal/withoutParents/ModelCategoryElementsCountCache.ts @@ -9,7 +9,7 @@ import { assert } from "@itwin/core-bentley"; import type { Id64String } from "@itwin/core-bentley"; import type { Subscription } from "rxjs"; import type { LimitingECSqlQueryExecutor } from "@itwin/presentation-hierarchies"; -import type { CategoryId, ModelId } from "./Types.js"; +import type { CategoryId, ModelId } from "../Types.js"; type ModelCategoryKey = `${ModelId}-${CategoryId}`; diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/imodel-content-tree/IModelContentTree.tsx b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/imodel-content-tree/IModelContentTree.tsx index dd30ad568c..f7d07bd0e2 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/imodel-content-tree/IModelContentTree.tsx +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/imodel-content-tree/IModelContentTree.tsx @@ -24,7 +24,6 @@ import type { PresentationHierarchyNode } from "@itwin/presentation-hierarchies- import type { BaseTreeRendererProps } from "../common/components/BaseTreeRenderer.js"; import type { TreeProps } from "../common/components/Tree.js"; - /** @beta */ export type IModelContentTreeProps = Pick & Pick & { diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/ModelsTreeDefinition.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/ModelsTreeDefinition.ts index 3a69d4b38c..443e20bf2f 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/ModelsTreeDefinition.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/ModelsTreeDefinition.ts @@ -3,7 +3,7 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { bufferCount, defer, firstValueFrom, from, lastValueFrom, map, merge, mergeAll, mergeMap, reduce, switchMap } from "rxjs"; +import { bufferCount, defer, EMPTY, firstValueFrom, from, lastValueFrom, map, merge, mergeAll, mergeMap, of, reduce, switchMap } from "rxjs"; import { IModel } from "@itwin/core-common"; import { createNodesQueryClauseFactory, @@ -25,9 +25,10 @@ import { import { collect } from "../common/internal/Rxjs.js"; import { createIdsSelector, parseIdsSelectorResult, releaseMainThreadOnItemsCount } from "../common/internal/Utils.js"; import { FilterLimitExceededError } from "../common/TreeErrors.js"; +import { ModelsTreeNode } from "./internal/ModelsTreeNode.js"; import type { Id64String } from "@itwin/core-bentley"; -import type { Observable } from "rxjs"; +import type { Observable, OperatorFunction } from "rxjs"; import type { ECClassHierarchyInspector, ECSchemaProvider, @@ -301,8 +302,10 @@ export class ModelsTreeDefinition implements HierarchyDefinition { selector: ` IFNULL(( SELECT 1 - FROM ${this._hierarchyConfig.elementClassSpecification} e - WHERE e.Model.Id = m.ECInstanceId + FROM ${ELEMENT_CLASS_NAME} e + WHERE + e.Model.Id = m.ECInstanceId + AND e.ECClassId IS (${this._hierarchyConfig.elementClassSpecification}) LIMIT 1 ), 0) `, @@ -424,7 +427,9 @@ export class ModelsTreeDefinition implements HierarchyDefinition { }); const modeledElements = await firstValueFrom( from(modelIds).pipe( - mergeMap(async (modelId) => this._idsCache.getCategoriesModeledElements(modelId, categoryIds)), + mergeMap(async (modelId) => + this._idsCache.getCategoriesModeledElements({ modelId, categoryIds, parentIds: parentNode.extendedData?.parentElementIds, includeNested: false }), + ), reduce((acc, foundModeledElements) => { return acc.concat(foundModeledElements); }, new Array()), @@ -455,8 +460,10 @@ export class ModelsTreeDefinition implements HierarchyDefinition { 1, IFNULL(( SELECT 1 - FROM ${this._hierarchyConfig.elementClassSpecification} ce - WHERE ce.Parent.Id = this.ECInstanceId + FROM ${ELEMENT_CLASS_NAME} ce + WHERE + ce.Parent.Id = this.ECInstanceId + AND ce.ECClassId IS (${this._hierarchyConfig.elementClassSpecification}) LIMIT 1 ), 0) ) @@ -474,7 +481,7 @@ export class ModelsTreeDefinition implements HierarchyDefinition { WHERE this.Category.Id IN (${categoryIds.map(() => "?").join(",")}) AND this.Model.Id IN (${modelIds.map(() => "?").join(",")}) - AND this.Parent.Id IS NULL + AND this.Parent.Id ${parentNode.extendedData?.parentElementIds ? `IN (${parseIdsSelectorResult(parentNode.extendedData.parentElementIds).join(", ")})` : "IS NULL"} ${instanceFilterClauses.where ? `AND ${instanceFilterClauses.where}` : ""} `, bindings: [...categoryIds.map((id) => ({ type: "id", value: id })), ...modelIds.map((id) => ({ type: "id", value: id }))] as ECSqlBinding[], @@ -486,12 +493,58 @@ export class ModelsTreeDefinition implements HierarchyDefinition { private async createGeometricElement3dChildrenQuery({ parentNodeInstanceIds: elementIds, instanceFilter, + parentNode, }: DefineInstanceNodeChildHierarchyLevelProps): Promise { - const instanceFilterClauses = await this._selectQueryFactory.createFilterClauses({ + const parentsCategory = ModelsTreeNode.getCategoryId(parentNode); + + if (!parentsCategory) { + throw new Error(`Invalid geometric element node "${parentNode.label}" - missing category information.`); + } + const categoryInstanceFilterClauses = await this._selectQueryFactory.createFilterClauses({ + filter: instanceFilter, + contentClass: { fullName: SPATIAL_CATEGORY_CLASS_NAME, alias: "this" }, + }); + const elementInstanceFilterClauses = await this._selectQueryFactory.createFilterClauses({ filter: instanceFilter, contentClass: { fullName: this._hierarchyConfig.elementClassSpecification, alias: "this" }, }); + return [ + { + fullClassName: SPATIAL_CATEGORY_CLASS_NAME, + query: { + ecsql: ` + SELECT + ${await this._selectQueryFactory.createSelectClause({ + ecClassId: { selector: "this.ECClassId" }, + ecInstanceId: { selector: "this.ECInstanceId" }, + nodeLabel: { + selector: await this._nodeLabelSelectClauseFactory.createSelectClause({ + classAlias: "this", + className: SPATIAL_CATEGORY_CLASS_NAME, + }), + }, + grouping: { byLabel: { action: "merge", groupId: "category" } }, + hasChildren: true, + extendedData: { + imageId: "icon-layers", + isCategory: true, + modelIds: { selector: "json_array(CAST(IdToHex(el.Model.Id) AS TEXT))" }, + parentElementIds: { selector: createIdsSelector(elementIds) }, + }, + supportsFiltering: true, + })} + FROM ${categoryInstanceFilterClauses.from} this + JOIN ${this._hierarchyConfig.elementClassSpecification} el ON el.Category.Id = this.ECInstanceId + ${categoryInstanceFilterClauses.joins} + WHERE + el.Parent.Id IN (${elementIds.join(", ")}) + AND this.ECInstanceId != ${parentsCategory} + ${categoryInstanceFilterClauses.where ? `AND ${categoryInstanceFilterClauses.where}` : ""} + GROUP BY this.ECInstanceId + `, + }, + }, { fullClassName: this._hierarchyConfig.elementClassSpecification, query: { @@ -513,9 +566,14 @@ export class ModelsTreeDefinition implements HierarchyDefinition { selector: ` IFNULL(( SELECT 1 - FROM ${this._hierarchyConfig.elementClassSpecification} ce + FROM ${ELEMENT_CLASS_NAME} ce JOIN ${MODEL_CLASS_NAME} m ON ce.Model.Id = m.ECInstanceId - WHERE ce.Parent.Id = this.ECInstanceId OR (ce.Model.Id = this.ECInstanceId AND m.IsPrivate = false) + WHERE + ce.ECClassId IS (${this._hierarchyConfig.elementClassSpecification}) + AND ( + ce.Parent.Id = this.ECInstanceId + OR (ce.Model.Id = this.ECInstanceId AND m.IsPrivate = false) + ) LIMIT 1 ), 0) `, @@ -527,11 +585,12 @@ export class ModelsTreeDefinition implements HierarchyDefinition { }, supportsFiltering: this.supportsFiltering(), })} - FROM ${instanceFilterClauses.from} this - ${instanceFilterClauses.joins} + FROM ${elementInstanceFilterClauses.from} this + ${elementInstanceFilterClauses.joins} WHERE this.Parent.Id IN (${elementIds.map(() => "?").join(",")}) - ${instanceFilterClauses.where ? `AND ${instanceFilterClauses.where}` : ""} + AND this.Category.Id = ${parentsCategory} + ${elementInstanceFilterClauses.where ? `AND ${elementInstanceFilterClauses.where}` : ""} `, bindings: elementIds.map((id) => ({ type: "id", value: id })), }, @@ -613,11 +672,12 @@ function createGeometricElementInstanceKeyPaths( `InstanceElementsWithClassGroupingNodes(ECInstanceId, ECClassId, ParentId, ModelId, CategoryId, GroupingNodeIndex) AS ( ${[...(targetElementsInfoQuery ? [targetElementsInfoQuery] : []), ...targetGroupingNodesElementInfoQueries].join(" UNION ALL ")} )`, - `ModelsCategoriesElementsHierarchy(ECInstanceId, ParentId, ModelId, GroupingNodeIndex, Path) AS ( + `ModelsCategoriesElementsHierarchy(ECInstanceId, ParentId, ModelId, CategoryId, GroupingNodeIndex, Path) AS ( SELECT e.ECInstanceId, e.ParentId, e.ModelId, + e.CategoryId, e.GroupingNodeIndex, IIF(e.ParentId IS NULL, 'm${separator}' || CAST(IdToHex([m].[ECInstanceId]) AS TEXT) || '${separator}c${separator}' || CAST(IdToHex([c].[ECInstanceId]) AS TEXT) || '${separator}e${separator}' || CAST(IdToHex([e].[ECInstanceId]) AS TEXT), @@ -634,10 +694,11 @@ function createGeometricElementInstanceKeyPaths( pe.ECInstanceId, pe.Parent.Id, pe.Model.Id, + pe.Category.Id, ce.GroupingNodeIndex, IIF(pe.Parent.Id IS NULL, - 'm${separator}' || CAST(IdToHex([m].[ECInstanceId]) AS TEXT) || '${separator}c${separator}' || CAST(IdToHex([c].[ECInstanceId]) AS TEXT) || '${separator}e${separator}' || CAST(IdToHex([pe].[ECInstanceId]) AS TEXT) || '${separator}' || ce.Path, - 'e${separator}' || CAST(IdToHex([pe].[ECInstanceId]) AS TEXT) || '${separator}' || ce.Path + 'm${separator}' || CAST(IdToHex([m].[ECInstanceId]) AS TEXT) || '${separator}c${separator}' || CAST(IdToHex([c].[ECInstanceId]) AS TEXT) || '${separator}e${separator}' || CAST(IdToHex([pe].[ECInstanceId]) AS TEXT) || '${separator}' || IIF(ce.ParentId IS NOT NULL AND ce.CategoryId != pe.Category.Id, 'c${separator}' || CAST(IdToHex(ce.CategoryId) AS TEXT) || '${separator}', '') || ce.Path, + 'e${separator}' || CAST(IdToHex([pe].[ECInstanceId]) AS TEXT) || '${separator}' || IIF(ce.ParentId IS NOT NULL AND ce.CategoryId != pe.Category.Id, 'c${separator}' || CAST(IdToHex(ce.CategoryId) AS TEXT) || '${separator}', '') || ce.Path ) FROM ModelsCategoriesElementsHierarchy ce JOIN ${hierarchyConfig.elementClassSpecification} pe ON (pe.ECInstanceId = ce.ParentId OR pe.ECInstanceId = ce.ModelId AND ce.ParentId IS NULL) @@ -646,43 +707,144 @@ function createGeometricElementInstanceKeyPaths( )`, ]; const ecsql = ` - SELECT mce.ModelId, mce.Path, mce.GroupingNodeIndex + SELECT + mce.ModelId modelId, + mce.Path path, + mce.GroupingNodeIndex groupingNodeIndex FROM ModelsCategoriesElementsHierarchy mce WHERE mce.ParentId IS NULL `; - return imodelAccess.createQueryReader({ ctes, ecsql }, { rowFormat: "Indexes", limit: "unbounded" }); + return imodelAccess.createQueryReader({ ctes, ecsql }, { rowFormat: "ECSqlPropertyNames", limit: "unbounded" }); }).pipe( releaseMainThreadOnItemsCount(300), - map((row) => parseQueryRow(row, groupInfos, separator, hierarchyConfig.elementClassSpecification)), - mergeMap(({ modelId, elementHierarchyPath, groupingNode }) => - from(idsCache.createModelInstanceKeyPaths(modelId)).pipe( - mergeAll(), - map((modelPath) => { - // We dont want to modify the original path, we create a copy that we can modify - const newModelPath = [...modelPath]; - newModelPath.pop(); // model is already included in the element hierarchy path - const path = [...newModelPath, ...elementHierarchyPath]; - if (!groupingNode) { - return path; - } - return { - path, - options: { - autoExpand: { - key: groupingNode.key, - depth: groupingNode.parentKeys.length, + createInstanceKeyPathsFromECSqlRow({ groupInfos, separator, elementClassName: hierarchyConfig.elementClassSpecification, idsCache }), + ); +} + +function createCategoryInstanceKeyPaths( + imodelAccess: ECClassHierarchyInspector & LimitingECSqlQueryExecutor, + idsCache: ModelsTreeIdsCache, + hierarchyConfig: ModelsTreeHierarchyConfiguration, + targetItems: Array, +): Observable { + if (targetItems.length === 0) { + return EMPTY; + } + + const separator = ";"; + + return defer(() => { + const ctes = [ + `CategoriesElements(ECInstanceId, ParentId, CategoryId, ModelId) AS ( + SELECT + e.ECInstanceId, + e.Parent.Id, + e.Category.Id, + e.Model.Id + FROM ${hierarchyConfig.elementClassSpecification} e + WHERE e.Category.Id IN (${targetItems.join(", ")}) + )`, + `ChildrenCategoriesElementsHierarchy(ECInstanceId, ParentId, ModelId, CategoryId, Path) AS ( + SELECT + e.ECInstanceId, + e.Parent.Id, + e.Model.Id, + e.Category.Id, + 'e${separator}' || CAST(IdToHex([e].[ECInstanceId]) AS TEXT) || '${separator}c${separator}' || CAST(IdToHex([c].[ECInstanceId]) AS TEXT) + + FROM CategoriesElements ce + JOIN ${hierarchyConfig.elementClassSpecification} e ON (ce.ParentId = e.ECInstanceId AND ce.CategoryId != e.Category.Id) + LEFT JOIN ${SPATIAL_CATEGORY_CLASS_NAME} c ON c.ECInstanceId = ce.CategoryId + WHERE ce.ParentId IS NOT NULL + + UNION ALL + + SELECT + pe.ECInstanceId, + pe.Parent.Id, + pe.Model.Id, + pe.Category.Id, + IIF(pe.Category.Id = cce.CategoryId, + 'e${separator}' || CAST(IdToHex([pe].[ECInstanceId]) AS TEXT) || '${separator}' || cce.Path, + 'e${separator}' || CAST(IdToHex([pe].[ECInstanceId]) AS TEXT) || '${separator}c${separator}' || CAST(IdToHex([c].[ECInstanceId]) AS TEXT) || '${separator}' || cce.Path + ) + FROM ChildrenCategoriesElementsHierarchy cce + JOIN ${hierarchyConfig.elementClassSpecification} pe ON pe.ECInstanceId = cce.ParentId + LEFT JOIN ${SPATIAL_CATEGORY_CLASS_NAME} c ON c.ECInstanceId = cce.CategoryId + )`, + ]; + const ecsql = ` + SELECT + cce.ModelId modelId, + ('m${separator}' || CAST(IdToHex([m].[ECInstanceId]) AS TEXT) || '${separator}c${separator}' || CAST(IdToHex([c].[ECInstanceId]) AS TEXT) || '${separator}' || cce.Path) path + FROM ChildrenCategoriesElementsHierarchy cce + JOIN ${GEOMETRIC_MODEL_3D_CLASS_NAME} m ON m.ECInstanceId = cce.ModelId + JOIN ${SPATIAL_CATEGORY_CLASS_NAME} c ON c.ECInstanceId = cce.CategoryId + WHERE cce.ParentId IS NULL + + UNION ALL + SELECT + ce.ModelId modelId, + ('m${separator}' || CAST(IdToHex([m].[ECInstanceId]) AS TEXT) || '${separator}c${separator}' || CAST(IdToHex([c].[ECInstanceId]) AS TEXT)) path + FROM CategoriesElements ce + JOIN ${GEOMETRIC_MODEL_3D_CLASS_NAME} m ON m.ECInstanceId = ce.ModelId + JOIN ${SPATIAL_CATEGORY_CLASS_NAME} c ON c.ECInstanceId = ce.CategoryId + WHERE ce.ParentId IS NULL + GROUP BY ce.CategoryId + `; + + return imodelAccess.createQueryReader({ ctes, ecsql }, { rowFormat: "ECSqlPropertyNames", limit: "unbounded" }); + }).pipe( + releaseMainThreadOnItemsCount(300), + createInstanceKeyPathsFromECSqlRow({ idsCache, separator, elementClassName: hierarchyConfig.elementClassSpecification }), + ); +} + +function createInstanceKeyPathsFromECSqlRow({ + idsCache, + separator, + elementClassName, + groupInfos, +}: { + idsCache: ModelsTreeIdsCache; + separator: string; + elementClassName: string; + groupInfos?: ElementsGroupInfo[]; +}): OperatorFunction { + return (obs) => { + return from(obs).pipe( + map((row) => parseQueryRow({ row, separator, elementClassName, groupInfos })), + mergeMap(({ modelId, instanceKeyPath, groupingNode }) => + from(idsCache.createModelInstanceKeyPaths(modelId)).pipe( + mergeAll(), + map((modelPath) => { + // We dont want to modify the original path, we create a copy that we can modify + const newModelPath = [...modelPath]; + newModelPath.pop(); // model is already included in the element hierarchy path + const path = [...newModelPath, ...instanceKeyPath]; + if (!groupingNode) { + return path; + } + return { + path, + options: { + autoExpand: { + key: groupingNode.key, + depth: groupingNode.parentKeys.length, + }, }, - }, - }; - }), + }; + }), + ), ), - ), - ); + ); + }; } -function parseQueryRow(row: ECSqlQueryRow, groupInfos: ElementsGroupInfo[], separator: string, elementClassName: string) { - const rowElements: string[] = row[1].split(separator); +function parseQueryRow(props: { row: ECSqlQueryRow; groupInfos?: ElementsGroupInfo[]; separator: string; elementClassName: string }) { + const { row, groupInfos, separator, elementClassName } = props; + const rowElements: string[] = row.path.split(separator); const path = new Array(); for (let i = 0; i < rowElements.length; i += 2) { switch (rowElements[i]) { @@ -698,9 +860,9 @@ function parseQueryRow(row: ECSqlQueryRow, groupInfos: ElementsGroupInfo[], sepa } } return { - modelId: row[0], - elementHierarchyPath: path, - groupingNode: row[2] === -1 ? undefined : groupInfos[row[2]].groupingNode, + modelId: row.modelId, + instanceKeyPath: path, + groupingNode: row.groupingNodeIndex === -1 || row.groupingNodeIndex === undefined ? undefined : groupInfos?.[row.groupingNodeIndex].groupingNode, }; } @@ -767,7 +929,7 @@ async function createInstanceKeyPathsFromTargetItems({ merge( from(ids.subjectIds).pipe(mergeMap((id) => from(idsCache.createSubjectInstanceKeysPath(id)))), from(ids.modelIds).pipe(mergeMap((id) => from(idsCache.createModelInstanceKeyPaths(id)).pipe(mergeAll()))), - from(ids.categoryIds).pipe(mergeMap((id) => from(idsCache.createCategoryInstanceKeyPaths(id)).pipe(mergeAll()))), + of(ids.categoryIds).pipe(mergeMap((categoryIds) => createCategoryInstanceKeyPaths(imodelAccess, idsCache, hierarchyConfig, categoryIds))), from(ids.elementIds).pipe( bufferCount(Math.ceil(elementsLength / Math.ceil(elementsLength / 5000))), releaseMainThreadOnItemsCount(1), diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/FilteredTree.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/FilteredTree.ts index 746d161052..0645ff0cbb 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/FilteredTree.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/FilteredTree.ts @@ -10,7 +10,7 @@ import { CATEGORY_CLASS_NAME, MODEL_CLASS_NAME, SUBJECT_CLASS_NAME } from "../.. import type { Id64Set, Id64String } from "@itwin/core-bentley"; import type { HierarchyNode } from "@itwin/presentation-hierarchies"; import type { ECClassHierarchyInspector, InstanceKey } from "@itwin/presentation-shared"; -import type { CategoryId, ElementId, ModelId } from "../../common/internal/Types.js"; +import type { CategoryId, ElementId, ModelId, ParentId } from "../../common/internal/Types.js"; interface FilteredTreeRootNode { children: Map; @@ -29,12 +29,14 @@ interface GenericFilteredTreeNode extends BaseFilteredTreeNode { interface CategoryFilteredTreeNode extends BaseFilteredTreeNode { type: "category"; modelId: Id64String; + parentId: ElementId | undefined; } interface ElementFilteredTreeNode extends BaseFilteredTreeNode { type: "element"; modelId: Id64String; categoryId: Id64String; + parentId: ElementId | undefined; } type FilteredTreeNode = GenericFilteredTreeNode | CategoryFilteredTreeNode | ElementFilteredTreeNode; @@ -43,16 +45,15 @@ export interface FilteredTree { getVisibilityChangeTargets(node: HierarchyNode): VisibilityChangeTargets; } -type CategoryKey = `${ModelId}-${CategoryId}`; +type CategoryKey = `${ModelId}-${ParentId}-${CategoryId}`; -function createCategoryKey(modelId: Id64String, categoryId: Id64String): CategoryKey { - return `${modelId}-${categoryId}`; +function createCategoryKey(modelId: Id64String, categoryId: Id64String, parentId: ElementId | undefined): CategoryKey { + return `${modelId}-${parentId ?? ""}-${categoryId}`; } -/** @internal */ export function parseCategoryKey(key: CategoryKey) { - const [modelId, categoryId] = key.split("-"); - return { modelId, categoryId }; + const [modelId, parentId, categoryId] = key.split("-"); + return { modelId, categoryId, parentId: parentId !== "" ? parentId : undefined }; } interface VisibilityChangeTargets { @@ -173,10 +174,10 @@ function addTarget(filterTargets: VisibilityChangeTargets, node: FilteredTreeNod (filterTargets.modelIds ??= new Set()).add(node.id); return; case "category": - (filterTargets.categories ??= new Set()).add(createCategoryKey(node.modelId, node.id)); + (filterTargets.categories ??= new Set()).add(createCategoryKey(node.modelId, node.id, node.parentId)); return; case "element": - const categoryKey = createCategoryKey(node.modelId, node.categoryId); + const categoryKey = createCategoryKey(node.modelId, node.categoryId, node.parentId); const elements = (filterTargets.elements ??= new Map()).get(categoryKey); if (elements) { elements.add(node.id); @@ -207,12 +208,13 @@ function createFilteredTreeNode({ } if (type === "category") { - assert("type" in parent && parent.type === "model"); + assert("id" in parent); return { id, isFilterTarget, type, - modelId: parent.id, + modelId: "modelId" in parent ? parent.modelId : parent.id, + parentId: parent.id, }; } @@ -223,6 +225,7 @@ function createFilteredTreeNode({ type, modelId: parent.modelId, categoryId: parent.id, + parentId: parent.parentId, }; } @@ -233,6 +236,7 @@ function createFilteredTreeNode({ type, modelId: parent.modelId, categoryId: parent.categoryId, + parentId: parent.id, }; } diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/ModelsTreeIdsCache.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/ModelsTreeIdsCache.ts index fc1391141a..8985a978bd 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/ModelsTreeIdsCache.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/ModelsTreeIdsCache.ts @@ -3,23 +3,23 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { assert } from "@itwin/core-bentley"; +import { assert, Id64 } from "@itwin/core-bentley"; import { IModel } from "@itwin/core-common"; import { + ELEMENT_CLASS_NAME, GEOMETRIC_MODEL_3D_CLASS_NAME, INFORMATION_PARTITION_ELEMENT_CLASS_NAME, MODEL_CLASS_NAME, - SPATIAL_CATEGORY_CLASS_NAME, SUBJECT_CLASS_NAME, } from "../../common/internal/ClassNameDefinitions.js"; -import { ModelCategoryElementsCountCache } from "../../common/internal/ModelCategoryElementsCountCache.js"; import { pushToMap } from "../../common/internal/Utils.js"; +import { ModelCategoryElementsCountCache } from "../../common/internal/withParents/ModelCategoryElementsCountCache.js"; import type { InstanceKey } from "@itwin/presentation-shared"; import type { ModelsTreeDefinition } from "../ModelsTreeDefinition.js"; -import type { Id64Array, Id64Set, Id64String } from "@itwin/core-bentley"; +import type { Id64Arg, Id64Array, Id64Set, Id64String } from "@itwin/core-bentley"; import type { HierarchyNodeIdentifiersPath, LimitingECSqlQueryExecutor } from "@itwin/presentation-hierarchies"; -import type { CategoryId, ElementId, ModelId, SubjectId } from "../../common/internal/Types.js"; +import type { CategoryId, ElementId, ModelId, ParentId, SubjectId } from "../../common/internal/Types.js"; interface SubjectInfo { parentSubjectId: Id64String | undefined; @@ -28,26 +28,21 @@ interface SubjectInfo { childModelIds: Id64Set; } -interface ModelInfo { - isModelPrivate: boolean; - categoryIds: Id64Set; - elementCount: number; -} - type ModelsTreeHierarchyConfiguration = ConstructorParameters[0]["hierarchyConfig"]; -type ModelCategoryKey = `${ModelId}-${CategoryId}`; +/** @internal */ +export type ParentElementMap = Map>>; /** @internal */ export class ModelsTreeIdsCache { private readonly _categoryElementCounts: ModelCategoryElementsCountCache; private _subjectInfos: Promise> | undefined; private _parentSubjectIds: Promise | undefined; // the list should contain a subject id if its node should be shown as having children - private _modelInfos: Promise> | undefined; - private _modelWithCategoryModeledElements: Promise>> | undefined; + private _modelsCategoriesInfos: Promise>> | undefined; + private _modeledElementsMap: Promise> | undefined; private _modelKeyPaths: Map>; private _subjectKeyPaths: Map>; - private _categoryKeyPaths: Map>; + private _modelParentInfoMap: Map>; constructor( private _queryExecutor: LimitingECSqlQueryExecutor, @@ -56,7 +51,7 @@ export class ModelsTreeIdsCache { this._categoryElementCounts = new ModelCategoryElementsCountCache(_queryExecutor, this._hierarchyConfig.elementClassSpecification); this._modelKeyPaths = new Map(); this._subjectKeyPaths = new Map(); - this._categoryKeyPaths = new Map(); + this._modelParentInfoMap = new Map(); } public [Symbol.dispose]() { @@ -258,36 +253,34 @@ export class ModelsTreeIdsCache { return entry; } - private async *queryModelElementCounts(): AsyncIterableIterator<{ modelId: Id64String; elementCount: number }> { - const query = ` - SELECT Model.Id modelId, COUNT(*) elementCount - FROM ${this._hierarchyConfig.elementClassSpecification} - GROUP BY Model.Id - `; - for await (const row of this._queryExecutor.createQueryReader({ ecsql: query }, { rowFormat: "ECSqlPropertyNames", limit: "unbounded" })) { - yield { modelId: row.modelId, elementCount: row.elementCount }; - } - } - - private async *queryModelCategories(): AsyncIterableIterator<{ modelId: Id64String; categoryId: Id64String; isModelPrivate: boolean }> { + private async *queryModelsCategories(): AsyncIterableIterator<{ modelId: Id64String; categoryId: Id64String; isCategoryOfRootElement: boolean }> { const query = ` - SELECT this.Model.Id modelId, this.Category.Id categoryId, m.IsPrivate isModelPrivate + SELECT + this.Model.Id modelId, + this.Category.Id categoryId, + MAX(IIF(this.Parent.Id IS NULL, 1, 0)) isCategoryOfRootElement FROM ${MODEL_CLASS_NAME} m JOIN ${this._hierarchyConfig.elementClassSpecification} this ON m.ECInstanceId = this.Model.Id - WHERE this.Parent.Id IS NULL - GROUP BY modelId, categoryId, isModelPrivate + WHERE m.IsPrivate = false + GROUP BY modelId, categoryId `; for await (const row of this._queryExecutor.createQueryReader({ ecsql: query }, { rowFormat: "ECSqlPropertyNames", limit: "unbounded" })) { - yield { modelId: row.modelId, categoryId: row.categoryId, isModelPrivate: !!row.isModelPrivate }; + yield { modelId: row.modelId, categoryId: row.categoryId, isCategoryOfRootElement: !!row.isCategoryOfRootElement }; } } - private async *queryModeledElements(): AsyncIterableIterator<{ modelId: Id64String; categoryId: Id64String; modeledElementId: Id64String }> { + private async *queryModeledElements(): AsyncIterableIterator<{ + modelId: Id64String; + categoryId: Id64String; + modeledElementId: Id64String; + parentId?: Id64String; + }> { const query = ` SELECT pe.ECInstanceId modeledElementId, pe.Category.Id categoryId, - pe.Model.Id modelId + pe.Model.Id modelId, + pe.Parent.Id parentId FROM ${MODEL_CLASS_NAME} m JOIN ${this._hierarchyConfig.elementClassSpecification} pe ON pe.ECInstanceId = m.ModeledElement.Id WHERE @@ -295,87 +288,101 @@ export class ModelsTreeIdsCache { AND m.ECInstanceId IN (SELECT Model.Id FROM ${this._hierarchyConfig.elementClassSpecification}) `; for await (const row of this._queryExecutor.createQueryReader({ ecsql: query }, { rowFormat: "ECSqlPropertyNames", limit: "unbounded" })) { - yield { modelId: row.modelId, categoryId: row.categoryId, modeledElementId: row.modeledElementId }; + yield { modelId: row.modelId, categoryId: row.categoryId, modeledElementId: row.modeledElementId, parentId: row.parentId }; } } - private async getModelWithCategoryModeledElements() { - this._modelWithCategoryModeledElements ??= (async () => { - const modelWithCategoryModeledElements = new Map>(); - for await (const { modelId, categoryId, modeledElementId } of this.queryModeledElements()) { - const key: ModelCategoryKey = `${modelId}-${categoryId}`; - const entry = modelWithCategoryModeledElements.get(key); - if (entry === undefined) { - modelWithCategoryModeledElements.set(key, new Set([modeledElementId])); - } else { - entry.add(modeledElementId); + private async getModeledElementsMap() { + this._modeledElementsMap ??= (async () => { + const modeledElementsMap = new Map(); + for await (const { modelId, categoryId, modeledElementId, parentId } of this.queryModeledElements()) { + let modelEntry = modeledElementsMap.get(modelId); + if (!modelEntry) { + modelEntry = new Map(); + modeledElementsMap.set(modelId, modelEntry); + } + let parentEntry = modelEntry.get(parentId); + if (!parentEntry) { + parentEntry = new Map(); + modelEntry.set(parentId, parentEntry); + } + let categoryEntry = parentEntry.get(categoryId); + if (!categoryEntry) { + categoryEntry = new Set(); + parentEntry.set(categoryId, categoryEntry); } + categoryEntry.add(modeledElementId); } - return modelWithCategoryModeledElements; + return modeledElementsMap; })(); - return this._modelWithCategoryModeledElements; + return this._modeledElementsMap; } - private async getModelInfos() { - this._modelInfos ??= (async () => { - const modelInfos = new Map(); - await Promise.all([ - (async () => { - for await (const { modelId, categoryId, isModelPrivate } of this.queryModelCategories()) { - const entry = modelInfos.get(modelId); - if (entry) { - entry.categoryIds.add(categoryId); - entry.isModelPrivate = isModelPrivate; - } else { - modelInfos.set(modelId, { categoryIds: new Set([categoryId]), elementCount: 0, isModelPrivate }); - } - } - })(), - (async () => { - for await (const { modelId, elementCount } of this.queryModelElementCounts()) { - const entry = modelInfos.get(modelId); - if (entry) { - entry.elementCount = elementCount; - } else { - modelInfos.set(modelId, { categoryIds: new Set(), elementCount, isModelPrivate: false }); - } - } - })(), - ]); + private async getModelsCategoriesInfos() { + this._modelsCategoriesInfos ??= (async () => { + const modelInfos = new Map>(); + for await (const { modelId, categoryId, isCategoryOfRootElement } of this.queryModelsCategories()) { + const entry = modelInfos.get(modelId); + if (entry) { + entry.set(categoryId, isCategoryOfRootElement); + } else { + modelInfos.set(modelId, new Map([[categoryId, isCategoryOfRootElement]])); + } + } return modelInfos; })(); - return this._modelInfos; + return this._modelsCategoriesInfos; } public async getModelCategoryIds(modelId: Id64String): Promise { - const modelInfos = await this.getModelInfos(); - const categories = modelInfos.get(modelId)?.categoryIds; - return categories ? [...categories] : []; + const modelInfos = await this.getModelsCategoriesInfos(); + const categories = modelInfos.get(modelId); + return categories ? [...categories].filter(([, isCategoryOfRootElement]) => isCategoryOfRootElement).map(([categoryId]) => categoryId) : []; } - public async getModelElementCount(modelId: Id64String): Promise { - const modelInfos = await this.getModelInfos(); - return modelInfos.get(modelId)?.elementCount ?? 0; + public async getAllModelCategoryIds(modelId: Id64String): Promise { + const modelInfos = await this.getModelsCategoriesInfos(); + const categories = modelInfos.get(modelId); + return categories ? [...categories.keys()] : []; } public async hasSubModel(elementId: Id64String): Promise { - const modelInfos = await this.getModelInfos(); - const modeledElementInfo = modelInfos.get(elementId); - if (!modeledElementInfo) { - return false; - } - return !modeledElementInfo.isModelPrivate; + const modelInfos = await this.getModelsCategoriesInfos(); + return modelInfos.has(elementId); } - public async getCategoriesModeledElements(modelId: Id64String, categoryIds: Id64Array): Promise { - const modelWithCategoryModeledElements = await this.getModelWithCategoryModeledElements(); + public async getCategoriesModeledElements(props: { + modelId: Id64String; + categoryIds: Id64Arg; + parentIds?: Id64Arg; + includeNested: boolean; + }): Promise { + const modelWithCategoryModeledElements = await this.getModeledElementsMap(); + const { modelId, categoryIds, parentIds, includeNested } = props; const result = new Array(); - for (const categoryId of categoryIds) { - const entry = modelWithCategoryModeledElements.get(`${modelId}-${categoryId}`); - if (entry !== undefined) { - result.push(...entry); + for (const parentId of parentIds ? Id64.iterable(parentIds) : [undefined]) { + for (const categoryId of Id64.iterable(categoryIds)) { + const entry = modelWithCategoryModeledElements.get(modelId)?.get(parentId)?.get(categoryId); + if (entry !== undefined) { + result.push(...entry); + } } } + if (!includeNested) { + return result; + } + const childCategoriesMap = await this.getCategoryChildCategories({ modelId, categoryIds, parentElementIds: parentIds }); + await Promise.all( + [...childCategoriesMap.entries()].map(async ([childParent, childCategories]) => { + const childModeledElements = await this.getCategoriesModeledElements({ + modelId, + categoryIds: childCategories, + parentIds: childParent, + includeNested: true, + }); + result.push(...childModeledElements); + }), + ); return result; } @@ -399,34 +406,244 @@ export class ModelsTreeIdsCache { return entry; } - public async getCategoryElementsCount(modelId: Id64String, categoryId: Id64String): Promise { - return this._categoryElementCounts.getCategoryElementsCount(modelId, categoryId); + public async getCategoryElementsCount(modelId: Id64String, categoryId: Id64String, parentElementIds?: Id64Arg): Promise { + const parentIdsForCount = !parentElementIds ? undefined : typeof parentElementIds === "string" ? [parentElementIds] : [...parentElementIds]; + return this._categoryElementCounts.getCategoryElementsCount(modelId, categoryId, parentIdsForCount); } - public async createCategoryInstanceKeyPaths(categoryId: Id64String): Promise { - let entry = this._categoryKeyPaths.get(categoryId); - if (!entry) { - entry = (async () => { - const result = new Set(); - const modelInfos = await this.getModelInfos(); - modelInfos?.forEach((modelInfo, modelId) => { - if (modelInfo.categoryIds.has(categoryId)) { - result.add(modelId); - } - }); + private async queryParentElementMap({ modelId }: { modelId: ModelId }): Promise { + const reader = this._queryExecutor.createQueryReader( + { + ecsql: ` + SELECT + * + FROM + ( + SELECT + childElement.Parent.Id parentId, + childElement.Category.Id categoryId, + IIF( + EXISTS ( + SELECT + 1 + FROM + ${ELEMENT_CLASS_NAME} childOfChild + WHERE + childOfChild.Parent.Id = childElement.ECInstanceId + AND childOfChild.ECClassId IS (${this._hierarchyConfig.elementClassSpecification}) + ), + IdToHex(childElement.ECInstanceId), + CAST(NULL AS TEXT) + ) AS id + FROM + ${this._hierarchyConfig.elementClassSpecification} childElement + WHERE + childElement.Parent.Id IS NOT NULL + AND childElement.Model.Id = ${modelId} + ) + GROUP BY + parentId, + categoryId, + id + + UNION ALL + + SELECT + rootParentElement.Parent.Id parentId, + rootParentElement.Category.Id categoryId, + IdToHex(rootParentElement.ECInstanceId) id + FROM + ${this._hierarchyConfig.elementClassSpecification} rootParentElement + WHERE + rootParentElement.Parent.Id IS NULL + AND rootParentElement.Model.Id = ${modelId} + AND EXISTS ( + SELECT + 1 + FROM + ${ELEMENT_CLASS_NAME} childElement + WHERE + childElement.Parent.Id = rootParentElement.ECInstanceId + AND childElement.ECClassId IS (${this._hierarchyConfig.elementClassSpecification}) + ) + `, + }, + { rowFormat: "ECSqlPropertyNames", limit: "unbounded" }, + ); + + const result: ParentElementMap = new Map(); + for await (const row of reader) { + const parentElementId = row.parentId ?? undefined; + let categoryMap = result.get(parentElementId); + if (!categoryMap) { + categoryMap = new Map(); + result.set(parentElementId, categoryMap); + } + let childElements = categoryMap.get(row.categoryId); + if (!childElements) { + childElements = new Set(); + categoryMap.set(row.categoryId, childElements); + } + if (row.id) { + childElements.add(row.id); + } + } + return result; + } - const categoryPaths = new Array(); - for (const categoryModelId of [...result]) { - const modelPaths = await this.createModelInstanceKeyPaths(categoryModelId); - for (const modelPath of modelPaths) { - categoryPaths.push([...modelPath, { className: SPATIAL_CATEGORY_CLASS_NAME, id: categoryId }]); - } + private async getParentElementMap(modelId: Id64String): Promise { + let parentElementMap = this._modelParentInfoMap.get(modelId); + if (!parentElementMap) { + parentElementMap = this.queryParentElementMap({ modelId }); + this._modelParentInfoMap.set(modelId, parentElementMap); + } + return parentElementMap; + } + + public async getCategoryChildCategories(props: { + modelId: Id64String; + categoryIds: Id64Arg; + parentElementIds?: Id64Arg; + }): Promise>> { + const { modelId, categoryIds, parentElementIds } = props; + const parentElementMap = await this.getParentElementMap(modelId); + const result = new Map>(); + for (const parentElementId of parentElementIds ? Id64.iterable(parentElementIds) : [undefined]) { + const allDirectChildren = new Set(); + for (const categoryId of Id64.iterable(categoryIds)) { + const directChildren = parentElementMap.get(parentElementId)?.get(categoryId); + directChildren?.forEach((directChild) => allDirectChildren.add(directChild)); + } + + for (const childElement of allDirectChildren) { + const childElementChildCategoriesMap = parentElementMap.get(childElement); + if (childElementChildCategoriesMap) { + result.set(childElement, new Set(childElementChildCategoriesMap.keys())); } - return categoryPaths; - })(); - this._categoryKeyPaths.set(categoryId, entry); + } } - return entry; + return result; + } + + public async getElementsChildCategories(props: { modelId: Id64String; elementIds: Id64Set }): Promise>> { + const { modelId, elementIds } = props; + const parentElementMap = await this.getParentElementMap(modelId); + const result = new Map>(); + for (const elementId of elementIds) { + const childCategories = parentElementMap.get(elementId); + if (childCategories) { + result.set(elementId, new Set(childCategories.keys())); + } + } + return result; + } + + private async queryCategoryAllIndirectChildren(props: { + modelId: Id64String; + categoryId: Id64String; + parentElementIds?: Id64Array; + }): Promise>> { + const reader = this._queryExecutor.createQueryReader( + { + ctes: [ + `ParentsChildrenInfo (Id, CategoryId, IsDirectChild) AS ( + SELECT + this.ECInstanceId, + this.Category.Id, + true + FROM ${this._hierarchyConfig.elementClassSpecification} this + WHERE + this.Model.Id = ${props.modelId} AND this.Category.Id = ${props.categoryId} AND this.Parent.Id ${props.parentElementIds && props.parentElementIds.length > 0 ? `IN (${props.parentElementIds.join(", ")})` : "IS NULL"} + UNION ALL + SELECT + c.ECInstanceId, + c.Category.Id, + false + FROM + ${this._hierarchyConfig.elementClassSpecification} c + JOIN ParentsChildrenInfo p ON c.Parent.Id = p.Id + )`, + ], + ecsql: ` + SELECT + this.CategoryId categoryId, + this.Id id + FROM ParentsChildrenInfo this + WHERE this.IsDirectChild = false + `, + }, + { rowFormat: "ECSqlPropertyNames", limit: "unbounded" }, + ); + + const result = new Map>(); + for await (const row of reader) { + let elements = result.get(row.categoryId); + if (!elements) { + elements = new Set(); + result.set(row.categoryId, elements); + } + elements.add(row.id); + } + return result; + } + + public async getCategoryAllIndirectChildren(props: { + modelId: Id64String; + categoryId: Id64String; + parentElementIds?: Id64Arg; + }): Promise>> { + const parentIdsForQuery = !props.parentElementIds + ? undefined + : typeof props.parentElementIds === "string" + ? [props.parentElementIds] + : [...props.parentElementIds]; + return this.queryCategoryAllIndirectChildren({ ...props, parentElementIds: parentIdsForQuery }); + } + + private async queryElementsAllChildren(props: { modelId: Id64String; elementIds: Id64Array }): Promise>> { + const reader = this._queryExecutor.createQueryReader( + { + ctes: [ + `ParentsChildrenInfo (Id, CategoryId) AS ( + SELECT + this.ECInstanceId, + this.Category.Id + FROM ${this._hierarchyConfig.elementClassSpecification} this + WHERE + this.Model.Id = ${props.modelId} AND this.Parent.Id IN (${props.elementIds.join(", ")}) + UNION ALL + SELECT + c.ECInstanceId, + c.Category.Id + FROM + ${this._hierarchyConfig.elementClassSpecification} c + JOIN ParentsChildrenInfo p ON c.Parent.Id = p.Id + )`, + ], + ecsql: ` + SELECT + this.CategoryId categoryId, + this.Id id + FROM ParentsChildrenInfo this + `, + }, + { rowFormat: "ECSqlPropertyNames", limit: "unbounded" }, + ); + + const result = new Map>(); + for await (const row of reader) { + let elements = result.get(row.categoryId); + if (!elements) { + elements = new Set(); + result.set(row.categoryId, elements); + } + elements.add(row.id); + } + return result; + } + + public async getElementsAllChildren(props: { modelId: Id64String; elementIds: Id64Array }): Promise>> { + return this.queryElementsAllChildren(props); } } diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/ModelsTreeVisibilityHandler.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/ModelsTreeVisibilityHandler.ts index f841baf657..bf84483055 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/ModelsTreeVisibilityHandler.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/ModelsTreeVisibilityHandler.ts @@ -30,25 +30,28 @@ import { import { assert, Id64 } from "@itwin/core-bentley"; import { PerModelCategoryVisibility } from "@itwin/core-frontend"; import { HierarchyNode, HierarchyNodeKey } from "@itwin/presentation-hierarchies"; -import { AlwaysAndNeverDrawnElementInfo } from "../../common/internal/AlwaysAndNeverDrawnElementInfo.js"; import { GEOMETRIC_ELEMENT_3D_CLASS_NAME } from "../../common/internal/ClassNameDefinitions.js"; import { toVoidPromise } from "../../common/internal/Rxjs.js"; import { createVisibilityStatus } from "../../common/internal/Tooltip.js"; import { releaseMainThreadOnItemsCount, setDifference, setIntersection } from "../../common/internal/Utils.js"; import { createVisibilityChangeEventListener } from "../../common/internal/VisibilityChangeEventListener.js"; import { + changeChildrenDisplayStatus, changeElementStateNoChildrenOperator, filterSubModeledElementIds, + getChildrenDisplayStatus, getElementOverriddenVisibility, getElementVisibility, getSubModeledElementsVisibilityStatus, getVisibilityFromAlwaysAndNeverDrawnElementsImpl, mergeVisibilityStatuses, } from "../../common/internal/VisibilityUtils.js"; +import { AlwaysAndNeverDrawnElementInfo } from "../../common/internal/withParents/AlwaysAndNeverDrawnElementInfo.js"; import { createVisibilityHandlerResult } from "../../common/UseHierarchyVisibility.js"; import { createFilteredTree, parseCategoryKey } from "./FilteredTree.js"; import { ModelsTreeNode } from "./ModelsTreeNode.js"; +import type { NonPartialVisibilityStatus } from "../../common/internal/Tooltip.js"; import type { GetVisibilityFromAlwaysAndNeverDrawnElementsProps } from "../../common/internal/VisibilityUtils.js"; import type { Observable, Subscription } from "rxjs"; import type { GroupingHierarchyNode, HierarchyFilteringPath } from "@itwin/presentation-hierarchies"; @@ -56,17 +59,16 @@ import type { HierarchyVisibilityHandler, HierarchyVisibilityHandlerOverridableM import type { ModelsTreeIdsCache } from "./ModelsTreeIdsCache.js"; import type { Id64Arg, Id64Array, Id64Set, Id64String } from "@itwin/core-bentley"; import type { Viewport } from "@itwin/core-frontend"; -import type { NonPartialVisibilityStatus } from "../../common/internal/Tooltip.js"; import type { ECClassHierarchyInspector } from "@itwin/presentation-shared"; import type { FilteredTree } from "./FilteredTree.js"; -import type { CategoryAlwaysOrNeverDrawnElementsQueryProps } from "../../common/internal/AlwaysAndNeverDrawnElementInfo.js"; import type { IVisibilityChangeEventListener } from "../../common/internal/VisibilityChangeEventListener.js"; import type { ElementId } from "../../common/internal/Types.js"; /** @beta */ interface GetCategoryVisibilityStatusProps { - categoryId: Id64String; + categoryIds: Id64Arg; modelId: Id64String; + parentElementIds?: Id64Arg; } /** @beta */ @@ -112,7 +114,7 @@ interface ChangeFilteredNodeVisibilityProps extends GetFilteredNodeVisibilityPro */ export interface ModelsTreeVisibilityHandlerOverrides { getSubjectNodeVisibility?: HierarchyVisibilityHandlerOverridableMethod<(props: { ids: Id64Array }) => Promise>; - getModelDisplayStatus?: HierarchyVisibilityHandlerOverridableMethod<(props: { id: Id64String }) => Promise>; + getModelDisplayStatus?: HierarchyVisibilityHandlerOverridableMethod<(props: { ids: Id64Arg }) => Promise>; getCategoryDisplayStatus?: HierarchyVisibilityHandlerOverridableMethod<(props: GetCategoryVisibilityStatusProps) => Promise>; getElementGroupingNodeDisplayStatus?: HierarchyVisibilityHandlerOverridableMethod<(props: { node: GroupingHierarchyNode }) => Promise>; getElementDisplayStatus?: HierarchyVisibilityHandlerOverridableMethod<(props: GetGeometricElementVisibilityStatusProps) => Promise>; @@ -244,7 +246,7 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { } if (ModelsTreeNode.isModelNode(node)) { - return this.getModelVisibilityStatus({ modelId: node.key.instanceKeys[0].id }); + return this.getModelVisibilityStatus({ modelIds: node.key.instanceKeys.map((instanceKey) => instanceKey.id) }); } const modelId = ModelsTreeNode.getModelId(node); @@ -254,8 +256,9 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { if (ModelsTreeNode.isCategoryNode(node)) { return this.getCategoryDisplayStatus({ - categoryId: node.key.instanceKeys[0].id, + categoryIds: node.key.instanceKeys.map((instanceKey) => instanceKey.id), modelId, + parentElementIds: getParentElementIds(node.parentKeys, modelId), }); } @@ -280,15 +283,15 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { } if (models?.size) { - observables.push(from(models).pipe(mergeMap((modelId) => this.getModelVisibilityStatus({ modelId })))); + observables.push(this.getModelVisibilityStatus({ modelIds: models })); } if (categories?.size) { observables.push( from(categories).pipe( mergeMap((key) => { - const { modelId, categoryId } = parseCategoryKey(key); - return this.getCategoryDisplayStatus({ modelId, categoryId }); + const { modelId, categoryId, parentId } = parseCategoryKey(key); + return this.getCategoryDisplayStatus({ modelId, categoryIds: categoryId, parentElementIds: parentId }); }), ), ); @@ -321,87 +324,136 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { return of(createVisibilityStatus("disabled")); } - return from(this._idsCache.getSubjectModelIds(subjectIds)).pipe( - concatAll(), - distinct(), - mergeMap((modelId) => this.getModelVisibilityStatus({ modelId })), - mergeVisibilityStatuses, - ); + return from(this._idsCache.getSubjectModelIds(subjectIds)).pipe(mergeMap((modelIds) => this.getModelVisibilityStatus({ modelIds }))); }); return createVisibilityHandlerResult(this, { ids: subjectIds }, result, this._props.overrides?.getSubjectNodeVisibility); } - private getModelVisibilityStatus({ modelId }: { modelId: Id64String }): Observable { + private getModelVisibilityStatus({ modelIds }: { modelIds: Id64Arg }): Observable { const result = defer(() => { const viewport = this._props.viewport; if (!viewport.view.isSpatialView()) { return of(createVisibilityStatus("disabled")); } - - if (!viewport.view.viewsModel(modelId)) { - return from(this._idsCache.getModelCategoryIds(modelId)).pipe( - mergeMap((categoryIds) => from(this._idsCache.getCategoriesModeledElements(modelId, categoryIds))), - getSubModeledElementsVisibilityStatus({ - parentNodeVisibilityStatus: createVisibilityStatus("hidden"), - getModelVisibilityStatus: (modelProps) => this.getModelVisibilityStatus(modelProps), - }), - ); - } - - return from(this._idsCache.getModelCategoryIds(modelId)).pipe( - concatAll(), - mergeMap((categoryId) => this.getCategoryDisplayStatus({ modelId, categoryId })), + return from(Id64.iterable(modelIds)).pipe( + distinct(), + mergeMap((modelId) => { + if (!viewport.view.viewsModel(modelId)) { + return from(this._idsCache.getAllModelCategoryIds(modelId)).pipe( + mergeMap((categoryIds) => from(this._idsCache.getCategoriesModeledElements({ modelId, categoryIds, includeNested: true }))), + getSubModeledElementsVisibilityStatus({ + parentNodeVisibilityStatus: createVisibilityStatus("hidden"), + getModelVisibilityStatus: (modelProps) => this.getModelVisibilityStatus(modelProps), + }), + ); + } + return from(this._idsCache.getModelCategoryIds(modelId)).pipe( + mergeMap((categoryIds) => + categoryIds.length > 0 ? this.getCategoryDisplayStatus({ modelId, categoryIds }) : of(createVisibilityStatus("visible")), + ), + ); + }), mergeVisibilityStatuses, ); }); - return createVisibilityHandlerResult(this, { id: modelId }, result, this._props.overrides?.getModelDisplayStatus); + return createVisibilityHandlerResult(this, { ids: modelIds }, result, this._props.overrides?.getModelDisplayStatus); } - private getDefaultCategoryVisibilityStatus({ modelId, categoryId }: { categoryId: Id64String; modelId: Id64String }): NonPartialVisibilityStatus { + private getDefaultCategoryVisibilityStatus({ modelId, categoryIds }: { categoryIds: Id64Arg; modelId: Id64String }): VisibilityStatus { const viewport = this._props.viewport; - if (!viewport.view.viewsModel(modelId)) { + if (!viewport.view.viewsModel(modelId) || Id64.sizeOf(categoryIds) === 0) { return createVisibilityStatus("hidden"); } - switch (this._props.viewport.perModelCategoryVisibility.getOverride(modelId, categoryId)) { - case PerModelCategoryVisibility.Override.Show: - return createVisibilityStatus("visible"); - case PerModelCategoryVisibility.Override.Hide: - return createVisibilityStatus("hidden"); + let visibility: "visible" | "hidden" | "unknown" = "unknown"; + for (const categoryId of Id64.iterable(categoryIds)) { + const override = this._props.viewport.perModelCategoryVisibility.getOverride(modelId, categoryId); + if (override === PerModelCategoryVisibility.Override.Show) { + if (visibility === "hidden") { + return createVisibilityStatus("partial"); + } + visibility = "visible"; + continue; + } + if (override === PerModelCategoryVisibility.Override.Hide) { + if (visibility === "visible") { + return createVisibilityStatus("partial"); + } + visibility = "hidden"; + continue; + } + const isVisible = viewport.view.viewsCategory(categoryId); + if (isVisible && visibility === "hidden") { + return createVisibilityStatus("partial"); + } + if (!isVisible && visibility === "visible") { + return createVisibilityStatus("partial"); + } + visibility = isVisible ? "visible" : "hidden"; } + assert(visibility !== "unknown"); - return createVisibilityStatus(viewport.view.viewsCategory(categoryId) ? "visible" : "hidden"); + return createVisibilityStatus(visibility); } private getCategoryDisplayStatus(props: GetCategoryVisibilityStatusProps): Observable { const result = defer(() => { - if (!this._props.viewport.view.viewsModel(props.modelId)) { - return from(this._idsCache.getCategoriesModeledElements(props.modelId, [props.categoryId])).pipe( - getSubModeledElementsVisibilityStatus({ - parentNodeVisibilityStatus: createVisibilityStatus("hidden"), - getModelVisibilityStatus: (modelProps) => this.getModelVisibilityStatus(modelProps), - }), - ); - } - - return this.getVisibilityFromAlwaysAndNeverDrawnElements({ - queryProps: { - categoryIds: [props.categoryId], - modelId: props.modelId, - }, - defaultStatus: () => this.getDefaultCategoryVisibilityStatus(props), - }).pipe( - mergeMap((visibilityStatusAlwaysAndNeverDraw) => { - return from(this._idsCache.getCategoriesModeledElements(props.modelId, [props.categoryId])).pipe( - getSubModeledElementsVisibilityStatus({ - parentNodeVisibilityStatus: visibilityStatusAlwaysAndNeverDraw, - getModelVisibilityStatus: (modelProps) => this.getModelVisibilityStatus(modelProps), + return of(props.categoryIds).pipe( + mergeMap((categoryIds) => { + if (!this._props.viewport.view.viewsModel(props.modelId)) { + return from( + this._idsCache.getCategoriesModeledElements({ modelId: props.modelId, categoryIds, parentIds: props.parentElementIds, includeNested: true }), + ).pipe( + getSubModeledElementsVisibilityStatus({ + parentNodeVisibilityStatus: createVisibilityStatus("hidden"), + getModelVisibilityStatus: (modelProps) => this.getModelVisibilityStatus(modelProps), + }), + ); + } + + return this.getVisibilityFromAlwaysAndNeverDrawnElements({ + categoryProps: { categoryIds: props.categoryIds, modelId: props.modelId, parentElementIds: props.parentElementIds }, + defaultStatus: (categoryId) => + categoryId + ? this.getDefaultCategoryVisibilityStatus({ modelId: props.modelId, categoryIds: categoryId }) + : this.getDefaultCategoryVisibilityStatus({ modelId: props.modelId, categoryIds: props.categoryIds }), + }).pipe( + mergeMap((visibilityStatusAlwaysAndNeverDraw) => { + if (visibilityStatusAlwaysAndNeverDraw.state === "partial") { + return of(visibilityStatusAlwaysAndNeverDraw); + } + + return from(this._idsCache.getCategoryChildCategories(props)).pipe( + getChildrenDisplayStatus({ + parentNodeVisibilityStatus: visibilityStatusAlwaysAndNeverDraw, + getCategoryDisplayStatus: (categoryProps) => this.getCategoryDisplayStatus({ modelId: props.modelId, ...categoryProps }), + }), + ); + }), + mergeMap((visibilityStatusOfChildren) => { + if (visibilityStatusOfChildren.state === "partial") { + return of(visibilityStatusOfChildren); + } + return from( + this._idsCache.getCategoriesModeledElements({ + modelId: props.modelId, + categoryIds: props.categoryIds, + parentIds: props.parentElementIds, + includeNested: true, + }), + ).pipe( + getSubModeledElementsVisibilityStatus({ + parentNodeVisibilityStatus: visibilityStatusOfChildren, + getModelVisibilityStatus: (modelProps) => this.getModelVisibilityStatus(modelProps), + }), + ); }), ); }), ); }); + return createVisibilityHandlerResult(this, props, result, this._props.overrides?.getCategoryDisplayStatus); } @@ -411,7 +463,7 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { const { modelId, categoryId, elementIds } = info; if (!this._props.viewport.view.viewsModel(modelId)) { - return of([...elementIds]).pipe( + return of(elementIds).pipe( filterSubModeledElementIds({ doesSubModelExist: async (id) => this._idsCache.hasSubModel(id) }), getSubModeledElementsVisibilityStatus({ parentNodeVisibilityStatus: createVisibilityStatus("hidden"), @@ -422,13 +474,33 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { return this.getVisibilityFromAlwaysAndNeverDrawnElements({ elements: elementIds, - defaultStatus: () => this.getDefaultCategoryVisibilityStatus({ categoryId, modelId }), + defaultStatus: () => this.getDefaultCategoryVisibilityStatus({ categoryIds: categoryId, modelId }), }).pipe( mergeMap((visibilityStatusAlwaysAndNeverDraw) => { + if (visibilityStatusAlwaysAndNeverDraw.state === "partial") { + return of(visibilityStatusAlwaysAndNeverDraw); + } + + return from( + this._idsCache.getElementsChildCategories({ + modelId, + elementIds, + }), + ).pipe( + getChildrenDisplayStatus({ + parentNodeVisibilityStatus: visibilityStatusAlwaysAndNeverDraw, + getCategoryDisplayStatus: (categoryProps) => this.getCategoryDisplayStatus({ modelId, ...categoryProps }), + }), + ); + }), + mergeMap((visibilityStatusOfChildren) => { + if (visibilityStatusOfChildren.state === "partial") { + return of(visibilityStatusOfChildren); + } return of([...elementIds]).pipe( filterSubModeledElementIds({ doesSubModelExist: async (id) => this._idsCache.hasSubModel(id) }), getSubModeledElementsVisibilityStatus({ - parentNodeVisibilityStatus: visibilityStatusAlwaysAndNeverDraw, + parentNodeVisibilityStatus: visibilityStatusOfChildren, getModelVisibilityStatus: (modelProps) => this.getModelVisibilityStatus(modelProps), }), ); @@ -450,10 +522,31 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { }); return from(this._idsCache.hasSubModel(elementId)).pipe( - mergeMap((hasSubModel) => (hasSubModel ? this.getModelVisibilityStatus({ modelId: elementId }) : of(undefined))), + mergeMap((hasSubModel) => (hasSubModel ? this.getModelVisibilityStatus({ modelIds: elementId }) : of(undefined))), map((subModelVisibilityStatus) => - getElementVisibility(viewsModel, elementStatus, this.getDefaultCategoryVisibilityStatus({ categoryId, modelId }), subModelVisibilityStatus), + getElementVisibility( + viewsModel, + elementStatus, + this.getDefaultCategoryVisibilityStatus({ categoryIds: categoryId, modelId }), + subModelVisibilityStatus, + ), ), + mergeMap((elementVisibilityStatus) => { + if (elementVisibilityStatus.state === "partial") { + return of(elementVisibilityStatus); + } + return from( + this._idsCache.getElementsChildCategories({ + modelId, + elementIds: new Set([elementId]), + }), + ).pipe( + getChildrenDisplayStatus({ + parentNodeVisibilityStatus: elementVisibilityStatus, + getCategoryDisplayStatus: (categoryProps) => this.getCategoryDisplayStatus({ modelId, ...categoryProps }), + }), + ); + }), ); }); return createVisibilityHandlerResult(this, props, result, this._props.overrides?.getElementDisplayStatus); @@ -491,9 +584,10 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { if (ModelsTreeNode.isCategoryNode(node)) { return this.changeCategoryState({ - categoryId: node.key.instanceKeys[0].id, + categoryIds: node.key.instanceKeys.map((instanceKey) => instanceKey.id), modelId, on, + parentElementIds: getParentElementIds(node.parentKeys, modelId), }); } @@ -503,7 +597,7 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { } return this.changeElementsState({ - elementIds: new Set([...node.key.instanceKeys.map(({ id }) => id)]), + elementIds: new Set(node.key.instanceKeys.map(({ id }) => id)), modelId, categoryId, on, @@ -531,8 +625,8 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { observables.push( from(categories).pipe( mergeMap((key) => { - const { modelId, categoryId } = parseCategoryKey(key); - return this.changeCategoryState({ modelId, categoryId, on }); + const { modelId, categoryId, parentId } = parseCategoryKey(key); + return this.changeCategoryState({ modelId, categoryIds: categoryId, on, parentElementIds: parentId }); }), ), ); @@ -583,7 +677,7 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { viewport.changeModelDisplay(ids, false); return idsObs.pipe( mergeMap(async (modelId) => ({ modelId, categoryIds: await this._idsCache.getModelCategoryIds(modelId) })), - mergeMap(({ modelId, categoryIds }) => from(this._idsCache.getCategoriesModeledElements(modelId, categoryIds))), + mergeMap(({ modelId, categoryIds }) => from(this._idsCache.getCategoriesModeledElements({ modelId, categoryIds, includeNested: true }))), mergeMap((modeledElementIds) => this.changeModelState({ ids: modeledElementIds, on })), ); } @@ -596,8 +690,7 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { idsObs.pipe( mergeMap((modelId) => { return from(this._idsCache.getModelCategoryIds(modelId)).pipe( - concatAll(), - mergeMap((categoryId) => this.changeCategoryState({ categoryId, modelId, on: true })), + mergeMap((categoryIds) => this.changeCategoryState({ categoryIds, modelId, on: true })), ); }), ), @@ -609,7 +702,7 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { private showModelWithoutAnyCategoriesOrElements(modelId: Id64String): Observable { const viewport = this._props.viewport; return forkJoin({ - categories: this._idsCache.getModelCategoryIds(modelId), + categories: this._idsCache.getAllModelCategoryIds(modelId), alwaysDrawnElements: this._alwaysAndNeverDrawnElements.getAlwaysDrawnElements({ modelId }), }).pipe( mergeMap(async ({ categories, alwaysDrawnElements }) => { @@ -645,14 +738,32 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { private changeCategoryState(props: ChangeCategoryVisibilityStateProps): Observable { const result = defer(() => { const viewport = this._props.viewport; - const { modelId, categoryId, on } = props; + const { modelId, categoryIds, on, parentElementIds } = props; return concat( - props.on && !viewport.view.viewsModel(modelId) ? this.showModelWithoutAnyCategoriesOrElements(modelId) : EMPTY, + (props.on && !viewport.view.viewsModel(modelId) ? this.showModelWithoutAnyCategoriesOrElements(modelId) : of(undefined)).pipe( + mergeMap(() => { + return from(Id64.iterable(categoryIds)).pipe( + mergeMap(async (categoryId) => this._idsCache.getCategoryAllIndirectChildren({ categoryId, modelId, parentElementIds })), + changeChildrenDisplayStatus({ + queueElementsVisibilityChange: (queuedIds, isDiplayedByDefault) => this.queueElementsVisibilityChange(queuedIds, on, isDiplayedByDefault), + getDefaultCategoryVisibilityStatus: (defaultCategoryId) => + this.getDefaultCategoryVisibilityStatus({ modelId, categoryIds: defaultCategoryId }) as NonPartialVisibilityStatus, + createChangeSubModelsObservable: (elementIdsToChange) => this.createChangeSubModelsObservable(elementIdsToChange, on), + }), + ); + }), + ), defer(() => { - this.changeCategoryStateInViewportAccordingToModelVisibility(modelId, categoryId, on); - return this._alwaysAndNeverDrawnElements.clearAlwaysAndNeverDrawnElements({ categoryIds: [props.categoryId], modelId: props.modelId }); + for (const categoryId of Id64.iterable(categoryIds)) { + this.changeCategoryStateInViewportAccordingToModelVisibility(modelId, categoryId, on); + } + return this._alwaysAndNeverDrawnElements.clearAlwaysAndNeverDrawnElements({ + categoryIds, + modelId, + parentElementIds, + }); }), - from(this._idsCache.getCategoriesModeledElements(modelId, [categoryId])).pipe( + from(this._idsCache.getCategoriesModeledElements({ modelId, categoryIds, includeNested: true, parentIds: parentElementIds })).pipe( mergeMap((modeledElementIds) => this.changeModelState({ ids: modeledElementIds, on })), ), ); @@ -664,23 +775,34 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { return defer(() => { const { modelId, categoryId, elementIds, on } = props; const viewport = this._props.viewport; - return concat( - on && !viewport.view.viewsModel(modelId) ? this.showModelWithoutAnyCategoriesOrElements(modelId) : EMPTY, - defer(() => { - const categoryVisibility = this.getDefaultCategoryVisibilityStatus({ categoryId, modelId }); - const isDisplayedByDefault = categoryVisibility.state === "visible"; - return this.queueElementsVisibilityChange(elementIds, on, isDisplayedByDefault); + + return (on && !viewport.view.viewsModel(modelId) ? this.showModelWithoutAnyCategoriesOrElements(modelId) : of(undefined)).pipe( + mergeMap(() => { + return from(this._idsCache.getElementsAllChildren({ modelId, elementIds: [...elementIds] })).pipe( + changeChildrenDisplayStatus({ + queueElementsVisibilityChange: (queuedIds, isDiplayedByDefault) => this.queueElementsVisibilityChange(queuedIds, on, isDiplayedByDefault), + getDefaultCategoryVisibilityStatus: (defaultCategoryId) => + this.getDefaultCategoryVisibilityStatus({ modelId, categoryIds: defaultCategoryId }) as NonPartialVisibilityStatus, + createChangeSubModelsObservable: (elementIdsToChange) => this.createChangeSubModelsObservable(elementIdsToChange, on), + parentsInfo: { + elementIds, + parentsCategoryVisibility: this.getDefaultCategoryVisibilityStatus({ modelId, categoryIds: categoryId }).state as "visible" | "hidden", + }, + }), + ); }), - from(elementIds).pipe( - mergeMap(async (elementId) => ({ elementId, isSubModel: await this._idsCache.hasSubModel(elementId) })), - filter(({ isSubModel }) => isSubModel), - map(({ elementId }) => elementId), - toArray(), - mergeMap((subModelIds) => this.changeModelState({ ids: subModelIds, on })), - ), ); }); } + private createChangeSubModelsObservable(elementIds: Id64Set | Id64Array, on: boolean): Observable { + return from(elementIds).pipe( + mergeMap(async (elementId) => ({ elementId, isSubModel: await this._idsCache.hasSubModel(elementId) })), + filter(({ isSubModel }) => isSubModel), + map(({ elementId }) => elementId), + toArray(), + mergeMap((subModelIds) => this.changeModelState({ ids: subModelIds, on })), + ); + } /** * Updates visibility of all grouping node's elements. @@ -738,7 +860,8 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { } private getVisibilityFromAlwaysAndNeverDrawnElements( - props: GetVisibilityFromAlwaysAndNeverDrawnElementsProps & ({ elements: Set } | { queryProps: CategoryAlwaysOrNeverDrawnElementsQueryProps }), + props: GetVisibilityFromAlwaysAndNeverDrawnElementsProps & + ({ elements: Set } | { categoryProps: { categoryIds: Id64Arg; modelId: Id64String; parentElementIds?: Id64Arg } }), ): Observable { const viewport = this._props.viewport; if (viewport.isAlwaysDrawnExclusive) { @@ -760,22 +883,26 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { }), ); } - - const { modelId, categoryIds } = props.queryProps; - assert(modelId !== undefined); - const totalCount = this._idsCache.getCategoryElementsCount(modelId, categoryIds[0]); - return forkJoin({ - totalCount, - alwaysDrawn: this._alwaysAndNeverDrawnElements.getAlwaysDrawnElements(props.queryProps), - neverDrawn: this._alwaysAndNeverDrawnElements.getNeverDrawnElements(props.queryProps), - }).pipe( - map((state) => { - return getVisibilityFromAlwaysAndNeverDrawnElementsImpl({ - ...props, - ...state, - viewport, - }); + const { modelId, categoryIds, parentElementIds } = props.categoryProps; + return from(Id64.iterable(categoryIds)).pipe( + mergeMap((categoryId) => { + const totalCount = this._idsCache.getCategoryElementsCount(modelId, categoryId, parentElementIds); + return forkJoin({ + totalCount, + alwaysDrawn: this._alwaysAndNeverDrawnElements.getAlwaysDrawnElements({ categoryIds: categoryId, modelId, parentElementIds }), + neverDrawn: this._alwaysAndNeverDrawnElements.getNeverDrawnElements({ categoryIds: categoryId, modelId, parentElementIds }), + }).pipe( + map((state) => { + return getVisibilityFromAlwaysAndNeverDrawnElementsImpl({ + ...props, + ...state, + defaultStatus: () => props.defaultStatus(categoryId), + viewport, + }); + }), + ); }), + mergeVisibilityStatuses, ); } @@ -788,3 +915,17 @@ class ModelsTreeVisibilityHandlerImpl implements HierarchyVisibilityHandler { return { modelId, categoryId, elementIds }; } } + +/** @internal */ +export function getParentElementIds(parentKeys: HierarchyNodeKey[], modelId: Id64String): Array | undefined { + for (let i = parentKeys.length - 1; i >= 0; --i) { + const parentNodeKey = parentKeys[i]; + if (HierarchyNodeKey.isInstances(parentNodeKey)) { + if (parentNodeKey.instanceKeys.some((instanceKey) => instanceKey.id === modelId)) { + return undefined; + } + return parentNodeKey.instanceKeys.map((instanceKey) => instanceKey.id); + } + } + return undefined; +}