diff --git a/react-components/src/components/RevealCanvas/ViewerContext.ts b/react-components/src/components/RevealCanvas/ViewerContext.ts deleted file mode 100644 index 77ce6fee6f8..00000000000 --- a/react-components/src/components/RevealCanvas/ViewerContext.ts +++ /dev/null @@ -1,21 +0,0 @@ -/*! - * Copyright 2023 Cognite AS - */ -import { type Cognite3DViewer } from '@cognite/reveal'; -import { createContext, useContext } from 'react'; -import { type RevealRenderTarget } from '../../architecture/base/renderTarget/RevealRenderTarget'; - -export const ViewerContext = createContext(null); - -export const useReveal = (): Cognite3DViewer => { - const renderTarget = useRenderTarget(); - return renderTarget.viewer; -}; - -export const useRenderTarget = (): RevealRenderTarget => { - const renderTarget = useContext(ViewerContext); - if (renderTarget === null) { - throw new Error('useRenderTarget must be used within a ViewerProvider'); - } - return renderTarget; -}; diff --git a/react-components/src/components/RevealCanvas/ViewerContext.tsx b/react-components/src/components/RevealCanvas/ViewerContext.tsx new file mode 100644 index 00000000000..9a7f61ddce9 --- /dev/null +++ b/react-components/src/components/RevealCanvas/ViewerContext.tsx @@ -0,0 +1,54 @@ +/*! + * Copyright 2023 Cognite AS + */ +import { type Cognite3DViewer } from '@cognite/reveal'; +import { createContext, type ReactElement, type ReactNode, useContext } from 'react'; +import { type RevealRenderTarget } from '../../architecture/base/renderTarget/RevealRenderTarget'; +import { type CameraStateParameters, useCameraStateControl } from './hooks/useCameraStateControl'; + +const ViewerContext = createContext(null); + +export type ViewerContextProviderProps = { + cameraState?: CameraStateParameters; + setCameraState?: (cameraState?: CameraStateParameters) => void; + value: RevealRenderTarget | null; + children: ReactNode; +}; + +export const ViewerContextProvider = ({ + cameraState, + setCameraState, + value, + children +}: ViewerContextProviderProps): ReactElement => { + return ( + + + {children} + + ); +}; + +const ViewerControls = ({ + cameraState, + setCameraState +}: { + cameraState?: CameraStateParameters; + setCameraState?: (cameraState?: CameraStateParameters) => void; +}): ReactNode => { + useCameraStateControl(cameraState, setCameraState); + return null; +}; + +export const useReveal = (): Cognite3DViewer => { + const renderTarget = useRenderTarget(); + return renderTarget.viewer; +}; + +export const useRenderTarget = (): RevealRenderTarget => { + const renderTarget = useContext(ViewerContext); + if (renderTarget === null) { + throw new Error('useRenderTarget must be used within a ViewerProvider'); + } + return renderTarget; +}; diff --git a/react-components/src/components/RevealCanvas/hooks/useCameraStateControl.ts b/react-components/src/components/RevealCanvas/hooks/useCameraStateControl.ts new file mode 100644 index 00000000000..30ed98d3dc4 --- /dev/null +++ b/react-components/src/components/RevealCanvas/hooks/useCameraStateControl.ts @@ -0,0 +1,98 @@ +/*! + * Copyright 2023 Cognite AS + */ + +import { type MutableRefObject, useEffect, useRef } from 'react'; +import { useReveal } from '../ViewerContext'; +import { type CameraState } from '@cognite/reveal'; + +export type CameraStateParameters = Omit, 'rotation'>; + +export const useCameraStateControl = ( + externalCameraState?: CameraStateParameters, + setCameraState?: (cameraState?: CameraStateParameters) => void +): void => { + const lastSetExternalState = useRef( + externalCameraState === undefined + ? undefined + : { + position: externalCameraState.position.clone(), + target: externalCameraState.target.clone() + } + ); + + useSetInternalCameraStateOnExternalUpdate(externalCameraState, lastSetExternalState); + + useSetExternalCameraStateOnCameraMove(setCameraState, externalCameraState, lastSetExternalState); +}; + +const useSetInternalCameraStateOnExternalUpdate = ( + externalCameraState: CameraStateParameters | undefined, + lastSetExternalState: MutableRefObject +): void => { + const reveal = useReveal(); + + useEffect(() => { + if ( + externalCameraState === undefined || + isCameraStatesEqual(externalCameraState, lastSetExternalState.current) + ) { + return; + } + + reveal.cameraManager.setCameraState(externalCameraState); + }, [externalCameraState]); +}; + +const useSetExternalCameraStateOnCameraMove = ( + setCameraState: ((cameraState?: CameraStateParameters) => void) | undefined, + externalCameraState: CameraStateParameters | undefined, + lastSetExternalState: MutableRefObject +): void => { + const reveal = useReveal(); + useEffect(() => { + const updateStateOnCameraStop = (): void => { + const currentCameraManagerState = reveal.cameraManager.getCameraState(); + + if ( + externalCameraState !== undefined && + isCameraStatesEqual(externalCameraState, currentCameraManagerState) + ) { + return; + } + + lastSetExternalState.current = { + position: currentCameraManagerState.position.clone(), + target: currentCameraManagerState.target.clone() + }; + + setCameraState?.(currentCameraManagerState); + }; + + reveal.cameraManager.on('cameraStop', updateStateOnCameraStop); + return () => { + reveal.cameraManager.off('cameraStop', updateStateOnCameraStop); + }; + }, [externalCameraState, setCameraState, lastSetExternalState]); +}; + +function isCameraStatesEqual( + previous: CameraStateParameters | undefined, + current: CameraStateParameters | undefined +): boolean { + if (previous === undefined && current === undefined) { + return true; + } + + if (previous === undefined || current === undefined) { + return false; + } + + const epsilon = 0.001; + const { position: previousPosition, target: previousTarget } = previous; + const { position: currentPosition, target: currentTarget } = current; + return ( + previousPosition.distanceToSquared(currentPosition) <= epsilon && + previousTarget.distanceToSquared(currentTarget) <= epsilon + ); +} diff --git a/react-components/src/components/RevealContext/RevealContext.tsx b/react-components/src/components/RevealContext/RevealContext.tsx index 680eb100024..4733eac8f9e 100644 --- a/react-components/src/components/RevealContext/RevealContext.tsx +++ b/react-components/src/components/RevealContext/RevealContext.tsx @@ -6,7 +6,7 @@ import { type CogniteClient } from '@cognite/sdk/dist/src'; import { type ReactNode, useEffect, useMemo, useState, type ReactElement } from 'react'; import { type Color } from 'three'; import { I18nContextProvider } from '../i18n/I18n'; -import { ViewerContext } from '../RevealCanvas/ViewerContext'; +import { ViewerContextProvider } from '../RevealCanvas/ViewerContext'; import { NodeCacheProvider } from '../CacheProvider/NodeCacheProvider'; import { AssetMappingAndNode3DCacheProvider } from '../CacheProvider/AssetMappingAndNode3DCacheProvider'; import { PointCloudAnnotationCacheProvider } from '../CacheProvider/PointCloudAnnotationCacheProvider'; @@ -17,6 +17,7 @@ import { useRevealKeepAlive } from '../RevealKeepAlive/RevealKeepAliveContext'; import { Image360AnnotationCacheProvider } from '../CacheProvider/Image360AnnotationCacheProvider'; import { RevealRenderTarget } from '../../architecture/base/renderTarget/RevealRenderTarget'; import { LoadedSceneProvider } from '../SceneContainer/LoadedSceneContext'; +import { type CameraStateParameters } from '../RevealCanvas/hooks/useCameraStateControl'; export type RevealContextProps = { color?: Color; @@ -24,6 +25,8 @@ export type RevealContextProps = { appLanguage?: string; children?: ReactNode; useCoreDm?: boolean; + cameraState?: CameraStateParameters; + setCameraState?: (cameraState?: CameraStateParameters) => void; viewerOptions?: Pick< Cognite3DViewerOptions, | 'antiAliasingHint' @@ -51,7 +54,10 @@ export const RevealContext = (props: RevealContextProps): ReactElement => { - + @@ -63,7 +69,7 @@ export const RevealContext = (props: RevealContextProps): ReactElement => { - + diff --git a/react-components/src/higher-order-components/withCameraStateUrlParam.tsx b/react-components/src/higher-order-components/withCameraStateUrlParam.tsx deleted file mode 100644 index 36730c4761b..00000000000 --- a/react-components/src/higher-order-components/withCameraStateUrlParam.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/*! - * Copyright 2023 Cognite AS - */ - -import { useEffect, type ReactElement, type FunctionComponent } from 'react'; -import { useReveal } from '..'; -import { type CameraState } from '@cognite/reveal'; - -export type CameraStateParameters = Omit, 'rotation'>; -export type CameraStateProps = { - cameraState: CameraStateParameters | undefined; - setCameraState: (value: CameraStateParameters | undefined) => void; -}; - -export function withCameraStateControl( - Component: FunctionComponent -): FunctionComponent { - return function CameraStateUrlParam(props: T & CameraStateProps): ReactElement { - const reveal = useReveal(); - const externalCameraState = props.cameraState; - - const setUrlParamOnCameraStop = (): void => { - const currentCameraManagerState = reveal.cameraManager.getCameraState(); - if ( - externalCameraState !== undefined && - !hasCameraStateChanged(externalCameraState, currentCameraManagerState) - ) { - return; - } - - props.setCameraState(currentCameraManagerState); - }; - - useEffect(() => { - reveal.cameraManager.on('cameraStop', setUrlParamOnCameraStop); - return () => { - reveal.cameraManager.off('cameraStop', setUrlParamOnCameraStop); - }; - }, []); - - return ; - }; - - function hasCameraStateChanged( - previous: CameraStateParameters, - current: CameraStateParameters - ): boolean { - const epsilon = 0.001; - const { position: previousPosition, target: previousTarget } = previous; - const { position: currentPosition, target: currentTarget } = current; - return ( - previousPosition.distanceToSquared(currentPosition) > epsilon || - previousTarget.distanceToSquared(currentTarget) > epsilon - ); - } -} diff --git a/react-components/src/index.ts b/react-components/src/index.ts index c9e847d8e2a..370001fd6d3 100644 --- a/react-components/src/index.ts +++ b/react-components/src/index.ts @@ -82,11 +82,7 @@ export { useModelsForInstanceQuery } from './query/useModelsForInstanceQuery'; // Higher order components export { withSuppressRevealEvents } from './higher-order-components/withSuppressRevealEvents'; -export { - withCameraStateControl, - type CameraStateParameters, - type CameraStateProps -} from './higher-order-components/withCameraStateUrlParam'; +export { type CameraStateParameters } from './components/RevealCanvas/hooks/useCameraStateControl'; // Types export { type PointCloudModelStyling, diff --git a/react-components/tests/unit-tests/components/RevealCanvas/hooks/useCameraStateControl.test.ts b/react-components/tests/unit-tests/components/RevealCanvas/hooks/useCameraStateControl.test.ts new file mode 100644 index 00000000000..00f1819d587 --- /dev/null +++ b/react-components/tests/unit-tests/components/RevealCanvas/hooks/useCameraStateControl.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test, vi, beforeEach, beforeAll, afterAll } from 'vitest'; + +import { renderHook } from '@testing-library/react'; + +import { viewerMock } from '../../../fixtures/viewer'; +import { + CameraStateParameters, + useCameraStateControl +} from '../../../../../src/components/RevealCanvas/hooks/useCameraStateControl'; +import { Vector3 } from 'three'; +import { cameraManagerGlobalCameraEvents } from '../../../fixtures/cameraManager'; + +vi.mock('../../../../../src/components/RevealCanvas/ViewerContext', () => ({ + useReveal: () => viewerMock +})); + +describe(useCameraStateControl.name, () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + test('does nothing when inputs are undefined', () => { + const { rerender } = renderHook(() => useCameraStateControl()); + + vi.runAllTimers(); + rerender(); + vi.runAllTimers(); + + cameraManagerGlobalCameraEvents.cameraStop.forEach((mockCallback) => + expect(mockCallback).not.toBeCalled() + ); + }); + + test('does nothing if external camera state is undefined', () => { + const setter = vi.fn(); + const { rerender } = renderHook(() => useCameraStateControl(undefined, setter)); + + vi.runAllTimers(); + rerender(); + vi.runAllTimers(); + + expect(setter).not.toBeCalled(); + }); + + test('calls internal cameraStop delegates but not external setter on applying external camera state', () => { + const setter = vi.fn<[CameraStateParameters | undefined], void>(); + + const { rerender } = renderHook( + ({ position }: { position: Vector3 }) => + useCameraStateControl({ position: position.clone(), target: new Vector3(1, 1, 1) }, setter), + { initialProps: { position: new Vector3(0, 0, 0) } } + ); + + vi.runAllTimers(); + rerender({ position: new Vector3(1, 0, 0) }); + + vi.runAllTimers(); + expect(setter).not.toBeCalled(); + + cameraManagerGlobalCameraEvents.cameraStop.forEach((mockCallback) => + expect(mockCallback).toBeCalledTimes(1) + ); + }); + + test('provided setter is called after updating camera state internally', () => { + const setter = vi.fn<[CameraStateParameters | undefined], void>(); + + const { rerender } = renderHook(() => + useCameraStateControl( + { position: new Vector3(0, 0, 0), target: new Vector3(1, 1, 1) }, + setter + ) + ); + + vi.runAllTimers(); + + viewerMock.cameraManager.setCameraState({ + position: new Vector3(1, 0, 0), + target: new Vector3(1, 1, 1) + }); + + vi.runAllTimers(); + + rerender(); + vi.runAllTimers(); + + expect(setter).toHaveBeenCalled(); + }); +}); diff --git a/react-components/tests/unit-tests/fixtures/cameraManager.ts b/react-components/tests/unit-tests/fixtures/cameraManager.ts new file mode 100644 index 00000000000..7612a8c9774 --- /dev/null +++ b/react-components/tests/unit-tests/fixtures/cameraManager.ts @@ -0,0 +1,44 @@ +import { CameraManager, CameraManagerEventType, CameraState } from '@cognite/reveal'; +import { remove } from 'lodash'; +import { Mock } from 'moq.ts'; +import { Vector3 } from 'three'; + +import { vi, Mock as viMock } from 'vitest'; + +export const cameraManagerGlobalCameraEvents: Record< + CameraManagerEventType, + viMock<[Vector3, Vector3], void>[] +> = { + cameraChange: [], + cameraStop: [] +}; + +const cameraManagerGlobalCurrentCameraState: CameraState = {}; + +export const cameraManagerMock = new Mock() + .setup((p) => p.on) + .returns((eventType, callback) => + cameraManagerGlobalCameraEvents[eventType].push(vi.fn().mockImplementation(callback)) + ) + .setup((p) => p.off) + .returns((eventType, callback) => + remove( + cameraManagerGlobalCameraEvents[eventType], + (element) => element.getMockImplementation() === callback + ) + ) + .setup((p) => p.setCameraState) + .returns(({ position, target }) => { + cameraManagerGlobalCurrentCameraState.position = position; + cameraManagerGlobalCurrentCameraState.target = target; + setTimeout( + () => + cameraManagerGlobalCameraEvents.cameraStop.forEach((callback) => + callback(position!, target!) + ), + 50 + ); + }) + .setup((p) => p.getCameraState()) + .returns(cameraManagerGlobalCurrentCameraState as Required) + .object(); diff --git a/react-components/tests/unit-tests/fixtures/viewer.ts b/react-components/tests/unit-tests/fixtures/viewer.ts index c86f1401729..5c9f797f883 100644 --- a/react-components/tests/unit-tests/fixtures/viewer.ts +++ b/react-components/tests/unit-tests/fixtures/viewer.ts @@ -1,7 +1,15 @@ -import { vi } from 'vitest'; +import { vi, Mock as viMock } from 'vitest'; -import { Cognite3DViewer, CogniteModel, Image360Collection } from '@cognite/reveal'; +import { + CameraManagerEventType, + Cognite3DViewer, + CogniteModel, + Image360Collection +} from '@cognite/reveal'; import { Mock, It } from 'moq.ts'; +import { Vector3 } from 'three'; +import { remove } from 'lodash'; +import { cameraManagerMock } from './cameraManager'; const domElement = document.createElement('div').appendChild(document.createElement('canvas')); @@ -20,4 +28,6 @@ export const viewerMock = new Mock() .callback(viewerImage360CollectionsMock) .setup((p) => p.removeModel) .returns(viewerRemoveModelsMock) + .setup((p) => p.cameraManager) + .returns(cameraManagerMock) .object();