diff --git a/react-components/src/components/Reveal3DResources/Reveal3DResources.tsx b/react-components/src/components/Reveal3DResources/Reveal3DResources.tsx index 30a1b329f07..a98a1588deb 100644 --- a/react-components/src/components/Reveal3DResources/Reveal3DResources.tsx +++ b/react-components/src/components/Reveal3DResources/Reveal3DResources.tsx @@ -5,7 +5,8 @@ import { useRef, type ReactElement, useContext, useState, useEffect } from 'reac import { type NodeAppearance, type Cognite3DViewer, - type PointCloudAppearance + type PointCloudAppearance, + type PointerEventData } from '@cognite/reveal'; import { ModelsLoadingStateContext } from './ModelsLoadingContext'; import { CadModelContainer, type CadModelStyling } from '../CadModelContainer/CadModelContainer'; @@ -19,11 +20,14 @@ import { type AddReveal3DModelOptions, type AddImageCollection360Options, type TypedReveal3DModel, - type AddResourceOptions + type AddResourceOptions, + type NodeDataResult } from './types'; import { type CogniteExternalId } from '@cognite/sdk'; import { type FdmAssetMappingsConfig } from '../../hooks/types'; import { useCalculateModelsStyling } from '../../hooks/useCalculateModelsStyling'; +import { queryMappedData } from './queryMappedData'; +import { useFdmSdk, useSDK } from '../RevealContainer/SDKProvider'; export type FdmAssetStylingGroup = { fdmAssetExternalIds: CogniteExternalId[]; @@ -35,17 +39,19 @@ export type Reveal3DResourcesStyling = { groups?: FdmAssetStylingGroup[]; }; -export type Reveal3DResourcesProps = { +export type Reveal3DResourcesProps = { resources: AddResourceOptions[]; - fdmAssetMappingConfig?: FdmAssetMappingsConfig; + fdmAssetMappingConfig: FdmAssetMappingsConfig; styling?: Reveal3DResourcesStyling; + onNodeClick?: (node: NodeDataResult) => void; }; -export const Reveal3DResources = ({ +export const Reveal3DResources = ({ resources, styling, - fdmAssetMappingConfig -}: Reveal3DResourcesProps): ReactElement => { + fdmAssetMappingConfig, + onNodeClick +}: Reveal3DResourcesProps): ReactElement => { const [reveal3DModels, setReveal3DModels] = useState([]); const [reveal3DModelsStyling, setReveal3DModelsStyling] = useState< Array @@ -53,6 +59,8 @@ export const Reveal3DResources = ({ const { setModelsAdded } = useContext(ModelsLoadingStateContext); const viewer = useReveal(); + const fdmSdk = useFdmSdk(); + const client = useSDK(); const numModelsLoaded = useRef(0); useEffect(() => { @@ -65,6 +73,29 @@ export const Reveal3DResources = ({ setReveal3DModelsStyling(modelsStyling); }, [modelsStyling]); + useEffect(() => { + const callback = (event: PointerEventData): void => { + void (async (event: PointerEventData): Promise => { + const data = await queryMappedData( + viewer, + client, + fdmSdk, + fdmAssetMappingConfig, + event + ); + if (onNodeClick !== undefined && data !== undefined) { + onNodeClick?.(data); + } + })(event); + }; + + viewer.on('click', callback); + + return () => { + viewer.off('click', callback); + }; + }, [viewer, client, fdmSdk, fdmAssetMappingConfig, onNodeClick]); + const image360CollectionAddOptions = resources.filter( (resource): resource is AddImageCollection360Options => (resource as AddImageCollection360Options).siteId !== undefined diff --git a/react-components/src/components/Reveal3DResources/queryMappedData.ts b/react-components/src/components/Reveal3DResources/queryMappedData.ts new file mode 100644 index 00000000000..7deee733e36 --- /dev/null +++ b/react-components/src/components/Reveal3DResources/queryMappedData.ts @@ -0,0 +1,172 @@ +/*! + * Copyright 2023 Cognite AS + */ + +import { type Cognite3DViewer, type PointerEventData, type CogniteCadModel } from '@cognite/reveal'; +import { type CogniteInternalId, type CogniteClient, type Node3D } from '@cognite/sdk'; +import { + type EdgeItem, + type InspectResultList, + type FdmSDK, + type DmsUniqueIdentifier, + type Source, + type FdmNode +} from '../../utilities/FdmSDK'; +import { type FdmAssetMappingsConfig } from '../../hooks/types'; +import { type NodeDataResult } from './types'; +import assert from 'assert'; + +export async function queryMappedData( + viewer: Cognite3DViewer, + cdfClient: CogniteClient, + fdmClient: FdmSDK, + fdmConfig: FdmAssetMappingsConfig, + clickEvent: PointerEventData +): Promise | undefined> { + const intersection = await viewer.getIntersectionFromPixel( + clickEvent.offsetX, + clickEvent.offsetY + ); + + if (intersection === null || intersection.type !== 'cad') { + return; + } + + const cadIntersection = intersection; + const model = cadIntersection.model; + + const ancestors = await getAncestorNodesForTreeIndex(cdfClient, model, cadIntersection.treeIndex); + + const mappings = await getMappingEdges( + fdmClient, + fdmConfig, + model, + ancestors.map((n) => n.id) + ); + + if (mappings.edges.length === 0) { + return; + } + + const selectedEdge = mappings.edges[0]; + const selectedNodeId = + selectedEdge.properties[fdmConfig.source.space][ + `${fdmConfig.source.externalId}/${fdmConfig.source.version}` + ].revisionNodeId; + const selectedNode = ancestors.find((n) => n.id === selectedNodeId); + assert(selectedNode !== undefined); + + const dataNode = selectedEdge.startNode; + + const inspectionResult = await inspectNode(fdmClient, dataNode); + + const dataView = + inspectionResult.items[0]?.inspectionResults.involvedViewsAndContainers?.views[0]; + + const nodeData = await filterNodeData(fdmClient, dataNode, dataView); + + if (nodeData === undefined) { + return undefined; + } + + return { + data: nodeData, + view: dataView, + cadNode: selectedNode, + model: cadIntersection.model + }; +} + +async function getAncestorNodesForTreeIndex( + client: CogniteClient, + model: CogniteCadModel, + treeIndex: number +): Promise { + const nodeId = await model.mapTreeIndexToNodeId(treeIndex); + + const ancestorNodes = await client.revisions3D.list3DNodeAncestors( + model.modelId, + model.revisionId, + nodeId + ); + + return ancestorNodes.items; +} + +async function getMappingEdges( + fdmClient: FdmSDK, + fdmConfig: FdmAssetMappingsConfig, + model: CogniteCadModel, + ancestorIds: CogniteInternalId[] +): Promise<{ edges: Array>> }> { + const filter = { + and: [ + { + equals: { + property: ['edge', 'endNode'], + value: { + space: fdmConfig.global3dSpace, + externalId: `model_3d_${model.modelId}` + } + } + }, + { + equals: { + property: [ + fdmConfig.source.space, + `${fdmConfig.source.externalId}/${fdmConfig.source.version}`, + 'revisionId' + ], + value: model.revisionId + } + }, + { + in: { + property: [ + fdmConfig.source.space, + `${fdmConfig.source.externalId}/${fdmConfig.source.version}`, + 'revisionNodeId' + ], + values: ancestorIds + } + } + ] + }; + + return await fdmClient.filterAllInstances(filter, 'edge', fdmConfig.source); +} + +async function inspectNode( + fdmClient: FdmSDK, + dataNode: DmsUniqueIdentifier +): Promise { + const inspectionResult = await fdmClient.inspectInstances({ + inspectionOperations: { involvedViewsAndContainers: {} }, + items: [ + { + instanceType: 'node', + externalId: dataNode.externalId, + space: dataNode.space + } + ] + }); + + return inspectionResult; +} + +async function filterNodeData( + fdmClient: FdmSDK, + dataNode: DmsUniqueIdentifier, + dataView: Source +): Promise | undefined> { + if (dataView === undefined) { + return undefined; + } + + const dataQueryResult = await fdmClient.getByExternalIds( + [{ instanceType: 'node', ...dataNode }], + dataView + ); + + return dataQueryResult.items[0]; +} diff --git a/react-components/src/components/Reveal3DResources/types.ts b/react-components/src/components/Reveal3DResources/types.ts index 87d0319ffc1..821e635c621 100644 --- a/react-components/src/components/Reveal3DResources/types.ts +++ b/react-components/src/components/Reveal3DResources/types.ts @@ -2,14 +2,29 @@ * Copyright 2023 Cognite AS */ -import { type AddModelOptions, type SupportedModelTypes } from '@cognite/reveal'; +import { + type CogniteCadModel, + type AddModelOptions, + type SupportedModelTypes +} from '@cognite/reveal'; import { type Matrix4 } from 'three'; +import { type FdmNode, type Source } from '../../utilities/FdmSDK'; +import { type Node3D } from '@cognite/sdk/dist/src'; export type AddImageCollection360Options = { siteId: string; }; +export type FdmPropertyType = Record>; + export type AddResourceOptions = AddReveal3DModelOptions | AddImageCollection360Options; export type AddReveal3DModelOptions = AddModelOptions & { transform?: Matrix4 }; export type TypedReveal3DModel = AddReveal3DModelOptions & { type: SupportedModelTypes | '' }; + +export type NodeDataResult = { + data: FdmNode; + view: Source; + cadNode: Node3D; + model: CogniteCadModel; +}; diff --git a/react-components/src/hooks/types.ts b/react-components/src/hooks/types.ts index feb77073956..95253774f16 100644 --- a/react-components/src/hooks/types.ts +++ b/react-components/src/hooks/types.ts @@ -12,6 +12,11 @@ export type FdmAssetMappingsConfig = { * FDM space where model assets are located */ assetFdmSpace: string; + /* + * Global FDM 3D space + * TODO: Remove when the system data model is functional + */ + global3dSpace: string; }; export type ThreeDModelMappings = { diff --git a/react-components/src/index.ts b/react-components/src/index.ts index fc84d481b80..d3f559987c4 100644 --- a/react-components/src/index.ts +++ b/react-components/src/index.ts @@ -28,8 +28,10 @@ export { CameraController } from './components/CameraController/CameraController export type { AddImageCollection360Options, AddResourceOptions, - AddReveal3DModelOptions + AddReveal3DModelOptions, + NodeDataResult } from './components/Reveal3DResources/types'; +export type { Source } from './utilities/FdmSDK'; export { RevealToolbar } from './components/RevealToolbar/RevealToolbar'; export { useFdmAssetMappings } from './hooks/useFdmAssetMappings'; export { type FdmAssetMappingsConfig } from './hooks/types'; diff --git a/react-components/src/utilities/FdmSDK.ts b/react-components/src/utilities/FdmSDK.ts index edfd866b161..9507b7c54e5 100644 --- a/react-components/src/utilities/FdmSDK.ts +++ b/react-components/src/utilities/FdmSDK.ts @@ -3,6 +3,7 @@ */ import { type CogniteClient } from '@cognite/sdk'; +import { type FdmPropertyType } from '../components/Reveal3DResources/types'; type InstanceType = 'node' | 'edge'; @@ -26,17 +27,78 @@ export type EdgeItem = { properties: PropertiesType; }; +export type InspectFilter = { + inspectionOperations: { involvedViewsAndContainers: Record }; + items: Array<{ instanceType: InstanceType; externalId: string; space: string }>; +}; + +export type InspectResult = { + involvedViewsAndContainers: { + containers: Array<{ + type: 'container'; + space: string; + externalId: string; + }>; + views: Source[]; + }; +}; + +export type InspectResultList = { + items: Array<{ + instanceType: InstanceType; + externalId: string; + space: string; + inspectionResults: InspectResult; + }>; +}; + +export type FdmNode = { + instanceType: InstanceType; + version: number; + space: string; + externalId: string; + createdTime: number; + lastUpdatedTime: number; + deletedTime: number; + properties: FdmPropertyType; +}; + +export type ExternalIdsResultList = { + items: Array>; + typing?: Record< + string, + Record< + string, + Record< + string, + { + nullable?: boolean; + autoIncrement?: boolean; + defaultValue?: any; + description?: string; + name?: string; + type: { type: string }; + } + > + > + >; +}; + export class FdmSDK { private readonly _sdk: CogniteClient; private readonly _byIdsEndpoint: string; private readonly _listEndpoint: string; + private readonly _inspectEndpoint: string; constructor(sdk: CogniteClient) { const baseUrl = sdk.getBaseUrl(); const project = sdk.project; - this._listEndpoint = `${baseUrl}/api/v1/projects/${project}/models/instances/list`; - this._byIdsEndpoint = `${baseUrl}/api/v1/projects/${project}/models/instances/byids`; + const instancesBaseUrl = `${baseUrl}/api/v1/projects/${project}/models/instances`; + + this._listEndpoint = `${instancesBaseUrl}/list`; + this._byIdsEndpoint = `${instancesBaseUrl}/byids`; + this._inspectEndpoint = `${instancesBaseUrl}/inspect`; this._sdk = sdk; } @@ -64,4 +126,55 @@ export class FdmSDK { } throw new Error(`Failed to fetch instances. Status: ${result.status}`); } + + public async filterAllInstances>( + filter: any, + instanceType: InstanceType, + source?: Source + ): Promise<{ edges: Array> }> { + let mappings = await this.filterInstances(filter, instanceType, source); + + while (mappings.nextCursor !== undefined) { + const nextMappings = await this.filterInstances( + filter, + instanceType, + source, + mappings.nextCursor + ); + + mappings = { + edges: [...mappings.edges, ...nextMappings.edges], + nextCursor: nextMappings.nextCursor + }; + } + + return { edges: mappings.edges }; + } + + public async getByExternalIds( + queries: Array<{ instanceType: InstanceType; externalId: string; space: string }>, + source?: Source + ): Promise> { + const data: any = { items: queries, includeTyping: true }; + if (source !== null) { + data.sources = [{ source }]; + } + + const result = await this._sdk.post(this._byIdsEndpoint, { data }); + if (result.status === 200) { + return result.data; + } + throw new Error(`Failed to fetch instances. Status: ${result.status}`); + } + + public async inspectInstances(inspectFilter: InspectFilter): Promise { + const data: any = inspectFilter; + const result = await this._sdk.post(this._inspectEndpoint, { data }); + + if (result.status === 200) { + return result.data as InspectResultList; + } + + throw new Error(`Failed to fetch instances. Status: ${result.status}`); + } } diff --git a/react-components/stories/HighlightNode.stories.tsx b/react-components/stories/HighlightNode.stories.tsx new file mode 100644 index 00000000000..83f62109de3 --- /dev/null +++ b/react-components/stories/HighlightNode.stories.tsx @@ -0,0 +1,105 @@ +/*! + * Copyright 2023 Cognite AS + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import { + type FdmAssetMappingsConfig, + RevealContainer, + RevealToolbar, + Reveal3DResources, + type NodeDataResult, + type AddResourceOptions +} from '../src'; +import { Color, Matrix4 } from 'three'; +import { type ReactElement, useState } from 'react'; +import { DefaultNodeAppearance, TreeIndexNodeCollection } from '@cognite/reveal'; +import { createSdkByUrlToken } from './utilities/createSdkByUrlToken'; + +const DefaultFdmConfig: FdmAssetMappingsConfig = { + source: { + space: 'hf_3d_schema', + version: '1', + type: 'view', + externalId: 'cdf_3d_connection_data' + }, + global3dSpace: 'hf_3d_global_data', + assetFdmSpace: 'hf_customer_a' +}; + +const meta = { + title: 'Example/HighlightNode', + component: Reveal3DResources, + tags: ['autodocs'] +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const sdk = createSdkByUrlToken(); + +export const Main: Story = { + args: { + resources: [ + { + modelId: 2551525377383868, + revisionId: 2143672450453400, + transform: new Matrix4().makeTranslation(-340, -480, 80) + } + ], + styling: {}, + fdmAssetMappingConfig: DefaultFdmConfig + }, + render: ({ resources, fdmAssetMappingConfig }) => { + return ( + + + + ); + } +}; + +const StoryContent = ({ + resources, + fdmAssetMappingConfig +}: { + resources: AddResourceOptions[]; + fdmAssetMappingConfig: FdmAssetMappingsConfig; +}): ReactElement => { + const [nodeData, setNodeData] = useState(); + + const [highlightedId, setHighlightedId] = useState(''); + + const callback = (nodeData: NodeDataResult): void => { + setNodeData(nodeData.data); + + setHighlightedId(nodeData.data.externalId); + nodeData.model.assignStyledNodeCollection( + new TreeIndexNodeCollection([nodeData.cadNode.treeIndex]), + DefaultNodeAppearance.Highlighted + ); + }; + + return ( + <> + + + NodeData is: {JSON.stringify(nodeData)} + + ); +}; diff --git a/react-components/stories/Reveal3DResources.stories.tsx b/react-components/stories/Reveal3DResources.stories.tsx index 2710bcb5b4e..a47b358e01f 100644 --- a/react-components/stories/Reveal3DResources.stories.tsx +++ b/react-components/stories/Reveal3DResources.stories.tsx @@ -120,7 +120,8 @@ export const Main: Story = { type: 'view', externalId: 'CDF_3D_Connection_Data' }, - assetFdmSpace: 'bark-corporation' + assetFdmSpace: 'bark-corporation', + global3dSpace: 'hf_3d_global_data' } }, render: ({ resources, styling, fdmAssetMappingConfig }) => {