From 244a535b5ae563399642cca9ce194416526ef333 Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Fri, 24 May 2024 16:49:37 +0200 Subject: [PATCH] (feat) Architecture on top of Reveal (#4403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial commit * Update .prettierrc * Fixing lint errors * Update RevealRenderTarget.ts * More fixes * Update RevealRenderTarget.ts * Initial commit * Finishing * Fixing * Moving around * Moving * Revert "Moving around" This reverts commit f09ef09cd3f9d84e3a1379a2e121a66307109454. * Update RevealRenderTarget.ts * Removing constructors * Update SurfaceRenderStyle.ts * Fixing smaller issues * SurfaceView * Lights * Moving code around * More * More fixes * Remove name() * Fixing boundingBox * Dispose materials * Fix matrial * Fix materials * Fixing * Moving files * Edit tool * Move of box * More * Fix events * Update BaseTool.ts * Fix interface * Update BaseTool.ts * Add rotation * Update BoxEditTool.ts * Update BaseTool.ts * Moving things around * Move files * Fix tool controller * Before refacoring box edit * Fixing edit of box * Add box edit * More edit of points * Comments * Fixes * Update BoxThreeView.ts * Update BoxThreeView.ts * Fixing * Fixes * Fixing editing * More edit fixes * Fix lint * Updating lock files * Update BoxThreeView.ts * Tuning * Add axis * Renaming * Renaming and small fixes * Optimizing * Update README.md * Update vectorExtensions.ts * Update README.md * Update AxisThreeView.ts * Fix crash in stories * Update AxisThreeView.ts * Update AxisThreeView.ts * Update AxisThreeView.ts * Fixes * Update GroupThreeView.ts * Update README.md * Update README.md * Update README.md * Update README.md * Update README.md * Some reorginizing * Update README.md * Rewrite hover point in box * removed typescript-eslint/class-literal-property-style lint rule * Update tsconfig.json * Add some getters * Add area, strings and fix linter errors * Update BoxThreeView.ts * Add measure line and generalized a lot * Some renaming * fix: typos (#4504) Co-authored-by: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> * More fixes * add support of depthTest * Update reveal-react-components.json * feat: add added/deleted events * remove one option * Fix smaller bugs in picking * feat: React panel reacting to domain object updates * Fixing linting + base class for all measure domain objects * Generalizing the card (Part 1) * fix: don't recreate Reveal on every RevealStoryBook rerender * Fix area * Fixes * Refacor the updating of the panel * Fixing the panel * Add proper Icons * Fixing the panel * Update DomainObjectPanel.tsx * Fixed strings * Simple Unit support * Add ShowMeasurmentsOnTopCommand * Update MeasureRenderStyle.ts * Add extra toolbar * Optimizing * Active tool updater * Move some files * Implement popup style for placement * Smaller fixes * Smaller fixes * Fix some selection issue * Fix focus * Fixing selection and focus * Some refacturing * chore: update visual test image * Fix react stuff * Renaming * chore: export relevant React components and fix typo * chore: don't export CommandButton * Fix text alignment and size --------- Co-authored-by: Pramod S Co-authored-by: Christopher J. Tannum Co-authored-by: Håkon Flatval --- .gitignore | 1 + react-components/.eslintrc.cjs | 3 + .../RenderTarget/RevealRenderTarget.ts | 75 -- .../src/architecture/base/README.md | 186 +++++ .../architecture/base/commands/BaseCommand.ts | 99 +++ .../base/commands/BaseEditTool.ts | 97 +++ .../architecture/base/commands/BaseTool.ts | 189 +++++ .../base/commands/NavigationTool.ts | 66 ++ .../base/commands/RenderTargetCommand.ts | 29 + .../base/concreteCommands/FitViewCommand.ts | 28 + .../SetFlexibleControlsTypeCommand.ts | 98 +++ .../base/domainObjects/DomainObject.ts | 729 ++++++++++++++++++ .../base/domainObjects/FolderDomainObject.ts | 15 + .../base/domainObjects/RootDomainObject.ts | 24 + .../base/domainObjects/VisualDomainObject.ts | 103 +++ .../base/domainObjectsHelpers/BaseCreator.ts | 110 +++ .../base/domainObjectsHelpers/BaseDragger.ts | 43 ++ .../base/domainObjectsHelpers/Changes.ts | 28 + .../base/domainObjectsHelpers/Class.ts | 10 + .../base/domainObjectsHelpers/ColorType.ts | 12 + .../DomainObjectChange.ts | 125 +++ .../DomainObjectIntersection.ts | 29 + .../base/domainObjectsHelpers/FocusType.ts | 13 + .../base/domainObjectsHelpers/PanelInfo.ts | 82 ++ .../base/domainObjectsHelpers/PopupStyle.ts | 63 ++ .../base/domainObjectsHelpers/RenderStyle.ts | 7 + .../base/domainObjectsHelpers/Views.ts | 95 +++ .../base/domainObjectsHelpers/VisibleState.ts | 11 + .../base/reactUpdaters/ActiveToolUpdater.ts | 34 + .../reactUpdaters/DomainObjectPanelUpdater.ts | 40 + .../base/renderTarget/RevealRenderTarget.ts | 242 ++++++ .../base/renderTarget/ToolController.ts | 225 ++++++ .../base/utilities/box/BoxFace.ts | 138 ++++ .../base/utilities/box/BoxPickInfo.ts | 39 + .../createLineSegmentsBufferGeometryForBox.ts | 62 ++ .../utilities/colors/ColorInterpolation.ts | 9 + .../base/utilities/colors/ColorMap.ts | 146 ++++ .../base/utilities/colors/ColorMapItem.ts | 42 + .../base/utilities/colors/ColorMapType.ts | 14 + .../base/utilities/colors/colorExtensions.ts | 58 ++ .../base/utilities/colors/colorMaps.ts | 115 +++ .../base/utilities/colors/create1DTexture.ts | 30 + .../base/utilities/colors/getNextColor.ts | 56 ++ .../utilities/extensions/arrayExtensions.ts | 34 + .../utilities/extensions/mathExtensions.ts | 184 +++++ .../utilities/extensions/rayExtensions.ts | 21 + .../utilities/extensions/stringExtensions.ts | 24 + .../utilities/extensions/vectorExtensions.ts | 53 ++ .../base/utilities/geometry/Index2.ts | 78 ++ .../base/utilities/geometry/Points.ts | 48 ++ .../base/utilities/geometry/Polyline.ts | 69 ++ .../base/utilities/geometry/Range1.ts | 268 +++++++ .../base/utilities/geometry/Range3.ts | 242 ++++++ .../base/utilities/geometry/Shape.ts | 50 ++ .../utilities/geometry/TrianglesBuffers.ts | 138 ++++ .../base/utilities/geometry/Vector3Pool.ts | 28 + .../utilities/geometry/getResizeCursor.ts | 28 + .../base/utilities/sprites/createSprite.ts | 196 +++++ .../src/architecture/base/views/BaseView.ts | 94 +++ .../architecture/base/views/GroupThreeView.ts | 221 ++++++ .../src/architecture/base/views/ThreeView.ts | 120 +++ .../concrete/axis/AxisDomainObject.ts | 31 + .../concrete/axis/AxisRenderStyle.ts | 84 ++ .../concrete/axis/AxisThreeView.ts | 628 +++++++++++++++ .../concrete/axis/SetAxisVisibleCommand.ts | 54 ++ .../boxDomainObject/MeasureBoxCreator.ts | 187 +++++ .../boxDomainObject/MeasureBoxDomainObject.ts | 213 +++++ .../boxDomainObject/MeasureBoxDragger.ts | 248 ++++++ .../boxDomainObject/MeasureBoxRenderStyle.ts | 24 + .../boxDomainObject/MeasureBoxView.ts | 655 ++++++++++++++++ .../boxDomainObject/MeasureDomainObject.ts | 73 ++ .../boxDomainObject/MeasureLineCreator.ts | 102 +++ .../MeasureLineDomainObject.ts | 184 +++++ .../boxDomainObject/MeasureLineRenderStyle.ts | 25 + .../boxDomainObject/MeasureLineView.ts | 257 ++++++ .../boxDomainObject/MeasureRenderStyle.ts | 21 + .../concrete/boxDomainObject/MeasureType.ts | 92 +++ .../boxDomainObject/MeasurementFunctions.ts | 30 + .../boxDomainObject/MeasurementTool.ts | 343 ++++++++ .../SetMeasurmentTypeCommand.ts | 84 ++ .../ShowMeasurmentsOnTopCommand.ts | 59 ++ .../SetTerrainVisibleCommand.ts | 47 ++ .../TerrainDomainObject.ts | 79 ++ .../terrainDomainObject/TerrainRenderStyle.ts | 40 + .../terrainDomainObject/TerrainThreeView.ts | 272 +++++++ .../UpdateTerrainCommand.ts | 59 ++ .../geometry/ContouringService.ts | 176 +++++ .../terrainDomainObject/geometry/Grid2.ts | 62 ++ .../geometry/RegularGrid2.ts | 358 +++++++++ .../geometry/RegularGrid2Buffers.ts | 111 +++ .../geometry/createFractalRegularGrid2.ts | 109 +++ .../Architecture/ActiveToolToolbar.tsx | 62 ++ .../components/Architecture/CommandButton.tsx | 72 ++ .../Architecture/DomainObjectPanel.tsx | 160 ++++ .../components/Architecture/ToolButtons.tsx | 29 + .../components/RevealCanvas/ViewerContext.ts | 2 +- .../RevealContext/RevealContext.tsx | 2 +- .../RevealKeepAlive/RevealKeepAlive.tsx | 2 +- .../RevealKeepAlive/RevealKeepAliveContext.ts | 2 +- .../src/hooks/useGroundPlaneFromScene.tsx | 3 +- react-components/src/index.ts | 4 + .../stories/Architecture.stories.tsx | 140 ++++ .../utilities/RevealStoryContainer.tsx | 33 +- .../components/RevealContainer.test.tsx | 2 +- .../cad-model-container-storybook-1.png | Bin 115989 -> 120938 bytes react-components/tsconfig.json | 19 +- 106 files changed, 10655 insertions(+), 100 deletions(-) delete mode 100644 react-components/src/architecture/RenderTarget/RevealRenderTarget.ts create mode 100644 react-components/src/architecture/base/README.md create mode 100644 react-components/src/architecture/base/commands/BaseCommand.ts create mode 100644 react-components/src/architecture/base/commands/BaseEditTool.ts create mode 100644 react-components/src/architecture/base/commands/BaseTool.ts create mode 100644 react-components/src/architecture/base/commands/NavigationTool.ts create mode 100644 react-components/src/architecture/base/commands/RenderTargetCommand.ts create mode 100644 react-components/src/architecture/base/concreteCommands/FitViewCommand.ts create mode 100644 react-components/src/architecture/base/concreteCommands/SetFlexibleControlsTypeCommand.ts create mode 100644 react-components/src/architecture/base/domainObjects/DomainObject.ts create mode 100644 react-components/src/architecture/base/domainObjects/FolderDomainObject.ts create mode 100644 react-components/src/architecture/base/domainObjects/RootDomainObject.ts create mode 100644 react-components/src/architecture/base/domainObjects/VisualDomainObject.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/BaseCreator.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/BaseDragger.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/Changes.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/Class.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/ColorType.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/DomainObjectChange.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/DomainObjectIntersection.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/FocusType.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/PanelInfo.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/PopupStyle.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/RenderStyle.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/Views.ts create mode 100644 react-components/src/architecture/base/domainObjectsHelpers/VisibleState.ts create mode 100644 react-components/src/architecture/base/reactUpdaters/ActiveToolUpdater.ts create mode 100644 react-components/src/architecture/base/reactUpdaters/DomainObjectPanelUpdater.ts create mode 100644 react-components/src/architecture/base/renderTarget/RevealRenderTarget.ts create mode 100644 react-components/src/architecture/base/renderTarget/ToolController.ts create mode 100644 react-components/src/architecture/base/utilities/box/BoxFace.ts create mode 100644 react-components/src/architecture/base/utilities/box/BoxPickInfo.ts create mode 100644 react-components/src/architecture/base/utilities/box/createLineSegmentsBufferGeometryForBox.ts create mode 100644 react-components/src/architecture/base/utilities/colors/ColorInterpolation.ts create mode 100644 react-components/src/architecture/base/utilities/colors/ColorMap.ts create mode 100644 react-components/src/architecture/base/utilities/colors/ColorMapItem.ts create mode 100644 react-components/src/architecture/base/utilities/colors/ColorMapType.ts create mode 100644 react-components/src/architecture/base/utilities/colors/colorExtensions.ts create mode 100644 react-components/src/architecture/base/utilities/colors/colorMaps.ts create mode 100644 react-components/src/architecture/base/utilities/colors/create1DTexture.ts create mode 100644 react-components/src/architecture/base/utilities/colors/getNextColor.ts create mode 100644 react-components/src/architecture/base/utilities/extensions/arrayExtensions.ts create mode 100644 react-components/src/architecture/base/utilities/extensions/mathExtensions.ts create mode 100644 react-components/src/architecture/base/utilities/extensions/rayExtensions.ts create mode 100644 react-components/src/architecture/base/utilities/extensions/stringExtensions.ts create mode 100644 react-components/src/architecture/base/utilities/extensions/vectorExtensions.ts create mode 100644 react-components/src/architecture/base/utilities/geometry/Index2.ts create mode 100644 react-components/src/architecture/base/utilities/geometry/Points.ts create mode 100644 react-components/src/architecture/base/utilities/geometry/Polyline.ts create mode 100644 react-components/src/architecture/base/utilities/geometry/Range1.ts create mode 100644 react-components/src/architecture/base/utilities/geometry/Range3.ts create mode 100644 react-components/src/architecture/base/utilities/geometry/Shape.ts create mode 100644 react-components/src/architecture/base/utilities/geometry/TrianglesBuffers.ts create mode 100644 react-components/src/architecture/base/utilities/geometry/Vector3Pool.ts create mode 100644 react-components/src/architecture/base/utilities/geometry/getResizeCursor.ts create mode 100644 react-components/src/architecture/base/utilities/sprites/createSprite.ts create mode 100644 react-components/src/architecture/base/views/BaseView.ts create mode 100644 react-components/src/architecture/base/views/GroupThreeView.ts create mode 100644 react-components/src/architecture/base/views/ThreeView.ts create mode 100644 react-components/src/architecture/concrete/axis/AxisDomainObject.ts create mode 100644 react-components/src/architecture/concrete/axis/AxisRenderStyle.ts create mode 100644 react-components/src/architecture/concrete/axis/AxisThreeView.ts create mode 100644 react-components/src/architecture/concrete/axis/SetAxisVisibleCommand.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasureBoxCreator.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasureBoxDomainObject.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasureBoxDragger.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasureBoxRenderStyle.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasureBoxView.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasureDomainObject.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasureLineCreator.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasureLineDomainObject.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasureLineRenderStyle.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasureLineView.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasureRenderStyle.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasureType.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasurementFunctions.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/MeasurementTool.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/SetMeasurmentTypeCommand.ts create mode 100644 react-components/src/architecture/concrete/boxDomainObject/ShowMeasurmentsOnTopCommand.ts create mode 100644 react-components/src/architecture/concrete/terrainDomainObject/SetTerrainVisibleCommand.ts create mode 100644 react-components/src/architecture/concrete/terrainDomainObject/TerrainDomainObject.ts create mode 100644 react-components/src/architecture/concrete/terrainDomainObject/TerrainRenderStyle.ts create mode 100644 react-components/src/architecture/concrete/terrainDomainObject/TerrainThreeView.ts create mode 100644 react-components/src/architecture/concrete/terrainDomainObject/UpdateTerrainCommand.ts create mode 100644 react-components/src/architecture/concrete/terrainDomainObject/geometry/ContouringService.ts create mode 100644 react-components/src/architecture/concrete/terrainDomainObject/geometry/Grid2.ts create mode 100644 react-components/src/architecture/concrete/terrainDomainObject/geometry/RegularGrid2.ts create mode 100644 react-components/src/architecture/concrete/terrainDomainObject/geometry/RegularGrid2Buffers.ts create mode 100644 react-components/src/architecture/concrete/terrainDomainObject/geometry/createFractalRegularGrid2.ts create mode 100644 react-components/src/components/Architecture/ActiveToolToolbar.tsx create mode 100644 react-components/src/components/Architecture/CommandButton.tsx create mode 100644 react-components/src/components/Architecture/DomainObjectPanel.tsx create mode 100644 react-components/src/components/Architecture/ToolButtons.tsx create mode 100644 react-components/stories/Architecture.stories.tsx diff --git a/.gitignore b/.gitignore index 116e138ba5d..0e986dd983c 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ examples/public/ documentation/.docusaurus documentation/generated documentation/docs/api +/react-components/stories/public/cadModel diff --git a/react-components/.eslintrc.cjs b/react-components/.eslintrc.cjs index 31273384066..dadca32303d 100644 --- a/react-components/.eslintrc.cjs +++ b/react-components/.eslintrc.cjs @@ -29,6 +29,9 @@ module.exports = { ], '@typescript-eslint/consistent-type-definitions': ['error', 'type'], '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/class-literal-property-style': 'off', + '@typescript-eslint/no-extraneous-class': 'off', + '@typescript-eslint/prefer-optional-chain': 'off', 'no-console': [2, { allow: ['warn', 'error'] }], eqeqeq: ['error', 'always'] }, diff --git a/react-components/src/architecture/RenderTarget/RevealRenderTarget.ts b/react-components/src/architecture/RenderTarget/RevealRenderTarget.ts deleted file mode 100644 index 9503d8a950c..00000000000 --- a/react-components/src/architecture/RenderTarget/RevealRenderTarget.ts +++ /dev/null @@ -1,75 +0,0 @@ -/*! - * Copyright 2024 Cognite AS - */ - -import { - type Cognite3DViewer - // type IFlexibleCameraManager, - // asFlexibleCameraManager -} from '@cognite/reveal'; -// import { ToolController } from './ToolController'; -import { AxisGizmoTool } from '@cognite/reveal/tools'; -// import { NavigationTool } from '../ConcreteTools/NavigationTool'; - -// Note: ToolController will be added later -export class RevealRenderTarget { - // ================================================== - // INSTANCE FIELDS - // ================================================== - - private readonly _viewer: Cognite3DViewer; - // private readonly _toolController: ToolController; - private _axisGizmoTool: AxisGizmoTool | undefined; - - // ================================================== - // CONTRUCTORS - // ================================================== - - constructor(viewer: Cognite3DViewer) { - this._viewer = viewer; - // this._toolController = new ToolController(this.domElement); - // this._toolController.addEventListeners(); - } - - public initialize(): void { - this._axisGizmoTool = new AxisGizmoTool(); - this._axisGizmoTool.connect(this._viewer); - - // const navigationTool = new NavigationTool(this); - // this.toolController.add(navigationTool); - // this.toolController.setActiveTool(navigationTool); - } - - public dispose(): void { - // this.toolController.removeEventListeners(); - this._axisGizmoTool?.dispose(); - } - - // ================================================== - // INSTANCE PROPERTIES - // ================================================== - - public get /* override */ viewer(): Cognite3DViewer { - return this._viewer; - } - - public get /* override */ canvas(): HTMLCanvasElement { - return this._viewer.canvas; - } - - public get /* override */ domElement(): HTMLElement { - return this._viewer.domElement; - } - - // public get toolController(): ToolController { - // return this._toolController; - // } - - // public get cameraManager(): IFlexibleCameraManager { - // const cameraManager = asFlexibleCameraManager(this.viewer.cameraManager); - // if (cameraManager === undefined) { - // throw new Error('Camera manager is not flexible'); - // } - // return cameraManager; - // } -} diff --git a/react-components/src/architecture/base/README.md b/react-components/src/architecture/base/README.md new file mode 100644 index 00000000000..4f2bf2107d9 --- /dev/null +++ b/react-components/src/architecture/base/README.md @@ -0,0 +1,186 @@ +# Building and testing + +Do the following commands: + + yarn + yarn build + yarn storybook` + +This will automatically goto: https://localhost:3000/?modelUrl=primitivesOpen. + +Instead of yarn build, use can build with types by: + + yarn tsc --noEmit + +When the StoryBook is open, goto `Architecture/Main`. The toolbar on the right hand side is the one I have made. Here some commands and tools are added. + +# Motivation + +The objectives with this framework it that is should be easy to use and extend in the future. I hope all new related functionality will be developed using this framework. More is described in this document: https://docs.google.com/presentation/d/1Y50PeqoCS5BdWyDqRNNazeISy1SYTcx4QDcjeMKIm78/edit?usp=sharing + +# Coding + +You will probably find the coding style somewhat different than usually done in Reveal. Some difference: + +- **Headers:** Headers are used to mark sections in a file. This is important for fast navigation and to keep the code in correct order. The order in the file is always: + + - Public constants (exported) + - Instance fields. + - Instance properties + - Implementation of interface (if any) + - Overrides from the must basic base class (if any) + - : + - Overrides from the actually base class (if any) + - Instance methods (If many, break it up based on what they do) + - Static methods (if any) + - Local functions, types or classes used in this file only + +- **Virtual methods:**: Since TypeScript doesn't allow the virtual keyword, all functions are virtual. This is a weakness for the application developer. I have tried to mark function as virtual by comments. You should not override other function than this. A virtual function normally brings in unnecessary complexity and should general be used by care. + + - Function with prefix `Core` are always virtual and protected. They can be overridden, but must call the same function for the base class. A function without the `Core` prefix is the one you should call from outside and is public. Experience shows that this pattern will make the code easier to maintain and extend in the future. For instance `remove/removeCore` and `initialize/initializeCore` in the `DomainObject`. + - Overridden methods should alway be marked by the override keyword. You will unfortunate not get any linting error when you forget this. + - + +- **Static methods and classes:** The linker don't like static method, especially when the entire class have static method only. I wanted to use this more, but have used functions instead. I don't like this, because it does not group similar function together. It will also be less readable on the caller side, since you don't know what part of the code the function is coming from. In typescript we have for instance Math with act like a static class, but they use som magic to pretend it to be using interface. + +- **Reuse:** I try to reuse code whenever it is possible. Therefore you will find functions that are very small, + often one liners. This ensures the complexity of the code to be low and increase readability on the caller side. For instance: + + export function clear(array: T[]): void { + array.splice(0, array.length); + } + +- **Single responsibility:** I try to give each class one single responsibility. Therefore I use several base classes, each class adds some complexity. + Examples of this is `BaseView/ThreeView/GroupThreeView` which are all base classes. If they are merge to one single class, it will be too complex. + +- **Interdependence:** Try to keep must classes independent of other classes. For instance the tool should not know about the views. Utility function and classes can used whenever you like. + +- **Utility function and classes:** I have added som utility function/classes in `architecture/base/utilities`. + - Some utility functions may be a duplicate of some function in a package we use. Please tell me. + - Some utility classes you will see is like a duplicate, but is made by purpose. For instance `Range3` class which is almost the same as `THREE.Box`, but is far easier to work with. + - Some utility classes or function is already implemented in Reveal, and are similar. The reason for not reusing Reveal here is due to what we should expose out of Reveal and into Reveal-components. For the moment this is rather strick. Examples is `Vector3Pool` which is implemented both places. Also some of the color classes may seem to be similar. But all this is low level stuff, and is not related to any Reveal functionality. + - I will remove unused utility function or classes when I feel ready for it. Most of them are imported file by file from the node-visualizer project. + +# Architecture Overview + +## architecture/base + +Here is the architecture with all base classes and some utility functionality. + +### architecture/base/domainObject + +- **DomainObject:** This is the base class for the domain object. A domain object is some sort of data, that is a collection of other domainObject or the data itself. + + - An important property of the domainObject is that is has a list of views that will be updated. + - It has a parent and a collection of children. To ensure easy use, I have added several methods to access children, descendants and ancestors in several ways. + + - It has some properties like selected, expanded and active. This is not used yet, but I wanted it to be here as this is part of the pattern. They are connected to how they are visualized and behave in a tree view, but this is not used for the moment. + + - Methods with postfix `Interactive` are doing all necessary updates automatically. For instance: `addChild/addChildInteractive` or `setVisible/setVisibleInteractive` + +- **VisualDomainObject**: This subclass adds functionality to a domain object so it can be shown in 3D. The most important function here is `createThreeView()` which must be overridden. + +- **FolderDomainObject**: Concrete class for multi purpose folder. + +- **RootDomainObject**: Concrete class for the root of the hierarchy + +### architecture/base/renderTarget + +Normally, when I follow this pattern, the renderTarget is a domainObject itself, and you can have as many of them as you like. But this makes it a little bit more complicated. Since we always have one viewer only, I don't use any base class for this. + +- **RevealRenderTarget** Wrap around the Cognite3DViewer. It contains: + + - The `Cognite3DViewer` and assume this is constructed somewhere else. + - The `RootDomainObject`, where the data can be added. + - The `ToolController`, where the active tool is kept and where the events are handled. + - In addition, it has several utility functions for getting information from the viewer. + - It set up listeners on `cameraChange` and `beforeSceneRendered` events from the viewer. + +- **ToolController** + Holds the tools and all commands, the active tool and the previous tool. + - It inherits from `PointerEvents`, which is a base class on the Reveal side. All virtual functions are implemented in the `ToolControllers`. + - It creates a `PointerEventsTarget`, also on the Reveal side, that set up all the event listeners for the pointer (mouse or touch) handlers. The `PointerEventsTarget` will automatically call the functions `onHover`, `onClick`, `onDoubleClick`, `onPointerDown`, `onPointerUp` and `onPointerDrag` correctly. + - In addition it sets up some other events like `onKey` and `onFocus`. + - All event are routed to the active tool + +### architecture/base/commands + +- **BaseCommand**: Base class for all tools and commands. There are basically user actions. It contains all necessary information to create and update a button in the user interface, but it doesn't need or use React. The must important method to override is `invokeCore`, which is called when the user presses the button.A base command can be checkable. + +- **RenderTargetCommand** I wanted `BaseCommand` should be independent of any type of connection to the rest of the system. This class only brings in the connection to the `RevealRenderTarget`. + +- **BaseTool** This is the base class for all the tools, which is used when doing user interaction in the viewer itself. It defined a lot of virtual methods for user interactions. This should be overridden for creating the logic specific for a tool. + +### architecture/base/concreteCommands + +This is a collection of most commonly used tools and commands: + +- **FitViewCommand**: Fit view to the model bounding box +- **NavigationTool**: Reroute the events to the camera manager +- **SetFlexibleControlsTypeCommand**: This is another version than we had before. It is made for testing the architecture where the users are changing the state of the command from outside. Use the "1" or "2" key to change between Fly or Orbit mode. + +### architecture/base/views + +- **BaseView**: Represents a abstract base view class that provides common functionality for all types of views. This does not have any dependency to `three.js` and can be used in other types of views as well. + +- **ThreeView**: Represents an abstract base class for a `Three.js` view in the application. It adds basically 2 things: Concept of bounding box and a pointer to the renderTarget (viewer). The bounding box is a lazy calculation. The reason for this object is that we sometimes can have a view without any `Object3D`, for instance if a view manipulates another view, for instance a texture on a surface. + +- **GroupThreeView**: Represents an abstract base class for a `Three.js` view where it holds a pointer to a `THREE.Group` object. This object is the root of the `Object3D`'s that can be added to the view. The most important method is a`addChildren()` to be overridden. Here is where the children of the group should be added. The class will administrate the group and the children, and perform a lazy creation of these automatically. `GroupThreeView` implements the `CustomObject` which is injected into Reveal by `viewer.addCustomObject(this)` when the view is set to visible. It is removed from Reveal when view is hidden by `viewer.removeCustomObject(this)`. This magic should be hidden from the application developer. All concrete views I have made inherit from `GroupThreeView`. + +### architecture/base/domainObjectHelpers + +- **RenderStyle**: Is the base class for all render styles. + +- **Changes**: All changes that can be applied on a domainObject. it uses symbols as the type, because it can easily be extended. It looks like an enum when unit it, since I have used a static class, and this is done by purpose. + +- **DomainObjectChange** Here you can add several changes before you call domainObject.notify(....) + +### architecture/base/utilities + +Smaller files with utility classes and functions: + +- **architecture/base/utilities/box:** + + Geometry algorithms and enums for box manipulation. + +- **architecture/base/utilities/colors:** + + Color manipulation. Making various color maps and 1d textures from color maps. Used mostly by the terrain visualization. + +- **architecture/base/utilities/extensions:** + + Some simple math and THREE.Vector2/3 extension + +- **architecture/base/utilities/geometry:** + + Some useful geometry + +- **architecture/base/utilities/sprites:** + + Easy Creation of sprite with text + +# Some concrete examples + +These are made to test the architecture but should when ready be used by any of Cognite's applications: + +## architecture/concrete + +### architecture/concrete/axes + +- AxisDomainObject, AxisRenderStyle and AxisTreeView +- SetAxisVisibleCommand to set the axis visible + +### architecture/concrete/boxDomainObject + +- BoxDomainObject, BoxRenderStyle and BoxTreeView +- BotEditTool for manipulation + +### architecture/concrete/terrainDomainObject + +- TerrainDomainObject, TerrainRenderStyle and TerrainTreeView +- UpdateTerrainCommand and SetTerrainVisibleCommand for demo/example +- geometry: Folder with math and grid structures + +``` + +``` diff --git a/react-components/src/architecture/base/commands/BaseCommand.ts b/react-components/src/architecture/base/commands/BaseCommand.ts new file mode 100644 index 00000000000..3f8ad3f7e05 --- /dev/null +++ b/react-components/src/architecture/base/commands/BaseCommand.ts @@ -0,0 +1,99 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { clear, remove } from '../utilities/extensions/arrayExtensions'; + +type UpdateDelegate = (command: BaseCommand) => void; + +export type Tooltip = { + key: string; + fallback?: string; +}; + +export abstract class BaseCommand { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private readonly _listeners: UpdateDelegate[] = []; + + // ================================================== + // VIRTUAL METHODS (To be override) + // ================================================= + + public get name(): string { + return this.tooltip.fallback ?? this.tooltip.key; + } + + public get shortCutKey(): string | undefined { + return undefined; + } + + public get tooltip(): Tooltip { + return { key: '' }; + } + + public get icon(): string { + return 'Unknown'; + } + + public get isEnabled(): boolean { + return true; + } + + public get isVisible(): boolean { + return this.isEnabled; + } + + public get isCheckable(): boolean { + return false; + } + + public get isChecked(): boolean { + return false; + } + + /* + * Called when the command is invoked + * Return true if successful, false otherwise + * Override this method to implement the command logic + */ + protected invokeCore(): boolean { + return false; + } + + public equals(other: BaseCommand): boolean { + return this.constructor === other.constructor; + } + + public invoke(): boolean { + return this.invokeCore(); + } + + public dispose(): void { + this.removeEventListeners(); + } + + // ================================================== + // INSTANCE METHODS: Event listeners + // ================================================== + + public addEventListener(listener: UpdateDelegate): void { + this._listeners.push(listener); + } + + public removeEventListener(listener: UpdateDelegate): void { + remove(this._listeners, listener); + } + + public removeEventListeners(): void { + clear(this._listeners); + } + + public update(): void { + for (const listener of this._listeners) { + listener(this); + } + } +} diff --git a/react-components/src/architecture/base/commands/BaseEditTool.ts b/react-components/src/architecture/base/commands/BaseEditTool.ts new file mode 100644 index 00000000000..ebd552b168f --- /dev/null +++ b/react-components/src/architecture/base/commands/BaseEditTool.ts @@ -0,0 +1,97 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { NavigationTool } from './NavigationTool'; +import { type DomainObject } from '../domainObjects/DomainObject'; +import { isDomainObjectIntersection } from '../domainObjectsHelpers/DomainObjectIntersection'; +import { type BaseDragger } from '../domainObjectsHelpers/BaseDragger'; + +export abstract class BaseEditTool extends NavigationTool { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private _dragger: BaseDragger | undefined = undefined; + + // ================================================== + // OVERRIDES of BaseTool + // ================================================== + + public override get defaultCursor(): string { + return 'crosshair'; + } + + public override clearDragging(): void { + super.clearDragging(); + this._dragger = undefined; + } + + public override async onPointerDown(event: PointerEvent, leftButton: boolean): Promise { + this._dragger = await this.createDragger(event); + if (this._dragger === undefined) { + await super.onPointerDown(event, leftButton); + return; + } + this._dragger.onPointerDown(event); + this.deselectAll(this._dragger.domainObject); + this._dragger.domainObject.setSelectedInteractive(true); + } + + public override async onPointerDrag(event: PointerEvent, leftButton: boolean): Promise { + if (this._dragger === undefined) { + await super.onPointerDrag(event, leftButton); + return; + } + const ray = this.getRay(event, true); + this._dragger.onPointerDrag(event, ray); + } + + public override async onPointerUp(event: PointerEvent, leftButton: boolean): Promise { + if (this._dragger === undefined) { + await super.onPointerUp(event, leftButton); + } else { + this._dragger.onPointerUp(event); + this._dragger = undefined; + } + } + + // ================================================== + // VIRTUALS METHODS + // ================================================== + + /** + * Override this function to create custom dragger + * with other creation logic. Otherwise createDragger in + * the DomainObject itself + */ + protected async createDragger(event: PointerEvent): Promise { + const intersection = await this.getIntersection(event); + if (intersection === undefined) { + return undefined; + } + if (!isDomainObjectIntersection(intersection)) { + return undefined; + } + const domainObject = intersection.domainObject; + if (domainObject === undefined) { + return undefined; + } + return domainObject.createDragger(intersection); + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + protected deselectAll(except?: DomainObject | undefined): void { + const { renderTarget } = this; + const { rootDomainObject } = renderTarget; + for (const domainObject of rootDomainObject.getDescendants()) { + if (except !== undefined && domainObject === except) { + continue; + } + domainObject.setSelectedInteractive(false); + } + } +} diff --git a/react-components/src/architecture/base/commands/BaseTool.ts b/react-components/src/architecture/base/commands/BaseTool.ts new file mode 100644 index 00000000000..03c814a2342 --- /dev/null +++ b/react-components/src/architecture/base/commands/BaseTool.ts @@ -0,0 +1,189 @@ +/*! + * Copyright 2024 Cognite AS + * BaseTool: Base class for the tool are used to interact with the render target. + */ + +import { type Ray, Raycaster, type Vector2 } from 'three'; +import { RenderTargetCommand } from './RenderTargetCommand'; +import { + type CustomObjectIntersection, + type AnyIntersection, + CDF_TO_VIEWER_TRANSFORMATION +} from '@cognite/reveal'; +import { GroupThreeView } from '../views/GroupThreeView'; +import { + type DomainObjectIntersection, + isDomainObjectIntersection +} from '../domainObjectsHelpers/DomainObjectIntersection'; +import { type Class } from '../domainObjectsHelpers/Class'; +import { type DomainObject } from '../domainObjects/DomainObject'; +import { type BaseCommand } from './BaseCommand'; +import { ActiveToolUpdater } from '../reactUpdaters/ActiveToolUpdater'; +import { PopupStyle } from '../domainObjectsHelpers/PopupStyle'; + +export abstract class BaseTool extends RenderTargetCommand { + // ================================================== + // OVERRIDES + // ================================================= + + public override get isCheckable(): boolean { + return true; + } + + public override get isChecked(): boolean { + return this.renderTarget.toolController.activeTool === this; + } + + protected override invokeCore(): boolean { + if (this.isChecked) { + this.renderTarget.toolController.activateDefaultTool(); + } else { + this.renderTarget.toolController.setActiveTool(this); + } + return true; + } + + // ================================================== + // VIRTUAL METHODS: To be overridden + // ================================================== + + public get defaultCursor(): string { + return 'default'; + } + + public getToolbar(): Array | undefined { + return undefined; // Override this to add extra buttons to a separate toolbar + } + + public getToolbarStyle(): PopupStyle { + // Override this to pclase the extra separate toolbar + // Default lower left corner + return new PopupStyle({ bottom: 0, left: 0 }); + } + + public onActivate(): void { + this.update(); + this.setDefaultCursor(); + this.clearDragging(); + ActiveToolUpdater.update(); + } + + public onDeactivate(): void { + this.update(); + this.clearDragging(); + ActiveToolUpdater.update(); + } + + public clearDragging(): void { + // Override this to clear any temporary objects in the tool, like the dragger + } + + public onHover(_event: PointerEvent): void {} + + public async onClick(_event: PointerEvent): Promise { + await Promise.resolve(); + } + + public async onDoubleClick(_event: PointerEvent): Promise { + await Promise.resolve(); + } + + public async onPointerDown(_event: PointerEvent, _leftButton: boolean): Promise { + await Promise.resolve(); + } + + public async onPointerDrag(_event: PointerEvent, _leftButton: boolean): Promise { + await Promise.resolve(); + } + + public async onPointerUp(_event: PointerEvent, _leftButton: boolean): Promise { + await Promise.resolve(); + } + + public async onWheel(_event: WheelEvent): Promise { + await Promise.resolve(); + } + + public onFocusChanged(_haveFocus: boolean): void {} + + public onKey(_event: KeyboardEvent, _down: boolean): void {} + + // ================================================== + // INSTANCE METHODS: Intersections + // ================================================== + + public setDefaultCursor(): void { + this.renderTarget.cursor = this.defaultCursor; + } + + protected async getIntersection(event: PointerEvent): Promise { + const { renderTarget } = this; + const { viewer } = renderTarget; + const point = viewer.getPixelCoordinatesFromEvent(event); + const intersection = await viewer.getAnyIntersectionFromPixel(point); + if (intersection === undefined) { + return undefined; + } + return intersection; + } + + protected getSpecificIntersection( + event: PointerEvent, + classType: Class + ): DomainObjectIntersection | undefined { + // This function is similar to getIntersection, but it only considers a specific DomainObject + const { renderTarget } = this; + const { rootDomainObject } = renderTarget; + const { viewer } = renderTarget; + + const point = viewer.getPixelCoordinatesFromEvent(event); + const intersectInput = viewer.createCustomObjectIntersectInput(point); + + let closestIntersection: CustomObjectIntersection | undefined; + let closestDistanceToCamera: number | undefined; + for (const domainObject of rootDomainObject.getDescendantsByType(classType)) { + for (const view of domainObject.views.getByType(GroupThreeView)) { + if (view.renderTarget !== renderTarget) { + continue; + } + const intersection = view.intersectIfCloser(intersectInput, closestDistanceToCamera); + if (intersection === undefined) { + continue; + } + closestDistanceToCamera = intersection.distanceToCamera; + closestIntersection = intersection; + } + } + if (!isDomainObjectIntersection(closestIntersection)) { + return undefined; + } + return closestIntersection; + } + + // ================================================== + // INSTANCE METHODS: Getters + // ================================================== + + protected getRaycaster(event: PointerEvent | WheelEvent): Raycaster { + const { renderTarget } = this; + const normalizedCoords = this.getNormalizedPixelCoordinates(event); + const raycaster = new Raycaster(); + raycaster.setFromCamera(normalizedCoords, renderTarget.camera); + return raycaster; + } + + protected getRay(event: PointerEvent | WheelEvent, convertToCdf: boolean = false): Ray { + const ray = this.getRaycaster(event).ray; + if (convertToCdf) { + ray.applyMatrix4(CDF_TO_VIEWER_TRANSFORMATION.clone().invert()); + } + return ray; + } + + protected getNormalizedPixelCoordinates(event: PointerEvent | WheelEvent): Vector2 { + const { renderTarget } = this; + const { viewer } = renderTarget; + const point = viewer.getPixelCoordinatesFromEvent(event); + return viewer.getNormalizedPixelCoordinates(point); + } +} diff --git a/react-components/src/architecture/base/commands/NavigationTool.ts b/react-components/src/architecture/base/commands/NavigationTool.ts new file mode 100644 index 00000000000..44826126fc1 --- /dev/null +++ b/react-components/src/architecture/base/commands/NavigationTool.ts @@ -0,0 +1,66 @@ +/*! + * Copyright 2024 Cognite AS + * BaseTool: Base class for the tool are used to interact with the render target. + */ + +import { BaseTool } from './BaseTool'; +import { type Tooltip } from './BaseCommand'; +import { type IFlexibleCameraManager } from '@cognite/reveal'; + +export class NavigationTool extends BaseTool { + // ================================================== + // INSTANVE PROPERTIES + // ================================================== + + private get cameraManager(): IFlexibleCameraManager { + return this.renderTarget.flexibleCameraManager; + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get shortCutKey(): string | undefined { + return 'N'; + } + + public override get icon(): string { + return 'Grab'; + } + + public override get tooltip(): Tooltip { + return { key: 'NAVIGATION', fallback: 'Navigation' }; + } + + public override async onClick(event: PointerEvent): Promise { + await this.cameraManager.onClick(event); + } + + public override async onDoubleClick(event: PointerEvent): Promise { + await this.cameraManager.onDoubleClick(event); + } + + public override async onPointerDown(event: PointerEvent, leftButton: boolean): Promise { + await this.cameraManager.onPointerDown(event, leftButton); + } + + public override async onPointerDrag(event: PointerEvent, leftButton: boolean): Promise { + await this.cameraManager.onPointerDrag(event, leftButton); + } + + public override async onPointerUp(event: PointerEvent, leftButton: boolean): Promise { + await this.cameraManager.onPointerUp(event, leftButton); + } + + public override async onWheel(event: WheelEvent): Promise { + await this.cameraManager.onWheel(event); + } + + public override onKey(event: KeyboardEvent, down: boolean): void { + this.cameraManager.onKey(event, down); + } + + public override onFocusChanged(haveFocus: boolean): void { + this.cameraManager.onFocusChanged(haveFocus); + } +} diff --git a/react-components/src/architecture/base/commands/RenderTargetCommand.ts b/react-components/src/architecture/base/commands/RenderTargetCommand.ts new file mode 100644 index 00000000000..47bf3fc9b48 --- /dev/null +++ b/react-components/src/architecture/base/commands/RenderTargetCommand.ts @@ -0,0 +1,29 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { BaseCommand } from './BaseCommand'; +import { type RevealRenderTarget } from '../renderTarget/RevealRenderTarget'; + +export abstract class RenderTargetCommand extends BaseCommand { + public _renderTarget: RevealRenderTarget | undefined = undefined; + + public get renderTarget(): RevealRenderTarget { + if (this._renderTarget === undefined) { + throw new Error('Render target is not set'); + } + return this._renderTarget; + } + + public override invoke(): boolean { + const success = this.invokeCore(); + if (success) { + this.renderTarget.toolController.update(); + } + return success; + } + + public attach(renderTarget: RevealRenderTarget): void { + this._renderTarget = renderTarget; + } +} diff --git a/react-components/src/architecture/base/concreteCommands/FitViewCommand.ts b/react-components/src/architecture/base/concreteCommands/FitViewCommand.ts new file mode 100644 index 00000000000..9d830a965e2 --- /dev/null +++ b/react-components/src/architecture/base/concreteCommands/FitViewCommand.ts @@ -0,0 +1,28 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { RenderTargetCommand } from '../commands/RenderTargetCommand'; +import { type Tooltip } from '../commands/BaseCommand'; + +export class FitViewCommand extends RenderTargetCommand { + public override get icon(): string { + return 'ExpandAlternative'; + } + + public override get tooltip(): Tooltip { + return { key: 'FIT_VIEW_TOOLTIP', fallback: 'Fit view' }; + } + + protected override invokeCore(): boolean { + const { renderTarget } = this; + const { viewer } = renderTarget; + + const boundingBox = viewer.getSceneBoundingBox(); + if (boundingBox.isEmpty()) { + return false; + } + viewer.fitCameraToBoundingBox(boundingBox); + return true; + } +} diff --git a/react-components/src/architecture/base/concreteCommands/SetFlexibleControlsTypeCommand.ts b/react-components/src/architecture/base/concreteCommands/SetFlexibleControlsTypeCommand.ts new file mode 100644 index 00000000000..7c4056a1735 --- /dev/null +++ b/react-components/src/architecture/base/concreteCommands/SetFlexibleControlsTypeCommand.ts @@ -0,0 +1,98 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { RenderTargetCommand } from '../commands/RenderTargetCommand'; +import { type RevealRenderTarget } from '../renderTarget/RevealRenderTarget'; +import { FlexibleControlsType } from '@cognite/reveal'; +import { type BaseCommand, type Tooltip } from '../commands/BaseCommand'; + +export class SetFlexibleControlsTypeCommand extends RenderTargetCommand { + private readonly _controlsType: FlexibleControlsType; + + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor(controlsType: FlexibleControlsType) { + super(); + this._controlsType = controlsType; + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override equals(other: BaseCommand): boolean { + if (!(other instanceof SetFlexibleControlsTypeCommand)) { + return false; + } + return this._controlsType === other._controlsType; + } + + public override dispose(): void { + super.dispose(); + const { flexibleCameraManager } = this.renderTarget; + flexibleCameraManager.removeControlsTypeChangeListener(this._controlsTypeChangeHandler); + } + + public override get icon(): string { + switch (this._controlsType) { + case FlexibleControlsType.FirstPerson: + return 'Plane'; + case FlexibleControlsType.Orbit: + return 'Circle'; + case FlexibleControlsType.OrbitInCenter: + return 'Coordinates'; + default: + return 'Error'; + } + } + + public override get tooltip(): Tooltip { + switch (this._controlsType) { + case FlexibleControlsType.FirstPerson: + return { key: 'CONTROLS_TYPE_FIRST_PERSON', fallback: 'Fly' }; + case FlexibleControlsType.Orbit: + return { key: 'CONTROLS_TYPE_ORBIT', fallback: 'Orbit' }; + case FlexibleControlsType.OrbitInCenter: + return { key: 'CONTROLS_TYPE_ORBIT_IN_CENTER', fallback: 'Center Orbit' }; + default: + return super.tooltip; + } + } + + public override get isCheckable(): boolean { + return true; + } + + public override get isChecked(): boolean { + const { renderTarget } = this; + const { flexibleCameraManager } = renderTarget; + return flexibleCameraManager.controlsType === this._controlsType; + } + + protected override invokeCore(): boolean { + const { renderTarget } = this; + const { flexibleCameraManager } = renderTarget; + if (flexibleCameraManager.controlsType === this._controlsType) { + return false; + } + flexibleCameraManager.controlsType = this._controlsType; + return true; + } + + public override attach(renderTarget: RevealRenderTarget): void { + super.attach(renderTarget); + const { flexibleCameraManager } = renderTarget; + flexibleCameraManager.addControlsTypeChangeListener(this._controlsTypeChangeHandler); + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + private readonly _controlsTypeChangeHandler = (_newControlsType: FlexibleControlsType): void => { + this.update(); + }; +} diff --git a/react-components/src/architecture/base/domainObjects/DomainObject.ts b/react-components/src/architecture/base/domainObjects/DomainObject.ts new file mode 100644 index 00000000000..54dacfd389b --- /dev/null +++ b/react-components/src/architecture/base/domainObjects/DomainObject.ts @@ -0,0 +1,729 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type Color } from 'three'; +import { type RenderStyle } from '../domainObjectsHelpers/RenderStyle'; +import { DomainObjectChange } from '../domainObjectsHelpers/DomainObjectChange'; +import { Changes } from '../domainObjectsHelpers/Changes'; +import { isInstanceOf, type Class } from '../domainObjectsHelpers/Class'; +import { equalsIgnoreCase, isEmpty } from '../utilities/extensions/stringExtensions'; +import { VisibleState } from '../domainObjectsHelpers/VisibleState'; +import { clear, removeAt } from '../utilities/extensions/arrayExtensions'; +import { getNextColor } from '../utilities/colors/getNextColor'; +import { type RevealRenderTarget } from '../renderTarget/RevealRenderTarget'; +import { ColorType } from '../domainObjectsHelpers/ColorType'; +import { BLACK_COLOR, WHITE_COLOR } from '../utilities/colors/colorExtensions'; +import { type DomainObjectIntersection } from '../domainObjectsHelpers/DomainObjectIntersection'; +import { type BaseDragger } from '../domainObjectsHelpers/BaseDragger'; +import { Views } from '../domainObjectsHelpers/Views'; +import { type PanelInfo } from '../domainObjectsHelpers/PanelInfo'; +import { PopupStyle } from '../domainObjectsHelpers/PopupStyle'; + +/** + * Represents an abstract base class for domain objects. + * @abstract + * @extends BaseSubject + */ +export abstract class DomainObject { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + // Some basic states + private _name: string | undefined = undefined; + private _color: Color | undefined = undefined; + private _isSelected: boolean = false; + private _isActive: boolean = false; + private _isExpanded = false; + + // Parent-Child relationship + private readonly _children: DomainObject[] = []; + private _parent: DomainObject | undefined = undefined; + + // Render style + private _renderStyle: RenderStyle | undefined = undefined; + + // Views and listeners + public readonly views: Views = new Views(); + + // ================================================== + // INSTANCE/VIRTUAL PROPERTIES + // ================================================== + + public abstract get typeName(): string; // to be overridden + + public get path(): string { + return `${this.parent !== undefined ? this.parent.path : ''}\\${this.name}`; + } + + public get displayName(): string { + const nameExtension = this.nameExtension; + if (isEmpty(nameExtension)) { + return this.name; + } + return `${this.name} [${nameExtension}]`; + } + + // ================================================== + // VIRTUAL METHODS: Others + // ================================================== + + /** + * Initializes the core functionality of the domain object. + * This method should be overridden in derived classes to provide custom implementation. + * @param change - The change object representing the update. + * @remarks + * Always call `super.initializeCore()` in the overrides. + */ + protected initializeCore(): void {} + + /** + * Removes the core functionality of the domain object. + * This method should be overridden in derived classes to provide custom implementation. + * @remarks + * Always call `super.removeCore()` in the overrides. + */ + protected removeCore(): void { + this.views.clear(); + } + + public get icon(): string { + return 'Unknown'; + } + + // ================================================== + // INSTANCE/VIRTUAL METHODS: Nameing + // ================================================== + + public get canChangeName(): boolean { + return true; // to be overridden + } + + public get name(): string { + if (this._name === undefined || isEmpty(this._name)) { + this._name = this.generateNewName(); + } + return this._name; + } + + public set name(value: string) { + this._name = value; + } + + public get nameExtension(): string | undefined { + return undefined; // to be overridden, should be added to the name in UI for more information + } + + public hasEqualName(name: string): boolean { + return equalsIgnoreCase(this.name, name); + } + + // ================================================== + // INSTANCE/VIRTUAL METHODS: Notification + // ================================================== + + /** + * Notifies the registered views and listeners about a change in the domain object. + * This method should be overridden in derived classes to provide custom implementation. + * @param change - The change object representing the update. + * @remarks + * Always call `super.notifyCore()` in the overrides. + */ + protected notifyCore(change: DomainObjectChange): void { + this.views.notify(this, change); + } + + public notify(change: DomainObjectChange | symbol): void { + if (change instanceof DomainObjectChange) { + this.notifyCore(change); + } else { + this.notify(new DomainObjectChange(change)); + } + } + + // ================================================== + // INSTANCE/VIRTUAL METHODS: Color + // ================================================== + + public get canChangeColor(): boolean { + return true; // to be overridden + } + + public get hasIconColor(): boolean { + return this.canChangeColor; + } + + public get color(): Color { + if (this._color === undefined) { + this._color = this.generateNewColor(); + } + return this._color; + } + + public set color(value: Color) { + this._color = value; + } + + // ================================================== + // INSTANCE/VIRTUAL METHODS: Selected + // ================================================== + + public get canBeSelected(): boolean { + return true; // to be overridden + } + + public get isSelected(): boolean { + return this._isSelected; + } + + public set isSelected(value: boolean) { + this._isSelected = value; + } + + public setSelectedInteractive(value: boolean): boolean { + if (this.isSelected === value) { + return false; + } + this.isSelected = value; + this.notify(Changes.selected); + return true; + } + + // ================================================== + // INSTANCE/VIRTUAL METHODS: Active + // ================================================== + + public get canBeActive(): boolean { + return false; // to be overridden + } + + public get isActive(): boolean { + return this._isActive; + } + + public set isActive(value: boolean) { + this._isActive = value; + } + + public setActiveInteractive(): void { + // To be called when a object should be active + if (this.isActive) { + return; + } + if (!this.canBeActive) { + return; + } + if (this.parent !== undefined) { + // Turn the others off + for (const sibling of this.parent.getDescendants()) { + if (sibling === this) { + continue; + } + if (sibling.typeName !== this.typeName) { + continue; + } + if (!sibling.canBeActive) { + continue; + } + if (!sibling.isActive) { + continue; + } + sibling.isActive = false; + sibling.notify(Changes.active); + } + } + this.isActive = true; + this.notify(Changes.active); + } + + // ================================================== + // INSTANCE/INSTANCE METHODS: Expanded + // ================================================== + + public get canBeExpanded(): boolean { + return this.childCount > 0; // to be overridden + } + + public get isExpanded(): boolean { + return this._isExpanded; + } + + public set isExpanded(value: boolean) { + this._isExpanded = value; + } + + public setExpandedInteractive(value: boolean): boolean { + if (this.isExpanded === value) { + return false; + } + this.isExpanded = value; + this.notify(Changes.expanded); + return true; + } + + // ================================================== + // VIRTUAL METHODS: For updating the panel + // ================================================== + + public getPanelInfo(): PanelInfo | undefined { + return undefined; // to be overridden + } + + public getPanelInfoStyle(): PopupStyle { + // to be overridden + // Default lower left corner + return new PopupStyle({ bottom: 0, left: 0 }); + } + + // ================================================== + // VIRTUAL METHODS: Appearance in the explorer + // ================================================== + + public get canBeDeleted(): boolean { + return true; // to be overridden + } + + public canBeChecked(_target: RevealRenderTarget): boolean { + return true; // to be overridden + } + + // ================================================== + // VIRTUAL METHODS: Visibility + // ================================================== + + public getVisibleState(target: RevealRenderTarget): VisibleState { + let numCandidates = 0; + let numAll = 0; + let numNone = 0; + + for (const child of this.children) { + const childState = child.getVisibleState(target); + if (childState === VisibleState.Disabled) { + continue; + } + numCandidates++; + if (childState === VisibleState.All) { + numAll++; + } else if (childState === VisibleState.None || childState === VisibleState.CanNotBeChecked) { + numNone++; + } + if (numNone < numCandidates && numCandidates < numAll) { + return VisibleState.Some; + } + } + if (numCandidates === 0) { + return VisibleState.Disabled; + } + if (numCandidates === numAll) { + return VisibleState.All; + } + if (numCandidates === numNone) { + return this.canBeChecked(target) ? VisibleState.None : VisibleState.CanNotBeChecked; + } + return VisibleState.Some; + } + + public setVisibleInteractive( + visible: boolean, + target: RevealRenderTarget, + topLevel = true // When calling this from outside, this value should alwaus be true + ): boolean { + const visibleState = this.getVisibleState(target); + if (visibleState === VisibleState.Disabled) { + return false; + } + if (visibleState === VisibleState.None && !this.canBeChecked(target)) { + return false; + } + let hasChanged = false; + for (const child of this.children) { + if (child.setVisibleInteractive(visible, target, false)) { + hasChanged = true; + } + } + if (!hasChanged) { + return false; + } + if (topLevel) { + this.notifyVisibleStateChange(); + } + return true; + } + + protected notifyVisibleStateChange(): void { + const change = new DomainObjectChange(Changes.visibleState); + this.notify(change); + for (const ancestor of this.getAncestors()) { + ancestor.notify(change); + } + for (const descendant of this.getDescendants()) { + descendant.notify(change); + } + } + + public toggleVisibleInteractive(target: RevealRenderTarget): void { + const visibleState = this.getVisibleState(target); + if (visibleState === VisibleState.None) this.setVisibleInteractive(true, target); + else if (visibleState === VisibleState.Some || visibleState === VisibleState.All) + this.setVisibleInteractive(false, target); + } + + // ================================================== + // VIRTUAL METHODS: Render styles + // ================================================== + + public get renderStyleRoot(): DomainObject | undefined { + return undefined; // Override if the render style is taken from another domain object + } + + public createRenderStyle(): RenderStyle | undefined { + return undefined; // Override when creating a render style + } + + public verifyRenderStyle(_style: RenderStyle): void { + // override when validating the render style + } + + // ================================================== + // VIRTUAL METHODS: Create dragger + // ================================================== + + // override when creating a dragger operation in the BaseEditTool + public createDragger(_intersection: DomainObjectIntersection): BaseDragger | undefined { + return undefined; + } + + // ================================================== + // INSTANCE PROPERTIES: Child-Parent relationship + // ================================================== + + public get children(): DomainObject[] { + return this._children; + } + + public get childCount(): number { + return this._children.length; + } + + public get childIndex(): number | undefined { + return this.parent === undefined ? undefined : this.parent.children.indexOf(this); + } + + public get parent(): DomainObject | undefined { + return this._parent; + } + + public get root(): DomainObject { + return this.parent === undefined ? this : this.parent.root; + } + + public get hasParent(): boolean { + return this._parent !== undefined; + } + + // ================================================== + // INSTANCE METHODS: Get a child or children + // ================================================== + + public hasChildByType(classType: Class): boolean { + return this.getChildByType(classType) !== undefined; + } + + public getChild(index: number): DomainObject { + return this._children[index]; + } + + public getChildByName(name: string): DomainObject | undefined { + for (const child of this.children) { + if (child.hasEqualName(name)) { + return child; + } + } + return undefined; + } + + public getChildByType(classType: Class): T | undefined { + for (const child of this.children) { + if (isInstanceOf(child, classType)) { + return child; + } + } + return undefined; + } + + public getActiveChildByType(classType: Class): T | undefined { + for (const child of this.children) { + if (child.isActive && isInstanceOf(child, classType)) { + return child; + } + } + return undefined; + } + + public *getChildrenByType(classType: Class): Generator { + for (const child of this.children) { + if (isInstanceOf(child, classType)) { + yield child; + } + } + } + + // ================================================== + // INSTANCE METHODS: Get descendants + // ================================================== + + public getSelected(): DomainObject | undefined { + for (const descendant of this.getThisAndDescendants()) { + if (descendant.isSelected) { + return descendant; + } + } + return undefined; + } + + public *getDescendants(): Generator { + for (const child of this.children) { + yield child; + for (const descendant of child.getDescendants()) { + yield descendant; + } + } + } + + public *getThisAndDescendants(): Generator { + yield this; + for (const descendant of this.getDescendants()) { + yield descendant; + } + } + + public getDescendantByName(name: string): DomainObject | undefined { + for (const descendant of this.getDescendants()) { + if (descendant.hasEqualName(name)) { + return descendant; + } + } + return undefined; + } + + public getDescendantByTypeAndName( + classType: Class, + name: string + ): T | undefined { + for (const descendant of this.getDescendants()) { + if (isInstanceOf(descendant, classType) && descendant.hasEqualName(name)) { + return descendant; + } + } + return undefined; + } + + public getDescendantByType(classType: Class): T | undefined { + for (const descendant of this.getDescendants()) { + if (isInstanceOf(descendant, classType)) { + return descendant; + } + } + return undefined; + } + + public *getDescendantsByType(classType: Class): Generator { + for (const child of this.children) { + if (isInstanceOf(child, classType)) { + yield child; + } + for (const descendant of child.getDescendantsByType(classType)) { + yield descendant; + } + } + } + + public getActiveDescendantByType(classType: Class): T | undefined { + for (const child of this.children) { + if (child.isActive && isInstanceOf(child, classType)) { + return child; + } + const descendant = child.getActiveDescendantByType(classType); + if (descendant !== undefined) { + return descendant; + } + } + return undefined; + } + + // ================================================== + // INSTANCE METHODS: Get ancestors + // ================================================== + + public *getThisAndAncestors(): Generator { + yield this; + let ancestor = this.parent; + while (ancestor !== undefined) { + yield ancestor; + ancestor = ancestor.parent; + } + } + + public *getAncestors(): Generator { + let ancestor = this.parent; + while (ancestor !== undefined) { + yield ancestor; + ancestor = ancestor.parent; + } + } + + public getAncestorByType(classType: Class): T | undefined { + for (const ancestor of this.getAncestors()) { + if (isInstanceOf(ancestor, classType)) { + return ancestor as T; + } + } + return undefined; + } + + public getThisOrAncestorByType(classType: Class): T | undefined { + for (const ancestor of this.getThisAndAncestors()) { + if (isInstanceOf(ancestor, classType)) { + return ancestor as T; + } + } + return undefined; + } + + // ================================================== + // INSTANCE METHODS: Child-Parent relationship + // ================================================== + + public addChild(child: DomainObject, insertFirst = false): void { + if (child.hasParent) { + throw Error(`The child ${child.typeName} already has a parent`); + } + if (child === this) { + throw Error(`Trying to add illegal child ${child.typeName}`); + } + if (insertFirst) { + this._children.unshift(child); + } else { + this._children.push(child); + } + child._parent = this; + } + + public addChildInteractive(child: DomainObject, insertFirst = false): void { + this.addChild(child, insertFirst); + child.notify(Changes.added); + this.notify(Changes.childAdded); + } + + private remove(): boolean { + const { childIndex } = this; + if (childIndex === undefined) { + throw Error(`The child ${this.typeName} is not child of it's parent`); + } + clear(this._children); + this.removeCore(); + + if (this.parent !== undefined) { + removeAt(this.parent.children, childIndex); + this._parent = undefined; + } + return true; + } + + public removeInteractive(): void { + for (const child of this.children) { + child.removeInteractive(); + } + const { parent } = this; + this.notify(Changes.deleted); + this.remove(); + parent?.notify(Changes.childDeleted); + } + + public sortChildrenByName(): void { + this.children.sort((a, b) => a.name.localeCompare(b.name)); + } + + // ================================================== + // INSTANCE METHODS: Render styles + // ================================================== + + public getRenderStyle(): RenderStyle | undefined { + const root = this.renderStyleRoot; + if (root !== undefined && root !== this) { + return root.getRenderStyle(); + } + if (this._renderStyle === undefined) { + this._renderStyle = this.createRenderStyle(); + } + if (this._renderStyle !== undefined) { + this.verifyRenderStyle(this._renderStyle); + } + return this._renderStyle; + } + + public setRenderStyle(value: RenderStyle | undefined = undefined): void { + this._renderStyle = value; + } + + // ================================================== + // INSTANCE METHODS: Get auto name and color + // ================================================== + + protected generateNewColor(): Color { + return this.canChangeColor ? getNextColor().clone() : WHITE_COLOR.clone(); + } + + protected generateNewName(): string { + let result = this.typeName; + if (!this.canChangeName) { + return result; + } + if (this.parent === undefined) { + return result; + } + let childIndex = 0; + for (const child of this.parent.children) { + if (child === this) { + break; + } + if (this.typeName === child.typeName) { + childIndex += 1; + } + } + result += ` ${childIndex + 1}`; + return result; + } + + public supportsColorType(colorType: ColorType, solid: boolean): boolean { + switch (colorType) { + case ColorType.Specified: + case ColorType.Parent: + case ColorType.Black: + case ColorType.White: + return true; + + case ColorType.ColorMap: + return solid; + + default: + return false; + } + } + + public getColorByColorType(colorType: ColorType): Color { + switch (colorType) { + case ColorType.Specified: + return this.color; + case ColorType.Parent: + if (this.parent !== undefined) { + return this.parent.color; + } + break; + case ColorType.Black: + return BLACK_COLOR; + } + return WHITE_COLOR; + } +} diff --git a/react-components/src/architecture/base/domainObjects/FolderDomainObject.ts b/react-components/src/architecture/base/domainObjects/FolderDomainObject.ts new file mode 100644 index 00000000000..94ebce8c2f3 --- /dev/null +++ b/react-components/src/architecture/base/domainObjects/FolderDomainObject.ts @@ -0,0 +1,15 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { DomainObject } from './DomainObject'; + +export class FolderDomainObject extends DomainObject { + // ================================================== + // OVERRIDES of DomainObject + // ================================================== + + public override get typeName(): string { + return 'Folder'; + } +} diff --git a/react-components/src/architecture/base/domainObjects/RootDomainObject.ts b/react-components/src/architecture/base/domainObjects/RootDomainObject.ts new file mode 100644 index 00000000000..2ca67d20e3f --- /dev/null +++ b/react-components/src/architecture/base/domainObjects/RootDomainObject.ts @@ -0,0 +1,24 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { DomainObject } from './DomainObject'; + +export class RootDomainObject extends DomainObject { + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor() { + super(); + this.name = 'Root'; + } + + // ================================================== + // OVERRIDES of DomainObject + // ================================================== + + public override get typeName(): string { + return 'Root'; + } +} diff --git a/react-components/src/architecture/base/domainObjects/VisualDomainObject.ts b/react-components/src/architecture/base/domainObjects/VisualDomainObject.ts new file mode 100644 index 00000000000..dc9756c4b54 --- /dev/null +++ b/react-components/src/architecture/base/domainObjects/VisualDomainObject.ts @@ -0,0 +1,103 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type RevealRenderTarget } from '../renderTarget/RevealRenderTarget'; +import { ThreeView } from '../views/ThreeView'; +import { VisibleState } from '../domainObjectsHelpers/VisibleState'; +import { DomainObject } from './DomainObject'; + +export abstract class VisualDomainObject extends DomainObject { + // ================================================== + // OVERRIDES of DomainObject + // ================================================== + + public override getVisibleState(target: RevealRenderTarget): VisibleState { + if (this.isVisible(target)) { + return VisibleState.All; + } + if (this.canCreateThreeView()) { + if (this.canBeChecked(target)) { + return VisibleState.None; + } + return VisibleState.CanNotBeChecked; + } + return VisibleState.Disabled; + } + + public override setVisibleInteractive( + visible: boolean, + target: RevealRenderTarget, + topLevel = true + ): boolean { + if (visible && !this.canBeChecked(target)) { + return false; + } + if (!this.setVisible(visible, target)) { + return false; + } + if (topLevel) { + this.notifyVisibleStateChange(); + } + return true; + } + + // ================================================== + // VIRTUAL METHODS + // ================================================== + + protected abstract createThreeView(): ThreeView | undefined; + + protected canCreateThreeView(): boolean { + return true; + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public getViewByTarget(target: RevealRenderTarget): ThreeView | undefined { + for (const view of this.views.getByType(ThreeView)) { + if (view.renderTarget === target) { + return view; + } + } + } + + public isVisible(target: RevealRenderTarget): boolean { + return this.getViewByTarget(target) !== undefined; + } + + /** + * Sets the visibility of the visual domain object for a specific target. + * + * @param visible - A boolean indicating whether the visual domain object should be visible or not. + * @param target - The target RevealRenderTarget where the visual domain object will be attached. + * @returns A boolean indicating whether the state has changed. + */ + public setVisible(visible: boolean, target: RevealRenderTarget): boolean { + let view = this.getViewByTarget(target); + if (visible) { + if (view !== undefined) { + return false; + } + if (!this.canCreateThreeView()) { + return false; + } + view = this.createThreeView(); + if (view === undefined) { + return false; + } + this.views.addView(view); + view.attach(this, target); + view.initialize(); + view.onShow(); + } else { + if (view === undefined) { + return false; + } + this.views.removeView(view); + } + return true; // State has changed + } +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/BaseCreator.ts b/react-components/src/architecture/base/domainObjectsHelpers/BaseCreator.ts new file mode 100644 index 00000000000..c0398338d15 --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/BaseCreator.ts @@ -0,0 +1,110 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type Ray, type Vector3 } from 'three'; +import { replaceLast } from '../utilities/extensions/arrayExtensions'; +import { CDF_TO_VIEWER_TRANSFORMATION } from '@cognite/reveal'; +import { type DomainObject } from '../domainObjects/DomainObject'; + +/** + * Helper class for create a domain object by clicking around + */ +export abstract class BaseCreator { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private readonly _points: Vector3[] = []; // Clicked points + private _lastIsPending: boolean = false; // If true, the last point is hover and not confirmed. + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get points(): Vector3[] { + return this._points; + } + + public get pointCount(): number { + return this.points.length; + } + + public get realPointCount(): number { + return this.lastIsPending ? this.pointCount - 1 : this.pointCount; + } + + public get firstPoint(): Vector3 { + return this.points[0]; + } + + public get lastPoint(): Vector3 { + return this.points[this.pointCount - 1]; + } + + protected get lastIsPending(): boolean { + return this._lastIsPending; + } + + // ================================================== + // VIRTUAL METHODS + // ================================================== + + public get preferIntersection(): boolean { + return false; + } + + public abstract get domainObject(): DomainObject; + + public abstract get maximumPointCount(): number; + + public abstract get minimumPointCount(): number; + + protected abstract addPointCore( + ray: Ray, + point: Vector3 | undefined, + isPending: boolean + ): boolean; + + public handleEscape(): void {} + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public get isFinished(): boolean { + return this.realPointCount === this.maximumPointCount; + } + + public addPoint(ray: Ray, point: Vector3 | undefined, isPending: boolean): boolean { + if (point !== undefined) { + point = point.clone(); + } + this.convertToCdfCoords(ray, point); + return this.addPointCore(ray, point, isPending); + } + + protected addRawPoint(point: Vector3, isPending: boolean): void { + if (this.lastIsPending) { + replaceLast(this.points, point); + } else { + this.points.push(point); + } + this._lastIsPending = isPending; + } + + protected removePendingPoint(): void { + if (this.lastIsPending) { + this.points.pop(); + this._lastIsPending = false; + } + } + + private convertToCdfCoords(ray: Ray, point: Vector3 | undefined): void { + const matrix = CDF_TO_VIEWER_TRANSFORMATION.clone().invert(); + ray.applyMatrix4(matrix); + if (point !== undefined) { + point.applyMatrix4(matrix); + } + } +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/BaseDragger.ts b/react-components/src/architecture/base/domainObjectsHelpers/BaseDragger.ts new file mode 100644 index 00000000000..c2c918c8721 --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/BaseDragger.ts @@ -0,0 +1,43 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type Ray, Vector3 } from 'three'; +import { type DomainObject } from '../domainObjects/DomainObject'; + +/** + * The `BaseDragger` class represents a utility for dragging and manipulating any object in 3D space. + * It provides methods for onPointerDown, onPointerDrag, and onPointerUp based on user interactions. + */ +export abstract class BaseDragger { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public readonly point: Vector3 = new Vector3(); // Intersection point at pointer down + + // ================================================== + // CONTRUCTOR + // ================================================== + + protected constructor(startDragPoint: Vector3) { + this.point.copy(startDragPoint); + } + + // ================================================== + // VIRTUAL METHODS + // ================================================== + + public abstract get domainObject(): DomainObject; + + public onPointerDown(_event: PointerEvent): void { + // Empty, probably not needed + } + + // This must be overriden + public abstract onPointerDrag(_event: PointerEvent, ray: Ray): boolean; + + public onPointerUp(_event: PointerEvent): void { + // Empty, probably not needed + } +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/Changes.ts b/react-components/src/architecture/base/domainObjectsHelpers/Changes.ts new file mode 100644 index 00000000000..513f25812d8 --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/Changes.ts @@ -0,0 +1,28 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export class Changes { + // States changed + public static readonly visibleState: symbol = Symbol('visibleState'); + public static readonly active: symbol = Symbol('active'); + public static readonly expanded: symbol = Symbol('expanded'); + public static readonly selected: symbol = Symbol('selected'); + public static readonly focus: symbol = Symbol('focus'); + public static readonly loaded: symbol = Symbol('loaded'); + + // Fields changed + public static readonly naming: symbol = Symbol('naming'); + public static readonly color: symbol = Symbol('color'); + public static readonly icon: symbol = Symbol('icon'); + public static readonly colorMap: symbol = Symbol('colorMap'); + public static readonly geometry: symbol = Symbol('geometry'); + public static readonly renderStyle: symbol = Symbol('renderStyle'); + + // Parent-child relationship changed + public static readonly childDeleted: symbol = Symbol('childDeleted'); + public static readonly childAdded: symbol = Symbol('childAdded'); + + public static readonly added: symbol = Symbol('added'); + public static readonly deleted: symbol = Symbol('deleted'); +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/Class.ts b/react-components/src/architecture/base/domainObjectsHelpers/Class.ts new file mode 100644 index 00000000000..9d421cc3e4d --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/Class.ts @@ -0,0 +1,10 @@ +/*! + * Copyright 2024 Cognite AS + */ + +// eslint-disable-next-line @typescript-eslint/ban-types +export type Class = Function & { prototype: T }; + +export function isInstanceOf(value: any, classType: Class): value is T { + return value instanceof classType; +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/ColorType.ts b/react-components/src/architecture/base/domainObjectsHelpers/ColorType.ts new file mode 100644 index 00000000000..3ee6febda21 --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/ColorType.ts @@ -0,0 +1,12 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export enum ColorType { + ColorMap, // Color by the given color map + Specified, // Use the color of the node + Parent, // Use the color of the parent node + Black, + White, + Different // Use different colors (normally use for debugging) +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/DomainObjectChange.ts b/react-components/src/architecture/base/domainObjectsHelpers/DomainObjectChange.ts new file mode 100644 index 00000000000..3a874328405 --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/DomainObjectChange.ts @@ -0,0 +1,125 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export class DomainObjectChange { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private _changes: ChangedDescription[] | undefined = undefined; + + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor(change?: symbol, name?: string) { + if (change !== undefined) { + this.add(change, name); + } + } + + // ================================================== + // INSTANCE METHODS: Requests + // ================================================== + + public get isEmpty(): boolean { + return this._changes === undefined || this._changes.length === 0; + } + + public isChanged(...changes: symbol[]): boolean { + if (this._changes === undefined) { + return false; + } + for (const change of changes) { + if (this._changes.some((desc: ChangedDescription) => desc.change === change)) { + return true; + } + } + return false; + } + + public isNameChanged(change: symbol, ...names: string[]): boolean { + // This igonores space and case. + const name = this.getName(change); + if (name === undefined) { + return false; + } + + const isSpace = (s: string): boolean => s === ' '; + + const { length } = name; + for (const otherName of names) { + let found = true; + const otherLength = otherName.length; + + for (let i = 0, j = 0; i < length && found; i++) { + const a = name.charAt(i); + if (isSpace(a)) { + continue; + } + const lowerA = a.toLowerCase(); + for (; j < otherLength; j++) { + const b = otherName.charAt(j); + if (isSpace(b)) { + continue; + } + const lowerB = b.toLowerCase(); + if (lowerB === lowerA) { + continue; + } + found = false; + break; + } + } + if (found) { + return true; + } + } + return false; + } + + // ================================================== + // INSTANCE METHODS: Getters + // ================================================== + + private getChangedDescription(change: symbol): ChangedDescription | undefined { + if (this._changes === undefined) { + return undefined; + } + return this._changes.find((desc: ChangedDescription) => desc.change === change); + } + + private getName(change: symbol): string | undefined { + const changedDescription = this.getChangedDescription(change); + return changedDescription === undefined ? undefined : changedDescription.name; + } + + // ================================================== + // INSTANCE METHODS: Operations + // ================================================== + + public add(change: symbol, name?: string): void { + if (change === undefined) { + return; + } + if (this._changes === undefined) { + this._changes = []; + } + this._changes.push(new ChangedDescription(change, name)); + } +} + +// ================================================== +// LOCAL HELPER CLASS +// ================================================== + +class ChangedDescription { + public change: symbol; + public name: string | undefined; + + public constructor(change: symbol, name?: string) { + this.change = change; + this.name = name; + } +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/DomainObjectIntersection.ts b/react-components/src/architecture/base/domainObjectsHelpers/DomainObjectIntersection.ts new file mode 100644 index 00000000000..05a7a6af5e9 --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/DomainObjectIntersection.ts @@ -0,0 +1,29 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type CustomObjectIntersection, type AnyIntersection } from '@cognite/reveal'; +import { type DomainObject } from '../domainObjects/DomainObject'; + +// DomainObjectIntersection extends the CustomObjectIntersection with a domainObject property. +// This had been a lot simpler with object orienteted intersection objects, but Reveal has of some +// unknown reason used types here - to make it hard for us to extend them. +// It is working but I don't like it at all. + +export type DomainObjectIntersection = CustomObjectIntersection & { domainObject: DomainObject }; + +export function isDomainObjectIntersection( + intersection: AnyIntersection | undefined +): intersection is DomainObjectIntersection { + const domainObjectIntersection = intersection as DomainObjectIntersection; + return domainObjectIntersection?.domainObject !== undefined; +} + +export function isCustomObjectIntersection( + intersection: AnyIntersection | undefined +): intersection is CustomObjectIntersection { + if (intersection?.type !== 'customObject') { + return false; + } + return true; +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/FocusType.ts b/react-components/src/architecture/base/domainObjectsHelpers/FocusType.ts new file mode 100644 index 00000000000..95cf5dda053 --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/FocusType.ts @@ -0,0 +1,13 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export enum FocusType { + None, + Focus, // Focus not any particalar place + Face, // Focus on the face + Corner, // Focus on a corner + Rotation, // Focus at rotation + Body, // Pick on any other places then Face, Corner or Rotation + Pending // Focus during creation, made to stop picking during creation of an object +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/PanelInfo.ts b/react-components/src/architecture/base/domainObjectsHelpers/PanelInfo.ts new file mode 100644 index 00000000000..c1897bcc4a2 --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/PanelInfo.ts @@ -0,0 +1,82 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export enum NumberType { + Unitless, + Length, + Area, + Volume, + Degrees +} + +type PanelItemProps = { + key?: string; + fallback?: string; + icon?: string; + value?: number; + numberType?: NumberType; + decimals?: number; +}; + +export class PanelInfo { + public header?: PanelItem; + public readonly items: NumberPanelItem[] = []; + + public setHeader(key: string, fallback: string): void { + this.header = new PanelItem({ key, fallback }); + } + + public add(props: PanelItemProps): void { + const item = new NumberPanelItem(props); + this.items.push(item); + } +} + +export class PanelItem { + public key?: string; + public fallback?: string; + + constructor(props: PanelItemProps) { + this.key = props.key; + this.fallback = props.fallback; + } +} + +export class NumberPanelItem extends PanelItem { + public icon: string | undefined = undefined; + public value: number; + public numberType: NumberType; + public decimals: number; + + constructor(props: PanelItemProps) { + super(props); + this.icon = props.icon; + this.value = props.value ?? 0; + this.numberType = props.numberType ?? NumberType.Unitless; + this.decimals = props.decimals ?? 2; + } + + public get valueAsString(): string { + return this.value.toFixed(this.decimals); + } + + public get unit(): string { + return getUnit(this.numberType); + } +} + +function getUnit(numberType: NumberType): string { + switch (numberType) { + case NumberType.Unitless: + return ''; + case NumberType.Length: + return 'm'; + case NumberType.Area: + return 'm²'; + case NumberType.Volume: + return 'm³'; + case NumberType.Degrees: + return '°'; + } +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/PopupStyle.ts b/react-components/src/architecture/base/domainObjectsHelpers/PopupStyle.ts new file mode 100644 index 00000000000..2da9a0998d4 --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/PopupStyle.ts @@ -0,0 +1,63 @@ +/*! + * Copyright 2024 Cognite AS + * BaseTool: Base class for the tool are used to interact with the render target. + */ + +type PopupProps = { + left?: number; + right?: number; + top?: number; + bottom?: number; + margin?: number; + padding?: number; +}; + +export class PopupStyle { + private readonly _left?: number = undefined; + private readonly _right?: number = undefined; + private readonly _top?: number = undefined; + private readonly _bottom?: number = undefined; + private readonly _margin: number = 16; // margin ouside the popup + private readonly _padding: number = 16; // margin inside the popup + + public constructor(props: PopupProps) { + this._left = props.left; + this._right = props.right; + this._top = props.top; + this._bottom = props.bottom; + if (props.margin !== undefined) { + this._margin = props.margin; + } + if (props.padding !== undefined) { + this._padding = props.padding; + } + } + + public get leftPx(): string { + return PopupStyle.getStringWithPx(this._left); + } + + public get rightPx(): string { + return PopupStyle.getStringWithPx(this._right); + } + + public get topPx(): string { + return PopupStyle.getStringWithPx(this._top); + } + + public get bottomPx(): string { + return PopupStyle.getStringWithPx(this._bottom); + } + + public get marginPx(): string { + return PopupStyle.getStringWithPx(this._margin); + } + + public get paddingPx(): string { + return PopupStyle.getStringWithPx(this._padding); + } + + public static getStringWithPx(value?: number): string { + return value === undefined ? 'undefined' : value.toString() + 'px'; + } +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/RenderStyle.ts b/react-components/src/architecture/base/domainObjectsHelpers/RenderStyle.ts new file mode 100644 index 00000000000..b049cdde84b --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/RenderStyle.ts @@ -0,0 +1,7 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export abstract class RenderStyle { + public abstract clone(): RenderStyle; +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/Views.ts b/react-components/src/architecture/base/domainObjectsHelpers/Views.ts new file mode 100644 index 00000000000..7138c712d45 --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/Views.ts @@ -0,0 +1,95 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type DomainObjectChange } from './DomainObjectChange'; +import { isInstanceOf, type Class } from './Class'; +import { clear, remove } from '../utilities/extensions/arrayExtensions'; +import { type BaseView } from '../views/BaseView'; +import { type DomainObject } from '../domainObjects/DomainObject'; + +type NotifyDelegate = (domainObject: DomainObject, change: DomainObjectChange) => void; + +/** + * Represents the subject in the Observer pattern + * A subject is an abstract class that provides functionality for notifying views and + * listeners about changes. + */ +export class Views { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private readonly _views: BaseView[] = []; + private readonly _listeners: NotifyDelegate[] = []; + + // ================================================== + // VIRTUAL METHODS: + // ================================================== + + /** + * Notifies the listeners and views about a change in the domain object. + * The should be called from DomainObject class only + * @param domainObject - The domain object that has changed. + * @param change - The change that occurred in the domain object. + */ + public notify(domainObject: DomainObject, change: DomainObjectChange): void { + for (const listener of this._listeners) { + listener(domainObject, change); + } + for (const view of this._views) { + view.update(change); + } + } + + // ================================================== + // INSTANCE METHODS: Views admin + // ================================================== + + public *getByType(classType: Class): Generator { + for (const view of this._views) { + if (isInstanceOf(view, classType)) { + yield view; + } + } + } + + public addView(view: BaseView): void { + this._views.push(view); + } + + public removeView(view: BaseView): void { + view.onHide(); + view.dispose(); + remove(this._views, view); + } + + private removeAllViews(): void { + for (const view of this._views) { + view.onHide(); + view.dispose(); + } + clear(this._views); + } + + // ================================================== + // INSTANCE METHODS: Event listeners admin + // ================================================== + + public addEventListener(listener: NotifyDelegate): void { + this._listeners.push(listener); + } + + public removeEventListener(listener: NotifyDelegate): void { + remove(this._listeners, listener); + } + + private removeEventListeners(): void { + clear(this._listeners); + } + + public clear(): void { + this.removeEventListeners(); + this.removeAllViews(); + } +} diff --git a/react-components/src/architecture/base/domainObjectsHelpers/VisibleState.ts b/react-components/src/architecture/base/domainObjectsHelpers/VisibleState.ts new file mode 100644 index 00000000000..0d5b02109c5 --- /dev/null +++ b/react-components/src/architecture/base/domainObjectsHelpers/VisibleState.ts @@ -0,0 +1,11 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export enum VisibleState { + All, // Visible + Some, // Partly visible + None, // None visible + CanNotBeChecked, // Cab not be checked + Disabled // Visiable disabled +} diff --git a/react-components/src/architecture/base/reactUpdaters/ActiveToolUpdater.ts b/react-components/src/architecture/base/reactUpdaters/ActiveToolUpdater.ts new file mode 100644 index 00000000000..0348d513178 --- /dev/null +++ b/react-components/src/architecture/base/reactUpdaters/ActiveToolUpdater.ts @@ -0,0 +1,34 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export type SetCounterDelegate = (counter: number) => void; + +export class ActiveToolUpdater { + // ================================================== + // STATIC FIELDS + // ================================================== + + private static _setCounter: SetCounterDelegate | undefined = undefined; + private static _counter = 0; + + // ================================================== + // STATIC METHODS + // ================================================== + + public static setCounterDelegate(value: SetCounterDelegate | undefined): void { + this._setCounter = value; + } + + public static update(): void { + // Increment the counter, so the state change in React and force a redraw each time the active tool changes + // The reason for solution it that I only want to store the active tool at one single location, since this gives a more + // stabel code, and never goes out of sync. + // React get the active tool by: renderTarget.toolController.activeTool; + if (this._setCounter === undefined) { + return; + } + this._counter++; + this._setCounter(this._counter); + } +} diff --git a/react-components/src/architecture/base/reactUpdaters/DomainObjectPanelUpdater.ts b/react-components/src/architecture/base/reactUpdaters/DomainObjectPanelUpdater.ts new file mode 100644 index 00000000000..eab63ab5c26 --- /dev/null +++ b/react-components/src/architecture/base/reactUpdaters/DomainObjectPanelUpdater.ts @@ -0,0 +1,40 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type DomainObject } from '../domainObjects/DomainObject'; + +export type DomainObjectInfo = { domainObject: DomainObject }; +export type SetDomainObjectInfoDelegate = (domainObjectInfo?: DomainObjectInfo) => void; + +export class DomainObjectPanelUpdater { + // ================================================== + // STATIC FIELDS + // ================================================== + + private static _setDomainObject: SetDomainObjectInfoDelegate | undefined = undefined; + + // ================================================== + // STATIC METHODS + // ================================================== + + public static get isActive(): boolean { + return this._setDomainObject !== undefined; + } + + public static setDomainObjectDelegate(value: SetDomainObjectInfoDelegate | undefined): void { + this._setDomainObject = value; + } + + public static update(domainObject: DomainObject | undefined): void { + if (this._setDomainObject === undefined) { + return; + } + if (domainObject === undefined) { + this._setDomainObject(undefined); + return; + } + const info = { domainObject }; + this._setDomainObject(info); + } +} diff --git a/react-components/src/architecture/base/renderTarget/RevealRenderTarget.ts b/react-components/src/architecture/base/renderTarget/RevealRenderTarget.ts new file mode 100644 index 00000000000..dc1678b1104 --- /dev/null +++ b/react-components/src/architecture/base/renderTarget/RevealRenderTarget.ts @@ -0,0 +1,242 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { + type CameraManager, + CustomObject, + isFlexibleCameraManager, + type Cognite3DViewer, + type IFlexibleCameraManager +} from '@cognite/reveal'; +import { AxisGizmoTool } from '@cognite/reveal/tools'; +import { NavigationTool } from '../commands/NavigationTool'; +import { + Vector3, + AmbientLight, + DirectionalLight, + type PerspectiveCamera, + type Box3, + type WebGLRenderer +} from 'three'; +import { ToolControllers } from './ToolController'; +import { RootDomainObject } from '../domainObjects/RootDomainObject'; +import { getOctDir } from '../utilities/extensions/vectorExtensions'; +import { getResizeCursor } from '../utilities/geometry/getResizeCursor'; +import { VisualDomainObject } from '../domainObjects/VisualDomainObject'; +import { ThreeView } from '../views/ThreeView'; + +const DIRECTIONAL_LIGHT_NAME = 'DirectionalLight'; + +export class RevealRenderTarget { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private readonly _viewer: Cognite3DViewer; + private readonly _toolController: ToolControllers; + private readonly _rootDomainObject: RootDomainObject; + private _axisGizmoTool: AxisGizmoTool | undefined; + private _ambientLight: AmbientLight | undefined; + private _directionalLight: DirectionalLight | undefined; + + // ================================================== + // CONTRUCTORS + // ================================================== + + constructor(viewer: Cognite3DViewer) { + this._viewer = viewer; + + const cameraManager = this.cameraManager; + if (!isFlexibleCameraManager(cameraManager)) { + throw new Error('Can not use RevealRenderTarget without the FlexibleCameraManager'); + } + this._toolController = new ToolControllers(this.domElement); + this._toolController.addEventListeners(); + this._rootDomainObject = new RootDomainObject(); + + this.initializeLights(); + this._viewer.on('cameraChange', this.cameraChangeHandler); + this._viewer.on('beforeSceneRendered', this.beforeSceneRenderedHandler); + } + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get viewer(): Cognite3DViewer { + return this._viewer; + } + + public get rootDomainObject(): RootDomainObject { + return this._rootDomainObject; + } + + public get canvas(): HTMLCanvasElement { + return this._viewer.canvas; + } + + public get domElement(): HTMLElement { + return this._viewer.domElement; + } + + public get toolController(): ToolControllers { + return this._toolController; + } + + public get cursor(): string { + return this.domElement.style.cursor; + } + + public set cursor(value: string) { + this.domElement.style.cursor = value; + } + + public get cameraManager(): CameraManager { + return this.viewer.cameraManager; + } + + public get flexibleCameraManager(): IFlexibleCameraManager { + const cameraManager = this.cameraManager; + if (!isFlexibleCameraManager(cameraManager)) { + throw new Error('Camera manager is not flexible'); + } + return cameraManager; + } + + public get camera(): PerspectiveCamera { + return this.cameraManager.getCamera(); + } + + public get sceneBoundingBox(): Box3 { + return this.viewer.getSceneBoundingBox(); + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public initialize(): void { + this._axisGizmoTool = new AxisGizmoTool(); + this._axisGizmoTool.connect(this._viewer); + + const navigationTool = new NavigationTool(); + navigationTool.attach(this); + this.toolController.add(navigationTool); + this.toolController.setDefaultTool(navigationTool); + } + + public dispose(): void { + if (this._ambientLight !== undefined) { + this._viewer.removeObject3D(this._ambientLight); + } + if (this._directionalLight !== undefined) { + this._viewer.removeObject3D(this._directionalLight); + } + this.toolController.removeEventListeners(); + this.toolController.dispose(); + this._axisGizmoTool?.dispose(); + } + + public invalidate(): void { + this._viewer.requestRedraw(); + } + + private initializeLights(): void { + this._ambientLight = new AmbientLight(0xffffff, 0.25); // soft white light + this._directionalLight = new DirectionalLight(0xffffff, 2); + this._directionalLight.name = DIRECTIONAL_LIGHT_NAME; + this._directionalLight.position.set(0, 1, 0); + + const ambientLight = new CustomObject(this._ambientLight); + const directionalLight = new CustomObject(this._directionalLight); + ambientLight.isPartOfBoundingBox = false; + directionalLight.isPartOfBoundingBox = false; + + this.viewer.addCustomObject(ambientLight); + this.viewer.addCustomObject(directionalLight); + } + + // ================================================== + // EVENT HANDLERS + // ================================================== + + cameraChangeHandler = (_position: Vector3, _target: Vector3): void => { + const light = this._directionalLight; + if (light === undefined) { + return; + } + const camera = this.camera; + + // Get camera direction + const cameraDirection = new Vector3(); + camera.getWorldDirection(cameraDirection); + + cameraDirection.negate(); + light.position.copy(cameraDirection); + }; + + beforeSceneRenderedHandler = (event: { + frameNumber: number; + renderer: WebGLRenderer; + camera: PerspectiveCamera; + }): void => { + // TODO: Add beforeRender to the customObject in Reveal, so this can be general made. + // This way is a little bit time consuming since we have to iterate over all domainObjects and all views. + for (const domainObject of this._rootDomainObject.getDescendantsByType(VisualDomainObject)) { + for (const view of domainObject.views.getByType(ThreeView)) { + if (view.renderTarget === this) { + view.beforeRender(event.camera); + } + } + } + }; + + // ================================================== + // INSTANCE METHODS: Cursor + // See: https://developer.mozilla.org/en-US/docs/Web/CSS/cursor + // ================================================== + + public setDefaultCursor(): void { + this.cursor = 'default'; + } + + public setMoveCursor(): void { + this.cursor = 'move'; + } + + public setNavigateCursor(): void { + this.cursor = 'pointer'; + } + + public setGrabCursor(): void { + this.cursor = 'grab'; + } + + public setCrosshairCursor(): void { + this.cursor = 'crosshair'; + } + + /** + * Sets the resize cursor based on two points in 3D space to the resize + * // cursor has a correct direction. + * @param point1 - The first point in 3D space. + * @param point2 - The second point in 3D space. + */ + public setResizeCursor(point1: Vector3, point2: Vector3): void { + const screenPoint1 = this.viewer.worldToScreen(point1, false); + if (screenPoint1 === null) { + return; + } + const screenPoint2 = this.viewer.worldToScreen(point2, false); + if (screenPoint2 === null) { + return; + } + const screenVector = screenPoint2?.sub(screenPoint1).normalize(); + screenVector.y = -screenVector.y; // Flip y axis so the x-y axis is mathematically correct + const cursor = getResizeCursor(getOctDir(screenVector)); + if (cursor !== undefined) { + this.cursor = cursor; + } + } +} diff --git a/react-components/src/architecture/base/renderTarget/ToolController.ts b/react-components/src/architecture/base/renderTarget/ToolController.ts new file mode 100644 index 00000000000..b4826e873c3 --- /dev/null +++ b/react-components/src/architecture/base/renderTarget/ToolController.ts @@ -0,0 +1,225 @@ +/*! + * Copyright 2024 Cognite AS + * ToolController: Holds the tools, the active tool and the previous tool + */ + +import { PointerEvents, PointerEventsTarget } from '@cognite/reveal'; +import { type BaseTool } from '../commands/BaseTool'; +import { type BaseCommand } from '../commands/BaseCommand'; + +export class ToolControllers extends PointerEvents { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private _activeTool: BaseTool | undefined; + private _defaultTool: BaseTool | undefined; + private _previousTool: BaseTool | undefined; + private readonly _domElement: HTMLElement; + private readonly _commands = new Set(); + private readonly _pointerEventsTarget: PointerEventsTarget; + + // ================================================== + // CONTRUCTORS + // ================================================== + + constructor(domElement: HTMLElement) { + super(); + this._domElement = domElement; + this._pointerEventsTarget = new PointerEventsTarget(this._domElement, this); + } + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get activeTool(): BaseTool | undefined { + return this._activeTool; + } + + // ================================================ + // OVERRIDES of PointerEvents + // ================================================ + + public override get isEnabled(): boolean { + return true; + } + + public override onHover(event: PointerEvent): void { + this.activeTool?.onHover(event); + } + + public override async onClick(event: PointerEvent): Promise { + this._domElement.focus(); + await this.activeTool?.onClick(event); + } + + public override async onDoubleClick(event: PointerEvent): Promise { + this._domElement.focus(); + await this.activeTool?.onDoubleClick(event); + } + + public override async onPointerDown(event: PointerEvent, leftButton: boolean): Promise { + this._domElement.focus(); + await this.activeTool?.onPointerDown(event, leftButton); + } + + public override async onPointerUp(event: PointerEvent, leftButton: boolean): Promise { + await this.activeTool?.onPointerUp(event, leftButton); + } + + public override async onPointerDrag(event: PointerEvent, leftButton: boolean): Promise { + await this.activeTool?.onPointerDrag(event, leftButton); + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public getEqual(command: BaseCommand): BaseCommand | undefined { + // For some reason Set<> doesn't have find! + for (const oldCommand of this._commands) { + if (oldCommand.equals(command)) { + return oldCommand; + } + } + return undefined; + } + + public add(command: BaseCommand): void { + this._commands.add(command); + } + + public setPreviousTool(): void { + if (this._previousTool !== undefined) { + this.setActiveTool(this._previousTool); + } + } + + public setDefaultTool(tool: BaseTool | undefined): void { + if (tool === undefined) { + return; + } + this._defaultTool = tool; + this.activateDefaultTool(); + } + + public activateDefaultTool(): void { + if (this._defaultTool === undefined) { + return; + } + this.setActiveTool(this._defaultTool); + } + + public setActiveTool(tool: BaseTool | undefined): void { + if (tool === undefined) { + return; + } + const prevActiveTool = this._activeTool; + if (prevActiveTool === tool) { + return; + } + if (prevActiveTool !== undefined) { + this._activeTool = undefined; + prevActiveTool.onDeactivate(); + this._previousTool = prevActiveTool; + } + this._activeTool = tool; + this._activeTool.onActivate(); + } + + public update(): void { + for (const command of this._commands) { + command.update(); + } + } + + public dispose(): void { + for (const command of this._commands) { + command.dispose(); + } + } + + // ================================================ + // INSTANCE METHODS: Other events + // ================================================ + + public onKey(event: KeyboardEvent, down: boolean): void { + // code – the “key code” ("KeyA", "ArrowLeft" and so on), specific to the physical location of the key on keyboard. + // key – the character ("A", "a" and so on), for non-character keys, such as Esc, usually has the same value as code. + if (down) { + const key = event.key.toUpperCase(); + for (const command of this._commands) { + if (command.shortCutKey === key) { + command.invoke(); + return; + } + } + } + this.activeTool?.onKey(event, down); + } + + // ================================================== + // INSTANCE METHODS: Add and remove listeners + // ================================================== + + public addEventListeners(): void { + // https://www.w3schools.com/jsref/obj_mouseevent.asp + const domElement = this._domElement; + domElement.addEventListener('keydown', this._onKeyDown); + domElement.addEventListener('keyup', this._onKeyUp); + domElement.addEventListener('wheel', this._onWheel); + domElement.addEventListener('focus', this._onFocus); + domElement.addEventListener('blur', this._onBlur); + this._pointerEventsTarget.addEventListeners(); + } + + public removeEventListeners(): void { + const domElement = this._domElement; + domElement.removeEventListener('keydown', this._onKeyDown); + domElement.addEventListener('contextmenu', this._onContextMenu); + domElement.removeEventListener('keyup', this._onKeyUp); + domElement.removeEventListener('wheel', this._onWheel); + domElement.removeEventListener('focus', this._onFocus); + domElement.removeEventListener('blur', this._onBlur); + this._pointerEventsTarget.removeEventListeners(); + for (const commands of this._commands) { + commands.removeEventListeners(); + } + } + + // ================================================== + // INSTANCE METHODS: Events + // ================================================== + + private readonly _onKeyDown = (event: KeyboardEvent): void => { + this.onKey(event, true); + event.stopPropagation(); + event.preventDefault(); + }; + + private readonly _onContextMenu = (event: MouseEvent): void => { + event.stopPropagation(); + event.preventDefault(); + }; + + private readonly _onKeyUp = (event: KeyboardEvent): void => { + this.onKey(event, false); + event.stopPropagation(); + event.preventDefault(); + }; + + private readonly _onWheel = async (event: WheelEvent): Promise => { + await this.activeTool?.onWheel(event); + event.stopPropagation(); + event.preventDefault(); + }; + + private readonly _onFocus = (_event: FocusEvent): void => { + this.activeTool?.onFocusChanged(true); + }; + + private readonly _onBlur = (_event: FocusEvent): void => { + this.activeTool?.onFocusChanged(false); + }; +} diff --git a/react-components/src/architecture/base/utilities/box/BoxFace.ts b/react-components/src/architecture/base/utilities/box/BoxFace.ts new file mode 100644 index 00000000000..a4a84d7b212 --- /dev/null +++ b/react-components/src/architecture/base/utilities/box/BoxFace.ts @@ -0,0 +1,138 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { Vector2, Vector3 } from 'three'; + +/** + * Represents a face of a box. + */ +export class BoxFace { + // Face is 0-5, where 0-2 are positive faces and 3-5 are negative faces + private _face: number = 0; + + public constructor(face: number = 0) { + this._face = face; + } + + public get face(): number { + return this._face; + } + + public set face(value: number) { + this._face = value; + } + + public get index(): number { + return this._face % 3; // Give 0:X axis, 1:Y-axis, 2:Z-axis + } + + public get tangentIndex1(): number { + return (this.index + 1) % 3; // Give 0:X axis, 1:Y-axis, 2:Z-axis + } + + public get tangentIndex2(): number { + return (this.index + 2) % 3; // Give 0:X axis, 1:Y-axis, 2:Z-axis + } + + public get sign(): number { + return this._face < 3 ? 1 : -1; + } + + copy(other: BoxFace): this { + this._face = other._face; + return this; + } + + equals(other: BoxFace): boolean { + return this.face === other.face; + } + + public fromPositionAtFace(positionAtFace: Vector3): this { + // Assume the only on component in the positionAtEdge is set and the other are 0 + const x = Math.abs(positionAtFace.x); + const y = Math.abs(positionAtFace.y); + const z = Math.abs(positionAtFace.z); + + if (x >= y && x >= z) { + this._face = positionAtFace.x > 0 ? 0 : 3; + } else if (y >= x && y >= z) { + this._face = positionAtFace.y > 0 ? 1 : 4; + } else { + this._face = positionAtFace.z > 0 ? 2 : 5; + } + return this; + } + + public getPlanePoint(positionAtFace: Vector3): Vector2 { + // Assume the only on component in the positionAtEdge is set and the other are 0 + switch (this.face) { + case 0: + case 3: + return new Vector2(positionAtFace.y, positionAtFace.z); + case 1: + case 4: + return new Vector2(positionAtFace.x, positionAtFace.z); + case 2: + case 5: + return new Vector2(positionAtFace.x, positionAtFace.y); + default: + throw new Error('Invalid face'); + } + } + + public getNormal(target?: Vector3): Vector3 { + if (target === undefined) { + target = new Vector3(); + } + target.setScalar(0); + return target.setComponent(this.index, this.sign); + } + + public getTangent1(target?: Vector3): Vector3 { + if (target === undefined) { + target = new Vector3(); + } + target.setScalar(0); + return target.setComponent(this.tangentIndex1, 1); + } + + public getTangent2(target?: Vector3): Vector3 { + if (target === undefined) { + target = new Vector3(); + } + target.setScalar(0); + return target.setComponent(this.tangentIndex2, 1); + } + + public getCenter(target?: Vector3): Vector3 { + // Assume the box is centered at (0,0,0) and has size 1 in all directions + if (target === undefined) { + target = new Vector3(); + } + target.setScalar(0); + return target.setComponent(this.index, this.sign * 0.5); + } + + public static *getAllFaces(target?: BoxFace): Generator { + if (target === undefined) { + target = new BoxFace(); + } + for (target.face = 0; target.face < 6; target.face++) { + yield target; + } + } + + public static equals(face: BoxFace | undefined, other: BoxFace | undefined): boolean { + if (face === undefined || other === undefined) { + return true; + } + if (face === undefined && other !== undefined) { + return false; + } + if (face !== undefined && other === undefined) { + return false; + } + return face.equals(other); + } +} diff --git a/react-components/src/architecture/base/utilities/box/BoxPickInfo.ts b/react-components/src/architecture/base/utilities/box/BoxPickInfo.ts new file mode 100644 index 00000000000..4f5cb4656d7 --- /dev/null +++ b/react-components/src/architecture/base/utilities/box/BoxPickInfo.ts @@ -0,0 +1,39 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type FocusType } from '../../domainObjectsHelpers/FocusType'; +import { type BoxFace } from './BoxFace'; +import { type Vector3 } from 'three'; + +/** + * Represents information about a picked box. + */ +export class BoxPickInfo { + /** + * The face of the box that was picked. + */ + public readonly face: BoxFace; + + /** + * The type of focus on the picked box. + */ + public readonly focusType: FocusType; + + /** + * Indicates the corner of the face. + */ + public readonly cornerSign: Vector3; + + /** + * Creates a new instance of BoxPickInfo. + * @param face The face of the box that was picked. + * @param focusType The type of focus on the picked box. + * @param cornerSign Indicates the corner of the face. + */ + public constructor(face: BoxFace, focusType: FocusType, cornerSign: Vector3) { + this.face = face; + this.focusType = focusType; + this.cornerSign = cornerSign; + } +} diff --git a/react-components/src/architecture/base/utilities/box/createLineSegmentsBufferGeometryForBox.ts b/react-components/src/architecture/base/utilities/box/createLineSegmentsBufferGeometryForBox.ts new file mode 100644 index 00000000000..c5c431d5513 --- /dev/null +++ b/react-components/src/architecture/base/utilities/box/createLineSegmentsBufferGeometryForBox.ts @@ -0,0 +1,62 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { Vector3, BufferGeometry, BufferAttribute } from 'three'; +import { OBB } from 'three/addons/math/OBB.js'; + +const HALF_SIDE = 0.5; + +// ================================================== +// PUBLIC FUNCTIONS: Functions +// ================================================== + +export function createOrientedBox(): OBB { + return new OBB(new Vector3().setScalar(0), new Vector3().setScalar(HALF_SIDE)); +} + +export function createLineSegmentsBufferGeometryForBox(): BufferGeometry { + const vertices = createBoxGeometryAsVertices(); + const verticesArray = new Float32Array(vertices); + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(verticesArray, 3)); + return geometry; +} + +// ================================================== +// PRIVATE FUNCTIONS: +// ================================================== + +function createBoxGeometryAsVertices(): number[] { + // Define vertices of a cube + const a = HALF_SIDE; + const corners = [ + { x: -a, y: -a, z: -a }, // Bottom-left-back + { x: +a, y: -a, z: -a }, // Bottom-right-back + { x: +a, y: +a, z: -a }, // Top-right-back + { x: -a, y: +a, z: -a }, // Top-left-back + { x: -a, y: -a, z: +a }, // Bottom-left-front + { x: +a, y: -a, z: +a }, // Bottom-right-front + { x: +a, y: +a, z: +a }, // Top-right-front + { x: -a, y: +a, z: +a } // Top-left-front + ]; + const vertices = corners.flatMap((vertex) => [vertex.x, vertex.y, vertex.z]); + + // Define the order of the vertices to form line segments of the cube + const bottomIndices = [0, 1, 1, 2, 2, 3, 3, 0]; + const topIndices = [4, 5, 5, 6, 6, 7, 7, 4]; + const sideIndices = [0, 4, 1, 5, 2, 6, 3, 7]; + const indices = [...bottomIndices, ...topIndices, ...sideIndices]; + + return createLineSegmentsAsVertices(vertices, indices); +} + +function createLineSegmentsAsVertices(vertices: number[], indices: number[]): number[] { + // Convert indexed lines to lines only + const allVertices: number[] = []; + for (let i = 0; i < indices.length; i++) { + const index = 3 * indices[i]; + allVertices.push(vertices[index], vertices[index + 1], vertices[index + 2]); + } + return allVertices; +} diff --git a/react-components/src/architecture/base/utilities/colors/ColorInterpolation.ts b/react-components/src/architecture/base/utilities/colors/ColorInterpolation.ts new file mode 100644 index 00000000000..910278fbf8e --- /dev/null +++ b/react-components/src/architecture/base/utilities/colors/ColorInterpolation.ts @@ -0,0 +1,9 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export enum ColorInterpolation { + Rgb, + HslShort, + HslLong +} diff --git a/react-components/src/architecture/base/utilities/colors/ColorMap.ts b/react-components/src/architecture/base/utilities/colors/ColorMap.ts new file mode 100644 index 00000000000..f8fc87bc8fe --- /dev/null +++ b/react-components/src/architecture/base/utilities/colors/ColorMap.ts @@ -0,0 +1,146 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type ColorInterpolation } from './ColorInterpolation'; +import { type Color } from 'three'; +import { ColorMapItem } from './ColorMapItem'; +import { compare } from '../extensions/mathExtensions'; +import { ColorMapType } from './ColorMapType'; +import { + BLACK_COLOR, + MAX_BYTE, + WHITE_COLOR, + fractionToByte, + getMixedColor +} from './colorExtensions'; +import { type Range1 } from '../geometry/Range1'; + +export const BYTE_PR_COLOR = 4; // RGBA +export const TEXTURE_1D_WIDTH = 2; // Width 2 because this is the minimum +const TEXTURE_1D_SIZE = 1000; + +export class ColorMap { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private readonly _items: ColorMapItem[] = []; + private _maxIndex: number = 0; + public colorMapType: ColorMapType = ColorMapType.None; + + // ================================================== + // INSTANCE METHODS: Getters + // ================================================== + + public getColor(fraction: number): Color { + // Assume a limited number of colors, otherwise a binary search should be used. + for (let i = 0; i <= this._maxIndex; i++) { + const item = this._items[i]; + if (fraction > item.fraction) { + continue; + } + if (i === 0) { + return item.color; + } + return this._items[i - 1].getMixed(item, fraction); + } + return this._items[this._maxIndex].color; + } + + private getColorFast(fraction: number, indexInColorMap: Index): Color { + // Assume a limited number of colors, otherwise a binary search should be used. + for (; indexInColorMap.value <= this._maxIndex; indexInColorMap.value++) { + const item = this._items[indexInColorMap.value]; + if (fraction > item.fraction) { + continue; + } + if (indexInColorMap.value === 0) { + return item.color; + } + return this._items[indexInColorMap.value - 1].getMixed(item, fraction); + } + return this._items[this._maxIndex].color; + } + + // ================================================== + // INSTANCE METHODS: Operations + // ================================================== + + public add(color: Color, fraction: number, interpolation: ColorInterpolation): void { + this._items.push(new ColorMapItem(color, fraction, interpolation)); + // Make it consistent: + this._items.sort((a, b) => compare(a.fraction, b.fraction)); + this._maxIndex = this._items.length - 1; + } + + public reverse(): void { + this._items.reverse(); + for (const item of this._items) { + item.fraction = 1 - item.fraction; + } + } + + // ================================================== + // INSTANCE METHODS: Operations + // ================================================== + + public createColors(size: number = TEXTURE_1D_SIZE): Uint8Array { + const rgbaArray = new Uint8Array(size * BYTE_PR_COLOR * TEXTURE_1D_WIDTH); + const indexInColorMap = new Index(); + + for (let index1 = 0, index2 = size; index1 < size; index1++, index2++) { + const fraction = index1 / (size - 1); + const color = this.getColorFast(fraction, indexInColorMap); + setAt(rgbaArray, index1, color); + setAt(rgbaArray, index2, color); + } + return rgbaArray; + } + + public createColorsWithContours( + range: Range1, + increment: number, + volume: number, + solidColor?: Color, + size: number = TEXTURE_1D_SIZE + ): Uint8Array { + const rgbaArray = new Uint8Array(size * BYTE_PR_COLOR * TEXTURE_1D_WIDTH); + const indexInColorMap = new Index(); + for (let index1 = 0, index2 = size; index1 < size; index1++, index2++) { + const fraction = index1 / (size - 1); + const level = range.getValue(fraction); + const reminder = level % increment; + const contourFraction = reminder / increment; + + // Get color in the middle + const middleLevel = level - reminder + increment / 2; + const middleFraction = range.getFraction(middleLevel); + + let color = solidColor ?? this.getColorFast(middleFraction, indexInColorMap); + if (contourFraction < 0.5) { + color = getMixedColor(WHITE_COLOR, color, volume * (0.5 - contourFraction)); + } else { + color = getMixedColor(BLACK_COLOR, color, volume * (contourFraction - 0.5)); + } + setAt(rgbaArray, index1, color); + setAt(rgbaArray, index2, color); + } + return rgbaArray; + } +} + +function setAt(rgbaArray: Uint8Array, index: number, color: Color): void { + const i = index * BYTE_PR_COLOR; + rgbaArray[i + 0] = fractionToByte(color.r); + rgbaArray[i + 1] = fractionToByte(color.g); + rgbaArray[i + 2] = fractionToByte(color.b); + rgbaArray[i + 3] = MAX_BYTE; +} + +class Index { + public value: number; + constructor(value = 0) { + this.value = value; + } +} diff --git a/react-components/src/architecture/base/utilities/colors/ColorMapItem.ts b/react-components/src/architecture/base/utilities/colors/ColorMapItem.ts new file mode 100644 index 00000000000..698e1a8d432 --- /dev/null +++ b/react-components/src/architecture/base/utilities/colors/ColorMapItem.ts @@ -0,0 +1,42 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type Color } from 'three'; +import { ColorInterpolation } from './ColorInterpolation'; +import { getHslMixedColor, getMixedColor } from './colorExtensions'; + +export class ColorMapItem { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public readonly color: Color; + public fraction: number; + private readonly _interpolation: ColorInterpolation; + + // ================================================== + // CONSTRUCTOR + // ================================================== + + constructor(color: Color, value: number, interpolation: ColorInterpolation) { + this.color = color; + this.fraction = value; + this._interpolation = interpolation; + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public getMixed(other: ColorMapItem, value: number): Color { + const fractionOfOther = (value - this.fraction) / (other.fraction - this.fraction); + const fractionOfThis = 1 - fractionOfOther; + + if (this._interpolation === ColorInterpolation.Rgb) { + return getMixedColor(this.color, other.color, fractionOfThis); + } + const long = this._interpolation === ColorInterpolation.HslLong; + return getHslMixedColor(this.color, other.color, fractionOfThis, long); + } +} diff --git a/react-components/src/architecture/base/utilities/colors/ColorMapType.ts b/react-components/src/architecture/base/utilities/colors/ColorMapType.ts new file mode 100644 index 00000000000..d8d4f71a8b1 --- /dev/null +++ b/react-components/src/architecture/base/utilities/colors/ColorMapType.ts @@ -0,0 +1,14 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export enum ColorMapType { + None = 'None', + Seismic = 'Seismic', + SeismicReverse = 'Seismic Reverse', + Rainbow = 'Rainbow', + RainbowReverse = 'Rainbow Reverse', + GreyScale = 'GreyScale', + GreyScaleReverse = 'GreyScale Reverse', + Terrain = 'terrain' +} diff --git a/react-components/src/architecture/base/utilities/colors/colorExtensions.ts b/react-components/src/architecture/base/utilities/colors/colorExtensions.ts new file mode 100644 index 00000000000..12b2f7fae40 --- /dev/null +++ b/react-components/src/architecture/base/utilities/colors/colorExtensions.ts @@ -0,0 +1,58 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { Color, type HSL } from 'three'; + +export const WHITE_COLOR = new Color(1, 1, 1); +export const BLACK_COLOR = new Color(0, 0, 0); +export const MAX_BYTE = 255; + +export function getMixedColor(color: Color, other: Color, fraction = 0.5): Color { + const otherFraction = 1 - fraction; + const r = color.r * fraction + other.r * otherFraction; + const g = color.g * fraction + other.g * otherFraction; + const b = color.b * fraction + other.b * otherFraction; + return new Color(r, g, b); +} + +export function getHslMixedColor(color: Color, other: Color, fraction = 0.5, long: boolean): Color { + let hsl1: HSL = { h: 0, s: 0, l: 0 }; + let hsl2: HSL = { h: 0, s: 0, l: 0 }; + hsl1 = color.getHSL(hsl1); + hsl2 = other.getHSL(hsl2); + + if (long) { + if (hsl1.h < hsl2.h) { + if (hsl2.h - hsl1.h < 0.5) hsl2.h -= 1; + } else { + if (hsl1.h - hsl2.h < 0.5) hsl2.h += 1; + } + } else { + if (hsl1.h < hsl2.h) { + if (hsl2.h - hsl1.h > 0.5) hsl2.h -= 1; + } else { + if (hsl1.h - hsl2.h > 0.5) hsl2.h += 1; + } + } + const otherFraction = 1 - fraction; + const h = hsl1.h * fraction + hsl2.h * otherFraction; + const s = hsl1.s * fraction + hsl2.s * otherFraction; + const l = hsl1.l * fraction + hsl2.l * otherFraction; + return new Color().setHSL(h, s, l); +} + +export function getGammaCorrectedColor(color: Color, gamma = 2.2): Color { + const r = color.r ** gamma; + const g = color.g ** gamma; + const b = color.b ** gamma; + return new Color(r, g, b); +} + +export function fractionToByte(fraction: number): number { + return fraction * MAX_BYTE; +} + +export function getColorFromBytes(r: number, g: number, b: number): Color { + return new Color(r / MAX_BYTE, g / MAX_BYTE, b / MAX_BYTE); +} diff --git a/react-components/src/architecture/base/utilities/colors/colorMaps.ts b/react-components/src/architecture/base/utilities/colors/colorMaps.ts new file mode 100644 index 00000000000..b24f4666102 --- /dev/null +++ b/react-components/src/architecture/base/utilities/colors/colorMaps.ts @@ -0,0 +1,115 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { ColorInterpolation } from './ColorInterpolation'; +import { ColorMap } from './ColorMap'; +import { Color } from 'three'; +import { ColorMapType } from './ColorMapType'; +import { getColorFromBytes as getFromBytes } from './colorExtensions'; + +let colorMaps: Map | undefined; // Act as a sigleton + +// ================================================== +// PUBLIC FUNCTIONS: +// ================================================== + +export function getColorMap(colorMapType: ColorMapType): ColorMap | undefined { + const colorMaps = getColorMaps(); + return colorMaps.get(colorMapType); +} + +export function getOptions(): ColorMapType[] { + const colorMaps = getColorMaps(); + return Array.from(colorMaps.keys()); +} + +// ================================================== +// PRIVATE FUNCTIONS +// ================================================== + +function getColorMaps(): Map { + if (colorMaps === undefined) { + colorMaps = createColorMaps(); + } + return colorMaps; +} + +function createColorMaps(): Map { + const map = new Map(); + add(map, createTerrain()); + add(map, createRainbow(false)); + add(map, createRainbow(true)); + add(map, createSeismic(false)); + add(map, createSeismic(true)); + add(map, createGreyScale(false)); + add(map, createGreyScale(true)); + return map; + + function add(map: Map, colorMap: ColorMap): void { + map.set(colorMap.colorMapType, colorMap); + } +} + +// ================================================== +// PRIVATE FUNCTIONS: Create various color maps +// ================================================== + +function createSeismic(reverse: boolean): ColorMap { + const colorMap = new ColorMap(); + const a = 0.2; + const b = 0.25; + + const interpolation = ColorInterpolation.Rgb; + + colorMap.add(getFromBytes(161, 255, 255), 0, interpolation); + colorMap.add(getFromBytes(0, 0, 191), a, interpolation); + colorMap.add(getFromBytes(77, 77, 77), b, interpolation); + colorMap.add(getFromBytes(204, 204, 204), 0.5, interpolation); + colorMap.add(getFromBytes(97, 69, 0), 1 - b, interpolation); + colorMap.add(getFromBytes(191, 0, 0), 1 - a, interpolation); + colorMap.add(new Color(Color.NAMES.yellow), 1, interpolation); + colorMap.colorMapType = reverse ? ColorMapType.SeismicReverse : ColorMapType.Seismic; + if (reverse) { + colorMap.reverse(); + } + return colorMap; +} + +function createRainbow(reverse: boolean): ColorMap { + const colorMap = new ColorMap(); + const interpolation = ColorInterpolation.HslLong; + colorMap.add(new Color(Color.NAMES.magenta), 0, interpolation); + colorMap.add(new Color(Color.NAMES.red), 1, interpolation); + colorMap.colorMapType = reverse ? ColorMapType.RainbowReverse : ColorMapType.Rainbow; + if (reverse) { + colorMap.reverse(); + } + return colorMap; +} + +function createGreyScale(reverse: boolean): ColorMap { + const colorMap = new ColorMap(); + const interpolation = ColorInterpolation.HslLong; + colorMap.add(new Color(Color.NAMES.white), 0, interpolation); + colorMap.add(new Color(Color.NAMES.black), 1, interpolation); + colorMap.colorMapType = reverse ? ColorMapType.GreyScaleReverse : ColorMapType.GreyScale; + if (reverse) { + colorMap.reverse(); + } + return colorMap; +} + +function createTerrain(): ColorMap { + const colorMap = new ColorMap(); + const interpolation = ColorInterpolation.Rgb; + colorMap.add(new Color(Color.NAMES.white), 0, interpolation); + colorMap.add(getFromBytes(168, 144, 140), 0.2, interpolation); // brown + colorMap.add(getFromBytes(255, 255, 150), 0.4, interpolation); // Yellow + colorMap.add(getFromBytes(87, 221, 119), 0.6, interpolation); // green + colorMap.add(getFromBytes(0, 147, 255), 0.8, interpolation); // blue + colorMap.add(getFromBytes(50, 50, 156), 1, interpolation); // Dark blue + colorMap.colorMapType = ColorMapType.Terrain; + colorMap.reverse(); + return colorMap; +} diff --git a/react-components/src/architecture/base/utilities/colors/create1DTexture.ts b/react-components/src/architecture/base/utilities/colors/create1DTexture.ts new file mode 100644 index 00000000000..a3d0eafe53c --- /dev/null +++ b/react-components/src/architecture/base/utilities/colors/create1DTexture.ts @@ -0,0 +1,30 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { DataTexture, type Color } from 'three'; +import { BYTE_PR_COLOR, TEXTURE_1D_WIDTH, type ColorMap } from './ColorMap'; +import { type Range1 } from '../geometry/Range1'; + +export function create1DTexture(colorMap: ColorMap): DataTexture { + const rgbaArray = colorMap.createColors(); + return createDataTexture(rgbaArray); +} + +export function create1DTextureWithContours( + colorMap: ColorMap, + range: Range1, + increment: number, + volume: number, + solidColor?: Color +): DataTexture { + const rgbaArray = colorMap.createColorsWithContours(range, increment, volume, solidColor); + return createDataTexture(rgbaArray); +} + +function createDataTexture(rgbaArray: Uint8Array): DataTexture { + const width = rgbaArray.length / (TEXTURE_1D_WIDTH * BYTE_PR_COLOR); + const texture = new DataTexture(rgbaArray, width, TEXTURE_1D_WIDTH); + texture.needsUpdate = true; + return texture; +} diff --git a/react-components/src/architecture/base/utilities/colors/getNextColor.ts b/react-components/src/architecture/base/utilities/colors/getNextColor.ts new file mode 100644 index 00000000000..8bb92203dbf --- /dev/null +++ b/react-components/src/architecture/base/utilities/colors/getNextColor.ts @@ -0,0 +1,56 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { Color } from 'three'; +import { isEven } from '../extensions/mathExtensions'; + +let currentIndex = 0; +let uniqueColors: Color[] | undefined; + +const NUMBER_OF_UNIQUE_COLORS = 50; + +// ================================================== +// PUBLIC FUNCTIONS: +// ================================================== + +export function getNextColor(): Color { + const colors = getUniqueColors(); + currentIndex = (currentIndex + 1) % colors.length; + return colors[currentIndex]; +} + +export function getNextColorByIndex(index: number): Color { + const colors = getUniqueColors(); + return colors[index % colors.length]; +} + +// ================================================== +// PRIVATE FUNCTIONS +// ================================================== + +function getUniqueColors(): Color[] { + if (uniqueColors === undefined) { + uniqueColors = createUniqueColors(NUMBER_OF_UNIQUE_COLORS); + } + return uniqueColors; +} + +function createUniqueColors(count: number): Color[] { + // This function make different colors + const CONJUGATE_OF_GOLDEN_RATIO = 0.618033988749895; + const result: Color[] = []; + let h = 0.5; + for (let i = 0; i < count; i++) { + h += CONJUGATE_OF_GOLDEN_RATIO; + h %= 1; + + const s = isEven(i) ? 0.5 : 1; // Brighter + const l = 0.5; // Brighter && Darker + + const color = new Color(); + color.setHSL(h, s, l); + result.push(color); + } + return result; +} diff --git a/react-components/src/architecture/base/utilities/extensions/arrayExtensions.ts b/react-components/src/architecture/base/utilities/extensions/arrayExtensions.ts new file mode 100644 index 00000000000..d56bbbff541 --- /dev/null +++ b/react-components/src/architecture/base/utilities/extensions/arrayExtensions.ts @@ -0,0 +1,34 @@ +/*! + * Copyright 2024 Cognite AS + */ + +/* + * Utilitys function for missing array methods + * Use there function in order top increase the readability of your code + */ + +export function clear(array: T[]): void { + array.splice(0, array.length); +} + +export function copy(array: T[], from: T[]): void { + clear(array); + array.push(...from); +} + +export function removeAt(array: T[], index: number): void { + array.splice(index, 1); +} + +export function replaceLast(array: T[], element: T): void { + if (array.length >= 1) { + array[array.length - 1] = element; + } +} + +export function remove(array: T[], element: T): void { + const index = array.indexOf(element); + if (index >= 0) { + removeAt(array, index); + } +} diff --git a/react-components/src/architecture/base/utilities/extensions/mathExtensions.ts b/react-components/src/architecture/base/utilities/extensions/mathExtensions.ts new file mode 100644 index 00000000000..bf84cac6c48 --- /dev/null +++ b/react-components/src/architecture/base/utilities/extensions/mathExtensions.ts @@ -0,0 +1,184 @@ +/*! + * Copyright 2024 Cognite AS + */ + +// ================================================== +// CONSTANTS +// ================================================== + +const ERROR_TOLERANCE = 1.0e-10; + +// ================================================== +// FUNCTIONS: Requests +// ================================================== + +export function isZero(x: number): boolean { + return x < 0 ? x > -ERROR_TOLERANCE : x < ERROR_TOLERANCE; +} + +export function isEqual(x: number, y: number): boolean { + return isRelativeEqual(x, y, ERROR_TOLERANCE); +} + +export function isAbsEqual(x: number, y: number, tolerance: number): boolean { + const error = x - y; + if (error < 0) return error > tolerance; + return error < tolerance; +} + +export function isRelativeEqual(x: number, y: number, tolerance: number): boolean { + // Error = ||x-y||/(1 + (|x|+|y|)/2) + let error = x - y; + const absX = x < 0 ? -x : x; + const absY = y < 0 ? -y : y; + + if (error < 0) { + error = -error; + } + return error / (1 + (absX + absY) / 2) < tolerance; +} + +export function isInteger(value: number): boolean { + const diff = Math.round(value) - value; + return isZero(diff); +} + +export function isIncrement(value: number, increment: number): boolean { + return isInteger(value / increment); +} + +export function isEven(value: number): boolean { + return value % 2 === 0; +} + +export function isBetween(min: number, value: number, max: number): boolean { + return min < max ? min < value && value < max : max < value && value < min; +} + +// ================================================== +// FUNCTIONS: Returning a number +// ================================================== + +export function max(a: number, b: number, c: number): number { + return Math.max(a, Math.max(b, c)); +} + +export function min(a: number, b: number, c: number): number { + return Math.min(a, Math.min(b, c)); +} + +export function square(value: number): number { + return value * value; +} + +export function roundInc(increment: number): number { + // Get the exponent for the number [1-10] and scale the inc so the number is between 1 and 10. + let exp = 0; + let inc = increment; + let found = false; + for (let i = 0; i < 100; i++) { + if (inc < 1) { + exp -= 1; + inc *= 10; + } else if (inc > 10) { + exp += 1; + inc /= 10; + } else { + found = true; + break; + } + } + if (!found) { + return Number.NaN; + } + // Now round it + if (inc < 2) { + inc = 2; + } else if (inc < 2.5) { + inc = 2.5; + } else if (inc < 5) { + inc = 5; + } else { + inc = 10; + } + // Upscale the inc to the real number + if (exp < 0) { + for (; exp !== 0; exp++) inc /= 10; + } else { + for (; exp !== 0; exp--) inc *= 10; + } + return inc; +} + +export function round(value: number, delta: number): number { + // Rounding number up to nearest delta + let result = value / delta; + result = Math.round(result); + result *= delta; + return result; +} + +export function ceil(value: number, delta: number): number { + // Rounding number up to nearest delta + let result = value / delta; + result = Math.ceil(result); + result *= delta; + return result; +} + +export function floor(value: number, delta: number): number { + // Rounding number down to nearest delta + let result = value / delta; + result = Math.floor(result); + result *= delta; + return result; +} + +export function forceBetween0AndTwoPi(value: number): number { + while (value < 0) { + value += 2 * Math.PI; + } + while (value >= 2 * Math.PI) { + value -= 2 * Math.PI; + } + return value; +} + +export function forceBetween0AndPi(value: number): number { + while (value < 0) { + value += Math.PI; + } + while (value >= Math.PI) { + value -= Math.PI; + } + return value; +} + +// ================================================== +// FUNCTIONS: Comparing +// ================================================== + +export function compare(a: number, b: number): number { + if (a > b) return 1; + if (a < b) return -1; + return 0; +} + +// ================================================== +// FUNCTIONS: Random +// ================================================== + +export function getRandomGaussian(mean = 0, stdDev = 1): number { + for (;;) { + const a = Math.random(); + if (a <= Number.EPSILON) { + continue; + } + const b = Math.random(); + if (b <= Number.EPSILON) { + continue; + } + const gausian = Math.sqrt(-2 * Math.log(a)) * Math.cos(2 * Math.PI * b); + return gausian * stdDev + mean; + } +} diff --git a/react-components/src/architecture/base/utilities/extensions/rayExtensions.ts b/react-components/src/architecture/base/utilities/extensions/rayExtensions.ts new file mode 100644 index 00000000000..3c7f647b392 --- /dev/null +++ b/react-components/src/architecture/base/utilities/extensions/rayExtensions.ts @@ -0,0 +1,21 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { Vector3, type Ray } from 'three'; + +export function getClosestPointOnLine( + ray: Ray, + lineDirection: Vector3, + pointOnLine: Vector3, + optionalClosestPointOnLine?: Vector3 +): Vector3 { + if (optionalClosestPointOnLine === undefined) { + optionalClosestPointOnLine = new Vector3(); + } + // Three.js lack a distance to line function, so use the line segment function + const lineLength = ray.distanceToPoint(pointOnLine) * 100; + const v0 = pointOnLine.clone().addScaledVector(lineDirection, -lineLength); + const v1 = pointOnLine.clone().addScaledVector(lineDirection, +lineLength); + ray.distanceSqToSegment(v0, v1, undefined, optionalClosestPointOnLine); + return optionalClosestPointOnLine; +} diff --git a/react-components/src/architecture/base/utilities/extensions/stringExtensions.ts b/react-components/src/architecture/base/utilities/extensions/stringExtensions.ts new file mode 100644 index 00000000000..6b8f78c0350 --- /dev/null +++ b/react-components/src/architecture/base/utilities/extensions/stringExtensions.ts @@ -0,0 +1,24 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export function isEmpty(value: string | null | undefined): boolean { + return value === null || value === undefined || value.length === 0; +} + +export function equalsIgnoreCase(value1: string, value2: string): boolean { + return value1.toLowerCase() === value2.toLowerCase(); +} + +export function isNumber(text: string): boolean { + const value = Number(text); + return !Number.isNaN(value); +} + +export function getNumber(text: string): number { + const value = Number(text); + if (Number.isNaN(value)) { + return Number.NaN; + } + return value; +} diff --git a/react-components/src/architecture/base/utilities/extensions/vectorExtensions.ts b/react-components/src/architecture/base/utilities/extensions/vectorExtensions.ts new file mode 100644 index 00000000000..e9b65b96107 --- /dev/null +++ b/react-components/src/architecture/base/utilities/extensions/vectorExtensions.ts @@ -0,0 +1,53 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type Vector3, type Vector2 } from 'three'; +import { square } from './mathExtensions'; + +export function horizontalAngle(vector: Vector3): number { + // computes the angle in radians with respect to the positive x-axis + // Copied from tree.js source code (https://github.com/mrdoob/three.js/blob/master/src/math/Vector2.js) + return Math.atan2(-vector.y, -vector.x) + Math.PI; +} + +export function horizontalDistanceTo(from: Vector3, to: Vector3): number { + return Math.sqrt(square(from.x - to.x) + square(from.y - to.y)); +} + +export function verticalDistanceTo(from: Vector3, to: Vector3): number { + return Math.abs(from.z - to.z); +} + +export function getHorizontalCrossProduct(self: Vector3, other: Vector3): number { + return self.x * other.y - self.y * other.x; +} + +export function getOctDir(vector: Vector2): number { + // The octdirs are: + // North (Positive Y-axis) + // 3 2 1 + // West 4 * 0 East (Positive X-axis) + // 5 6 7 + // South + const angle = vector.angle(); + + // Normalize angle to (0,1) so it is easier to work with + let normalized = angle / (2 * Math.PI); + + // Force between 0 and 1 + while (normalized < 0) normalized += 1; + while (normalized > 1) normalized -= 1; + + // Convert it to integer between 0 and 7 + const octdir = Math.round(8 * normalized); + + // Check to be sure + if (octdir >= 8) { + return octdir - 8; + } + if (octdir < 0) { + return octdir + 8; + } + return octdir; +} diff --git a/react-components/src/architecture/base/utilities/geometry/Index2.ts b/react-components/src/architecture/base/utilities/geometry/Index2.ts new file mode 100644 index 00000000000..41e4f5e1ada --- /dev/null +++ b/react-components/src/architecture/base/utilities/geometry/Index2.ts @@ -0,0 +1,78 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export class Index2 { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public i: number; + public j: number; + + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor(i?: number, j?: number) { + this.i = i ?? 0; + this.j = j ?? this.i; + } + + public clone(): Index2 { + return new Index2(this.i, this.j); + } + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get size(): number { + return this.i * this.j; + } + + public get isZero(): boolean { + return this.i === 0 && this.j === 0; + } + + // ================================================== + // INSTANCE METHODS: Getters + // ================================================== + + public getAt(dimension: number): number { + switch (dimension) { + case 0: + return this.i; + case 1: + return this.j; + default: + return Number.NaN; + } + } + + public toString(): string { + return `(${this.i}, ${this.j})`; + } + + // ================================================== + // INSTANCE METHODS: Operations + // ================================================== + + public copy(value: Index2): this { + this.i = value.i; + this.j = value.j; + return this; + } + + public add(value: Index2): this { + this.i += value.i; + this.j += value.j; + return this; + } + + public sub(value: Index2): this { + this.i -= value.i; + this.j -= value.j; + return this; + } +} diff --git a/react-components/src/architecture/base/utilities/geometry/Points.ts b/react-components/src/architecture/base/utilities/geometry/Points.ts new file mode 100644 index 00000000000..e490507213a --- /dev/null +++ b/react-components/src/architecture/base/utilities/geometry/Points.ts @@ -0,0 +1,48 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { clear } from '../extensions/arrayExtensions'; +import { type Range3 } from './Range3'; +import { Shape } from './Shape'; +import { type Vector3 } from 'three'; + +export class Points extends Shape { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public list: Vector3[] = []; + + public get length(): number { + return this.list.length; + } + + // ================================================== + // OVERRIDES of Shape: + // ================================================== + + public override clone(): Shape { + const result = new Points(); + result.list = [...this.list]; + return result; + } + + public override expandBoundingBox(boundingBox: Range3): void { + for (const point of this.list) { + boundingBox.add(point); + } + } + + // ================================================== + // INSTANCE METHODS: Operations + // ================================================== + + public add(point: Vector3): void { + this.list.push(point); + } + + public clear(): void { + clear(this.list); + } +} diff --git a/react-components/src/architecture/base/utilities/geometry/Polyline.ts b/react-components/src/architecture/base/utilities/geometry/Polyline.ts new file mode 100644 index 00000000000..0b6d8a1c400 --- /dev/null +++ b/react-components/src/architecture/base/utilities/geometry/Polyline.ts @@ -0,0 +1,69 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { getHorizontalCrossProduct, horizontalDistanceTo } from '../extensions/vectorExtensions'; +import { Points } from './Points'; +import { type Shape } from './Shape'; +import { Vector3 } from 'three'; + +export class Polyline extends Points { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public isClosed: boolean = false; + + // ================================================== + // OVERRIDES of Shape: + // ================================================== + + public override clone(): Shape { + const result = new Polyline(); + result.list = [...this.list]; + return result; + } + + // ================================================== + // INSTANCE METHODS: Getters + // ================================================== + + public getLength(dimension: number = 3): number { + let length = 0; + const maxIndex = this.list.length - 1; + for (let i = 1; i <= maxIndex; i++) { + const p0 = this.list[i - 1]; + const p1 = this.list[i]; + length += dimension === 3 ? p0.distanceTo(p1) : horizontalDistanceTo(p0, p1); + } + if (this.isClosed) { + const p0 = this.list[maxIndex]; + const p1 = this.list[0]; + length += dimension === 3 ? p0.distanceTo(p1) : horizontalDistanceTo(p0, p1); + } + return length; + } + + public getArea(): number { + return Math.abs(this.getSignedArea()); + } + + public getSignedArea(): number { + const n = this.length; + if (n === 2) { + return 0; + } + let area = 0; + const first = this.list[0]; + const p0 = new Vector3(); + const p1 = new Vector3(); + + for (let index = 1; index <= n; index++) { + p1.copy(this.list[index % n]); + p1.sub(first); // Translate down to first point, to increase acceracy + area += getHorizontalCrossProduct(p0, p1); + p0.copy(p1); + } + return area * 0.5; + } +} diff --git a/react-components/src/architecture/base/utilities/geometry/Range1.ts b/react-components/src/architecture/base/utilities/geometry/Range1.ts new file mode 100644 index 00000000000..39ce6e636c6 --- /dev/null +++ b/react-components/src/architecture/base/utilities/geometry/Range1.ts @@ -0,0 +1,268 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { ceil, floor, isIncrement, roundInc as roundIncrement } from '../extensions/mathExtensions'; + +const MAX_NUMBER_OF_TICKS = 1000; +export class Range1 { + // ================================================== + // STATIC FIELDS + // ================================================== + + public static readonly empty = new Range1(); + + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private _min: number = 0; + private _max: number = 0; + private _isEmpty: boolean = true; + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get isEmpty(): boolean { + return this._isEmpty; + } + + public get isSingular(): boolean { + return this.min === this.max; + } + + public get hasSpan(): boolean { + return !this.isEmpty && !this.isSingular; + } + + public get min(): number { + return this._min; + } + + public set min(value: number) { + this._min = value; + } + + public get max(): number { + return this._max; + } + + public set max(value: number) { + this._max = value; + } + + public get delta(): number { + return this._max - this._min; + } + + public get center(): number { + return (this._min + this._max) / 2; + } + + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor(min?: number, max?: number) { + if (min === undefined && max !== undefined) this.set(max, max); + else if (min !== undefined && max === undefined) this.set(min, min); + else if (min !== undefined && max !== undefined) this.set(min, max); + } + + public clone(): Range1 { + const range = new Range1(); + range._min = this._min; + range._max = this._max; + range._isEmpty = this._isEmpty; + return range; + } + + // ================================================== + // INSTANCE METHODS: Requests + // ================================================== + + equals(other: Range1): boolean { + if (other === undefined) { + return false; + } + if (this._isEmpty && other._isEmpty) { + return true; + } + if (this._isEmpty !== other._isEmpty) { + return false; + } + return this.min === other.min && this.max === other.max; + } + + isInside(value: number): boolean { + return this.min <= value && value <= this.max; + } + + // ================================================== + // INSTANCE METHODS: Getters + // ================================================== + + public toString(): string { + return `(${this._min}, ${this._max})`; + } + + public getFraction(value: number): number { + // Opposite of getValue + return (value - this.min) / this.delta; + } + + public getTruncatedFraction(value: number): number { + const fraction = this.getFraction(value); + if (fraction < 0) return 0; + if (fraction > 1) return 1; + return fraction; + } + + public getValue(fraction: number): number { + // Opposite of getFraction + return fraction * this.delta + this.min; + } + + public getBestIncrement(numberOfTicks = 50): number { + const increment = this.delta / numberOfTicks; + return roundIncrement(increment); + } + + public getNumTicks(increment: number): number { + return Math.round(this.delta / increment); + } + + public *getTicks(increment: number): Generator { + const copy = this.clone(); + if (!copy.roundByInc(-increment)) { + return; + } + if (copy.getNumTicks(increment) > MAX_NUMBER_OF_TICKS) { + return; // This is a safety valve to prevent it going infinity loops + } + const tolerance = increment / 10000; + const max = copy.max + tolerance; + for (let tick = copy.min; tick <= max; tick += increment) { + yield Math.abs(tick) < tolerance ? 0 : tick; + } + } + + public *getFastTicks(increment: number, tolerance: number): Generator { + // This method overwrites this (optimalization) + if (!this.roundByInc(-increment)) { + return; + } + if (this.getNumTicks(increment) > MAX_NUMBER_OF_TICKS) { + return; // This is a safety valve to prevent it going infinity loops + } + const max = this.max + tolerance; + for (let tick = this.min; tick <= max; tick += increment) { + yield Math.abs(tick) < tolerance ? 0 : tick; + } + } + + public getBoldIncrement(increment: number, every = 2): number { + let numerOfTicks = 0; + const boldIncrement = increment * every; + for (const tick of this.getTicks(increment)) { + if (!isIncrement(tick, boldIncrement)) { + continue; + } + numerOfTicks += 1; + if (numerOfTicks > 2) { + return boldIncrement; + } + } + return increment; + } + + // ================================================== + // INSTANCE METHODS: Operations + // ================================================== + + public clear(): void { + this._min = 0; + this._max = 0; + this._isEmpty = true; + } + + public set(min: number, max: number): void { + this._min = Math.min(min, max); + this._max = Math.max(min, max); + this._isEmpty = false; + } + + public translate(value: number): void { + if (this.isEmpty) { + return; + } + this._min += value; + this._max += value; + } + + public scale(value: number): void { + if (this.isEmpty) { + return; + } + this._min *= value; + this._max *= value; + } + + public scaleDelta(scale: number): void { + if (this.isEmpty) { + return; + } + const { center } = this; + this._min = (this._min - center) * scale + center; + this._max = (this._max - center) * scale + center; + } + + public add(value: number): void { + if (Number.isNaN(value)) { + return; + } + if (this.isEmpty) { + this._isEmpty = false; + this._min = value; + this._max = value; + } else if (value < this._min) this._min = value; + else if (value > this._max) this._max = value; + } + + public addRange(value: Range1): void { + if (this.isEmpty) { + return; + } + this.add(value.min); + this.add(value.max); + } + + public expandByMargin(margin: number): void { + if (this.isEmpty) { + return; + } + this._min -= margin; + this._max += margin; + if (this._min > this._max) [this._max, this._min] = [this._min, this._max]; // Swap + } + + public expandByFraction(fraction: number): void { + if (!this.isEmpty) this.expandByMargin(this.delta * fraction); + } + + public roundByInc(increment: number): boolean { + if (this.isEmpty) { + return false; + } + if (increment < 0) { + this._min = ceil(this._min, -increment); + this._max = floor(this._max, -increment); + if (this._min > this._max) return false; + } else if (increment > 0) { + this._min = floor(this._min, increment); + this._max = ceil(this._max, increment); + } + return true; + } +} diff --git a/react-components/src/architecture/base/utilities/geometry/Range3.ts b/react-components/src/architecture/base/utilities/geometry/Range3.ts new file mode 100644 index 00000000000..b5d5dbadb43 --- /dev/null +++ b/react-components/src/architecture/base/utilities/geometry/Range3.ts @@ -0,0 +1,242 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type Vector2, Vector3, type Box3 } from 'three'; +import { Range1 } from './Range1'; +import { square } from '../extensions/mathExtensions'; + +export class Range3 { + // ================================================== + // STATIC FIELDS + // ================================================== + + public static readonly empty = new Range3(); + + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public x: Range1 = new Range1(); + public y: Range1 = new Range1(); + public z: Range1 = new Range1(); + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get isEmpty(): boolean { + return this.x.isEmpty || this.y.isEmpty || this.z.isEmpty; + } + + public get min(): Vector3 { + return new Vector3(this.x.min, this.y.min, this.z.min); + } + + public get max(): Vector3 { + return new Vector3(this.x.max, this.y.max, this.z.max); + } + + public get delta(): Vector3 { + return new Vector3(this.x.delta, this.y.delta, this.z.delta); + } + + public get center(): Vector3 { + return new Vector3(this.x.center, this.y.center, this.z.center); + } + + public get diagonal(): number { + return Math.sqrt(square(this.x.delta) + square(this.y.delta) + square(this.z.delta)); + } + + public get area(): number { + return 2 * (this.x.delta + this.y.delta + this.z.delta); + } + + public get volume(): number { + return this.x.delta * this.y.delta * this.z.delta; + } + + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor(min?: Vector3, max?: Vector3) { + if (min === undefined && max !== undefined) { + this.set(max, max); + } else if (min !== undefined && max === undefined) { + this.set(min, min); + } else if (min !== undefined && max !== undefined) { + this.set(min, max); + } + } + + public clone(): Range3 { + const range = new Range3(); + range.x = this.x.clone(); + range.y = this.y.clone(); + range.z = this.z.clone(); + return range; + } + + // ================================================== + // INSTANCE METHODS: Requests + // ================================================== + + public equals(other: Range3 | undefined): boolean { + if (other === undefined) { + return false; + } + return this.x.equals(other.x) && this.y.equals(other.y) && this.z.equals(other.z); + } + + // ================================================== + // INSTANCE METHODS: Getters + // ================================================== + + public toString(): string { + return `(X: ${this.x.toString()}, Y: ${this.y.toString()}, Z: ${this.z.toString()})`; + } + + public getMin(target: Vector3): Vector3 { + return target.set(this.x.min, this.y.min, this.z.min); + } + + public getMax(target: Vector3): Vector3 { + return target.set(this.x.max, this.y.max, this.z.max); + } + + public getDelta(target: Vector3): Vector3 { + return target.set(this.x.delta, this.y.delta, this.z.delta); + } + + public getCenter(target: Vector3): Vector3 { + return target.set(this.x.center, this.y.center, this.z.center); + } + + public getBox(target: Box3): Box3 { + target.min.set(this.x.min, this.y.min, this.z.min); + target.max.set(this.x.max, this.y.max, this.z.max); + return target; + } + + public getCornerPoints(corners: Vector3[]): Vector3[] { + for (let corner = 0; corner < 8; corner++) { + this.getCornerPoint(corner, corners[corner]); + } + return corners; + } + + public getCornerPoint(corner: number, target: Vector3): Vector3 { + // 7-------6 + // / | /| + // 4-------5 | + // | | | | + // Z 3----|--2 + // | / |Y + // 0---X---1 + + switch (corner) { + case 0: + return target.set(this.x.min, this.y.min, this.z.min); + case 1: + return target.set(this.x.max, this.y.min, this.z.min); + case 2: + return target.set(this.x.max, this.y.max, this.z.min); + case 3: + return target.set(this.x.min, this.y.max, this.z.min); + case 4: + return target.set(this.x.min, this.y.min, this.z.max); + case 5: + return target.set(this.x.max, this.y.min, this.z.max); + case 6: + return target.set(this.x.max, this.y.max, this.z.max); + case 7: + return target.set(this.x.min, this.y.max, this.z.max); + default: + throw Error('getCornerPoint'); + } + } + + // ================================================== + // INSTANCE METHODS: Operations + // ================================================== + + public copy(box: Box3): void { + this.set(box.min, box.max); + } + + public set(min: Vector3, max: Vector3): void { + this.x.set(min.x, max.x); + this.y.set(min.y, max.y); + this.z.set(min.z, max.z); + } + + public translate(value: Vector3): void { + this.x.translate(value.x); + this.y.translate(value.y); + this.z.translate(value.z); + } + + public scaleDelta(value: Vector3): void { + this.x.scaleDelta(value.x); + this.y.scaleDelta(value.y); + this.z.scaleDelta(value.z); + } + + public add(value: Vector3): void { + this.x.add(value.x); + this.y.add(value.y); + this.z.add(value.z); + } + + public add2(value: Vector2): void { + this.x.add(value.x); + this.y.add(value.y); + } + + public addRange(value: Range3 | undefined): void { + if (value === undefined) return; + this.x.addRange(value.x); + this.y.addRange(value.y); + this.z.addRange(value.z); + } + + public expandByMargin(margin: number): void { + this.x.expandByMargin(margin); + this.y.expandByMargin(margin); + this.z.expandByMargin(margin); + } + + public expandByMargin3(margin: Vector3): void { + this.x.expandByMargin(margin.x); + this.y.expandByMargin(margin.y); + this.z.expandByMargin(margin.z); + } + + public expandByFraction(fraction: number): void { + this.x.expandByFraction(fraction); + this.y.expandByFraction(fraction); + this.z.expandByFraction(fraction); + } + + // ================================================== + // STATIC METHODS + // ================================================== + + public static createByMinAndMax(xmin: number, ymin: number, xmax: number, ymax: number): Range3 { + const range = new Range3(); + range.x.set(xmin, xmax); + range.y.set(ymin, ymax); + range.z.set(0, 0); + return range; + } + + public static createByMinAndDelta(xmin: number, ymin: number, dx: number, dy: number): Range3 { + const range = new Range3(); + range.x.set(xmin, xmin + dx); + range.y.set(ymin, ymin + dy); + range.z.set(0, 0); + return range; + } +} diff --git a/react-components/src/architecture/base/utilities/geometry/Shape.ts b/react-components/src/architecture/base/utilities/geometry/Shape.ts new file mode 100644 index 00000000000..b6be1766aa9 --- /dev/null +++ b/react-components/src/architecture/base/utilities/geometry/Shape.ts @@ -0,0 +1,50 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type Range1 } from './Range1'; +import { Range3 } from './Range3'; + +export abstract class Shape { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private _boundingBox: Range3 | undefined; + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get zRange(): Range1 { + return this.boundingBox.z; + } + + public get boundingBox(): Range3 { + if (this._boundingBox === undefined) { + this._boundingBox = new Range3(); + this.expandBoundingBox(this._boundingBox); + } + return this._boundingBox; + } + + public set boundingBox(boundingBox) { + this._boundingBox = boundingBox.clone(); + } + + // ================================================== + // VIRTUAL METHODS + // ================================================== + + public abstract clone(): Shape; + + public abstract expandBoundingBox(boundingBox: Range3): void; + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public touch(): void { + this._boundingBox = undefined; + } +} diff --git a/react-components/src/architecture/base/utilities/geometry/TrianglesBuffers.ts b/react-components/src/architecture/base/utilities/geometry/TrianglesBuffers.ts new file mode 100644 index 00000000000..fff83523479 --- /dev/null +++ b/react-components/src/architecture/base/utilities/geometry/TrianglesBuffers.ts @@ -0,0 +1,138 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { BufferGeometry, Float32BufferAttribute, Uint32BufferAttribute, type Vector3 } from 'three'; + +export class TrianglesBuffers { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + protected positions: Float32Array; + protected normals: Float32Array | undefined = undefined; + protected uvs: Float32Array | undefined = undefined; + protected triangleIndexes: number[] = []; + protected uniqueIndex = 0; + + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor(pointCount: number, makeNormals = true, makeUvs = false) { + this.positions = new Float32Array(3 * pointCount); + if (makeNormals) { + this.normals = new Float32Array(3 * pointCount); + } + if (makeUvs) { + this.uvs = new Float32Array(2 * pointCount); + } + } + + // ================================================== + // INSTANCE METHODS: Creators + // ================================================== + + public createBufferGeometry(): BufferGeometry { + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(this.positions, 3, true)); + if (this.normals !== undefined) { + geometry.setAttribute('normal', new Float32BufferAttribute(this.normals, 3, false)); // Auto normalizing + } + geometry.setIndex(new Uint32BufferAttribute(this.triangleIndexes, 1, true)); + if (this.uvs !== undefined) { + geometry.setAttribute('uv', new Float32BufferAttribute(this.uvs, 2, true)); + } + return geometry; + } + + // ================================================== + // INSTANCE METHODS: Add operation + // ================================================== + + public addPair(p1: Vector3, p2: Vector3, n1: Vector3, n2: Vector3, u = 0): void { + if (this.uniqueIndex >= 2) { + // 2------3 + // | | + // 0------1 + const unique0 = this.uniqueIndex - 2; + const unique1 = this.uniqueIndex - 1; + const unique2 = this.uniqueIndex; + const unique3 = this.uniqueIndex + 1; + + this.addTriangle(unique0, unique2, unique3); + this.addTriangle(unique0, unique3, unique1); + } + + this.add(p1, n1, u); + this.add(p2, n2, u); + } + + public addPair2(p1: Vector3, p2: Vector3, normal: Vector3, u: number): void { + if (this.uniqueIndex >= 2) { + // 2------3 + // | | + // 0------1 + const unique0 = this.uniqueIndex - 2; + const unique1 = this.uniqueIndex - 1; + const unique2 = this.uniqueIndex; + const unique3 = this.uniqueIndex + 1; + + this.addTriangle(unique0, unique2, unique3); + this.addTriangle(unique0, unique3, unique1); + } + if (this.uvs !== undefined) { + this.add(p1, normal, u); + this.add(p2, normal, u); + } + } + + public addTriangle(index0: number, index1: number, index2: number): void { + this.triangleIndexes.push(index0, index1, index2); + } + + public add(position: Vector3, normal: Vector3, u = 0, v = 0): void { + this.setAt(this.uniqueIndex, position, normal, u, v); + this.uniqueIndex++; + } + + public addPosition(position: Vector3): void { + this.setPositionAt(this.uniqueIndex, position); + this.uniqueIndex++; + } + + // ================================================== + // INSTANCE METHODS: Set at position + // ================================================== + + public setAt(index: number, position: Vector3, normal: Vector3, u = 0, v = 0): void { + this.setPositionAt(index, position); + this.setNormalAt(index, normal); + this.setUvAt(index, u, v); + } + + private setPositionAt(index: number, position: Vector3): void { + const i = 3 * index; + this.positions[i + 0] = position.x; + this.positions[i + 1] = position.y; + this.positions[i + 2] = position.z; + } + + private setNormalAt(index: number, normal: Vector3): void { + if (this.normals === undefined) { + return; + } + const i = 3 * index; + this.normals[i + 0] = normal.x; + this.normals[i + 1] = normal.y; + this.normals[i + 2] = normal.z; + } + + private setUvAt(index: number, u = 0, v = 0): void { + if (this.uvs === undefined) { + return; + } + const i = 2 * index; + this.uvs[i + 0] = u; + this.uvs[i + 1] = v; + } +} diff --git a/react-components/src/architecture/base/utilities/geometry/Vector3Pool.ts b/react-components/src/architecture/base/utilities/geometry/Vector3Pool.ts new file mode 100644 index 00000000000..e7532e77ba2 --- /dev/null +++ b/react-components/src/architecture/base/utilities/geometry/Vector3Pool.ts @@ -0,0 +1,28 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { Vector3 } from 'three'; + +// Cache for using temporarily vectors to avoid allocations + +export class Vector3Pool { + private readonly _vectors: Vector3[]; + private _index: number = -1; + + public constructor(size = 30) { + this._vectors = new Array(size).fill(null).map(() => new Vector3()); + } + + public getNext(copyFrom?: Vector3): Vector3 { + // Increment the index and wrap around if it exceeds the length of the array + this._index++; + this._index %= this._vectors.length; + const vector = this._vectors[this._index]; + // Return the vector at the new index + if (copyFrom === undefined) { + return vector.setScalar(0); // Reset the vector to zero + } + return vector.copy(copyFrom); + } +} diff --git a/react-components/src/architecture/base/utilities/geometry/getResizeCursor.ts b/react-components/src/architecture/base/utilities/geometry/getResizeCursor.ts new file mode 100644 index 00000000000..ed83236af16 --- /dev/null +++ b/react-components/src/architecture/base/utilities/geometry/getResizeCursor.ts @@ -0,0 +1,28 @@ +/*! + * Copyright 2024 Cognite AS + */ + +export function getResizeCursor(octdir: number): string | undefined { + // https://developer.mozilla.org/en-US/docs/Web/CSS/cursor + + switch (octdir) { + case 2: + case 6: + return 'ns-resize'; + + case 1: + case 5: + return 'nesw-resize'; + + case 0: + case 4: + return 'ew-resize'; + + case 3: + case 7: + return 'nwse-resize'; + + default: + return undefined; + } +} diff --git a/react-components/src/architecture/base/utilities/sprites/createSprite.ts b/react-components/src/architecture/base/utilities/sprites/createSprite.ts new file mode 100644 index 00000000000..0a08cd909b4 --- /dev/null +++ b/react-components/src/architecture/base/utilities/sprites/createSprite.ts @@ -0,0 +1,196 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { + SpriteMaterial, + CanvasTexture, + type Vector3, + type Color, + Sprite, + LinearFilter, + NearestFilter, + type Texture +} from 'three'; + +const VIEWER_FONT_TYPE = 'sans-serif'; + +// ================================================== +// PUBLIC FUNCTIONS +// ================================================== + +/** + * Creates a sprite with the specified text. + * + * @param text - The text to be displayed on the sprite. + * @param worldHeight - The height of sprite in the world coordinates. + * @param color - The color of the text. + * @param bgColor - The background color of the sprite (optional). + * @returns The created sprite, or undefined if the canvas creation failed. + */ +export function createSpriteWithText( + text: string, + worldHeight: number, + color: Color, + bgColor?: Color +): Sprite | undefined { + const canvas = createCanvasWithText(text, color, bgColor); + if (canvas === undefined) { + return undefined; + } + return createSprite(canvas, worldHeight); +} + +/** + * Moves a sprite to a new position based on a given position and direction. + * @param sprite - The sprite to move. + * @param position - The current position of the sprite. + * @param direction - The direction in which the sprite should move. + */ +export function moveSpriteByPositionAndDirection( + sprite: Sprite, + position: Vector3, + direction: Vector3 +): void { + // Align the text by sprite.position = position + (direction * sprite.scale) / 2; + const newPosition = direction.clone(); + newPosition.multiply(sprite.scale); + newPosition.divideScalar(2); + newPosition.add(position); + sprite.position.copy(newPosition); +} + +/** + * Moves a sprite to a specified position and aligns it based on the given alignment value. + * @param sprite - The sprite to be moved. + * @param position - The position to move the sprite to. + * @param alignment - The alignment value to align the sprite. This is a number between 0 and 8. + * and is documented below. + */ +export function moveSpriteByPositionAndAlignment( + sprite: Sprite, + position: Vector3, + alignment: number +): void { + sprite.position.copy(position); + translateByAlignment(sprite, alignment); +} + +// ================================================== +// PRIVATE FUNCTIONS +// ================================================== + +function createSprite(canvas: HTMLCanvasElement, worldHeight: number): Sprite { + const texture = createTexture(canvas); + const spriteMaterial = new SpriteMaterial({ map: texture }); + const sprite = new Sprite(spriteMaterial); + sprite.scale.set((worldHeight * canvas.width) / canvas.height, worldHeight, 1); + return sprite; +} + +function createTexture(canvas: HTMLCanvasElement): Texture { + const texture = new CanvasTexture(canvas); + texture.minFilter = LinearFilter; // Don't change this, https://stackoverflow.com/questions/55175351/remove-texture-has-been-resized-console-logs-in-three-js + texture.magFilter = NearestFilter; + + // texture.generateMipmaps = true; // Default + // texture.wrapS = THREE.ClampToEdgeWrapping; + // texture.wrapT = THREE.ClampToEdgeWrapping; + texture.needsUpdate = true; + return texture; +} + +function createCanvasWithText( + text: string, + color: Color, + bgColor?: Color +): HTMLCanvasElement | undefined { + // https://www.javascripture.com/CanvasRenderingContext2D + const borderSize = 2; + const fontSize = 40; + const font = getNormalFont(fontSize); + + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (context === null) { + return undefined; + } + + // Measure how long the name will be + context.font = font; + const textWidth = context.measureText(text).width; + const doubleBorderSize = borderSize * 2; + const width = textWidth + 2 * doubleBorderSize; + const height = fontSize + doubleBorderSize; + + canvas.width = width; + canvas.height = height; + context.clearRect(0, 0, canvas.width, canvas.height); + + // Draw optional rounded rectangle + if (bgColor !== undefined) { + context.fillStyle = '#' + bgColor.getHexString(); + context.beginPath(); + context.roundRect(0, 0, width, height, 10); + context.fill(); + } + // Draw text + context.font = font; // need to set font again after resizing canvas + context.textBaseline = 'middle'; + context.textAlign = 'center'; + context.translate(width / 2, height / 2); + context.fillStyle = '#' + color.getHexString(); + context.fillText(text, 0, 2); + return canvas; +} + +function getFont(fontSize: number): string { + return `${fontSize}px ${VIEWER_FONT_TYPE}`; +} + +function getNormalFont(fontSize: number): string { + return `Normal ${getFont(fontSize)}`; +} + +function translateByAlignment(sprite: Sprite, alignment: number): void { + // alignment + // 6 7 8 + // 3 4 5 + // 0 1 2 + + // Examples: + // If alignment == 0: + // Text Here + // + <--- Point + // + // If alignment == 8 + // + <--- Point + // Text Here + + switch (alignment) { + case 0: + case 3: + case 6: + sprite.position.x -= sprite.scale.x / 2; + break; + + case 2: + case 5: + case 8: + sprite.position.x += sprite.scale.x / 2; + break; + } + switch (alignment) { + case 0: + case 1: + case 2: + sprite.position.z += sprite.scale.y / 2; + break; + + case 6: + case 7: + case 8: + sprite.position.z -= sprite.scale.y / 2; + break; + } +} diff --git a/react-components/src/architecture/base/views/BaseView.ts b/react-components/src/architecture/base/views/BaseView.ts new file mode 100644 index 00000000000..4f0a41c3d0d --- /dev/null +++ b/react-components/src/architecture/base/views/BaseView.ts @@ -0,0 +1,94 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type DomainObject } from '../domainObjects/DomainObject'; +import { type DomainObjectChange } from '../domainObjectsHelpers/DomainObjectChange'; + +/** + * Represents the observer in the Observer pattern + * It provides common functionality for all types of views. + */ +export abstract class BaseView { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private _domainObject: DomainObject | undefined = undefined; + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + protected get hasDomainObject(): boolean { + return this._domainObject !== undefined; + } + + public get domainObject(): DomainObject { + if (this._domainObject === undefined) { + throw Error('The DomainObject is missing in the view'); + } + return this._domainObject; + } + + protected set domainObject(value: DomainObject) { + this._domainObject = value; + } + + // ================================================== + // VIRTUAL METHODS + // ================================================== + + /** + * Initializes the view. + * Override this function to initialize your view. + * @remarks + * Always call `super.initialize()` in the overrides. + */ + public initialize(): void {} + + /** + * Updates the view with the given change. + * Override this function to update your view. + * + * @param change - The domain object change to apply to the view. + * @remarks + * Always call `super.update()` in the overrides. + */ + public update(_change: DomainObjectChange): void {} + + /** + * Clears the memory and removes redundant data. + * This method should be overridden in derived classes. + * @remarks + * Always call `super.clearMemory()` in the overrides. + */ + public clearMemory(): void {} + + /** + * Called when the view is set to be visible. + * Override this function to perform any necessary actions when the view becomes visible. + * @remarks + * Always call `super.onShow()` in the overrides. + */ + public onShow(): void {} + + /** + * Called when the view is set to NOT visible. + * Override this function to perform any necessary actions when the view is hidden. + * @remarks + * Always call `super.onHide()` in the overrides. + */ + public onHide(): void {} + + /** + * Dispose the view. + * Override this function to perform any necessary cleanup when the view is set to NOT visible. + * This method is called just before the view is removed from the view list and detached. + * @remarks + * Always call `super.dispose()` in the overrides. + */ + public dispose(): void { + this._domainObject = undefined; + } +} diff --git a/react-components/src/architecture/base/views/GroupThreeView.ts b/react-components/src/architecture/base/views/GroupThreeView.ts new file mode 100644 index 00000000000..e79fc8fbedb --- /dev/null +++ b/react-components/src/architecture/base/views/GroupThreeView.ts @@ -0,0 +1,221 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { Box3, Group, Mesh, type Object3D } from 'three'; +import { ThreeView } from './ThreeView'; +import { type DomainObjectChange } from '../domainObjectsHelpers/DomainObjectChange'; +import { Changes } from '../domainObjectsHelpers/Changes'; +import { + type CustomObjectIntersectInput, + type CustomObjectIntersection, + type ICustomObject +} from '@cognite/reveal'; +import { type DomainObjectIntersection } from '../domainObjectsHelpers/DomainObjectIntersection'; + +/** + * Represents an abstract class for a Three.js view that renders an Object3D. + * This class extends the ThreeView class. + * @remarks + * You only have to override addChildren() to create the object3D to be added to the group. + */ + +export abstract class GroupThreeView extends ThreeView implements ICustomObject { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + protected readonly _group: Group = new Group(); + + protected get isEmpty(): boolean { + return this._group.children.length === 0; + } + + // ================================================== + // IMPLEMENTATION of ICustomObject + // ================================================== + + public get object(): Object3D { + if (this.needsUpdate) { + this.removeChildren(); + } + if (this.isEmpty) { + this.makeChilderen(); + } + return this._group; + } + + public get shouldPick(): boolean { + return true; // To be overridden + } + + public get shouldPickBoundingBox(): boolean { + return true; // To be overridden + } + + public get isPartOfBoundingBox(): boolean { + return true; // To be overridden + } + + public getBoundingBox(target: Box3): Box3 { + target.copy(this.boundingBox); + return target; + } + + public intersectIfCloser( + intersectInput: CustomObjectIntersectInput, + closestDistance: number | undefined + ): undefined | CustomObjectIntersection { + const intersection = intersectInput.raycaster.intersectObject(this.object, true); + if (intersection === undefined || intersection.length === 0) { + return undefined; + } + const { point, distance } = intersection[0]; + if (closestDistance !== undefined && closestDistance < distance) { + return undefined; + } + if (!intersectInput.isVisible(point)) { + return undefined; + } + const customObjectIntersection: DomainObjectIntersection = { + type: 'customObject', + point, + distanceToCamera: distance, + userData: intersection[0], + customObject: this, + domainObject: this.domainObject + }; + if (this.shouldPickBoundingBox) { + const boundingBox = this.boundingBox; + if (!boundingBox.isEmpty()) { + customObjectIntersection.boundingBox = this.boundingBox; + } + } + return customObjectIntersection; + } + + // ================================================== + // OVERRIDES of BaseView + // ================================================== + + public override initialize(): void { + super.initialize(); + if (this.isEmpty) { + this.makeChilderen(); + } + const { viewer } = this.renderTarget; + viewer.addCustomObject(this); + } + + public override update(change: DomainObjectChange): void { + super.update(change); + if (change.isChanged(Changes.geometry)) { + this.removeChildren(); + this.invalidateRenderTarget(); + } + } + + public override clearMemory(): void { + super.clearMemory(); + this.removeChildren(); + } + + public override dispose(): void { + this.removeChildren(); + const { viewer } = this.renderTarget; + viewer.removeCustomObject(this); + super.dispose(); + } + + // ================================================== + // OVERRIDES of ThreeView + // ================================================== + + protected override calculateBoundingBox(): Box3 { + if (this.object === undefined) { + return new Box3().makeEmpty(); + } + const boundingBox = new Box3(); + boundingBox.setFromObject(this.object, true); + return boundingBox; + } + + // ================================================== + // VIRTUAL METHODS + // ================================================== + + /** + * Add the Object3 children to the view. Use the addChild() method to add the children. + * This method should always be overridden in derived classes. + */ + protected abstract addChildren(): void; + + /** + * Determines whether the view needs to be updated just before rendering. + * Typically needed to be implemented if the update function is not enough and + * the view depend on other factors as the model bounding box or the camera position. + * This method can be overridden in derived classes. + * + * When it returns true, the view will be rebuild by addChildren(). + * @returns A boolean value indicating whether the view needs to be updated. + */ + protected get needsUpdate(): boolean { + return false; + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + private makeChilderen(): void { + if (!this.isEmpty) { + throw Error('Can make the object when it is already made'); + } + this.addChildren(); + } + + protected removeChildren(): void { + if (this.isEmpty) { + return; + } + disposeMaterials(this._group); + this._group.remove(...this._group.children); + } + + protected addChild(child: Object3D | undefined): void { + if (child === undefined) { + return; + } + this._group.add(child); + } + + protected removeChild(child: Object3D | undefined): void { + if (child === undefined) { + return; + } + disposeMaterials(child); + this._group.remove(child); + } +} + +function disposeMaterials(object: Object3D): void { + if (object === undefined) { + return undefined; + } + if (object instanceof Group) { + for (const child of object.children) { + disposeMaterials(child); + } + } + if (!(object instanceof Mesh)) { + return; + } + const material = object.material; + if (material !== null && material !== undefined) { + const texture = material.texture; + if (texture !== undefined && texture !== null) { + texture.dispose(); + } + material.dispose(); + } +} diff --git a/react-components/src/architecture/base/views/ThreeView.ts b/react-components/src/architecture/base/views/ThreeView.ts new file mode 100644 index 00000000000..143e6045e0d --- /dev/null +++ b/react-components/src/architecture/base/views/ThreeView.ts @@ -0,0 +1,120 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type DomainObjectChange } from '../domainObjectsHelpers/DomainObjectChange'; +import { BaseView } from './BaseView'; +import { Changes } from '../domainObjectsHelpers/Changes'; +import { type RenderStyle } from '../domainObjectsHelpers/RenderStyle'; +import { type RevealRenderTarget } from '../renderTarget/RevealRenderTarget'; +import { type DomainObject } from '../domainObjects/DomainObject'; +import { type PerspectiveCamera, type Box3 } from 'three'; + +/** + * Represents an abstract base class for a Three.js view in the application. + * Extends the `BaseView` class. + */ +export abstract class ThreeView extends BaseView { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private _boundingBox: Box3 | undefined = undefined; // Cashe of the bounding box of the view + private _renderTarget: RevealRenderTarget | undefined = undefined; + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get hasRenderTarget(): boolean { + return this._renderTarget !== undefined; + } + + public get renderTarget(): RevealRenderTarget { + if (this._renderTarget === undefined) { + throw Error('The RevealRenderTarget is missing in the view'); + } + return this._renderTarget; + } + + protected get style(): RenderStyle | undefined { + return this.domainObject.getRenderStyle(); + } + + // ================================================== + // OVERRIDES of BaseView + // ================================================== + + public override update(change: DomainObjectChange): void { + super.update(change); + if (change.isChanged(Changes.geometry)) { + this.invalidateBoundingBox(); + this.invalidateRenderTarget(); + } + } + + public override clearMemory(): void { + super.clearMemory(); + this.invalidateBoundingBox(); + } + + public override onShow(): void { + super.onShow(); + this.invalidateRenderTarget(); + } + + public override onHide(): void { + super.onHide(); + this.invalidateRenderTarget(); + } + + public override dispose(): void { + super.dispose(); + this._renderTarget = undefined; + } + + // ================================================== + // VIRTUAL METHODS + // ================================================== + + /** + * Calculates the bounding box of the view. + * Override this function to recalculate the bounding box of the view. + * @returns The calculated bounding box of the view. + */ + protected abstract calculateBoundingBox(): Box3; + + /** + * This method is called before rendering the view. + * Override this function to perform any necessary operations + * just before rendering.Have in mind that the Object3D are build at the time this is + * called, so you can only do adjustment on existing object3D's. + * @remarks + * Always call `super.beforeRender(camera)` in the overrides. + */ + public beforeRender(_camera: PerspectiveCamera): void {} + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public get boundingBox(): Box3 { + if (this._boundingBox === undefined) { + this._boundingBox = this.calculateBoundingBox(); + } + return this._boundingBox; + } + + protected invalidateBoundingBox(): void { + this._boundingBox = undefined; + } + + public attach(domainObject: DomainObject, renderTarget: RevealRenderTarget): void { + this.domainObject = domainObject; + this._renderTarget = renderTarget; + } + + protected invalidateRenderTarget(): void { + this.renderTarget.invalidate(); + } +} diff --git a/react-components/src/architecture/concrete/axis/AxisDomainObject.ts b/react-components/src/architecture/concrete/axis/AxisDomainObject.ts new file mode 100644 index 00000000000..421b4f31167 --- /dev/null +++ b/react-components/src/architecture/concrete/axis/AxisDomainObject.ts @@ -0,0 +1,31 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { VisualDomainObject } from '../../base/domainObjects/VisualDomainObject'; +import { type RenderStyle } from '../../base/domainObjectsHelpers/RenderStyle'; +import { type ThreeView } from '../../base/views/ThreeView'; +import { AxisRenderStyle } from './AxisRenderStyle'; +import { AxisThreeView } from './AxisThreeView'; + +export class AxisDomainObject extends VisualDomainObject { + // ================================================== + // OVERRIDES of DomainObject + // ================================================== + + public override get typeName(): string { + return 'Axis3D'; + } + + public override createRenderStyle(): RenderStyle | undefined { + return new AxisRenderStyle(); + } + + // ================================================== + // OVERRIDES of VisualDomainObject + // ================================================== + + protected override createThreeView(): ThreeView | undefined { + return new AxisThreeView(); + } +} diff --git a/react-components/src/architecture/concrete/axis/AxisRenderStyle.ts b/react-components/src/architecture/concrete/axis/AxisRenderStyle.ts new file mode 100644 index 00000000000..5992ff0fcbf --- /dev/null +++ b/react-components/src/architecture/concrete/axis/AxisRenderStyle.ts @@ -0,0 +1,84 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { cloneDeep } from 'lodash'; +import { RenderStyle } from '../../base/domainObjectsHelpers/RenderStyle'; +import { Color } from 'three'; +import { getMixedColor } from '../../base/utilities/colors/colorExtensions'; + +const COLOR_WHITE = new Color().setScalar(1); +const COLOR_DARK_GREY = new Color().setScalar(0.23); +const COLOR_LIGHT_GREY = new Color().setScalar(0.35); +const COLOR_RED = new Color(1, 0, 0); +const COLOR_GREEN = new Color(0, 1, 0); +const COLOR_BLUE = new Color(0, 0, 1); + +export class AxisRenderStyle extends RenderStyle { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public showAxis = true; + public showAxisLabel = true; + public showAxisNumbers = true; + public showAxisTicks = true; + public showGrid = true; + + public numberOfTicks = 30; // Appoximately number of ticks for the largest axis + public tickLength = 0.005; // In fraction of the bounding box diagonal + public tickFontSize = 2; // In fraction of the real tickLength + public axisLabelFontSize = 4; // In fraction of the real tickLength + + public gridColor = COLOR_LIGHT_GREY; + public wallColor = COLOR_DARK_GREY; + public textColor = COLOR_WHITE; + + public mainAxisColor = COLOR_WHITE; + public xAxisColor = getMixedColor(COLOR_WHITE, COLOR_RED, 0.6); + public yAxisColor = getMixedColor(COLOR_WHITE, COLOR_GREEN, 0.6); + public zAxisColor = getMixedColor(COLOR_WHITE, COLOR_BLUE, 0.6); + + // ================================================== + // OVERRIDES of BaseStyle + // ================================================== + + public override clone(): RenderStyle { + return cloneDeep(this); + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public getAxisColor(isMainAxis: boolean, dimension: number): Color { + if (!isMainAxis) { + return this.mainAxisColor; + } + // Note: Y is up in viewer coordinated + switch (dimension) { + case 0: + return this.xAxisColor; + case 1: + return this.yAxisColor; + case 2: + return this.zAxisColor; + default: + throw Error('getAxisColor'); + } + } + + public getAxisLabel(dimension: number): string { + // Note: Y is up in viewer coordinated + switch (dimension) { + case 0: + return 'X'; + case 1: + return 'Y'; + case 2: + return 'Z'; + default: + throw Error('getAxisName'); + } + } +} diff --git a/react-components/src/architecture/concrete/axis/AxisThreeView.ts b/react-components/src/architecture/concrete/axis/AxisThreeView.ts new file mode 100644 index 00000000000..3e0d916008b --- /dev/null +++ b/react-components/src/architecture/concrete/axis/AxisThreeView.ts @@ -0,0 +1,628 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { + Mesh, + type Object3D, + LineSegments, + LineBasicMaterial, + Vector3, + MeshBasicMaterial, + type Color, + BackSide, + Box3, + OrthographicCamera, + BufferGeometry, + BufferAttribute, + type PerspectiveCamera +} from 'three'; +import { GroupThreeView } from '../../base/views/GroupThreeView'; +import { Range3 } from '../../base/utilities/geometry/Range3'; +import { isIncrement } from '../../base/utilities/extensions/mathExtensions'; +import { Range1 } from '../../base/utilities/geometry/Range1'; +import { type AxisRenderStyle } from './AxisRenderStyle'; +import { + createSpriteWithText, + moveSpriteByPositionAndDirection +} from '../../base/utilities/sprites/createSprite'; +import { TrianglesBuffers } from '../../base/utilities/geometry/TrianglesBuffers'; +import { type DomainObjectChange } from '../../base/domainObjectsHelpers/DomainObjectChange'; +import { Changes } from '../../base/domainObjectsHelpers/Changes'; +import { Vector3Pool } from '../../base/utilities/geometry/Vector3Pool'; + +const FACE_INDEX_NAME1 = 'faceIndex1'; +const FACE_INDEX_NAME2 = 'faceIndex2'; +const MAIN_AXIS_NAME = 'mainAxis'; + +export class AxisThreeView extends GroupThreeView { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private readonly _corners: Vector3[]; + private readonly _faceCenters: Vector3[]; + private readonly _sceneBoundingBox: Box3 = new Box3().makeEmpty(); // Caching the bounding box of the scene + private readonly _expandedSceneBoundingBox: Range3 = new Range3(); + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + protected override get style(): AxisRenderStyle { + return super.style as AxisRenderStyle; + } + + public constructor() { + super(); + this._corners = new Array(8).fill(null).map(() => new Vector3()); + this._faceCenters = new Array(6).fill(null).map(() => new Vector3()); + } + + // ================================================== + // OVERRIDES of BaseView + // ================================================== + + public override update(change: DomainObjectChange): void { + super.update(change); + if (this.isEmpty) { + return; + } + if (change.isChanged(Changes.renderStyle)) { + this.removeChildren(); + this.invalidateBoundingBox(); + this.invalidateRenderTarget(); + } + } + + // ================================================== + // OVERRIDES of ThreeView + // ================================================== + + public override beforeRender(camera: PerspectiveCamera): void { + super.beforeRender(camera); + if (this.isEmpty) { + return; + } + let cameraDirection: Vector3 | undefined; + let cameraPosition: Vector3 | undefined; + + if (camera instanceof OrthographicCamera) { + cameraDirection = camera.getWorldDirection(newVector3()); + } else { + cameraPosition = camera.position; + } + for (const child of this.object.children) { + this.updateVisibleAxis(child, cameraPosition, cameraDirection); + } + } + + // ================================================== + // OVERRIDES of GroupThreeView + // ================================================== + + public override get shouldPick(): boolean { + return false; + } + + public override get isPartOfBoundingBox(): boolean { + return false; + } + + protected override get needsUpdate(): boolean { + const target = this.renderTarget; + + // Check if bounding box is different + const sceneBoundingBox = target.sceneBoundingBox; + if (sceneBoundingBox.equals(this._sceneBoundingBox)) { + return false; + } + this._sceneBoundingBox.copy(sceneBoundingBox); + this._expandedSceneBoundingBox.copy(sceneBoundingBox); + this._expandedSceneBoundingBox.expandByFraction(0.02); + return true; + } + + protected override addChildren(): void { + const boundingBox = this._expandedSceneBoundingBox; + if (boundingBox === undefined) { + return; + } + if (boundingBox.isEmpty) { + return; + } + this.initializeCornersAndCenters(boundingBox); + const useFace = createUseFaceArray(boundingBox); + const { style } = this; + + // Add faces + for (let faceIndex = 0; faceIndex < 6; faceIndex++) { + this.addFace(style, useFace, faceIndex); + } + const increment = getBestIncrement(boundingBox, style.numberOfTicks); + const tickLength = boundingBox.diagonal * style.tickLength; + const props = { style, useFace, increment, tickLength }; + + // Add X-axis + if (boundingBox.x.hasSpan) { + this.addAxis(props, 0, 1, 0, 1, 2); + this.addAxis(props, 3, 2, 0, 2, 4); + this.addAxis(props, 7, 6, 0, 4, 5); + this.addAxis(props, 4, 5, 0, 1, 5); + } + // Add Y-axis + if (boundingBox.y.hasSpan) { + this.addAxis(props, 3, 0, 1, 0, 2); + this.addAxis(props, 1, 2, 1, 2, 3); + this.addAxis(props, 5, 6, 1, 3, 5); + this.addAxis(props, 7, 4, 1, 0, 5); + } + // Add Z-axis + if (boundingBox.z.hasSpan) { + this.addAxis(props, 0, 4, 2, 0, 1); + this.addAxis(props, 1, 5, 2, 1, 3); + this.addAxis(props, 2, 6, 2, 3, 4); + this.addAxis(props, 3, 7, 2, 0, 4); + } + // Add Grid + if (style.showGrid) { + this.addGrid(props, 0, 1, 2); + this.addGrid(props, 1, 0, 2); + this.addGrid(props, 2, 0, 1); + this.addGrid(props, 3, 1, 2); + this.addGrid(props, 4, 0, 2); + this.addGrid(props, 5, 0, 1); + } + } + + // ================================================== + // INSTANCE METHODS: Add axis + // ================================================== + + private addAxis( + props: AxisProps, + i0: number, + i1: number, + dimension: number, + faceIndex1: number, + faceIndex2: number + ): void { + const { style, useFace } = props; + + if (!useFace[faceIndex1] && !useFace[faceIndex2]) { + return; + } + if (style.showAxis) { + this.addAxisLine(style, i0, i1, dimension, faceIndex1, faceIndex2); + } + this.addAxisTickmarksAndLabels(props, i0, i1, dimension, faceIndex1, faceIndex2); + } + + private addAxisLine( + style: AxisRenderStyle, + i0: number, + i1: number, + dimension: number, + faceIndex1: number, + faceIndex2: number + ): void { + // Draw axis + for (let i = 0; i < 2; i++) { + const isMainAxis = i === 0; + + const color = style.getAxisColor(isMainAxis, convertToViewerDimension(dimension)); + const linewidth = isMainAxis ? 2 : 1; + const vertices: number[] = []; + + vertices.push(...this._corners[i0]); + vertices.push(...this._corners[i1]); + + const lineSegments = createLineSegments(vertices, color, linewidth); + this.setUserDataOnAxis(lineSegments, faceIndex1, faceIndex2, isMainAxis); + this.addChild(lineSegments); + } + } + + private addAxisTickmarksAndLabels( + props: AxisProps, + i0: number, + i1: number, + dimension: number, + faceIndex1: number, + faceIndex2: number + ): void { + const range = new Range1( + this._corners[i0].getComponent(dimension), + this._corners[i1].getComponent(dimension) + ); + if (range.isEmpty) { + return; + } + let minLabelTick = 0; + let labelCount = 0; + const { style, tickLength, increment } = props; + + // Draw ticks + const labelInc = range.getBoldIncrement(increment); + const tickDirection = getTickDirection(faceIndex1, faceIndex2, new Vector3()); + + // Add tick marks and labels + if (style.showAxisTicks || style.showAxisNumbers) { + const vertices: number[] = []; + const tickFontSize = style.tickFontSize * tickLength; + for (const tick of range.getTicks(increment)) { + const start = newVector3(this._corners[i0]); + start.setComponent(dimension, tick); + + const end = newVector3(start); + const vector = newVector3(tickDirection); + vector.multiplyScalar(tickLength); + + // Add tick mark + if (style.showAxisTicks) { + end.add(vector); + vertices.push(...start); + vertices.push(...end); + } + if (style.showAxisNumbers) { + if (!isIncrement(tick, labelInc)) { + continue; + } + if (labelCount === 0) { + minLabelTick = tick; + } + labelCount += 1; + end.add(vector); + + // Add sprite + const sprite = createSpriteWithText(`${tick}`, tickFontSize, style.textColor); + if (sprite !== undefined) { + moveSpriteByPositionAndDirection(sprite, end, tickDirection); + this.addChild(sprite); + this.setUserDataOnAxis(sprite, faceIndex1, faceIndex2, true); + } + } + } + if (style.showAxisTicks) { + const lineSegments = createLineSegments(vertices, style.mainAxisColor, 1); + this.setUserDataOnAxis(lineSegments, faceIndex1, faceIndex2, true); + this.addChild(lineSegments); + } + } + // Add axis sprite + if (style.showAxisLabel) { + const labelFontSize = style.axisLabelFontSize * tickLength; + + // Find the best position by collision detect + const position = newVector3(); + if (labelCount >= 2) { + let tick = minLabelTick + Math.round(0.5 * labelCount - 0.5) * labelInc; + if (labelInc === increment) { + tick -= increment / 2; + } else { + tick -= increment; + } + position.copy(this._corners[i0]); + position.setComponent(dimension, tick); + } else { + position.copy(this._corners[i0]); + position.add(this._corners[i1]); + } + position.addScaledVector(tickDirection, tickLength * 5); + + const sprite = createSpriteWithText( + style.getAxisLabel(convertToViewerDimension(dimension)), + labelFontSize, + style.textColor + ); + if (sprite !== undefined) { + moveSpriteByPositionAndDirection(sprite, position, tickDirection); + this.addChild(sprite); + this.setUserDataOnAxis(sprite, faceIndex1, faceIndex2, true); + } + } + } + + // ================================================== + // INSTANCE METHODS: Add face + // ================================================== + + private addFace(style: AxisRenderStyle, useFace: boolean[], faceIndex: number): void { + if (!useFace[faceIndex]) { + return; + } + const indexes = getFaceCornerIndexes(faceIndex); + + const buffer = new TrianglesBuffers(4); + buffer.addPosition(this._corners[indexes[0]]); + buffer.addPosition(this._corners[indexes[1]]); + buffer.addPosition(this._corners[indexes[2]]); + buffer.addPosition(this._corners[indexes[3]]); + buffer.addTriangle(0, 1, 2); + buffer.addTriangle(0, 2, 3); + + const squareMaterial = new MeshBasicMaterial({ + color: style.wallColor, + side: BackSide, + polygonOffset: true, + polygonOffsetFactor: 1.0, + polygonOffsetUnits: 4.0 + }); + const mesh = new Mesh(buffer.createBufferGeometry(), squareMaterial); + this.setUserDataOnFace(mesh, faceIndex); + this.addChild(mesh); + } + + // ================================================== + // INSTANCE METHODS: Add grid + // ================================================== + + private addGrid(props: AxisProps, faceIndex: number, dim1: number, dim2: number): void { + const { style, useFace, increment } = props; + if (!useFace[faceIndex]) { + return; + } + const indexes = getFaceCornerIndexes(faceIndex); + const vertices: number[] = []; + + this.addGridInOneDirection(vertices, increment, indexes[0], indexes[1], indexes[3], dim1); + this.addGridInOneDirection(vertices, increment, indexes[0], indexes[3], indexes[1], dim2); + + const lineSegments = createLineSegments(vertices, style.gridColor, 1); + this.setUserDataOnFace(lineSegments, faceIndex); + this.addChild(lineSegments); + } + + private addGridInOneDirection( + vertices: number[], + increment: number, + i0: number, + i1: number, + i2: number, + dimension: number + ): void { + // p2 + // +-----------+ + // | | | | | | | + // | | | | | | | <--- Draw these lines + // | | | | | | | + // +-----------+ + // p0 p1 + + const p0 = newVector3(this._corners[i0]); + const p1 = this._corners[i1]; + const p2 = newVector3(this._corners[i2]); + + const range = new Range1(p0.getComponent(dimension), p1.getComponent(dimension)); + if (range.isEmpty) { + return; + } + const boldIncrement = range.getBoldIncrement(increment); + for (const tick of range.getTicks(increment)) { + if (!isIncrement(tick, boldIncrement)) { + continue; + } + p0.setComponent(dimension, tick); + p2.setComponent(dimension, tick); + vertices.push(...p0); + vertices.push(...p2); + } + } + + // ================================================== + // INSTANCE METHODS: Visibility + // ================================================== + + private setUserDataOnFace(object: Object3D, setUserDataOnFace: number): void { + object.userData[FACE_INDEX_NAME1] = setUserDataOnFace; + } + + private setUserDataOnAxis( + object: Object3D, + faceIndex1: number, + faceIndex2: number, + mainAxis: boolean + ): void { + object.userData[FACE_INDEX_NAME1] = faceIndex1; + object.userData[FACE_INDEX_NAME2] = faceIndex2; + object.userData[MAIN_AXIS_NAME] = mainAxis; + } + + private updateVisibleAxis( + object: Object3D, + cameraPosition: Vector3 | undefined, + cameraDirection: Vector3 | undefined + ): void { + const faceIndex1 = object.userData[FACE_INDEX_NAME1] as number; + if (faceIndex1 === undefined) { + return; + } + const visible1 = this.isFaceVisible(faceIndex1, cameraPosition, cameraDirection); + const faceIndex2 = object.userData[FACE_INDEX_NAME2] as number; + if (faceIndex2 === undefined) { + object.visible = visible1; + return; + } + const visible2 = this.isFaceVisible(faceIndex2, cameraPosition, cameraDirection); + const mainAxis = object.userData[MAIN_AXIS_NAME] as boolean; + if (mainAxis) { + object.visible = visible1 !== visible2; + } else { + object.visible = visible1 && visible2; + } + } + + private isFaceVisible( + faceIndex: number, + cameraPosition: Vector3 | undefined, + cameraDirection: Vector3 | undefined + ): boolean { + if (cameraDirection === undefined) { + if (cameraPosition === undefined) { + return false; + } + cameraDirection = newVector3().subVectors(this._faceCenters[faceIndex], cameraPosition); + } + const normal = getFaceNormal(faceIndex, newVector3()); + return cameraDirection.dot(normal) > 0.02; + } + + protected initializeCornersAndCenters(boundingBox: Range3): void { + if (boundingBox.isEmpty) { + return; + } + // Initialize the corners and the centers + boundingBox.getCornerPoints(this._corners); + for (let faceIndex = 0; faceIndex < 6; faceIndex++) { + const indexes = getFaceCornerIndexes(faceIndex); + const center = this._faceCenters[faceIndex]; + center.copy(this._corners[indexes[0]]); + center.add(this._corners[indexes[1]]); + center.add(this._corners[indexes[2]]); + center.add(this._corners[indexes[3]]); + center.divideScalar(4); + } + } +} + +// ================================================== +// PRIVATE FUNCTIONS: Getters +// ================================================== + +function getBestIncrement(range: Range3, numberOfTicks: number): number { + let increment = 0; + if (range.x.hasSpan) increment = Math.max(increment, range.x.getBestIncrement(numberOfTicks)); + if (range.y.hasSpan) increment = Math.max(increment, range.y.getBestIncrement(numberOfTicks)); + if (range.z.hasSpan) increment = Math.max(increment, range.z.getBestIncrement(numberOfTicks)); + return increment; +} + +function convertToViewerDimension(dimension: number): number { + // This swaps the Z and Y axis + if (dimension === 1) { + return 2; + } + if (dimension === 2) { + return 1; + } + return dimension; +} + +// ================================================== +// PRIVATE FUNCTIONS: Creators +// ================================================== + +function createUseFaceArray(range: Range3): boolean[] { + const useFace: boolean[] = new Array(6); + useFace[0] = range.y.hasSpan && range.z.hasSpan; + useFace[1] = range.x.hasSpan && range.z.hasSpan; + useFace[2] = range.x.hasSpan && range.y.hasSpan; + useFace[3] = range.y.hasSpan && range.z.hasSpan; + useFace[4] = range.x.hasSpan && range.z.hasSpan; + useFace[5] = range.x.hasSpan && range.y.hasSpan; + return useFace; +} + +function createLineSegments(vertices: number[], color: Color, linewidth: number): LineSegments { + const material = new LineBasicMaterial({ color, linewidth }); + return new LineSegments(createBufferGeometry(vertices), material); + + function createBufferGeometry(vertices: number[]): BufferGeometry { + const verticesArray = new Float32Array(vertices); + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(verticesArray, 3)); + return geometry; + } +} + +// ================================================== +// PRIVATE METHODS: Some math for Range3 +// ================================================== + +// Corner and faces is pr. definition: +// 5 4 +// v / +// 7--------6 7-------6 +// / | /| / | /| +// 4-------5 | 4-------5 | +// 0-> | | | | <-3 | | | | +// | 3----|--2 | 3----|--2 +// | / | / | / | / +// 0-------1 0-------1 +// / ^ +// 1 2 +// Face number are marked with arrows + +function getFaceNormal(faceIndex: number, target: Vector3): Vector3 { + switch (faceIndex) { + case 0: + return target.set(-1, +0, +0); + case 1: + return target.set(+0, -1, +0); + case 2: + return target.set(+0, +0, -1); + case 3: + return target.set(+1, +0, +0); + case 4: + return target.set(+0, +1, +0); + case 5: + return target.set(+0, +0, +1); + default: + throw Error('getFaceNormal'); + } +} + +function getFaceCornerIndexes(faceIndex: number): number[] { + // These as CCW + switch (faceIndex) { + case 0: + return [3, 0, 4, 7]; + case 1: + return [0, 1, 5, 4]; + case 2: + return [3, 2, 1, 0]; + case 3: + return [1, 2, 6, 5]; + case 4: + return [2, 3, 7, 6]; + case 5: + return [4, 5, 6, 7]; + default: + Error('getFaceCornerIndexes'); + return [0, 0, 0, 0]; + } +} + +function getTickDirection(faceIndex1: number, faceIndex2: number, target: Vector3): Vector3 { + target.setScalar(0); + + if (faceIndex1 === 0 || faceIndex2 === 0) target.x = -Math.SQRT1_2; + if (faceIndex1 === 3 || faceIndex2 === 3) target.x = Math.SQRT1_2; + + if (faceIndex1 === 1 || faceIndex2 === 1) target.y = -Math.SQRT1_2; + if (faceIndex1 === 4 || faceIndex2 === 4) target.y = Math.SQRT1_2; + + if (faceIndex1 === 2 || faceIndex2 === 2) target.z = -Math.SQRT1_2; + if (faceIndex1 === 5 || faceIndex2 === 5) target.z = Math.SQRT1_2; + return target; +} + +// ================================================== +// PRIVATE FUNCTIONS: Vector pool +// ================================================== + +const VECTOR_POOL = new Vector3Pool(); +function newVector3(copyFrom?: Vector3): Vector3 { + return VECTOR_POOL.getNext(copyFrom); +} + +// ================================================== +// HELPER TYPE +// ================================================== + +type AxisProps = { + style: AxisRenderStyle; + useFace: boolean[]; + increment: number; + tickLength: number; +}; diff --git a/react-components/src/architecture/concrete/axis/SetAxisVisibleCommand.ts b/react-components/src/architecture/concrete/axis/SetAxisVisibleCommand.ts new file mode 100644 index 00000000000..81191d5f650 --- /dev/null +++ b/react-components/src/architecture/concrete/axis/SetAxisVisibleCommand.ts @@ -0,0 +1,54 @@ +/*! + * Copyright 2024 Cognite AS + * BaseTool: Base class for the tool are used to interact with the render target. + */ + +import { RenderTargetCommand } from '../../base/commands/RenderTargetCommand'; +import { type Tooltip } from '../../base/commands/BaseCommand'; +import { AxisDomainObject } from './AxisDomainObject'; + +export class SetAxisVisibleCommand extends RenderTargetCommand { + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): Tooltip { + return { key: 'SHOW_OR_HIDE_AXIS', fallback: 'Show or hide axis' }; + } + + public override get icon(): string { + return 'Axis3D'; + } + + public override get isEnabled(): boolean { + return true; + } + + public override get isCheckable(): boolean { + return true; + } + + public override get isChecked(): boolean { + const { renderTarget } = this; + const { rootDomainObject } = renderTarget; + + const axis = rootDomainObject.getDescendantByType(AxisDomainObject); + if (axis === undefined) { + return false; + } + return axis.isVisible(renderTarget); + } + + protected override invokeCore(): boolean { + const { renderTarget } = this; + const { rootDomainObject } = renderTarget; + + let axis = rootDomainObject.getDescendantByType(AxisDomainObject); + if (axis === undefined) { + axis = new AxisDomainObject(); + rootDomainObject.addChildInteractive(axis); + } + axis.toggleVisibleInteractive(renderTarget); + return true; + } +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxCreator.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxCreator.ts new file mode 100644 index 00000000000..5af7e11c7cc --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxCreator.ts @@ -0,0 +1,187 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { Matrix4, Plane, type Ray, Vector3 } from 'three'; +import { + horizontalAngle, + verticalDistanceTo +} from '../../base/utilities/extensions/vectorExtensions'; +import { Range3 } from '../../base/utilities/geometry/Range3'; +import { forceBetween0AndPi } from '../../base/utilities/extensions/mathExtensions'; +import { MeasureBoxDomainObject } from './MeasureBoxDomainObject'; +import { MeasureType } from './MeasureType'; +import { getClosestPointOnLine } from '../../base/utilities/extensions/rayExtensions'; +import { BaseCreator } from '../../base/domainObjectsHelpers/BaseCreator'; +import { FocusType } from '../../base/domainObjectsHelpers/FocusType'; +import { Changes } from '../../base/domainObjectsHelpers/Changes'; +import { type DomainObject } from '../../base/domainObjects/DomainObject'; + +const UP_VECTOR = new Vector3(0, 0, 1); +/** + * Helper class for generate a MeasureBoxDomainObject by clicking around + */ +export class MeasureBoxCreator extends BaseCreator { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private readonly _domainObject: MeasureBoxDomainObject; + + // ================================================== + // CONTRUCTOR + // ================================================== + + constructor(measureType: MeasureType) { + super(); + this._domainObject = new MeasureBoxDomainObject(measureType); + this._domainObject.focusType = FocusType.Pending; + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get domainObject(): DomainObject { + return this._domainObject; + } + + public override get minimumPointCount(): number { + return this.maximumPointCount; + } + + public override get maximumPointCount(): number { + switch (this._domainObject.measureType) { + case MeasureType.VerticalArea: + return 2; + case MeasureType.HorizontalArea: + return 3; + case MeasureType.Volume: + return 4; + default: + throw new Error('Unknown measurement type'); + } + } + + protected override addPointCore( + ray: Ray, + point: Vector3 | undefined, + isPending: boolean + ): boolean { + const domainObject = this._domainObject; + point = this.recalculatePoint(point, ray, domainObject.measureType); + if (point === undefined) { + return false; + } + this.addRawPoint(point, isPending); + if (!this.rebuild()) { + return false; + } + domainObject.notify(Changes.geometry); + if (this.isFinished) { + domainObject.setSelectedInteractive(true); + domainObject.setFocusInteractive(FocusType.Focus); + } + return true; + } + + public override handleEscape(): void { + if (this.realPointCount < this.minimumPointCount) { + this._domainObject.removeInteractive(); + } + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + private recalculatePoint( + point: Vector3 | undefined, + ray: Ray, + measureType: MeasureType + ): Vector3 | undefined { + if (measureType === MeasureType.VerticalArea) { + return point; + } + // Recalculate the point anywhy for >= 1 points + // This makes it more natural and you can pick in empty space + if (this.realPointCount === 1 || this.realPointCount === 2) { + const plane = new Plane().setFromNormalAndCoplanarPoint(UP_VECTOR, this.firstPoint); + const newPoint = ray.intersectPlane(plane, new Vector3()); + return newPoint ?? undefined; + } else if (this.realPointCount === 3 && measureType === MeasureType.Volume) { + return getClosestPointOnLine(ray, UP_VECTOR, this.points[2], point); + } + return point; + } + + /** + * Create the box by adding points. The first point will make a box with a center and a tiny size. + * The second point will give the zRotation and the size.x and center.x + * The third will give the size.y and center.y + * The third will give the size.z and center.z + */ + + private rebuild(): boolean { + if (this.pointCount === 0) { + throw new Error('Cannot create a box without points'); + } + const domainObject = this._domainObject; + if (this.pointCount === 1) { + domainObject.forceMinSize(); + domainObject.center.copy(this.firstPoint); + if (domainObject.measureType !== MeasureType.VerticalArea) { + domainObject.center.z += domainObject.size.z / 2; + } + return true; + } + if (this.pointCount === 2) { + // Set the zRotation + const vector = new Vector3().subVectors(this.firstPoint, this.lastPoint); + domainObject.zRotation = forceBetween0AndPi(horizontalAngle(vector)); + } + const measureType = domainObject.measureType; + if (this.pointCount <= 3) { + // Set the center and the size only in 2D space + const newCenter = new Vector3(); + const newSize = new Vector3(); + this.getCenterAndSizeFromBoundingBox(domainObject.zRotation, newCenter, newSize); + + domainObject.center.x = newCenter.x; + domainObject.size.x = newSize.x; + + if (measureType === MeasureType.VerticalArea) { + domainObject.center.z = newCenter.z; + domainObject.center.y = newCenter.y; + domainObject.size.z = newSize.z; + } else { + domainObject.center.y = newCenter.y; + domainObject.size.y = newSize.y; + } + domainObject.forceMinSize(); + } else { + // Now set the 3D center and size + const p2 = this.points[2]; + const p3 = this.points[3]; + const sizeZ = verticalDistanceTo(p2, p3); + const centerZ = (p2.z + p3.z) / 2; + domainObject.size.z = sizeZ; + domainObject.center.z = centerZ; + } + return true; + } + + private getCenterAndSizeFromBoundingBox(zRotation: number, center: Vector3, size: Vector3): void { + const matrix = new Matrix4().makeRotationZ(zRotation); + const inverseMatrix = matrix.clone().invert(); + const range = new Range3(); + for (const point of this.points) { + const copy = point.clone(); + copy.applyMatrix4(inverseMatrix); + range.add(copy); + } + range.getCenter(center); + range.getDelta(size); + center.applyMatrix4(matrix); + } +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxDomainObject.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxDomainObject.ts new file mode 100644 index 00000000000..2c5db32b464 --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxDomainObject.ts @@ -0,0 +1,213 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { MeasureBoxRenderStyle } from './MeasureBoxRenderStyle'; +import { type RenderStyle } from '../../base/domainObjectsHelpers/RenderStyle'; +import { type ThreeView } from '../../base/views/ThreeView'; +import { MeasureBoxView } from './MeasureBoxView'; +import { Matrix4, Vector3 } from 'three'; +import { Changes } from '../../base/domainObjectsHelpers/Changes'; +import { BoxFace } from '../../base/utilities/box/BoxFace'; +import { FocusType } from '../../base/domainObjectsHelpers/FocusType'; +import { MeasureType } from './MeasureType'; +import { type DomainObjectIntersection } from '../../base/domainObjectsHelpers/DomainObjectIntersection'; +import { type BoxPickInfo } from '../../base/utilities/box/BoxPickInfo'; +import { type BaseDragger } from '../../base/domainObjectsHelpers/BaseDragger'; +import { MeasureBoxDragger } from './MeasureBoxDragger'; +import { MeasureDomainObject } from './MeasureDomainObject'; +import { NumberType, PanelInfo } from '../../base/domainObjectsHelpers/PanelInfo'; +import { radToDeg } from 'three/src/math/MathUtils.js'; + +export const MIN_BOX_SIZE = 0.01; + +export class MeasureBoxDomainObject extends MeasureDomainObject { + // ================================================== + // INSTANCE FIELDS (This implements the IBox interface) + // ================================================== + + public readonly size = new Vector3().setScalar(MIN_BOX_SIZE); + public readonly center = new Vector3(); + public zRotation = 0; // Angle in radians in interval [0, 2*Pi> + + // For focus when edit in 3D (Used when isSelected is true only) + public focusType: FocusType = FocusType.None; + public focusFace: BoxFace | undefined = undefined; + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get diagonal(): number { + return this.size.length(); + } + + public get hasArea(): boolean { + let count = 0; + if (isValid(this.size.x)) count++; + if (isValid(this.size.y)) count++; + if (isValid(this.size.z)) count++; + return count >= 2; + } + + public get area(): number { + switch (this.measureType) { + case MeasureType.HorizontalArea: + return this.size.x * this.size.y; + case MeasureType.VerticalArea: + return this.size.x * this.size.z; + case MeasureType.Volume: { + const a = this.size.x * this.size.y + this.size.y * this.size.z + this.size.z * this.size.x; + return a * 2; + } + default: + throw new Error('Unknown MeasureType type'); + } + } + + public get hasHorizontalArea(): boolean { + return isValid(this.size.x) && isValid(this.size.y); + } + + public get horizontalArea(): number { + return this.size.x * this.size.y; + } + + public get hasVolume(): boolean { + return isValid(this.size.x) && isValid(this.size.y) && isValid(this.size.z); + } + + public get volume(): number { + return this.size.x * this.size.y * this.size.z; + } + + public override get renderStyle(): MeasureBoxRenderStyle { + return this.getRenderStyle() as MeasureBoxRenderStyle; + } + + // ================================================== + // CONSTRUCTORS + // ================================================== + + public constructor(measureType: MeasureType) { + super(measureType); + } + + // ================================================== + // OVERRIDES of DomainObject + // ================================================== + + public override createRenderStyle(): RenderStyle | undefined { + return new MeasureBoxRenderStyle(); + } + + public override createDragger(intersection: DomainObjectIntersection): BaseDragger | undefined { + const pickInfo = intersection.userData as BoxPickInfo; + if (pickInfo === undefined) { + return undefined; + } + return new MeasureBoxDragger(this, intersection.point, pickInfo); + } + + public override getPanelInfo(): PanelInfo | undefined { + const info = new PanelInfo(); + const { measureType } = this; + const isFinished = this.focusType !== FocusType.Pending; + + switch (measureType) { + case MeasureType.HorizontalArea: + info.setHeader('MEASUREMENTS_HORIZONTAL_AREA', 'Horizontal area'); + break; + case MeasureType.VerticalArea: + info.setHeader('MEASUREMENTS_VERTICAL_AREA', 'Vertical area'); + break; + case MeasureType.Volume: + info.setHeader('MEASUREMENTS_VOLUME', 'Volume'); + break; + } + if (isFinished || isValid(this.size.x)) { + add('MEASUREMENTS_LENGTH', 'Length', this.size.x, NumberType.Length); + } + if (measureType !== MeasureType.VerticalArea && (isFinished || isValid(this.size.y))) { + add('MEASUREMENTS_DEPTH', 'Depth', this.size.y, NumberType.Length); + } + if (measureType !== MeasureType.HorizontalArea && (isFinished || isValid(this.size.z))) { + add('MEASUREMENTS_HEIGHT', 'Height', this.size.z, NumberType.Length); + } + if (measureType !== MeasureType.Volume && (isFinished || this.hasArea)) { + add('MEASUREMENTS_AREA', 'Area', this.area, NumberType.Area); + } + if (measureType === MeasureType.Volume && (isFinished || this.hasHorizontalArea)) { + add('MEASUREMENTS_HORIZONTAL_AREA', 'Horizontal area', this.horizontalArea, NumberType.Area); + } + if (measureType === MeasureType.Volume && (isFinished || this.hasVolume)) { + add('MEASUREMENTS_VOLUME', 'Volume', this.volume, NumberType.Volume); + } + // I forgot to add text for rotation angle before the deadline, so I used a icon instead. + if (this.zRotation !== 0 && isFinished) { + info.add({ icon: 'Angle', value: radToDeg(this.zRotation), numberType: NumberType.Degrees }); + } + return info; + + function add(key: string, fallback: string, value: number, numberType: NumberType): void { + info.add({ key, fallback, value, numberType }); + } + } + + // ================================================== + // OVERRIDES of VisualDomainObject + // ================================================== + + protected override createThreeView(): ThreeView | undefined { + return new MeasureBoxView(); + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public forceMinSize(): void { + const { size } = this; + size.x = Math.max(MIN_BOX_SIZE, size.x); + size.y = Math.max(MIN_BOX_SIZE, size.y); + size.z = Math.max(MIN_BOX_SIZE, size.z); + } + + public getRotationMatrix(matrix: Matrix4 = new Matrix4()): Matrix4 { + matrix.makeRotationZ(this.zRotation); + return matrix; + } + + public getMatrix(matrix: Matrix4 = new Matrix4()): Matrix4 { + return this.getScaledMatrix(this.size, matrix); + } + + public getScaledMatrix(scale: Vector3, matrix: Matrix4 = new Matrix4()): Matrix4 { + matrix = this.getRotationMatrix(matrix); + matrix.setPosition(this.center); + matrix.scale(scale); + return matrix; + } + + public setFocusInteractive(focusType: FocusType, focusFace?: BoxFace): boolean { + if (focusType === FocusType.None) { + if (this.focusType === FocusType.None) { + return false; // No change + } + this.focusType = FocusType.None; + this.focusFace = undefined; // Ignore input face + } else { + if (focusType === this.focusType && BoxFace.equals(this.focusFace, focusFace)) { + return false; // No change + } + this.focusType = focusType; + this.focusFace = focusFace; + } + this.notify(Changes.focus); + return true; + } +} + +function isValid(value: number): boolean { + return value > MIN_BOX_SIZE; +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxDragger.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxDragger.ts new file mode 100644 index 00000000000..46711e76ce9 --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxDragger.ts @@ -0,0 +1,248 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type Ray, Vector3, Plane, Matrix4 } from 'three'; +import { Changes } from '../../base/domainObjectsHelpers/Changes'; +import { type BoxFace } from '../../base/utilities/box/BoxFace'; +import { FocusType } from '../../base/domainObjectsHelpers/FocusType'; +import { type DomainObject } from '../../base/domainObjects/DomainObject'; +import { type BoxPickInfo } from '../../base/utilities/box/BoxPickInfo'; +import { forceBetween0AndPi } from '../../base/utilities/extensions/mathExtensions'; +import { horizontalAngle } from '../../base/utilities/extensions/vectorExtensions'; +import { Vector3Pool } from '../../base/utilities/geometry/Vector3Pool'; +import { MeasureType } from './MeasureType'; +import { getClosestPointOnLine } from '../../base/utilities/extensions/rayExtensions'; +import { type MeasureBoxDomainObject } from './MeasureBoxDomainObject'; +import { BaseDragger } from '../../base/domainObjectsHelpers/BaseDragger'; +import { CDF_TO_VIEWER_TRANSFORMATION } from '@cognite/reveal'; + +/** + * The `BoxDragger` class represents a utility for dragging and manipulating a box in a 3D space. + * It provides methods for scaling, translating, and rotating the box based on user interactions. + * All geometry in this class assume Z-axis is up + */ +export class MeasureBoxDragger extends BaseDragger { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private readonly _domainObject: MeasureBoxDomainObject; + + private readonly _face; + private readonly _focusType: FocusType; + private readonly _normal: Vector3 = new Vector3(); // Intersection normal + private readonly _planeOfBox: Plane = new Plane(); // Plane of the intersection/normal + + // Original values when the drag started + private readonly _sizeOfBox: Vector3 = new Vector3(); + private readonly _centerOfBox: Vector3 = new Vector3(); + private readonly _zRotationOfBox: number = 0; + + private readonly _cornerSign = new Vector3(); // Indicate the corner of the face + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get face(): BoxFace { + return this._face; + } + + public get focusType(): FocusType { + return this._focusType; + } + + // ================================================== + // CONTRUCTOR + // ================================================== + + public constructor(domainObject: MeasureBoxDomainObject, point: Vector3, pickInfo: BoxPickInfo) { + point = point.clone(); + point.applyMatrix4(CDF_TO_VIEWER_TRANSFORMATION.clone().invert()); + super(point); + + this._domainObject = domainObject; + this._face = pickInfo.face; + this._focusType = pickInfo.focusType; + this._cornerSign.copy(pickInfo.cornerSign); + this._face.getNormal(this._normal); + + const rotationMatrix = this.getRotationMatrix(); + this._normal.applyMatrix4(rotationMatrix); + this._normal.normalize(); + + this._planeOfBox.setFromNormalAndCoplanarPoint(this._normal, point); + + // Back up the original values + this._sizeOfBox.copy(this._domainObject.size); + this._centerOfBox.copy(this._domainObject.center); + this._zRotationOfBox = this._domainObject.zRotation; + } + + // ================================================== + // OVERRIDES + // ================================================== + + public get domainObject(): DomainObject { + return this._domainObject; + } + + public override onPointerDown(_event: PointerEvent): void { + this._domainObject.setFocusInteractive(this.focusType, this.face); + } + + public override onPointerDrag(_event: PointerEvent, ray: Ray): boolean { + if (!this.applyByFocusType(this.focusType, ray)) { + return false; + } + this.domainObject.notify(Changes.geometry); + return true; + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + private applyByFocusType(focusType: FocusType, ray: Ray): boolean { + switch (focusType) { + case FocusType.Face: + return this.moveFace(ray); + case FocusType.Corner: + return this.resize(ray); + case FocusType.Body: + return this.translate(ray); + case FocusType.Rotation: + return this.rotate(ray); + default: + return false; + } + } + + private translate(ray: Ray): boolean { + // This translation can only be done in one plane, so we need to find the intersection point + const planeIntersect = ray.intersectPlane(this._planeOfBox, newVector3()); + if (planeIntersect === null) { + return false; + } + const deltaCenter = planeIntersect.sub(this.point); + if (deltaCenter.lengthSq() === 0) { + return false; + } + // First copy the original values + const { center } = this._domainObject; + center.copy(this._centerOfBox); + + // Then translate the center + center.add(deltaCenter); + return true; + } + + private moveFace(ray: Ray): boolean { + // Take find closest point between the ray and the line perpenducular to the face of in picked box. + // The distance from this point to the face of in picked box is the change. + const pointOnSegment = newVector3(); + + getClosestPointOnLine(ray, this._normal, this.point, pointOnSegment); + const deltaSize = this._planeOfBox.distanceToPoint(pointOnSegment); + if (deltaSize === 0) { + return false; // Nothing has changed + } + // First copy the original values + const { size, center } = this._domainObject; + size.copy(this._sizeOfBox); + center.copy(this._centerOfBox); + + const index = this._face.index; + let deltaCenter: number; + if (this._domainObject.measureType !== MeasureType.Volume) { + deltaCenter = this._face.sign * deltaSize; + } else { + // Set new size + size.setComponent(index, deltaSize + size.getComponent(index)); + this._domainObject.forceMinSize(); + + if (size.getComponent(index) === this._sizeOfBox.getComponent(index)) { + return false; // Nothing has changed + } + // The center of the box should be moved by half of the delta size and take the rotation into accont. + const newDeltaSize = size.getComponent(index) - this._sizeOfBox.getComponent(index); + deltaCenter = (this._face.sign * newDeltaSize) / 2; + } + // Set new center + const deltaCenterVector = newVector3(); + deltaCenterVector.setComponent(index, deltaCenter); + const rotationMatrix = this.getRotationMatrix(); + deltaCenterVector.applyMatrix4(rotationMatrix); + center.add(deltaCenterVector); + return true; + } + + private resize(ray: Ray): boolean { + const endPoint = ray.intersectPlane(this._planeOfBox, newVector3()); + if (endPoint === null) { + return false; + } + const startPoint = this._planeOfBox.projectPoint(this.point, newVector3()); + + const rotationMatrix = this.getRotationMatrix(); + const invRotationMatrix = rotationMatrix.clone().invert(); + const deltaSize = endPoint.sub(startPoint); + deltaSize.applyMatrix4(invRotationMatrix); + + deltaSize.multiply(this._cornerSign); + if (deltaSize.lengthSq() === 0) { + return false; // Nothing has changed + } + // First copy the original values + const { size, center } = this._domainObject; + size.copy(this._sizeOfBox); + center.copy(this._centerOfBox); + + // Apply the change + size.add(deltaSize); + this._domainObject.forceMinSize(); + + if (size.lengthSq() === this._sizeOfBox.lengthSq()) { + return false; // Nothing has changed + } + // The center of the box should be moved by half of the delta size and take the rotation into accont. + const newDeltaSize = newVector3().subVectors(size, this._sizeOfBox); + const deltaCenter = newDeltaSize.divideScalar(2); + deltaCenter.multiply(this._cornerSign); + deltaCenter.applyMatrix4(rotationMatrix); + center.add(deltaCenter); + return true; + } + + private rotate(ray: Ray): boolean { + const endPoint = ray.intersectPlane(this._planeOfBox, newVector3()); + if (endPoint === null) { + return false; + } + const center = this._planeOfBox.projectPoint(this._centerOfBox, newVector3()); + const centerToStartPoint = newVector3().subVectors(this.point, center); + const centerToEndPoint = newVector3().subVectors(endPoint, center); + + // Ignore Z-value since we are only interested in the rotation around the Z-axis + const deltaAngle = horizontalAngle(centerToEndPoint) - horizontalAngle(centerToStartPoint); + + // Rotate + this._domainObject.zRotation = forceBetween0AndPi(deltaAngle + this._zRotationOfBox); + return true; + } + + public getRotationMatrix(matrix: Matrix4 = new Matrix4()): Matrix4 { + matrix.makeRotationZ(this._domainObject.zRotation); + return matrix; + } +} + +// ================================================== +// PRIVATE FUNCTIONS: Vector pool +// ================================================== + +const VECTOR_POOL = new Vector3Pool(); +function newVector3(copyFrom?: Vector3): Vector3 { + return VECTOR_POOL.getNext(copyFrom); +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxRenderStyle.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxRenderStyle.ts new file mode 100644 index 00000000000..d74a4af16eb --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxRenderStyle.ts @@ -0,0 +1,24 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { cloneDeep } from 'lodash'; +import { MeasureRenderStyle } from './MeasureRenderStyle'; +import { type RenderStyle } from '../../base/domainObjectsHelpers/RenderStyle'; + +export class MeasureBoxRenderStyle extends MeasureRenderStyle { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public opacity = 0.5; + public opacityUse = true; + + // ================================================== + // OVERRIDES of BaseStyle + // ================================================== + + public override clone(): RenderStyle { + return cloneDeep(this); + } +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxView.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxView.ts new file mode 100644 index 00000000000..734b0ccf63f --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasureBoxView.ts @@ -0,0 +1,655 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { + Mesh, + MeshPhongMaterial, + type Object3D, + BoxGeometry, + DoubleSide, + LineSegments, + LineBasicMaterial, + Vector3, + type Matrix4, + RingGeometry, + Color, + type Sprite, + type Camera, + CircleGeometry, + type Material, + Plane, + FrontSide, + type PerspectiveCamera +} from 'three'; +import { type MeasureBoxDomainObject, MIN_BOX_SIZE } from './MeasureBoxDomainObject'; +import { type DomainObjectChange } from '../../base/domainObjectsHelpers/DomainObjectChange'; +import { Changes } from '../../base/domainObjectsHelpers/Changes'; +import { type MeasureBoxRenderStyle } from './MeasureBoxRenderStyle'; +import { GroupThreeView } from '../../base/views/GroupThreeView'; +import { + CDF_TO_VIEWER_TRANSFORMATION, + type CustomObjectIntersectInput, + type CustomObjectIntersection +} from '@cognite/reveal'; +import { type DomainObjectIntersection } from '../../base/domainObjectsHelpers/DomainObjectIntersection'; +import { BoxFace } from '../../base/utilities/box/BoxFace'; +import { FocusType } from '../../base/domainObjectsHelpers/FocusType'; +import { clear } from '../../base/utilities/extensions/arrayExtensions'; +import { Vector3Pool } from '../../base/utilities/geometry/Vector3Pool'; +import { createSpriteWithText } from '../../base/utilities/sprites/createSprite'; +import { + createLineSegmentsBufferGeometryForBox, + createOrientedBox +} from '../../base/utilities/box/createLineSegmentsBufferGeometryForBox'; +import { BoxPickInfo } from '../../base/utilities/box/BoxPickInfo'; +import { radToDeg } from 'three/src/math/MathUtils.js'; +import { Range1 } from '../../base/utilities/geometry/Range1'; +import { MeasureType } from './MeasureType'; +import { type MeasureRenderStyle } from './MeasureRenderStyle'; + +const RELATIVE_RESIZE_RADIUS = 0.15; +const RELATIVE_ROTATION_RADIUS = new Range1(0.6, 0.75); +const ARROW_AND_RING_COLOR = new Color(1, 1, 1); +const TOP_FACE = new BoxFace(2); +const CIRCULAR_SEGMENTS = 32; + +export class MeasureBoxView extends GroupThreeView { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private readonly _sprites: Array = []; + private readonly _visibleFaces: boolean[] = new Array(6); // Just for avoiding allocation + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + protected get boxDomainObject(): MeasureBoxDomainObject { + return super.domainObject as MeasureBoxDomainObject; + } + + protected override get style(): MeasureBoxRenderStyle { + return super.style as MeasureBoxRenderStyle; + } + + // ================================================== + // OVERRIDES of BaseView + // ================================================== + + public override update(change: DomainObjectChange): void { + super.update(change); + if (this.isEmpty) { + return; + } + if ( + change.isChanged(Changes.focus) || + change.isChanged(Changes.selected) || + change.isChanged(Changes.renderStyle) || + change.isChanged(Changes.color) + ) { + this.removeChildren(); + this.invalidateBoundingBox(); + this.invalidateRenderTarget(); + } + } + + // ================================================== + // OVERRIDES of ThreeView + // ================================================== + + public override beforeRender(camera: PerspectiveCamera): void { + super.beforeRender(camera); + if (this.isEmpty) { + return; + } + this.updateLabels(camera); + } + + // ================================================== + // OVERRIDES of GroupThreeView + // ================================================== + + protected override addChildren(): void { + const { boxDomainObject } = this; + const matrix = this.getMatrix(); + + const { focusType } = boxDomainObject; + this.addChild(this.createSolid(matrix)); + this.addChild(this.createLines(matrix)); + if (showMarkers(focusType)) { + this.addChild(this.createRotationRing(matrix)); + this.addEdgeCircles(matrix); + } + this.addLabels(matrix); + } + + public override intersectIfCloser( + intersectInput: CustomObjectIntersectInput, + closestDistance: number | undefined + ): undefined | CustomObjectIntersection { + const { boxDomainObject } = this; + if (boxDomainObject.focusType === FocusType.Pending) { + return undefined; // Should never be picked + } + const orientedBox = createOrientedBox(); + const matrix = this.getMatrix(); + orientedBox.applyMatrix4(matrix); + + const ray = intersectInput.raycaster.ray; + const point = orientedBox.intersectRay(ray, newVector3()); + if (point === null) { + return undefined; + } + const distanceToCamera = point.distanceTo(ray.origin); + if (closestDistance !== undefined && closestDistance < distanceToCamera) { + return undefined; + } + if (!intersectInput.isVisible(point)) { + return undefined; + } + const positionAtFace = newVector3(point).applyMatrix4(matrix.invert()); + const boxFace = new BoxFace().fromPositionAtFace(positionAtFace); + if (!this.isFaceVisible(boxFace)) { + return undefined; + } + const cornerSign = new Vector3(); + const cdfPosition = newVector3(point).applyMatrix4( + CDF_TO_VIEWER_TRANSFORMATION.clone().invert() + ); + const focusType = this.getPickedFocusType(cdfPosition, boxFace, cornerSign); + const customObjectIntersection: DomainObjectIntersection = { + type: 'customObject', + point, + distanceToCamera, + userData: new BoxPickInfo(boxFace, focusType, cornerSign), + customObject: this, + domainObject: boxDomainObject + }; + if (this.shouldPickBoundingBox) { + customObjectIntersection.boundingBox = this.boundingBox; + } + return customObjectIntersection; + } + + // ================================================== + // INSTANCE METHODS: Getters + // ================================================== + + private getTextHeight(relativeTextSize: number): number { + return relativeTextSize * this.boxDomainObject.diagonal; + } + + private getFaceRadius(boxFace: BoxFace): number { + const { size } = this.boxDomainObject; + const size1 = size.getComponent(boxFace.tangentIndex1); + const size2 = size.getComponent(boxFace.tangentIndex2); + return (size1 + size2) / 4; + } + + private getMatrix(): Matrix4 { + const { boxDomainObject } = this; + const matrix = boxDomainObject.getMatrix(); + matrix.premultiply(CDF_TO_VIEWER_TRANSFORMATION); + return matrix; + } + + private getRotationMatrix(): Matrix4 { + const { boxDomainObject } = this; + const matrix = boxDomainObject.getRotationMatrix(); + matrix.premultiply(CDF_TO_VIEWER_TRANSFORMATION); + return matrix; + } + + // ================================================== + // INSTANCE METHODS: Create Object3D's + // ================================================== + + private createSolid(matrix: Matrix4): Object3D | undefined { + const { boxDomainObject } = this; + const { style } = this; + + const material = new MeshPhongMaterial(); + updateSolidMaterial(material, boxDomainObject, style); + const geometry = new BoxGeometry(1, 1, 1); + const result = new Mesh(geometry, material); + result.applyMatrix4(matrix); + return result; + } + + private createLines(matrix: Matrix4): Object3D | undefined { + const { boxDomainObject } = this; + const { style } = this; + + const material = new LineBasicMaterial(); + updateLineSegmentsMaterial(material, boxDomainObject, style); + const geometry = createLineSegmentsBufferGeometryForBox(); + const result = new LineSegments(geometry, material); + + result.applyMatrix4(matrix); + return result; + } + + private createRotationLabel(matrix: Matrix4, spriteHeight: number): Sprite | undefined { + if (!this.isFaceVisible(TOP_FACE)) { + return undefined; + } + const { boxDomainObject } = this; + const degrees = radToDeg(boxDomainObject.zRotation); + const text = degrees.toFixed(1); + if (text === '0.0') { + return undefined; // Not show when about 0 + } + const sprite = createSprite(text + '°', this.style, spriteHeight); + if (sprite === undefined) { + return undefined; + } + const faceCenter = TOP_FACE.getCenter(newVector3()); + faceCenter.applyMatrix4(matrix); + adjustLabel(faceCenter, boxDomainObject, spriteHeight); + sprite.position.copy(faceCenter); + return sprite; + } + + private createPendingLabel(matrix: Matrix4, spriteHeight: number): Sprite | undefined { + if (!this.isFaceVisible(TOP_FACE)) { + return undefined; + } + const sprite = createSprite('Pending', this.style, spriteHeight); + if (sprite === undefined) { + return undefined; + } + const faceCenter = TOP_FACE.getCenter(newVector3()); + faceCenter.applyMatrix4(matrix); + adjustLabel(faceCenter, this.boxDomainObject, spriteHeight); + sprite.position.copy(faceCenter); + return sprite; + } + + private createRotationRing(matrix: Matrix4): Mesh | undefined { + if (!this.isFaceVisible(TOP_FACE)) { + return undefined; + } + const { boxDomainObject, style } = this; + const { focusType } = boxDomainObject; + const radius = this.getFaceRadius(TOP_FACE); + + const outerRadius = RELATIVE_ROTATION_RADIUS.max * radius; + const innerRadius = RELATIVE_ROTATION_RADIUS.min * radius; + const geometry = new RingGeometry(innerRadius, outerRadius, CIRCULAR_SEGMENTS); + + const material = new MeshPhongMaterial(); + updateMarkerMaterial(material, boxDomainObject, style, focusType === FocusType.Rotation); + material.clippingPlanes = this.createClippingPlanes(matrix, TOP_FACE.index); + const mesh = new Mesh(geometry, material); + + const center = TOP_FACE.getCenter(newVector3()); + center.applyMatrix4(matrix); + mesh.position.copy(center); + mesh.rotateX(-Math.PI / 2); + return mesh; + } + + private createEdgeCircle(matrix: Matrix4, material: Material, face: BoxFace): Mesh | undefined { + const { boxDomainObject } = this; + const adjecentSize1 = boxDomainObject.size.getComponent(face.tangentIndex1); + if (!isValid(adjecentSize1)) { + return undefined; + } + const adjecentSize2 = boxDomainObject.size.getComponent(face.tangentIndex2); + if (!isValid(adjecentSize2)) { + return undefined; + } + const radius = RELATIVE_RESIZE_RADIUS * this.getFaceRadius(face); + const geometry = new CircleGeometry(radius, CIRCULAR_SEGMENTS); + material.transparent = true; + material.depthWrite = false; + const mesh = new Mesh(geometry, material); + + const center = face.getCenter(newVector3()); + center.applyMatrix4(matrix); + mesh.position.copy(center); + + // Must be roteted correctly because of sideness + if (face.face === 2) { + mesh.rotateX(-Math.PI / 2); + } else if (face.face === 5) { + mesh.rotateX(Math.PI / 2); + } else if (face.face === 0) { + mesh.rotateY(Math.PI / 2 + boxDomainObject.zRotation); + } else if (face.face === 3) { + mesh.rotateY(-Math.PI / 2 + boxDomainObject.zRotation); + } else if (face.face === 1) { + mesh.rotateY(Math.PI + boxDomainObject.zRotation); + } else if (face.face === 4) { + mesh.rotateY(boxDomainObject.zRotation); + } + return mesh; + } + + private createClippingPlanes(matrix: Matrix4, faceIndex: number): Plane[] { + const planes: Plane[] = []; + + for (const boxFace of BoxFace.getAllFaces()) { + if (boxFace.index === faceIndex) { + continue; + } + const center = boxFace.getCenter(newVector3()); + const normal = boxFace.getNormal(newVector3()).negate(); + const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center); + plane.applyMatrix4(matrix); + planes.push(plane); + } + return planes; + } + + // ================================================== + // INSTANCE METHODS: Add Object3D's + // ================================================== + + private addLabels(matrix: Matrix4): void { + const { boxDomainObject, style } = this; + const spriteHeight = this.getTextHeight(style.relativeTextSize); + clear(this._sprites); + for (let index = 0; index < 3; index++) { + const size = boxDomainObject.size.getComponent(index); + if (!isValid(size)) { + this._sprites.push(undefined); + continue; + } + const sprite = createSprite(size.toFixed(2), style, spriteHeight); + if (sprite === undefined) { + this._sprites.push(undefined); + continue; + } + this._sprites.push(sprite); + this.addChild(sprite); + } + this.updateLabels(this.renderTarget.camera); + const { focusType } = boxDomainObject; + if (focusType === FocusType.Pending && boxDomainObject.hasArea) { + this.addChild(this.createPendingLabel(matrix, spriteHeight)); + } else if (showRotationLabel(focusType)) { + this.addChild(this.createRotationLabel(matrix, spriteHeight)); + } + } + + private updateLabels(camera: Camera): void { + const { boxDomainObject, style } = this; + const matrix = this.getMatrix(); + + const rotationMatrix = this.getRotationMatrix(); + const centerOfBox = newVector3(boxDomainObject.center); + centerOfBox.applyMatrix4(CDF_TO_VIEWER_TRANSFORMATION); + const cameraPosition = camera.getWorldPosition(newVector3()); + const cameraDirection = centerOfBox.sub(cameraPosition).normalize(); + + // Calculate which face of the box are visible + const boxFace = new BoxFace(); // Due to reuse in both loops + const visibleFaces = this._visibleFaces; + for (const face of BoxFace.getAllFaces(boxFace)) { + const normal = boxFace.getNormal(newVector3()); + normal.applyMatrix4(rotationMatrix); + visibleFaces[face.face] = normal.dot(cameraDirection) < 0; + } + const spriteHeight = this.getTextHeight(style.relativeTextSize); + + // If the 2 adjecent faces are visible, show the sprite along the edge + for (let index = 0; index < this._sprites.length; index++) { + const sprite = this._sprites[index]; + if (sprite === undefined) { + continue; + } + sprite.visible = false; + for (let i = 0; i < 2; i++) { + const face1 = (index + (i === 0 ? 1 : 4)) % 6; + if (!visibleFaces[face1]) { + continue; + } + boxFace.face = face1; + const showFace1 = this.isFaceVisible(boxFace); + const faceCenter1 = boxFace.getCenter(newVector3()); + for (let j = 0; j < 2; j++) { + const face2 = (index + (j === 0 ? 2 : 5)) % 6; + if (!visibleFaces[face2]) { + continue; + } + boxFace.face = face2; + if (!showFace1 && !this.isFaceVisible(boxFace)) { + continue; + } + const faceCenter2 = boxFace.getCenter(newVector3()); + const edgeCenter = faceCenter2.add(faceCenter1); + edgeCenter.applyMatrix4(matrix); + + adjustLabel(edgeCenter, boxDomainObject, spriteHeight); + + // Move the sprite slightly away from the box to avoid z-fighting + edgeCenter.addScaledVector(cameraDirection, -spriteHeight / 2); + sprite.position.copy(edgeCenter); + sprite.visible = true; + break; + } + if (sprite.visible) { + break; + } + } + } + } + + private addEdgeCircles(matrix: Matrix4): void { + const { boxDomainObject, style } = this; + let selectedFace = boxDomainObject.focusFace; + if (this.boxDomainObject.focusType !== FocusType.Face) { + selectedFace = undefined; + } + const material = new MeshPhongMaterial(); + updateMarkerMaterial(material, boxDomainObject, style, false); + for (const boxFace of BoxFace.getAllFaces()) { + if (!this.isFaceVisible(boxFace)) { + continue; + } + if (selectedFace === undefined || !selectedFace.equals(boxFace)) { + this.addChild(this.createEdgeCircle(matrix, material, boxFace)); + } + } + if (selectedFace !== undefined && this.isFaceVisible(selectedFace)) { + const material = new MeshPhongMaterial(); + updateMarkerMaterial(material, boxDomainObject, style, true); + this.addChild(this.createEdgeCircle(matrix, material, selectedFace)); + } + } + + // ================================================== + // INSTANCE METHODS: For picking + // ================================================== + + private getPickedFocusType( + realPosition: Vector3, + boxFace: BoxFace, + outputCornerSign: Vector3 + ): FocusType { + const { boxDomainObject } = this; + const scale = newVector3().setScalar(this.getFaceRadius(boxFace)); + const scaledMatrix = boxDomainObject.getScaledMatrix(scale); + scaledMatrix.invert(); + const scaledPositionAtFace = newVector3(realPosition).applyMatrix4(scaledMatrix); + const planePoint = boxFace.getPlanePoint(scaledPositionAtFace); + const relativeDistance = planePoint.length(); + + outputCornerSign.copy(this.getCornerSign(realPosition, boxFace)); + const corner = this.getCorner(outputCornerSign, boxFace); + + if (relativeDistance < RELATIVE_RESIZE_RADIUS) { + return FocusType.Face; + } + if (realPosition.distanceTo(corner) < 0.2 * this.getFaceRadius(boxFace)) { + return FocusType.Corner; + } + if (boxFace.face === 2) { + if (RELATIVE_ROTATION_RADIUS.isInside(relativeDistance)) { + return FocusType.Rotation; + } + } + return FocusType.Body; + } + + private getCornerSign(realPosition: Vector3, boxFace: BoxFace): Vector3 { + const { boxDomainObject } = this; + const scale = newVector3().setScalar(this.getFaceRadius(boxFace)); + const scaledMatrix = boxDomainObject.getScaledMatrix(scale); + scaledMatrix.invert(); + const scaledPositionAtFace = realPosition.clone().applyMatrix4(scaledMatrix); + scaledPositionAtFace.setComponent(boxFace.index, 0); + scaledPositionAtFace.setComponent( + boxFace.tangentIndex1, + Math.sign(scaledPositionAtFace.getComponent(boxFace.tangentIndex1)) + ); + scaledPositionAtFace.setComponent( + boxFace.tangentIndex2, + Math.sign(scaledPositionAtFace.getComponent(boxFace.tangentIndex2)) + ); + return scaledPositionAtFace; + } + + private getCorner(cornerSign: Vector3, boxFace: BoxFace): Vector3 { + const { boxDomainObject } = this; + const center = boxFace.getCenter(new Vector3()); // In range (-0.5, 0.5) + const corner = center.addScaledVector(cornerSign, 0.5); + const matrix = boxDomainObject.getMatrix(); + corner.applyMatrix4(matrix); + return corner; + } + + private isFaceVisible(boxFace: BoxFace): boolean { + const { boxDomainObject } = this; + switch (boxDomainObject.measureType) { + case MeasureType.VerticalArea: + return boxFace.index === 1; // Y Face visible + + case MeasureType.HorizontalArea: + return boxFace.index === 2; // Z face visible + } + return true; + } +} + +// ================================================== +// PRIVATE FUNCTIONS: Update materials +// ================================================== + +function showRotationLabel(focusType: FocusType): boolean { + switch (focusType) { + case FocusType.Face: + case FocusType.Body: + case FocusType.Pending: + return false; + default: + return true; + } +} + +function showMarkers(focusType: FocusType): boolean { + switch (focusType) { + case FocusType.Pending: + case FocusType.None: + return false; + default: + return true; + } +} + +// ================================================== +// PRIVATE FUNCTIONS: Update materials +// ================================================== + +function updateSolidMaterial( + material: MeshPhongMaterial, + boxDomainObject: MeasureBoxDomainObject, + style: MeasureBoxRenderStyle +): void { + const color = boxDomainObject.getColorByColorType(style.colorType); + const isSelected = boxDomainObject.isSelected; + const opacity = isSelected ? style.opacity : style.opacity / 4; + material.polygonOffset = true; + material.polygonOffsetFactor = 1; + material.polygonOffsetUnits = 4.0; + material.color = color; + material.opacity = style.opacityUse ? opacity : 1; + material.transparent = true; + material.emissive = color; + material.emissiveIntensity = 0.2; + material.side = DoubleSide; + material.flatShading = true; + material.depthWrite = false; + material.depthTest = style.depthTest; +} + +function updateLineSegmentsMaterial( + material: LineBasicMaterial, + boxDomainObject: MeasureBoxDomainObject, + style: MeasureBoxRenderStyle +): void { + const color = boxDomainObject.getColorByColorType(style.colorType); + material.color = color; + material.transparent = true; + material.depthWrite = false; + material.depthTest = style.depthTest; +} + +function updateMarkerMaterial( + material: MeshPhongMaterial, + boxDomainObject: MeasureBoxDomainObject, + style: MeasureBoxRenderStyle, + hasFocus: boolean +): void { + material.color = ARROW_AND_RING_COLOR; + material.polygonOffset = true; + material.polygonOffsetFactor = 1; + material.polygonOffsetUnits = 4.0; + material.transparent = true; + material.emissive = ARROW_AND_RING_COLOR; + material.emissiveIntensity = hasFocus ? 0.8 : 0.3; + material.side = FrontSide; + material.flatShading = true; + material.depthWrite = false; + material.depthTest = style.depthTest; +} + +// ================================================== +// PRIVATE FUNCTIONS: Create object3D's +// ================================================== + +function createSprite(text: string, style: MeasureRenderStyle, height: number): Sprite | undefined { + const result = createSpriteWithText(text, height, style.textColor, style.textBgColor); + if (result === undefined) { + return undefined; + } + result.material.transparent = true; + result.material.opacity = style.textOpacity; + result.material.depthTest = style.depthTest; + return result; +} + +function adjustLabel( + point: Vector3, + domainObject: MeasureBoxDomainObject, + spriteHeight: number +): void { + if (domainObject.measureType !== MeasureType.VerticalArea) { + point.y += (1.1 * spriteHeight) / 2; + } +} + +function isValid(value: number): boolean { + return value > MIN_BOX_SIZE; +} +// ================================================== +// PRIVATE FUNCTIONS: Vector pool +// ================================================== + +const VECTOR_POOL = new Vector3Pool(); +function newVector3(copyFrom?: Vector3): Vector3 { + return VECTOR_POOL.getNext(copyFrom); +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasureDomainObject.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasureDomainObject.ts new file mode 100644 index 00000000000..0f5cd6f5434 --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasureDomainObject.ts @@ -0,0 +1,73 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { VisualDomainObject } from '../../base/domainObjects/VisualDomainObject'; +import { getIconByMeasureType, getNameByMeasureType, type MeasureType } from './MeasureType'; +import { type MeasureRenderStyle } from './MeasureRenderStyle'; +import { type DomainObjectChange } from '../../base/domainObjectsHelpers/DomainObjectChange'; +import { DomainObjectPanelUpdater } from '../../base/reactUpdaters/DomainObjectPanelUpdater'; +import { Changes } from '../../base/domainObjectsHelpers/Changes'; +import { PopupStyle } from '../../base/domainObjectsHelpers/PopupStyle'; + +export abstract class MeasureDomainObject extends VisualDomainObject { + private readonly _measureType: MeasureType; + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get renderStyle(): MeasureRenderStyle { + return this.getRenderStyle() as MeasureRenderStyle; + } + + public get measureType(): MeasureType { + return this._measureType; + } + + // ================================================== + // CONSTRUCTORS + // ================================================== + + public constructor(measureType: MeasureType) { + super(); + this._measureType = measureType; + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get icon(): string { + return getIconByMeasureType(this.measureType); + } + + public override get typeName(): string { + return getNameByMeasureType(this.measureType); + } + + public override getPanelInfoStyle(): PopupStyle { + // bottom = 66 because the measurement toolbar is below + return new PopupStyle({ bottom: 66, left: 0 }); + } + + protected override notifyCore(change: DomainObjectChange): void { + super.notifyCore(change); + + if (!DomainObjectPanelUpdater.isActive) { + return; + } + if (this.isSelected) { + if (change.isChanged(Changes.deleted)) { + DomainObjectPanelUpdater.update(undefined); + } + if (change.isChanged(Changes.selected, Changes.geometry)) { + DomainObjectPanelUpdater.update(this); + } + } else { + if (change.isChanged(Changes.selected)) { + DomainObjectPanelUpdater.update(undefined); // Deselected + } + } + } +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasureLineCreator.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasureLineCreator.ts new file mode 100644 index 00000000000..fdf106735ff --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasureLineCreator.ts @@ -0,0 +1,102 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { Plane, type Ray, Vector3 } from 'three'; +import { MeasureType } from './MeasureType'; +import { BaseCreator } from '../../base/domainObjectsHelpers/BaseCreator'; +import { MeasureLineDomainObject } from './MeasureLineDomainObject'; +import { copy } from '../../base/utilities/extensions/arrayExtensions'; +import { Changes } from '../../base/domainObjectsHelpers/Changes'; +import { type DomainObject } from '../../base/domainObjects/DomainObject'; +import { FocusType } from '../../base/domainObjectsHelpers/FocusType'; + +/** + * Helper class for generate a MeasureLineDomainObject by clicking around + */ +export class MeasureLineCreator extends BaseCreator { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private readonly _domainObject: MeasureLineDomainObject; + + // ================================================== + // CONTRUCTOR + // ================================================== + + constructor(measureType: MeasureType) { + super(); + this._domainObject = new MeasureLineDomainObject(measureType); + this._domainObject.focusType = FocusType.Pending; + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get preferIntersection(): boolean { + return true; + } + + public override get domainObject(): DomainObject { + return this._domainObject; + } + + public override get minimumPointCount(): number { + return 2; + } + + public override get maximumPointCount(): number { + switch (this._domainObject.measureType) { + case MeasureType.Line: + return 2; + case MeasureType.Polyline: + case MeasureType.Polygon: + return Number.MAX_SAFE_INTEGER; + default: + throw new Error('Unknown measurement type'); + } + } + + protected override addPointCore( + ray: Ray, + point: Vector3 | undefined, + isPending: boolean + ): boolean { + // Figure out where the point should be if no intersection + if (isPending && this.realPointCount >= 1 && point === undefined) { + const lastPoint = this.points[this.realPointCount - 1]; + const plane = new Plane().setFromNormalAndCoplanarPoint(ray.direction, lastPoint); + const newPoint = ray.intersectPlane(plane, new Vector3()); + if (newPoint === null) { + return false; + } + point = newPoint; + } + if (point === undefined) { + return false; + } + this.addRawPoint(point, isPending); + const domainObject = this._domainObject; + copy(domainObject.points, this.points); + + domainObject.notify(Changes.geometry); + if (this.isFinished) { + domainObject.setSelectedInteractive(true); + domainObject.setFocusInteractive(FocusType.Focus); + } + return true; + } + + public override handleEscape(): void { + const domainObject = this._domainObject; + if (this.realPointCount < this.minimumPointCount) { + domainObject.removeInteractive(); + } else if (this.lastIsPending) { + domainObject.points.pop(); + this.removePendingPoint(); + domainObject.notify(Changes.geometry); + } + } +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasureLineDomainObject.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasureLineDomainObject.ts new file mode 100644 index 00000000000..4cf8c08c67a --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasureLineDomainObject.ts @@ -0,0 +1,184 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type RenderStyle } from '../../base/domainObjectsHelpers/RenderStyle'; +import { type ThreeView } from '../../base/views/ThreeView'; +import { MeasureLineView } from './MeasureLineView'; +import { Vector3 } from 'three'; +import { MeasureType } from './MeasureType'; +import { MeasureLineRenderStyle } from './MeasureLineRenderStyle'; +import { MeasureDomainObject } from './MeasureDomainObject'; +import { + getHorizontalCrossProduct, + horizontalDistanceTo, + verticalDistanceTo +} from '../../base/utilities/extensions/vectorExtensions'; +import { NumberType, PanelInfo } from '../../base/domainObjectsHelpers/PanelInfo'; +import { Changes } from '../../base/domainObjectsHelpers/Changes'; +import { FocusType } from '../../base/domainObjectsHelpers/FocusType'; + +export class MeasureLineDomainObject extends MeasureDomainObject { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public readonly points: Vector3[] = []; + public focusType = FocusType.None; + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public override get renderStyle(): MeasureLineRenderStyle { + return this.getRenderStyle() as MeasureLineRenderStyle; + } + + // ================================================== + // CONSTRUCTORS + // ================================================== + + public constructor(measureType: MeasureType) { + super(measureType); + } + + // ================================================== + // OVERRIDES of DomainObject + // ================================================== + + public override createRenderStyle(): RenderStyle | undefined { + return new MeasureLineRenderStyle(); + } + + public override getPanelInfo(): PanelInfo | undefined { + if (this.focusType === FocusType.Pending && this.points.length <= 1) { + return undefined; + } + const info = new PanelInfo(); + switch (this.measureType) { + case MeasureType.Line: + info.setHeader('MEASUREMENTS_LINE', 'Line'); + add('MEASUREMENTS_LENGTH', 'Length', this.getTotalLength()); + add('MEASUREMENTS_HORIZONTAL_LENGTH', 'Horizontal length', this.getHorizontalLength()); + add('MEASUREMENTS_VERTICAL_LENGTH', 'Vertical length', this.getVerticalLength()); + break; + + case MeasureType.Polyline: + info.setHeader('MEASUREMENTS_POLYLINE', 'Polyline'); + add('MEASUREMENTS_TOTAL_LENGTH', 'Total length', this.getTotalLength()); + break; + case MeasureType.Polygon: + info.setHeader('MEASUREMENTS_POLYGON', 'Polygon'); + add('MEASUREMENTS_TOTAL_LENGTH', 'Total length', this.getTotalLength()); + if (this.points.length > 2) { + add( + 'MEASUREMENTS_HORIZONTAL_AREA', + 'Horizontal area', + this.getHorizontalArea(), + NumberType.Area + ); + } + break; + + default: + throw new Error('Unknown MeasureType type'); + } + return info; + + function add( + key: string, + fallback: string, + value: number, + numberType = NumberType.Length + ): void { + info.add({ key, fallback, value, numberType }); + } + } + + // ================================================== + // OVERRIDES of VisualDomainObject + // ================================================== + + protected override createThreeView(): ThreeView | undefined { + return new MeasureLineView(); + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public getTotalLength(): number { + let prevPoint: Vector3 | undefined; + let sum = 0.0; + for (const point of this.points) { + if (prevPoint !== undefined) { + sum += point.distanceTo(prevPoint); + } + prevPoint = point; + } + return sum; + } + + public getAverageLength(): number { + const count = this.points.length; + if (count <= 1) { + return 0; + } + return this.getTotalLength() / (count - 1); + } + + public getHorizontalLength(): number { + let prevPoint: Vector3 | undefined; + let sum = 0.0; + for (const point of this.points) { + if (prevPoint !== undefined) { + sum += horizontalDistanceTo(point, prevPoint); + continue; + } + prevPoint = point; + } + return sum; + } + + public getVerticalLength(): number { + let prevPoint: Vector3 | undefined; + let sum = 0.0; + for (const point of this.points) { + if (prevPoint !== undefined) { + sum += verticalDistanceTo(point, prevPoint); + continue; + } + prevPoint = point; + } + return sum; + } + + public getHorizontalArea(): number { + const { points } = this; + const count = points.length; + if (count <= 2) { + return 0; + } + let sum = 0.0; + const first = points[0]; + const p0 = new Vector3(); + const p1 = new Vector3(); + + for (let index = 1; index <= count; index++) { + p1.copy(points[index % count]); + p1.sub(first); // Translate down to first point, to increase acceracy + sum += getHorizontalCrossProduct(p0, p1); + p0.copy(p1); + } + return Math.abs(sum) / 2; + } + + public setFocusInteractive(focusType: FocusType): boolean { + if (this.focusType === focusType) { + return false; + } + this.focusType = focusType; + this.notify(Changes.focus); + return true; + } +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasureLineRenderStyle.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasureLineRenderStyle.ts new file mode 100644 index 00000000000..729ec22e9f4 --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasureLineRenderStyle.ts @@ -0,0 +1,25 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { cloneDeep } from 'lodash'; +import { MeasureRenderStyle } from './MeasureRenderStyle'; +import { type RenderStyle } from '../../base/domainObjectsHelpers/RenderStyle'; + +export class MeasureLineRenderStyle extends MeasureRenderStyle { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public lineWidth = 2; + public pipeRadius = 0.03; + public selectedLineWidth = 2; + + // ================================================== + // OVERRIDES of BaseStyle + // ================================================== + + public override clone(): RenderStyle { + return cloneDeep(this); + } +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasureLineView.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasureLineView.ts new file mode 100644 index 00000000000..a0f74b9186a --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasureLineView.ts @@ -0,0 +1,257 @@ +/*! + * Copyright 2024 Cognite AS + */ + +/* eslint-disable @typescript-eslint/consistent-type-imports */ +import { + CylinderGeometry, + FrontSide, + Mesh, + MeshPhongMaterial, + Quaternion, + Sprite, + Vector2, + Vector3 +} from 'three'; +import { Wireframe } from 'three/examples/jsm/lines/Wireframe.js'; +import { MeasureLineDomainObject } from './MeasureLineDomainObject'; +import { DomainObjectChange } from '../../base/domainObjectsHelpers/DomainObjectChange'; +import { Changes } from '../../base/domainObjectsHelpers/Changes'; +import { MeasureLineRenderStyle } from './MeasureLineRenderStyle'; +import { GroupThreeView } from '../../base/views/GroupThreeView'; +import { + CDF_TO_VIEWER_TRANSFORMATION, + CustomObjectIntersectInput, + CustomObjectIntersection +} from '@cognite/reveal'; +import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js'; +import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; +import { MeasureType } from './MeasureType'; +import { createSpriteWithText } from '../../base/utilities/sprites/createSprite'; +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; +import { FocusType } from '../../base/domainObjectsHelpers/FocusType'; +import { MeasureRenderStyle } from './MeasureRenderStyle'; + +const CYLINDER_DEFAULT_AXIS = new Vector3(0, 1, 0); + +export class MeasureLineView extends GroupThreeView { + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + protected get lineDomainObject(): MeasureLineDomainObject { + return super.domainObject as MeasureLineDomainObject; + } + + protected override get style(): MeasureLineRenderStyle { + return super.style as MeasureLineRenderStyle; + } + + // ================================================== + // OVERRIDES of BaseView + // ================================================== + + public override update(change: DomainObjectChange): void { + super.update(change); + if (this.isEmpty) { + return; + } + if ( + change.isChanged(Changes.focus) || + change.isChanged(Changes.selected) || + change.isChanged(Changes.renderStyle) || + change.isChanged(Changes.color) + ) { + this.removeChildren(); + this.invalidateBoundingBox(); + this.invalidateRenderTarget(); + } + } + + // ================================================== + // OVERRIDES of GroupThreeView + // ================================================== + + protected override addChildren(): void { + this.addChild(this.createCylinders()); + this.addLabels(); + } + + public override intersectIfCloser( + intersectInput: CustomObjectIntersectInput, + closestDistance: number | undefined + ): undefined | CustomObjectIntersection { + if (this.lineDomainObject.focusType === FocusType.Pending) { + return undefined; // Should never be picked + } + return super.intersectIfCloser(intersectInput, closestDistance); + } + + // ================================================== + // INSTANCE METHODS: + // ================================================== + + private createCylinders(): Mesh | undefined { + const { lineDomainObject, style } = this; + const { points } = lineDomainObject; + const { length } = points; + if (length < 2) { + return undefined; + } + const radius = style.pipeRadius; + if (radius <= 0) { + return; + } + const geometries: CylinderGeometry[] = []; + const loopLength = lineDomainObject.measureType === MeasureType.Polygon ? length + 1 : length; + + // Just allocate all needed objects once + const prevPoint = new Vector3(); + const thisPoint = new Vector3(); + const quaternion = new Quaternion(); + const center = new Vector3(); + const direction = new Vector3(); + + for (let i = 0; i < loopLength; i++) { + thisPoint.copy(points[i % length]); + thisPoint.applyMatrix4(CDF_TO_VIEWER_TRANSFORMATION); + + if (i > 0) { + // create cylinder with length equal to the distance between successive vertices + const distance = prevPoint.distanceTo(thisPoint); + const cylinder = new CylinderGeometry(radius, radius, distance, 6, 1); + + // use quaterion to orient cylinder to align along the vector formed between + // the pair of vertices + direction.copy(thisPoint).sub(prevPoint).normalize(); + quaternion.setFromUnitVectors(CYLINDER_DEFAULT_AXIS, direction); + cylinder.applyQuaternion(quaternion); + + center.copy(thisPoint).add(prevPoint).divideScalar(2); + cylinder.translate(center.x, center.y, center.z); + geometries.push(cylinder); + } + prevPoint.copy(thisPoint); + } + const material = new MeshPhongMaterial(); + updateSolidMaterial(material, lineDomainObject, style); + return new Mesh(mergeGeometries(geometries, false), material); + } + + private createLines(): Wireframe | undefined { + const { lineDomainObject, style } = this; + const vertices = createVertices(lineDomainObject); + if (vertices === undefined) { + return undefined; + } + const color = lineDomainObject.getColorByColorType(style.colorType); + const linewidth = lineDomainObject.isSelected ? style.selectedLineWidth : style.lineWidth; + const geometry = new LineSegmentsGeometry().setPositions(vertices); + const material = new LineMaterial({ + linewidth: linewidth / 50, + color, + resolution: new Vector2(1000, 1000), + worldUnits: true, + depthTest: style.depthTest + }); + return new Wireframe(geometry, material); + } + + private addLabels(): void { + const { lineDomainObject, style } = this; + const { points } = lineDomainObject; + const { length } = points; + if (length < 2) { + return; + } + const spriteHeight = this.getTextHeight(style.relativeTextSize); + if (spriteHeight <= 0) { + return; + } + const loopLength = lineDomainObject.measureType === MeasureType.Polygon ? length : length - 1; + const center = new Vector3(); + for (let i = 0; i < loopLength; i++) { + const point1 = points[i % length]; + const point2 = points[(i + 1) % length]; + const distance = point1.distanceTo(point2); + + center.copy(point1).add(point2).divideScalar(2); + center.applyMatrix4(CDF_TO_VIEWER_TRANSFORMATION); + const sprite = createSprite(distance.toFixed(2), style, spriteHeight); + if (sprite === undefined) { + continue; + } + adjustLabel(center, lineDomainObject, style, spriteHeight); + sprite.position.copy(center); + this.addChild(sprite); + } + } + + private getTextHeight(relativeTextSize: number): number { + return relativeTextSize * this.lineDomainObject.getAverageLength(); + } +} + +// ================================================== +// PRIVATE FUNCTIONS: Create object3D's +// ================================================== + +function createVertices(domainObject: MeasureLineDomainObject): number[] | undefined { + const { points } = domainObject; + const { length } = points; + if (length < 2) { + return undefined; + } + const vertices: number[] = []; + const loopLength = domainObject.measureType === MeasureType.Polygon ? length + 1 : length; + + for (let i = 0; i < loopLength; i++) { + const point = points[i % length].clone(); + point.applyMatrix4(CDF_TO_VIEWER_TRANSFORMATION); + vertices.push(...point); + if (i > 0 && i < loopLength - 1) { + vertices.push(...point); + } + } + return vertices; +} + +function createSprite(text: string, style: MeasureRenderStyle, height: number): Sprite | undefined { + const result = createSpriteWithText(text, height, style.textColor, style.textBgColor); + if (result === undefined) { + return undefined; + } + result.material.transparent = true; + result.material.opacity = style.textOpacity; + result.material.depthTest = style.depthTest; + return result; +} + +function updateSolidMaterial( + material: MeshPhongMaterial, + boxDomainObject: MeasureLineDomainObject, + style: MeasureLineRenderStyle +): void { + const color = boxDomainObject.getColorByColorType(style.colorType); + const selected = boxDomainObject.isSelected; + material.color = color; + material.opacity = 1; + material.transparent = true; + material.emissive = color; + material.emissiveIntensity = selected ? 0.6 : 0.2; + material.side = FrontSide; + material.flatShading = false; + material.depthWrite = false; + material.depthTest = style.depthTest; +} + +function adjustLabel( + point: Vector3, + domainObject: MeasureLineDomainObject, + style: MeasureLineRenderStyle, + spriteHeight: number +): void { + if (domainObject.measureType !== MeasureType.VerticalArea) { + point.y += (1.1 * spriteHeight) / 2 + style.pipeRadius; + } +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasureRenderStyle.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasureRenderStyle.ts new file mode 100644 index 00000000000..bc0ccc71bdd --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasureRenderStyle.ts @@ -0,0 +1,21 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { ColorType } from '../../base/domainObjectsHelpers/ColorType'; +import { RenderStyle } from '../../base/domainObjectsHelpers/RenderStyle'; +import { Color } from 'three'; +import { WHITE_COLOR } from '../../base/utilities/colors/colorExtensions'; + +export abstract class MeasureRenderStyle extends RenderStyle { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public depthTest = true; + public colorType = ColorType.Specified; + public textColor = WHITE_COLOR.clone(); + public textBgColor = new Color().setScalar(0.05); // Dark gray + public textOpacity = 0.75; // Dark gray + public relativeTextSize = 0.05; // Relative to diagonal of the measurment object for box and average of lenght of line segments for line +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasureType.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasureType.ts new file mode 100644 index 00000000000..c51b84d27b5 --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasureType.ts @@ -0,0 +1,92 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type Tooltip } from '../../base/commands/BaseCommand'; + +export enum MeasureType { + None, + Line, + Polyline, + Polygon, + HorizontalArea, + VerticalArea, + Volume +} + +export function getIconByMeasureType(measureType: MeasureType): string { + switch (measureType) { + case MeasureType.Line: + return 'VectorLine'; + case MeasureType.Polyline: + return 'VectorZigzag'; + case MeasureType.Polygon: + return 'Polygon'; + case MeasureType.HorizontalArea: + return 'FrameTool'; + case MeasureType.VerticalArea: + return 'Perspective'; + case MeasureType.Volume: + return 'Cube'; + default: + throw new Error('Unknown MeasureType type'); + } +} + +export function getNameByMeasureType(measureType: MeasureType): string { + switch (measureType) { + case MeasureType.Line: + return 'Line'; + case MeasureType.Polyline: + return 'Polyline'; + case MeasureType.Polygon: + return 'Polygon'; + case MeasureType.HorizontalArea: + return 'Horizontal area'; + case MeasureType.VerticalArea: + return 'Vertical area'; + case MeasureType.Volume: + return 'Volume'; + default: + throw new Error('Unknown MeasureType type'); + } +} + +export function getTooltipByMeasureType(measureType: MeasureType): Tooltip { + switch (measureType) { + case MeasureType.Line: + return { + key: 'MEASUREMENTS_ADD_LINE', + fallback: 'Measure distance between two points. Click at the start point and the end point.' + }; + case MeasureType.Polyline: + return { + key: 'MEASUREMENTS_ADD_POLYLINE', + fallback: + 'Measure the length of a continuous polyline. Click at any number of points and end with Esc.' + }; + case MeasureType.Polygon: + return { + key: 'MEASUREMENTS_ADD_POLYGON', + fallback: 'Measure an area of a polygon. Click at least 3 points and end with Esc.' + }; + case MeasureType.VerticalArea: + return { + key: 'MEASUREMENTS_ADD_VERTICAL_AREA', + fallback: 'Measure rectangular vertical Area. Click at two points in a vertical plan.' + }; + case MeasureType.HorizontalArea: + return { + key: 'MEASUREMENTS_ADD_HORIZONTAL_AREA', + fallback: 'Measure rectangular horizontal Area. Click at three points in a horizontal plan.' + }; + case MeasureType.Volume: + return { + key: 'MEASUREMENTS_ADD_VOLUME', + fallback: + 'Measure volume of a box. Click at three points in a horizontal plan and the fourth to give it height.' + }; + default: + throw new Error('Unknown MeasureType type'); + } +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasurementFunctions.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasurementFunctions.ts new file mode 100644 index 00000000000..e524204d598 --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasurementFunctions.ts @@ -0,0 +1,30 @@ +/*! + * Copyright 2024 Cognite AS + * BaseTool: Base class for the tool are used to interact with the render target. + */ + +import { type RevealRenderTarget } from '../../base/renderTarget/RevealRenderTarget'; +import { MeasureDomainObject } from './MeasureDomainObject'; + +// ================================================== +// PUBLIC FUNCTIONS +// ================================================== + +export function getAnyMeasureDomainObject( + renderTarget: RevealRenderTarget +): MeasureDomainObject | undefined { + // eslint-disable-next-line no-unreachable-loop + for (const domainObject of getMeasureDomainObjects(renderTarget)) { + return domainObject; + } + return undefined; +} + +export function* getMeasureDomainObjects( + renderTarget: RevealRenderTarget +): Generator { + const { rootDomainObject } = renderTarget; + for (const descendant of rootDomainObject.getDescendantsByType(MeasureDomainObject)) { + yield descendant; + } +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/MeasurementTool.ts b/react-components/src/architecture/concrete/boxDomainObject/MeasurementTool.ts new file mode 100644 index 00000000000..9796f3c3e91 --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/MeasurementTool.ts @@ -0,0 +1,343 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { MeasureBoxDomainObject } from './MeasureBoxDomainObject'; +import { type AnyIntersection, CDF_TO_VIEWER_TRANSFORMATION } from '@cognite/reveal'; +import { type BaseCommand, type Tooltip } from '../../base/commands/BaseCommand'; +import { isDomainObjectIntersection } from '../../base/domainObjectsHelpers/DomainObjectIntersection'; +import { FocusType } from '../../base/domainObjectsHelpers/FocusType'; +import { type BoxPickInfo } from '../../base/utilities/box/BoxPickInfo'; +import { type Vector3 } from 'three'; +import { MeasureBoxCreator } from './MeasureBoxCreator'; +import { MeasureType } from './MeasureType'; +import { type BaseCreator } from '../../base/domainObjectsHelpers/BaseCreator'; +import { MeasureLineCreator } from './MeasureLineCreator'; +import { BaseEditTool } from '../../base/commands/BaseEditTool'; +import { MeasureLineDomainObject } from './MeasureLineDomainObject'; +import { getAnyMeasureDomainObject, getMeasureDomainObjects } from './MeasurementFunctions'; +import { MeasureRenderStyle } from './MeasureRenderStyle'; +import { type DomainObject } from '../../base/domainObjects/DomainObject'; +import { type RevealRenderTarget } from '../../base/renderTarget/RevealRenderTarget'; +import { MeasureDomainObject } from './MeasureDomainObject'; +import { ShowMeasurmentsOnTopCommand } from './ShowMeasurmentsOnTopCommand'; +import { SetMeasurmentTypeCommand } from './SetMeasurmentTypeCommand'; +import { PopupStyle } from '../../base/domainObjectsHelpers/PopupStyle'; + +export class MeasurementTool extends BaseEditTool { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private _creator: BaseCreator | undefined = undefined; + public measureType: MeasureType = MeasureType.Line; + + // ================================================== + // OVERRIDES of BaseCommand + // ================================================== + + public override get icon(): string { + return 'Ruler'; + } + + public override get tooltip(): Tooltip { + return { key: 'MEASUREMENTS', fallback: 'Measurements' }; + } + + public override getToolbar(): Array | undefined { + const result = new Array(); + result.push(new SetMeasurmentTypeCommand(MeasureType.Line)); + result.push(new SetMeasurmentTypeCommand(MeasureType.Polyline)); + result.push(new SetMeasurmentTypeCommand(MeasureType.Polygon)); + result.push(new SetMeasurmentTypeCommand(MeasureType.HorizontalArea)); + result.push(new SetMeasurmentTypeCommand(MeasureType.VerticalArea)); + result.push(new SetMeasurmentTypeCommand(MeasureType.Volume)); + result.push(undefined); // Means separator + result.push(new ShowMeasurmentsOnTopCommand()); + return result; + } + + public override getToolbarStyle(): PopupStyle { + return new PopupStyle({ bottom: 0, left: 0 }); + } + + // ================================================== + // OVERRIDES of BaseTool + // ================================================== + + public override onActivate(): void { + super.onActivate(); + this.setAllMeasurementsVisible(true); + } + + public override onDeactivate(): void { + this.handleEscape(); + super.onDeactivate(); + this.setAllMeasurementsVisible(false); + this.deselectAll(); + } + + public override clearDragging(): void { + super.clearDragging(); + this._creator = undefined; + } + + public override onKey(event: KeyboardEvent, down: boolean): void { + if (down && event.key === 'Delete') { + for (const domainObject of getMeasureDomainObjects(this.renderTarget)) { + if (!domainObject.isSelected) { + continue; + } + domainObject.removeInteractive(); + } + this._creator = undefined; + return; + } + if (down && event.key === 'Escape') { + this.handleEscape(); + } + super.onKey(event, down); + } + + public override async onHover(event: PointerEvent): Promise { + // Handle when creator is set first + if (this.measureType !== MeasureType.None && this._creator !== undefined) { + const { _creator: creator } = this; + if (!creator.preferIntersection) { + // Hover in the "air" + const ray = this.getRay(event); + if (creator.addPoint(ray, undefined, true)) { + this.setDefaultCursor(); + return; + } + } + const intersection = await this.getIntersection(event); + if (intersection === undefined) { + if (creator !== undefined && creator.preferIntersection) { + // Hover in the "air" + const ray = this.getRay(event); + if (creator.addPoint(ray, undefined, true)) { + this.setDefaultCursor(); + return; + } + } + this.renderTarget.setNavigateCursor(); + return; + } + if (this.getMeasurement(intersection) !== undefined) { + this.renderTarget.setNavigateCursor(); + return; + } + const ray = this.getRay(event); + if (creator.addPoint(ray, intersection.point, true)) { + this.setDefaultCursor(); + return; + } + this.renderTarget.setNavigateCursor(); + return; + } + const intersection = await this.getIntersection(event); + const domainObject = this.getMeasurement(intersection); + if (!isDomainObjectIntersection(intersection) || domainObject === undefined) { + this.defocusAll(); + if (this.measureType === MeasureType.None || intersection === undefined) { + this.renderTarget.setNavigateCursor(); + } else { + this.setDefaultCursor(); + } + return; + } + // Set focus on the hovered object + if (domainObject instanceof MeasureLineDomainObject) { + this.defocusAll(domainObject); + domainObject.setFocusInteractive(FocusType.Focus); + this.renderTarget.setDefaultCursor(); + } else if (domainObject instanceof MeasureBoxDomainObject) { + const pickInfo = intersection.userData as BoxPickInfo; + if (pickInfo === undefined) { + this.defocusAll(); + this.renderTarget.setDefaultCursor(); + return; + } + this.setCursor(domainObject, intersection.point, pickInfo); + this.defocusAll(domainObject); + domainObject.setFocusInteractive(pickInfo.focusType, pickInfo.face); + } + } + + public override async onClick(event: PointerEvent): Promise { + const { renderTarget } = this; + const { rootDomainObject } = renderTarget; + + const { _creator: creator } = this; + // Click in the "air" + if (creator !== undefined && !creator.preferIntersection) { + const ray = this.getRay(event); + if (creator.addPoint(ray, undefined, false)) { + if (creator.isFinished) { + this._creator = undefined; + } + return; + } + } + const intersection = await this.getIntersection(event); + if (intersection === undefined) { + // Click in the "air" + await super.onClick(event); + return; + } + const measurment = this.getMeasurement(intersection); + if (measurment !== undefined) { + // Click at "a measurement" + // Do not want to click on other measurments + this.deselectAll(measurment); + measurment.setSelectedInteractive(true); + return; + } + const ray = this.getRay(event); + if (creator === undefined) { + const creator = (this._creator = this.createCreator()); + if (creator === undefined) { + await super.onClick(event); + return; + } + if (creator.addPoint(ray, intersection.point, false)) { + const { domainObject } = creator; + initializeStyle(domainObject, renderTarget); + this.deselectAll(); + rootDomainObject.addChildInteractive(domainObject); + domainObject.setSelectedInteractive(true); + domainObject.setVisibleInteractive(true, renderTarget); + this.renderTarget.toolController.update(); + } + } else { + if (creator.addPoint(ray, intersection.point, false)) { + if (creator.isFinished) { + this._creator = undefined; + } + } + } + } + + public override async onPointerDown(event: PointerEvent, leftButton: boolean): Promise { + if (this._creator !== undefined) { + return; // Prevent draggin while creating the new + } + await super.onPointerDown(event, leftButton); + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public handleEscape(): void { + if (this._creator === undefined) { + return; + } + this._creator.handleEscape(); + this._creator = undefined; + } + + private setAllMeasurementsVisible(visible: boolean): void { + for (const domainObject of getMeasureDomainObjects(this.renderTarget)) { + domainObject.setVisibleInteractive(visible, this.renderTarget); + } + } + + private getMeasurement( + intersection: AnyIntersection | undefined + ): MeasureDomainObject | undefined { + if (intersection === undefined) { + return undefined; + } + // Do not want to click on other boxes + if (!isDomainObjectIntersection(intersection)) { + return undefined; + } + if (intersection.domainObject instanceof MeasureDomainObject) { + return intersection.domainObject; + } else { + return undefined; + } + } + + private setCursor( + boxDomainObject: MeasureBoxDomainObject, + point: Vector3, + pickInfo: BoxPickInfo + ): void { + if (pickInfo.focusType === FocusType.Body) { + this.renderTarget.setMoveCursor(); + } else if (pickInfo.focusType === FocusType.Face) { + const matrix = boxDomainObject.getMatrix(); + matrix.premultiply(CDF_TO_VIEWER_TRANSFORMATION); + + const boxCenter = boxDomainObject.center.clone(); + const faceCenter = pickInfo.face.getCenter().multiplyScalar(100); + const size = boxDomainObject.size.getComponent(pickInfo.face.index); + if (size < 1) { + // If they are too close, the pixel value will be the same, so multiply faceCenter + // so it pretend the size is at least 1. + faceCenter.multiplyScalar(1 / size); + } + boxCenter.applyMatrix4(CDF_TO_VIEWER_TRANSFORMATION); + faceCenter.applyMatrix4(matrix); + + this.renderTarget.setResizeCursor(boxCenter, faceCenter); + } else if (pickInfo.focusType === FocusType.Corner) { + const matrix = boxDomainObject.getMatrix(); + matrix.premultiply(CDF_TO_VIEWER_TRANSFORMATION); + + const faceCenter = pickInfo.face.getCenter(); + faceCenter.applyMatrix4(matrix); + + this.renderTarget.setResizeCursor(point, faceCenter); + } else if (pickInfo.focusType === FocusType.Rotation) { + this.renderTarget.setGrabCursor(); + } else { + this.setDefaultCursor(); + } + } + + private createCreator(): BaseCreator | undefined { + switch (this.measureType) { + case MeasureType.Line: + case MeasureType.Polyline: + case MeasureType.Polygon: + return new MeasureLineCreator(this.measureType); + case MeasureType.HorizontalArea: + case MeasureType.VerticalArea: + case MeasureType.Volume: + return new MeasureBoxCreator(this.measureType); + default: + return undefined; + } + } + + protected defocusAll(except?: DomainObject | undefined): void { + for (const domainObject of getMeasureDomainObjects(this.renderTarget)) { + if (except !== undefined && domainObject === except) { + continue; + } + if (domainObject instanceof MeasureLineDomainObject) { + domainObject.setFocusInteractive(FocusType.None); + } + if (domainObject instanceof MeasureBoxDomainObject) { + domainObject.setFocusInteractive(FocusType.None); + } + } + } +} + +function initializeStyle(domainObject: DomainObject, renderTarget: RevealRenderTarget): void { + const otherDomainObject = getAnyMeasureDomainObject(renderTarget); + if (otherDomainObject === undefined) { + return; + } + const otherStyle = otherDomainObject.renderStyle; + const style = domainObject.getRenderStyle(); + if (!(style instanceof MeasureRenderStyle)) { + return; + } + style.depthTest = otherStyle.depthTest; +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/SetMeasurmentTypeCommand.ts b/react-components/src/architecture/concrete/boxDomainObject/SetMeasurmentTypeCommand.ts new file mode 100644 index 00000000000..be6e75bbd44 --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/SetMeasurmentTypeCommand.ts @@ -0,0 +1,84 @@ +/*! + * Copyright 2024 Cognite AS + * BaseTool: Base class for the tool are used to interact with the render target. + */ + +import { RenderTargetCommand } from '../../base/commands/RenderTargetCommand'; +import { type BaseCommand, type Tooltip } from '../../base/commands/BaseCommand'; +import { MeasureType, getIconByMeasureType, getTooltipByMeasureType } from './MeasureType'; +import { MeasurementTool } from './MeasurementTool'; + +export class SetMeasurmentTypeCommand extends RenderTargetCommand { + private readonly _measureType: MeasureType; + + // ================================================== + // CONSTRUCTORS + // ================================================== + + public constructor(measureType: MeasureType) { + super(); + this._measureType = measureType; + } + + // ================================================== + // OVERRIDES of BaseCommand + // ================================================== + + public override equals(other: BaseCommand): boolean { + if (!(other instanceof SetMeasurmentTypeCommand)) { + return false; + } + return this._measureType === other._measureType; + } + + public override get icon(): string { + return getIconByMeasureType(this._measureType); + } + + public override get tooltip(): Tooltip { + return getTooltipByMeasureType(this._measureType); + } + + public override get isEnabled(): boolean { + return this.measurementTool !== undefined; + } + + public override get isCheckable(): boolean { + return true; + } + + public override get isChecked(): boolean { + const { measurementTool } = this; + if (measurementTool === undefined) { + return false; + } + return measurementTool.measureType === this._measureType; + } + + protected override invokeCore(): boolean { + const { measurementTool } = this; + if (measurementTool === undefined) { + return false; + } + if (measurementTool.measureType === this._measureType) { + measurementTool.measureType = MeasureType.None; + } else { + measurementTool.measureType = this._measureType; + } + measurementTool.handleEscape(); + measurementTool.clearDragging(); + return true; + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + private get measurementTool(): MeasurementTool | undefined { + const activeTool = this.renderTarget.toolController.activeTool; + if (!(activeTool instanceof MeasurementTool)) { + return undefined; + } + return activeTool; + } +} diff --git a/react-components/src/architecture/concrete/boxDomainObject/ShowMeasurmentsOnTopCommand.ts b/react-components/src/architecture/concrete/boxDomainObject/ShowMeasurmentsOnTopCommand.ts new file mode 100644 index 00000000000..319fcc7a964 --- /dev/null +++ b/react-components/src/architecture/concrete/boxDomainObject/ShowMeasurmentsOnTopCommand.ts @@ -0,0 +1,59 @@ +/*! + * Copyright 2024 Cognite AS + * BaseTool: Base class for the tool are used to interact with the render target. + */ + +import { RenderTargetCommand } from '../../base/commands/RenderTargetCommand'; +import { type Tooltip } from '../../base/commands/BaseCommand'; +import { Changes } from '../../base/domainObjectsHelpers/Changes'; +import { getAnyMeasureDomainObject, getMeasureDomainObjects } from './MeasurementFunctions'; + +export class ShowMeasurmentsOnTopCommand extends RenderTargetCommand { + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): Tooltip { + return { key: 'MEASUREMENTS_SHOW_ON_TOP', fallback: 'Show all measurements on top' }; + } + + public override get icon(): string { + return 'EyeShow'; + } + + public override get isEnabled(): boolean { + const domainObject = getAnyMeasureDomainObject(this.renderTarget); + return domainObject !== undefined; + } + + public override get isCheckable(): boolean { + return true; + } + + public override get isChecked(): boolean { + return !this.getDepthTest(); + } + + protected override invokeCore(): boolean { + const depthTest = this.getDepthTest(); + for (const domainObject of getMeasureDomainObjects(this.renderTarget)) { + const style = domainObject.renderStyle; + style.depthTest = !depthTest; + domainObject.notify(Changes.renderStyle); + } + return true; + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + public getDepthTest(): boolean { + const domainObject = getAnyMeasureDomainObject(this.renderTarget); + if (domainObject === undefined) { + return false; + } + const style = domainObject.renderStyle; + return style.depthTest; + } +} diff --git a/react-components/src/architecture/concrete/terrainDomainObject/SetTerrainVisibleCommand.ts b/react-components/src/architecture/concrete/terrainDomainObject/SetTerrainVisibleCommand.ts new file mode 100644 index 00000000000..60e3a491190 --- /dev/null +++ b/react-components/src/architecture/concrete/terrainDomainObject/SetTerrainVisibleCommand.ts @@ -0,0 +1,47 @@ +/*! + * Copyright 2024 Cognite AS + * BaseTool: Base class for the tool are used to interact with the render target. + */ + +import { RenderTargetCommand } from '../../base/commands/RenderTargetCommand'; +import { Vector3 } from 'three'; +import { Range3 } from '../../base/utilities/geometry/Range3'; +import { createFractalRegularGrid2 } from './geometry/createFractalRegularGrid2'; +import { DEFAULT_TERRAIN_NAME, TerrainDomainObject } from './TerrainDomainObject'; +import { type Tooltip } from '../../base/commands/BaseCommand'; + +export class SetTerrainVisibleCommand extends RenderTargetCommand { + // ================================================== + // OVERRIDES + // ================================================== + + public override get icon(): string { + return 'EyeShow'; + } + + public override get tooltip(): Tooltip { + return { key: 'UNKNOWN', fallback: 'Set terrain visible. Create it if not done' }; + } + + protected override invokeCore(): boolean { + const { renderTarget } = this; + const { rootDomainObject } = renderTarget; + + let terrainDomainObject = rootDomainObject.getDescendantByTypeAndName( + TerrainDomainObject, + DEFAULT_TERRAIN_NAME + ); + if (terrainDomainObject === undefined) { + terrainDomainObject = new TerrainDomainObject(); + const range = new Range3(new Vector3(0, 0, 0), new Vector3(1000, 1000, 200)); + terrainDomainObject.grid = createFractalRegularGrid2(range); + terrainDomainObject.name = DEFAULT_TERRAIN_NAME; + + rootDomainObject.addChildInteractive(terrainDomainObject); + terrainDomainObject.setVisibleInteractive(true, renderTarget); + } else { + terrainDomainObject.toggleVisibleInteractive(renderTarget); + } + return true; + } +} diff --git a/react-components/src/architecture/concrete/terrainDomainObject/TerrainDomainObject.ts b/react-components/src/architecture/concrete/terrainDomainObject/TerrainDomainObject.ts new file mode 100644 index 00000000000..dcaad6dd14d --- /dev/null +++ b/react-components/src/architecture/concrete/terrainDomainObject/TerrainDomainObject.ts @@ -0,0 +1,79 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { VisualDomainObject } from '../../base/domainObjects/VisualDomainObject'; +import { TerrainRenderStyle } from './TerrainRenderStyle'; +import { type RegularGrid2 } from './geometry/RegularGrid2'; +import { type RenderStyle } from '../../base/domainObjectsHelpers/RenderStyle'; +import { type ThreeView } from '../../base/views/ThreeView'; +import { TerrainThreeView } from './TerrainThreeView'; + +export const DEFAULT_TERRAIN_NAME = 'Terrain'; + +export class TerrainDomainObject extends VisualDomainObject { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private _grid: RegularGrid2 | undefined = undefined; + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get grid(): RegularGrid2 | undefined { + return this._grid; + } + + public set grid(value: RegularGrid2 | undefined) { + this._grid = value; + } + + public get renderStyle(): TerrainRenderStyle | undefined { + return this.getRenderStyle() as TerrainRenderStyle; + } + + // ================================================== + // OVERRIDES of DomainObject + // ================================================== + + public override get typeName(): string { + return DEFAULT_TERRAIN_NAME; + } + + public override createRenderStyle(): RenderStyle | undefined { + return new TerrainRenderStyle(); + } + + public override verifyRenderStyle(style: RenderStyle): void { + if (!(style instanceof TerrainRenderStyle)) { + return; + } + // The rest checks if the increment is valid. To many contour lines with hang/crash the app. + const { grid } = this; + if (grid === undefined) { + return; + } + const { boundingBox } = grid; + const zRange = boundingBox.z; + if (zRange.isEmpty || !zRange.hasSpan) { + return; + } + if ( + style.increment <= 0 || // Not set + style.increment > zRange.delta || // Too large + style.increment < zRange.delta / 200 // Too small + ) { + style.increment = zRange.getBestIncrement(20); + } + } + + // ================================================== + // OVERRIDES of VisualDomainObject + // ================================================== + + protected override createThreeView(): ThreeView | undefined { + return new TerrainThreeView(); + } +} diff --git a/react-components/src/architecture/concrete/terrainDomainObject/TerrainRenderStyle.ts b/react-components/src/architecture/concrete/terrainDomainObject/TerrainRenderStyle.ts new file mode 100644 index 00000000000..81012efb3ea --- /dev/null +++ b/react-components/src/architecture/concrete/terrainDomainObject/TerrainRenderStyle.ts @@ -0,0 +1,40 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { cloneDeep } from 'lodash'; +import { ColorType } from '../../base/domainObjectsHelpers/ColorType'; +import { RenderStyle } from '../../base/domainObjectsHelpers/RenderStyle'; +import { ColorMapType } from '../../base/utilities/colors/ColorMapType'; + +export class TerrainRenderStyle extends RenderStyle { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public showContours = true; + public contoursColorType = ColorType.Black; + + public showSolid = true; + public solidColorType = ColorType.ColorMap; + public solidColorMapType = ColorMapType.Rainbow; + + public solidContourVolume = 0.5; + public solidContourUse = true; + + public solidShininess = 0.03; + public solidShininessUse = true; + + public solidOpacity = 0.5; + public solidOpacityUse = false; + + public increment = 0; + + // ================================================== + // OVERRIDES of BaseStyle + // ================================================== + + public override clone(): RenderStyle { + return cloneDeep(this); + } +} diff --git a/react-components/src/architecture/concrete/terrainDomainObject/TerrainThreeView.ts b/react-components/src/architecture/concrete/terrainDomainObject/TerrainThreeView.ts new file mode 100644 index 00000000000..f51d16972c9 --- /dev/null +++ b/react-components/src/architecture/concrete/terrainDomainObject/TerrainThreeView.ts @@ -0,0 +1,272 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { + BufferGeometry, + type DataTexture, + DoubleSide, + Float32BufferAttribute, + LineBasicMaterial, + LineSegments, + Mesh, + MeshPhongMaterial, + type Color, + type Object3D, + Box3 +} from 'three'; +import { ContouringService } from './geometry/ContouringService'; +import { type TerrainDomainObject } from './TerrainDomainObject'; +import { type DomainObjectChange } from '../../base/domainObjectsHelpers/DomainObjectChange'; +import { Changes } from '../../base/domainObjectsHelpers/Changes'; +import { RegularGrid2Buffers } from './geometry/RegularGrid2Buffers'; +import { + create1DTextureWithContours, + create1DTexture +} from '../../base/utilities/colors/create1DTexture'; +import { type TerrainRenderStyle } from './TerrainRenderStyle'; +import { ColorType } from '../../base/domainObjectsHelpers/ColorType'; +import { WHITE_COLOR } from '../../base/utilities/colors/colorExtensions'; +import { getColorMap } from '../../base/utilities/colors/colorMaps'; +import { GroupThreeView } from '../../base/views/GroupThreeView'; +import { CDF_TO_VIEWER_TRANSFORMATION } from '@cognite/reveal'; +import { type RegularGrid2 } from './geometry/RegularGrid2'; + +const SOLID_NAME = 'Solid'; +const CONTOURS_NAME = 'Contour'; + +export class TerrainThreeView extends GroupThreeView { + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + private get terrainDomainObject(): TerrainDomainObject { + return super.domainObject as TerrainDomainObject; + } + + protected override get style(): TerrainRenderStyle { + return super.style as TerrainRenderStyle; + } + + // ================================================== + // OVERRIDES of BaseView + // ================================================== + + public override update(change: DomainObjectChange): void { + super.update(change); + if (this.isEmpty) { + return; + } + if (change.isChanged(Changes.colorMap)) { + this.clearMemory(); + this.invalidateRenderTarget(); + } + if (change.isChanged(Changes.renderStyle)) { + // TODO: Need better change handling + const style = this.style; + { + const solid = this.object.getObjectByName(SOLID_NAME) as Mesh; + if (solid !== undefined && !style.showSolid) { + this.removeChild(solid); + this.invalidateRenderTarget(); + } else if (solid === undefined && style.showSolid) { + this.addChild(this.createSolid()); + this.invalidateRenderTarget(); + } else if (solid !== undefined) { + updateSolidMaterial(solid.material as MeshPhongMaterial, this.terrainDomainObject, style); + this.invalidateRenderTarget(); + } + } + { + const contours = this.object.getObjectByName(CONTOURS_NAME) as LineSegments; + if (contours !== undefined && !style.showContours) { + this.removeChild(contours); + this.invalidateRenderTarget(); + } else if (contours === undefined && style.showContours) { + this.addChild(this.createContours()); + this.invalidateRenderTarget(); + } else if (contours !== undefined) { + updateContoursMaterial( + contours.material as LineBasicMaterial, + this.terrainDomainObject, + style + ); + this.invalidateRenderTarget(); + } + } + } + } + + // ================================================== + // OVERRIDES of ThreeView + // ================================================== + + protected override calculateBoundingBox(): Box3 { + const { terrainDomainObject } = this; + const { grid } = terrainDomainObject; + if (grid === undefined) { + return new Box3().makeEmpty(); + } + const range = grid.boundingBox; + if (range.isEmpty) { + return new Box3().makeEmpty(); + } + const boundingBox = new Box3(); + boundingBox.min.set(range.x.min, range.y.min, range.z.min); + boundingBox.max.set(range.x.max, range.y.max, range.z.max); + + // Convert to viewer space + boundingBox.applyMatrix4(CDF_TO_VIEWER_TRANSFORMATION); + return boundingBox; + } + + // ================================================== + // OVERRIDES of GroupThreeView + // ================================================== + + protected override addChildren(): void { + const { terrainDomainObject } = this; + const { grid } = terrainDomainObject; + if (grid === undefined) { + return undefined; + } + this.addChild(this.createSolid()); + this.addChild(this.createContours()); + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + private createSolid(): Object3D | undefined { + const { style } = this; + if (!style.showSolid) { + return undefined; + } + const { terrainDomainObject } = this; + const { grid } = terrainDomainObject; + if (grid === undefined) { + return undefined; + } + const buffers = new RegularGrid2Buffers(grid, true); + const geometry = buffers.createBufferGeometry(); + + const material = new MeshPhongMaterial(); + updateSolidMaterial(material, terrainDomainObject, style); + + const result = new Mesh(geometry, material); + result.name = SOLID_NAME; + applyMatrix(result, grid); + return result; + } + + private createContours(): Object3D | undefined { + const { style } = this; + if (!style.showContours) { + return undefined; + } + const { terrainDomainObject } = this; + const { grid } = terrainDomainObject; + if (grid === undefined) { + return undefined; + } + const service = new ContouringService(style.increment); + const contoursBuffer = service.createContoursAsXyzArray(grid); + if (contoursBuffer.length === 0) { + return undefined; + } + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(contoursBuffer, 3)); + + const material = new LineBasicMaterial(); + updateContoursMaterial(material, terrainDomainObject, style); + + const result = new LineSegments(geometry, material); + result.name = CONTOURS_NAME; + applyMatrix(result, grid); + return result; + } +} +// ================================================== +// PRIVATE FUNCTIONS +// ================================================== + +function applyMatrix(object: Object3D, grid: RegularGrid2): void { + object.rotateZ(grid.rotationAngle); + object.position.set(grid.origin.x, grid.origin.y, 0); + object.applyMatrix4(CDF_TO_VIEWER_TRANSFORMATION); +} + +function updateSolidMaterial( + material: MeshPhongMaterial, + terrainDomainObject: TerrainDomainObject, + style: TerrainRenderStyle, + is2D: boolean = false +): void { + material.side = DoubleSide; // the terrain must be seen from both side + material.polygonOffset = style.showContours; // Because of the countours to be visible + material.polygonOffsetFactor = 1; + material.polygonOffsetUnits = 4.0; + + material.opacity = style.solidOpacityUse ? style.solidOpacity : 1; + material.transparent = style.solidOpacityUse; + if (is2D) { + material.shininess = 0; + } else { + material.shininess = style.solidShininessUse ? 100 * style.solidShininess : 0; + } + material.specular = WHITE_COLOR; + const texture = createTexture(terrainDomainObject, style); + if (texture !== undefined) { + texture.anisotropy = 2; + material.color = WHITE_COLOR; + material.map = texture; + } else { + if (material.map !== null) { + material.map.dispose(); + material.map = null; + } + material.color = terrainDomainObject.getColorByColorType(style.solidColorType); + } +} + +function updateContoursMaterial( + material: LineBasicMaterial, + terrainDomainObject: TerrainDomainObject, + style: TerrainRenderStyle +): void { + material.color = terrainDomainObject.getColorByColorType(style.contoursColorType); + material.linewidth = 1; +} + +function createTexture( + terrainDomainObject: TerrainDomainObject, + style: TerrainRenderStyle +): DataTexture | undefined { + if (style.solidColorType !== ColorType.ColorMap && !style.solidContourUse) { + return undefined; + } + const colorMap = getColorMap(style.solidColorMapType); + if (colorMap === undefined) { + return undefined; + } + let color: Color | undefined; + if (style.solidColorType === ColorType.ColorMap) { + if (!style.solidContourUse) { + return create1DTexture(colorMap); + } + } else { + color = terrainDomainObject.getColorByColorType(style.solidColorType); + } + const { grid } = terrainDomainObject; + if (grid === undefined) { + return undefined; + } + return create1DTextureWithContours( + colorMap, + grid.boundingBox.z, + style.increment, + style.solidContourVolume, + color + ); +} diff --git a/react-components/src/architecture/concrete/terrainDomainObject/UpdateTerrainCommand.ts b/react-components/src/architecture/concrete/terrainDomainObject/UpdateTerrainCommand.ts new file mode 100644 index 00000000000..d23d5eb55a3 --- /dev/null +++ b/react-components/src/architecture/concrete/terrainDomainObject/UpdateTerrainCommand.ts @@ -0,0 +1,59 @@ +/*! + * Copyright 2024 Cognite AS + * BaseTool: Base class for the tool are used to interact with the render target. + */ + +import { RenderTargetCommand } from '../../base/commands/RenderTargetCommand'; +import { Vector3 } from 'three'; +import { Range3 } from '../../base/utilities/geometry/Range3'; +import { createFractalRegularGrid2 } from './geometry/createFractalRegularGrid2'; +import { DEFAULT_TERRAIN_NAME, TerrainDomainObject } from './TerrainDomainObject'; +import { Changes } from '../../base/domainObjectsHelpers/Changes'; +import { type Tooltip } from '../../base/commands/BaseCommand'; + +export class UpdateTerrainCommand extends RenderTargetCommand { + // ================================================== + // OVERRIDES + // ================================================== + + public override get icon(): string { + return 'Refresh'; + } + + public override get tooltip(): Tooltip { + return { key: 'UNKNOWN', fallback: 'Change the visible terrain' }; + } + + public override get isEnabled(): boolean { + const { renderTarget } = this; + const { rootDomainObject } = renderTarget; + const terrainDomainObject = rootDomainObject.getDescendantByTypeAndName( + TerrainDomainObject, + DEFAULT_TERRAIN_NAME + ); + if (terrainDomainObject === undefined) { + return false; + } + return terrainDomainObject.isVisible(renderTarget); + } + + protected override invokeCore(): boolean { + const { renderTarget } = this; + const { rootDomainObject } = renderTarget; + + const terrainDomainObject = rootDomainObject.getDescendantByTypeAndName( + TerrainDomainObject, + DEFAULT_TERRAIN_NAME + ); + if (terrainDomainObject === undefined) { + return false; + } + if (!terrainDomainObject.isVisible(renderTarget)) { + return false; + } + const range = new Range3(new Vector3(0, 0, 0), new Vector3(1000, 1000, 200)); + terrainDomainObject.grid = createFractalRegularGrid2(range); + terrainDomainObject.notify(Changes.geometry); + return true; + } +} diff --git a/react-components/src/architecture/concrete/terrainDomainObject/geometry/ContouringService.ts b/react-components/src/architecture/concrete/terrainDomainObject/geometry/ContouringService.ts new file mode 100644 index 00000000000..fe7c7ece6b9 --- /dev/null +++ b/react-components/src/architecture/concrete/terrainDomainObject/geometry/ContouringService.ts @@ -0,0 +1,176 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { Vector3 } from 'three'; +import { Range1 } from '../../../base/utilities/geometry/Range1'; +import { type RegularGrid2 } from './RegularGrid2'; +import { isAbsEqual, isBetween, max, min } from '../../../base/utilities/extensions/mathExtensions'; + +export class ContouringService { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + private readonly _tempRange = new Range1(); + private readonly _increment: number; + private readonly _tolerance: number; + private readonly _positions: number[] = []; + + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor(increment: number) { + this._increment = increment; + this._tolerance = this._increment / 1000; + } + + // ================================================== + // INSTANCE METHODS: Create functions + // ================================================== + + public createContoursAsXyzArray(grid: RegularGrid2): number[] { + const p0 = new Vector3(); + const p1 = new Vector3(); + const p2 = new Vector3(); + const p3 = new Vector3(); + + for (let i = 0; i < grid.nodeSize.i - 1; i++) { + for (let j = 0; j < grid.nodeSize.j - 1; j++) { + const isDef0 = grid.getRelativeNodePosition(i + 0, j + 0, p0); + const isDef1 = grid.getRelativeNodePosition(i + 1, j + 0, p1); + const isDef2 = grid.getRelativeNodePosition(i + 1, j + 1, p2); + const isDef3 = grid.getRelativeNodePosition(i + 0, j + 1, p3); + + let triangleCount = 0; + if (isDef0) triangleCount += 1; + if (isDef2) triangleCount += 1; + if (isDef2) triangleCount += 1; + if (isDef3) triangleCount += 1; + + if (triangleCount < 3) { + continue; + } + // (i,j+1) (i+1,j+1) + // 3------2 + // | | + // 0------1 + // (i,j) (i+1,j) + + if (!isDef0) { + this.addTriangle(p1, p2, p3); + } + if (triangleCount === 4 || !isDef1) { + this.addTriangle(p0, p2, p3); + } + if (!isDef2) { + this.addTriangle(p0, p1, p3); + } + if (triangleCount === 4 || !isDef3) { + this.addTriangle(p0, p1, p2); + } + } + } + return this._positions; + } + + // ================================================== + // INSTANCE METHODS: Helpers + // ================================================== + + private addTriangle(a: Vector3, b: Vector3, c: Vector3): void { + this._tempRange.set(min(a.z, b.z, c.z), max(a.z, b.z, c.z)); + + for (const anyTick of this._tempRange.getFastTicks(this._increment, this._tolerance)) { + const z = Number(anyTick); + this.addLevelAt(z, a, b, c); + } + } + + private addLevelAt(z: number, a: Vector3, b: Vector3, c: Vector3): boolean { + // Make sure we don't run into numerical problems + if (isAbsEqual(a.z, z, this._tolerance)) a.z = z + this._tolerance; + if (isAbsEqual(b.z, z, this._tolerance)) b.z = z + this._tolerance; + if (isAbsEqual(c.z, z, this._tolerance)) c.z = z + this._tolerance; + if (isAbsEqual(a.z, b.z, this._tolerance)) b.z = a.z + this._tolerance; + + // Special cases, check exact intersection on the corner or along the edges + if (a.z === z) { + if (isBetween(b.z, z, c.z)) { + this.add(a); + this.addLinearInterpolation(b, c, z); + return true; + } + if (b.z === z && c.z !== z) { + this.add(a); + this.add(b); + return true; + } + if (c.z === z && b.z !== z) { + this.add(c); + this.add(a); + return true; + } + } + if (b.z === z) { + if (isBetween(c.z, z, a.z)) { + this.add(b); + this.addLinearInterpolation(c, a, z); + return true; + } + if (c.z === z && a.z !== z) { + this.add(b); + this.add(c); + return true; + } + } + if (c.z === z && isBetween(a.z, z, b.z)) { + this.add(c); + this.addLinearInterpolation(a, b, z); + return true; + } + // Intersection of two of the edges + let numPoints = 0; + if (isBetween(a.z, z, b.z)) { + this.addLinearInterpolation(a, b, z); + numPoints += 1; + } + if (isBetween(b.z, z, c.z)) { + if (numPoints === 0) this.addLinearInterpolation(b, c, z); + else this.addLinearInterpolation(b, c, z); + numPoints += 1; + } + if (numPoints < 2 && isBetween(c.z, z, a.z)) { + if (numPoints === 0) this.addLinearInterpolation(c, a, z); + else this.addLinearInterpolation(c, a, z); + numPoints += 1; + } + if (numPoints === 2) { + return true; + } + if (numPoints === 1) { + // Remove the last added + this._positions.pop(); + this._positions.pop(); + this._positions.pop(); + } + return false; + } + + private add(position: Vector3): void { + this.addXyz(position.y, position.y, position.z); + } + + private addLinearInterpolation(a: Vector3, b: Vector3, z: number): void { + // Z is assumed to be on or between a.Z and b.Z, used by the function below + // a.Z and b.Z is assumed to be different (Check by yourself) + // Returns a + (b-a)*(z-a.Z)/(b.Z-a.Z); (unrolled code) + const f = (z - a.z) / (b.z - a.z); + this.addXyz((b.x - a.x) * f + a.x, (b.y - a.y) * f + a.y, z); + } + + private addXyz(x: number, y: number, z: number): void { + this._positions.push(x, y, z); + } +} diff --git a/react-components/src/architecture/concrete/terrainDomainObject/geometry/Grid2.ts b/react-components/src/architecture/concrete/terrainDomainObject/geometry/Grid2.ts new file mode 100644 index 00000000000..3392fe91945 --- /dev/null +++ b/react-components/src/architecture/concrete/terrainDomainObject/geometry/Grid2.ts @@ -0,0 +1,62 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type Index2 } from '../../../base/utilities/geometry/Index2'; +import { type Range3 } from '../../../base/utilities/geometry/Range3'; +import { Shape } from '../../../base/utilities/geometry/Shape'; + +export class Grid2 extends Shape { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public readonly nodeSize: Index2; + public readonly cellSize: Index2; + + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor(nodeSize: Index2) { + super(); + this.nodeSize = nodeSize.clone(); + this.cellSize = nodeSize.clone(); + this.cellSize.i -= 1; + this.cellSize.j -= 1; + } + + // ================================================== + // OVERRIDES of Shape: + // ================================================== + + public override clone(): Shape { + return new Grid2(this.nodeSize); + } + + public override expandBoundingBox(_: Range3): void {} + + // ================================================== + // INSTANCE METHODS: Getters + // ================================================== + + public getNodeIndex(i: number, j: number): number { + return i + this.nodeSize.i * j; + } + + public getCellIndex(i: number, j: number): number { + return i + this.cellSize.i * j; + } + + // ================================================== + // INSTANCE METHODS: Requests + // ================================================== + + public isNodeInside(i: number, j: number): boolean { + return i >= 0 && j >= 0 && i < this.nodeSize.i && j < this.nodeSize.j; + } + + public isCellInside(i: number, j: number): boolean { + return i >= 0 && j >= 0 && i < this.cellSize.i && j < this.cellSize.j; + } +} diff --git a/react-components/src/architecture/concrete/terrainDomainObject/geometry/RegularGrid2.ts b/react-components/src/architecture/concrete/terrainDomainObject/geometry/RegularGrid2.ts new file mode 100644 index 00000000000..513b280c5bd --- /dev/null +++ b/react-components/src/architecture/concrete/terrainDomainObject/geometry/RegularGrid2.ts @@ -0,0 +1,358 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { type Vector2, Vector3 } from 'three'; +import { type Range1 } from '../../../base/utilities/geometry/Range1'; +import { Range3 } from '../../../base/utilities/geometry/Range3'; +import { Grid2 } from './Grid2'; + +import cloneDeep from 'lodash/cloneDeep'; +import { type Shape } from '../../../base/utilities/geometry/Shape'; +import { Index2 } from '../../../base/utilities/geometry/Index2'; + +export class RegularGrid2 extends Grid2 { + // ================================================== + // INSTANCE FIELDS + // ================================================== + + public readonly origin: Vector2; + public readonly increment: Vector2; + + private _buffer: Float32Array; // NaN value in this array means undefined node + private _hasRotationAngle = false; + private _rotationAngle = 0; + private _sinRotationAngle = 0; // Due to speed + private _cosRotationAngle = 1; // Due to speed + static _tempVectorA = new Vector3(); + static _tempVectorB = new Vector3(); + + // ================================================== + // INSTANCE PROPERTIES + // ================================================== + + public get rotationAngle(): number { + return this._rotationAngle; + } + + public set rotationAngle(value: number) { + this._hasRotationAngle = value !== 0; + if (this._hasRotationAngle) { + this._rotationAngle = value; + this._sinRotationAngle = Math.sin(this._rotationAngle); + this._cosRotationAngle = Math.cos(this._rotationAngle); + } else { + this._rotationAngle = 0; + this._sinRotationAngle = 0; + this._cosRotationAngle = 1; + } + } + + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor( + nodeSize: Index2, + origin: Vector2, + increment: Vector2, + rotationAngle: number | undefined = undefined + ) { + super(nodeSize); + this.origin = origin; + this.increment = increment; + if (rotationAngle !== undefined) { + this.rotationAngle = rotationAngle; + } + this._buffer = new Float32Array(nodeSize.size); + } + + // ================================================== + // OVERRIDES of object + // ================================================== + + public override toString(): string { + return `nodeSize: (${this.nodeSize.toString()}) origin: (${this.origin.x}, ${this.origin.y}) inc: (${this.increment.x}, ${this.increment.y})`; + } + + // ================================================== + // OVERRIDES of Shape + // ================================================== + + public override clone(): Shape { + return cloneDeep(this); + } + + public override expandBoundingBox(boundingBox: Range3): void { + const position = new Vector3(); + for (let j = this.nodeSize.j - 1; j >= 0; j--) { + for (let i = this.nodeSize.i - 1; i >= 0; i--) { + if (this.getNodePosition(i, j, position)) { + boundingBox.add(position); + } + } + } + } + + // ================================================== + // INSTANCE METHODS: Requests + // ================================================== + + public isNodeDef(i: number, j: number): boolean { + return !Number.isNaN(this.getZ(i, j)); + } + + public isNodeInsideDef(i: number, j: number): boolean { + return this.isNodeInside(i, j) && this.isNodeDef(i, j); + } + + // ================================================== + // INSTANCE METHODS: Getters + // ================================================== + + public getZ(i: number, j: number): number { + const index = this.getNodeIndex(i, j); + return this._buffer[index]; + } + + // ================================================== + // INSTANCE METHODS: Getters: Node position + // ================================================== + + public getNodePosition(i: number, j: number, resultPosition?: Vector3): boolean { + if (resultPosition === undefined) { + resultPosition = new Vector3(); + } + const z = this.getZ(i, j); + if (Number.isNaN(z)) { + return false; + } + if (this._hasRotationAngle) { + const dx = this.increment.x * i; + const dy = this.increment.y * j; + resultPosition.x = dx * this._cosRotationAngle - dy * this._sinRotationAngle; + resultPosition.y = dx * this._sinRotationAngle + dy * this._cosRotationAngle; + } else { + resultPosition.x = this.increment.x * i; + resultPosition.y = this.increment.y * j; + } + resultPosition.x += this.origin.x; + resultPosition.y += this.origin.y; + resultPosition.z = z; + return true; + } + + public getNodePosition2(i: number, j: number, resultPosition: Vector3): void { + if (this._hasRotationAngle) { + const dx = this.increment.x * i; + const dy = this.increment.y * j; + resultPosition.x = dx * this._cosRotationAngle - dy * this._sinRotationAngle; + resultPosition.y = dx * this._sinRotationAngle + dy * this._cosRotationAngle; + } else { + resultPosition.x = this.increment.x * i; + resultPosition.y = this.increment.y * j; + } + resultPosition.x += this.origin.x; + resultPosition.y += this.origin.y; + } + + public getRelativeNodePosition(i: number, j: number, resultPosition: Vector3): boolean { + const z = this.getZ(i, j); + if (Number.isNaN(z)) return false; + + resultPosition.x = this.increment.x * i; + resultPosition.y = this.increment.y * j; + resultPosition.z = z; + return true; + } + + // ================================================== + // INSTANCE METHODS: Getters: Cell position + // ================================================== + + public getCellFromPosition(position: Vector3, resultCell?: Index2): Index2 { + if (resultCell === undefined) { + resultCell = new Index2(); + } + const dx = position.x - this.origin.x; + const dy = position.y - this.origin.y; + + let i; + let j: number; + if (this._hasRotationAngle) { + const x = dx * this._cosRotationAngle + dy * this._sinRotationAngle; + const y = -dx * this._sinRotationAngle + dy * this._cosRotationAngle; + i = x / this.increment.x; + j = y / this.increment.y; + } else { + i = dx / this.increment.x; + j = dy / this.increment.y; + } + resultCell.i = Math.floor(i); + resultCell.j = Math.floor(j); + return resultCell; + } + + // ================================================== + // INSTANCE METHODS: Getters: Others + // ================================================== + + public getNormal( + i: number, + j: number, + z: number | undefined, + normalize: boolean, + resultNormal?: Vector3 + ): Vector3 { + if (resultNormal === undefined) { + resultNormal = new Vector3(); + } + if (z === undefined) { + z = this.getZ(i, j); + } + resultNormal.set(0, 0, 0); + + let def0 = this.isNodeInside(i + 1, j + 0); + let def1 = this.isNodeInside(i + 0, j + 1); + let def2 = this.isNodeInside(i - 1, j + 0); + let def3 = this.isNodeInside(i + 0, j - 1); + + const i0 = def0 ? this.getNodeIndex(i + 1, j + 0) : -1; + const i1 = def1 ? this.getNodeIndex(i + 0, j + 1) : -1; + const i2 = def2 ? this.getNodeIndex(i - 1, j + 0) : -1; + const i3 = def3 ? this.getNodeIndex(i + 0, j - 1) : -1; + + let z0 = def0 ? this._buffer[i0] : 0; + let z1 = def1 ? this._buffer[i1] : 0; + let z2 = def2 ? this._buffer[i2] : 0; + let z3 = def3 ? this._buffer[i3] : 0; + + if (def0) { + if (Number.isNaN(z0)) def0 = false; + else z0 -= z; + } + if (def1) { + if (Number.isNaN(z1)) def1 = false; + else z1 -= z; + } + if (def2) { + if (Number.isNaN(z2)) def2 = false; + else z2 -= z; + } + if (def3) { + if (Number.isNaN(z3)) def3 = false; + else z3 -= z; + } + + const a = RegularGrid2._tempVectorA; + const b = RegularGrid2._tempVectorB; + + if (def0 && def1) { + a.set(+this.increment.x, 0, z0); + b.set(0, +this.increment.y, z1); + a.cross(b); + resultNormal.add(a); + } + if (def1 && def2) { + a.set(0, +this.increment.y, z1); + b.set(-this.increment.x, 0, z2); + a.cross(b); + resultNormal.add(a); + } + if (def2 && def3) { + a.set(-this.increment.x, 0, z2); + b.set(0, -this.increment.y, z3); + a.cross(b); + resultNormal.add(a); + } + if (def3 && def0) { + a.set(0, -this.increment.y, z3); + b.set(+this.increment.x, 0, z0); + a.cross(b); + resultNormal.add(a); + } + if (normalize) { + resultNormal.normalize(); + if (resultNormal.lengthSq() < 0.5) { + resultNormal.set(0, 0, 1); // If the normal is too small, we assume it is a flat surface + } + } + return resultNormal; + } + + public getCornerRange(): Range3 { + const corner = new Vector3(); + const range = new Range3(); + range.add2(this.origin); + this.getNodePosition2(0, this.nodeSize.j - 1, corner); + range.add(corner); + this.getNodePosition2(this.nodeSize.i - 1, 0, corner); + range.add(corner); + this.getNodePosition2(this.nodeSize.i - 1, this.nodeSize.j - 1, corner); + range.add(corner); + return range; + } + + // ================================================== + // INSTANCE METHODS: Setters + // ================================================== + + public setNodeUndef(i: number, j: number): void { + this.setZ(i, j, Number.NaN); + } + + public setZ(i: number, j: number, value: number): void { + const index = this.getNodeIndex(i, j); + this._buffer[index] = value; + } + + // ================================================== + // INSTANCE METHODS: Operation + // ================================================== + + public normalizeZ(wantedRange?: Range1): void { + const currentRange = this.zRange; + for (let i = this._buffer.length - 1; i >= 0; i--) { + let z = this._buffer[i]; + z = currentRange.getFraction(z); + if (wantedRange !== undefined) z = wantedRange.getValue(z); + this._buffer[i] = z; + } + this.touch(); + } + + public smoothSimple(numberOfPasses: number = 1): void { + if (numberOfPasses <= 0) return; + let buffer = new Float32Array(this.nodeSize.size); + for (let pass = 0; pass < numberOfPasses; pass++) { + for (let i = this.nodeSize.i - 1; i >= 0; i--) + for (let j = this.nodeSize.j - 1; j >= 0; j--) { + if (!this.isNodeDef(i, j)) continue; + + const iMin = Math.max(i - 1, 0); + const iMax = Math.min(i + 1, this.cellSize.i); + const jMin = Math.max(j - 1, 0); + const jMax = Math.min(j + 1, this.cellSize.j); + + let count = 0; + let sum = 0; + + // New value = (Sum the surrunding values + 2 * Current value) / N + for (let ii = iMin; ii <= iMax; ii++) + for (let jj = jMin; jj <= jMax; jj++) { + if (ii === i && jj === j) continue; + + if (!this.isNodeDef(ii, jj)) continue; + + sum += this.getZ(ii, jj); + count += 1; + } + sum += this.getZ(i, j) * count; + count += count; + const index = this.getNodeIndex(i, j); + buffer[index] = sum / count; + } + [this._buffer, buffer] = [buffer, this._buffer]; // Swap buffers + } + this.touch(); + } +} diff --git a/react-components/src/architecture/concrete/terrainDomainObject/geometry/RegularGrid2Buffers.ts b/react-components/src/architecture/concrete/terrainDomainObject/geometry/RegularGrid2Buffers.ts new file mode 100644 index 00000000000..0a2c1717821 --- /dev/null +++ b/react-components/src/architecture/concrete/terrainDomainObject/geometry/RegularGrid2Buffers.ts @@ -0,0 +1,111 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { Vector3 } from 'three'; +import { type RegularGrid2 } from './RegularGrid2'; +import { TrianglesBuffers } from '../../../base/utilities/geometry/TrianglesBuffers'; + +export class RegularGrid2Buffers extends TrianglesBuffers { + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor(grid: RegularGrid2, makeUvs: boolean) { + const [uniqueIndexes, numUniqueIndex] = RegularGrid2Buffers.createUniqueIndexes(grid); + super(numUniqueIndex, true, makeUvs); + this.makeBuffers(grid, uniqueIndexes); + this.makeTriangleIndexes(grid, uniqueIndexes); + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + makeBuffers(grid: RegularGrid2, uniqueIndexes: number[]): void { + // Generate the position, normal and uvs + const { zRange } = grid; + const position = new Vector3(); + const normal = new Vector3(); + + for (let j = grid.nodeSize.j - 1; j >= 0; j--) { + for (let i = grid.nodeSize.i - 1; i >= 0; i--) { + const nodeIndex = grid.getNodeIndex(i, j); + const uniqueIndex = uniqueIndexes[nodeIndex]; + if (uniqueIndex < 0) { + continue; + } + if (!grid.getRelativeNodePosition(i, j, position)) { + continue; + } + grid.getNormal(i, j, position.z, true, normal); + + const u = zRange.getFraction(position.z); + this.setAt(uniqueIndex, position, normal, u); + } + } + } + + private makeTriangleIndexes(grid: RegularGrid2, uniqueIndexes: number[]): void { + // Generate the triangle indices + // Should be strip, but could not get it to work + for (let i = 0; i < grid.nodeSize.i - 1; i++) { + for (let j = 0; j < grid.nodeSize.j - 1; j++) { + const nodeIndex0 = grid.getNodeIndex(i, j); + const nodeIndex1 = grid.getNodeIndex(i + 1, j); + const nodeIndex2 = grid.getNodeIndex(i + 1, j + 1); + const nodeIndex3 = grid.getNodeIndex(i, j + 1); + + const unique0 = uniqueIndexes[nodeIndex0]; + const unique1 = uniqueIndexes[nodeIndex1]; + const unique2 = uniqueIndexes[nodeIndex2]; + const unique3 = uniqueIndexes[nodeIndex3]; + + let triangleCount = 0; + if (unique0 >= 0) triangleCount += 1; + if (unique1 >= 0) triangleCount += 1; + if (unique2 >= 0) triangleCount += 1; + if (unique3 >= 0) triangleCount += 1; + + if (triangleCount < 3) { + continue; + } + // (i,j+1) (i+1,j+1) + // 3------2 + // | | + // 0------1 + // (i,j) (i+1,j) + + if (unique0 < 0) { + this.addTriangle(unique1, unique2, unique3); + } + if (triangleCount === 4 || unique1 < 0) { + this.addTriangle(unique0, unique2, unique3); + } + if (unique2 < 0) { + this.addTriangle(unique0, unique1, unique3); + } + if (triangleCount === 4 || unique3 < 0) { + this.addTriangle(unique0, unique1, unique2); + } + } + } + } + + private static createUniqueIndexes(grid: RegularGrid2): [number[], number] { + const uniqueIndexes = new Array(grid.nodeSize.size); + let numUniqueIndex = 0; + for (let j = grid.nodeSize.j - 1; j >= 0; j--) { + for (let i = grid.nodeSize.i - 1; i >= 0; i--) { + const nodeIndex = grid.getNodeIndex(i, j); + if (!grid.isNodeDef(i, j)) { + uniqueIndexes[nodeIndex] = -1; + continue; + } + uniqueIndexes[nodeIndex] = numUniqueIndex; + numUniqueIndex += 1; + } + } + return [uniqueIndexes, numUniqueIndex]; + } +} diff --git a/react-components/src/architecture/concrete/terrainDomainObject/geometry/createFractalRegularGrid2.ts b/react-components/src/architecture/concrete/terrainDomainObject/geometry/createFractalRegularGrid2.ts new file mode 100644 index 00000000000..4de57908fa7 --- /dev/null +++ b/react-components/src/architecture/concrete/terrainDomainObject/geometry/createFractalRegularGrid2.ts @@ -0,0 +1,109 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { Vector2 } from 'three'; +import { type Range3 } from '../../../base/utilities/geometry/Range3'; +import { Index2 } from '../../../base/utilities/geometry/Index2'; +import { getRandomGaussian } from '../../../base/utilities/extensions/mathExtensions'; +import { RegularGrid2 } from './RegularGrid2'; + +export function createFractalRegularGrid2( + boundingBox: Range3, + powerOf2: number = 8, + dampning: number = 0.7, + smoothNumberOfPasses: number = 2, + rotationAngle: number = 0 +): RegularGrid2 { + const origin = new Vector2(); + const increment = new Vector2(1, 1); + const nodeSize = new Index2(2 ** powerOf2 + 1); + const stdDev = 1; + const grid = new RegularGrid2(nodeSize, origin, increment, rotationAngle); + + const i0 = 0; + const j0 = 0; + const i1 = grid.cellSize.i; + const j1 = grid.cellSize.j; + + grid.setZ(i0, j0, getRandomGaussian(0, stdDev)); + grid.setZ(i1, j0, getRandomGaussian(0, stdDev)); + grid.setZ(i0, j1, getRandomGaussian(0, stdDev)); + grid.setZ(i1, j1, getRandomGaussian(0, stdDev)); + + subDivide(grid, i0, j0, i1, j1, stdDev, powerOf2, dampning); + + grid.origin.x = boundingBox.x.min; + grid.origin.y = boundingBox.y.min; + grid.increment.x = boundingBox.x.delta / grid.cellSize.i; + grid.increment.y = boundingBox.y.delta / grid.cellSize.j; + + grid.normalizeZ(boundingBox.z); + grid.smoothSimple(smoothNumberOfPasses); + return grid; +} + +// ================================================== +// PRIVATE FUNCTIONS: Helpers +// ================================================== + +function setValueBetween( + grid: RegularGrid2, + i0: number, + j0: number, + i2: number, + j2: number, + stdDev: number, + zMean?: number +): number { + const i1 = Math.trunc((i0 + i2) / 2); + const j1 = Math.trunc((j0 + j2) / 2); + + const oldZ = grid.getZ(i1, j1); + if (oldZ !== 0) { + return oldZ; // Assume already calculated (little bit dirty...) + } + if (zMean === undefined) { + zMean = (grid.getZ(i0, j0) + grid.getZ(i2, j2)) / 2; + } + const newZ = getRandomGaussian(zMean, stdDev); + grid.setZ(i1, j1, newZ); + return newZ; +} + +function subDivide( + grid: RegularGrid2, + i0: number, + j0: number, + i2: number, + j2: number, + stdDev: number, + level: number, + dampning: number +): void { + if (i2 - i0 <= 1 && j2 - j0 <= 1) { + return; // Nothing more to update + } + if (i2 - i0 !== j2 - j0) { + throw Error('Logical bug, the grid should be a square'); + } + stdDev *= dampning; + let z = 0; + z += setValueBetween(grid, i0, j0, i2, j0, stdDev); + z += setValueBetween(grid, i0, j2, i2, j2, stdDev); + z += setValueBetween(grid, i0, j0, i0, j2, stdDev); + z += setValueBetween(grid, i2, j0, i2, j2, stdDev); + + setValueBetween(grid, i0, j0, i2, j2, stdDev, z / 4); + + level -= 1; + if (level === 0) { + return; + } + const i1 = Math.trunc((i0 + i2) / 2); + const j1 = Math.trunc((j0 + j2) / 2); + + subDivide(grid, i0, j0, i1, j1, stdDev, level, dampning); + subDivide(grid, i0, j1, i1, j2, stdDev, level, dampning); + subDivide(grid, i1, j0, i2, j1, stdDev, level, dampning); + subDivide(grid, i1, j1, i2, j2, stdDev, level, dampning); +} diff --git a/react-components/src/components/Architecture/ActiveToolToolbar.tsx b/react-components/src/components/Architecture/ActiveToolToolbar.tsx new file mode 100644 index 00000000000..954ae760eef --- /dev/null +++ b/react-components/src/components/Architecture/ActiveToolToolbar.tsx @@ -0,0 +1,62 @@ +/*! + * Copyright 2024 Cognite AS + */ +import { Divider, ToolBar } from '@cognite/cogs.js'; +import styled from 'styled-components'; +import { useState, type ReactElement } from 'react'; +import { withSuppressRevealEvents } from '../../higher-order-components/withSuppressRevealEvents'; +import { CommandButton } from './CommandButton'; +import { type BaseCommand } from '../../architecture/base/commands/BaseCommand'; +import { useRenderTarget } from '../RevealCanvas/ViewerContext'; +import { ActiveToolUpdater } from '../../architecture/base/reactUpdaters/ActiveToolUpdater'; + +export const ActiveToolToolbar = (): ReactElement => { + const [_activeToolUpdater, setActiveToolUpdater] = useState(0); + ActiveToolUpdater.setCounterDelegate(setActiveToolUpdater); + + const renderTarget = useRenderTarget(); + if (renderTarget === undefined) { + return <>; + } + const activeTool = renderTarget.toolController.activeTool; + if (activeTool === undefined) { + return <>; + } + const commands = activeTool.getToolbar(); + if (commands === undefined || commands.length === 0) { + return <>; + } + const style = activeTool.getToolbarStyle(); + return ( + + + <>{commands.map((command, index): ReactElement => addCommand(command, index))} + + + ); +}; + +function addCommand(command: BaseCommand | undefined, index: number): ReactElement { + if (command === undefined) { + return ; + } + return ; +} + +const Container = styled.div` + zindex: 1000px; + position: absolute; + display: block; +`; + +const MyCustomToolbar = styled(withSuppressRevealEvents(ToolBar))` + flex-direction: row; +`; diff --git a/react-components/src/components/Architecture/CommandButton.tsx b/react-components/src/components/Architecture/CommandButton.tsx new file mode 100644 index 00000000000..f5a32649a66 --- /dev/null +++ b/react-components/src/components/Architecture/CommandButton.tsx @@ -0,0 +1,72 @@ +/*! + * Copyright 2023 Cognite AS + */ + +import { type ReactElement, useState, useEffect } from 'react'; +import { useRenderTarget } from '../RevealCanvas/ViewerContext'; +import { Button, Tooltip as CogsTooltip, type IconType } from '@cognite/cogs.js'; +import { useTranslation } from '../i18n/I18n'; +import { type BaseCommand } from '../../architecture/base/commands/BaseCommand'; +import { type RevealRenderTarget } from '../../architecture/base/renderTarget/RevealRenderTarget'; +import { RenderTargetCommand } from '../../architecture/base/commands/RenderTargetCommand'; + +export const CreateButton = (command: BaseCommand): ReactElement => { + return ; +}; + +export const CommandButton = ({ command }: { command: BaseCommand }): ReactElement => { + const renderTarget = useRenderTarget(); + const { t } = useTranslation(); + const [newCommand] = useState(getDefaultCommand(command, renderTarget)); + + // These are redundant, but react fore me to add these to update + const [isChecked, setChecked] = useState(false); + const [isEnabled, setEnabled] = useState(true); + const [isVisible, setVisible] = useState(true); + + useEffect(() => { + function update(command: BaseCommand): void { + setChecked(command.isChecked); + setEnabled(command.isEnabled); + setVisible(command.isVisible); + } + update(newCommand); + newCommand.addEventListener(update); + return () => { + newCommand.removeEventListener(update); + }; + }, [newCommand]); + + if (!isVisible) { + return <>; + } + const { key, fallback } = newCommand.tooltip; + return ( + + + + ); +}; + +const exampleHighQualitySettings: QualitySettings = { + cadBudget: { + maximumRenderCost: 95000000, + highDetailProximityThreshold: 100 + }, + pointCloudBudget: { + numberOfPoints: 12000000 + }, + resolutionOptions: { + maxRenderResolution: Infinity, + movingCameraResolutionFactor: 1 + } +}; + +const exampleLowQualitySettings: QualitySettings = { + cadBudget: { + maximumRenderCost: 10_000_000, + highDetailProximityThreshold: 100 + }, + pointCloudBudget: { + numberOfPoints: 2_000_000 + }, + resolutionOptions: { + maxRenderResolution: 1e5, + movingCameraResolutionFactor: 1 + } +}; + +export const Main: Story = { + args: { + addModelOptions: getAddModelOptionsFromUrl('/primitives') + }, + render: ({ addModelOptions }) => { + return ( + + + + + + <> + + + + <> + + + + <> + + + + <> + + + + + + + + ); + } +}; + +function FitToUrlCameraState(): ReactElement { + const getCameraState = useGetCameraStateFromUrlParam(); + const cameraNavigation = useCameraNavigation(); + + useEffect(() => { + signalStoryReadyForScreenshot(); + const currentCameraState = getCameraState(); + if (currentCameraState === undefined) return; + cameraNavigation.fitCameraToState(currentCameraState); + }, []); + + return <>; +} diff --git a/react-components/stories/utilities/RevealStoryContainer.tsx b/react-components/stories/utilities/RevealStoryContainer.tsx index 43ec9b4c98e..ccf79985118 100644 --- a/react-components/stories/utilities/RevealStoryContainer.tsx +++ b/react-components/stories/utilities/RevealStoryContainer.tsx @@ -16,7 +16,7 @@ import { } from '../../src/components/RevealContext/RevealContext'; import { type Image360AnnotationCache } from '../../src/components/CacheProvider/Image360AnnotationCache'; import { type SceneIdentifiers } from '../../src/components/SceneContainer/sceneTypes'; -import { RevealRenderTarget } from '../../src/architecture/RenderTarget/RevealRenderTarget'; +import { RevealRenderTarget } from '../../src/architecture/base/renderTarget/RevealRenderTarget'; type RevealStoryContainerProps = Omit & { sdk?: CogniteClient; @@ -38,20 +38,23 @@ export const RevealStoryContext = ({ const isLocal = sdkInstance.project === ''; - let renderTarget: RevealRenderTarget | undefined; - if (viewer !== undefined) { - renderTarget = new RevealRenderTarget(viewer); - } else if (isLocal) { - const newViewer = new Cognite3DViewer({ - ...rest.viewerOptions, - sdk: sdkInstance, - // @ts-expect-error use local models - _localModels: true, - haveEventListeners: false - }); - renderTarget = new RevealRenderTarget(newViewer); - renderTarget.initialize(); - } + const renderTarget = useMemo(() => { + if (viewer !== undefined) { + return new RevealRenderTarget(viewer); + } else if (isLocal) { + const newViewer = new Cognite3DViewer({ + ...rest.viewerOptions, + sdk: sdkInstance, + // @ts-expect-error use local models + _localModels: true, + hasEventListeners: false, + useFlexibleCameraManager: true + }); + const renderTarget = new RevealRenderTarget(newViewer); + renderTarget.initialize(); + return renderTarget; + } + }, [viewer]); const renderTargetRef = useRef(renderTarget); const isRevealContainerMountedRef = useRef(true); diff --git a/react-components/tests/unit-tests/components/RevealContainer.test.tsx b/react-components/tests/unit-tests/components/RevealContainer.test.tsx index 1851feb5703..928f7d3dc85 100644 --- a/react-components/tests/unit-tests/components/RevealContainer.test.tsx +++ b/react-components/tests/unit-tests/components/RevealContainer.test.tsx @@ -10,7 +10,7 @@ import { type AssetMappingCache } from '../../../src/components/CacheProvider/As import { type PointCloudAnnotationCache } from '../../../src/components/CacheProvider/PointCloudAnnotationCache'; import { type Image360AnnotationCache } from '../../../src/components/CacheProvider/Image360AnnotationCache'; import { type SceneIdentifiers } from '../../../src/components/SceneContainer/sceneTypes'; -import { type RevealRenderTarget } from '../../../src/architecture/RenderTarget/RevealRenderTarget'; +import { type RevealRenderTarget } from '../../../src/architecture/base/renderTarget/RevealRenderTarget'; import { Cognite3DViewer } from '@cognite/reveal'; describe(RevealCanvas.name, () => { diff --git a/react-components/tests/visual-tests/__screenshots__/CadModelContainer.stories.spec.ts/cad-model-container-storybook-1.png b/react-components/tests/visual-tests/__screenshots__/CadModelContainer.stories.spec.ts/cad-model-container-storybook-1.png index 6c3f3a3cafaf2222770312f4eb533f0d515e4e29..4678e21a5edf8b59b26015734dab736697a86912 100644 GIT binary patch literal 120938 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

fQMzad7LpDMiJ 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%0AwG_CC!d*#}VrS8W+F}CgSV2lrmY< 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 E3_vp$b%rW+BsEPR^r4ik-= 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ᴹQ)$-8{hK%dHmWkqB@81cy;wO{{i{&l71x4nxu~JG z(6iOZtXW;{OAE+3=a@S?PWqmCFi8t!i;k3hKiU6MPfA{#bhg<3&Yyq%6kS>(Y+RnoXmwPOVrwqfFaB}CLN-O@E#qAU zC5|}>NE9Q6Rz{9)UoNA0?2n6&i5yiMmaEn;S)K%nEYSi;)}_s|R7aY}(RgMP_Sqbh zQ{KcC%4!1_N-{y)LL1c1n4nMDj4?jnCSZj{L8#BC-=M##|B%&ill%JxD$0bdXq4u= z;YM1u@4sm@p8HISs>XNF|bxKy;N9 z89WISU8~gkPk%ySzw1y%4E-m9Dv;-y$F{34?8J;!E5Lu7dRAA3DL6|sxU)pweKHM} z?H6a?O(40p?Z@mU+gI5r9w?p~X}KfHF&a4Ssapv6F7eH_(Ex(wVy{x9&MA^V%U(lQ zIXL2C#9_-@PcI7NbjO=-bKh?FXpURwm5P|{koq7G7T^sq%KGB&{?Sr&K?rX@H_*9~; zX|mi})*x&hD#`=z#uPG2OBo?kCe8bzvB>GH zgcrTY{QM6~Djxi!M+GUqU(l3UV|Xd?WxWpXcsnaSJp=v30x7gHSc0(kKr|qj>w=$C zoA9=K*9>aID%7UMwss2}Zu2r7$FgyrtDK0*x2j=s;q4~*F0Qvt zNa%|NsfeoG^H~S(w4d3s=0WlZ=kq_0<+8uH*jYF#Wfr3=&Dl|#YS1heYkJv2Z)0NI z9Lqup*L+)-95b>>l!`Kg)p*#SQQS?&*?_}f7X5U`J>WjS89NC)C!@v_QabBJSBzR4BF4Mgff>#9B0ZRB~w$V>Jj}F!cRL(w-NWG5kVaOxtxgXc~f`shd+#eEUKQn1R~7

(X?Ad4mVCi-L=()tybTKEk+}Ow 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
>gP`zAt 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

bZtmFODpIPLvb-c^r(Sr-hiS`YRTj$v{<` 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 diff --git a/react-components/tsconfig.json b/react-components/tsconfig.json index 44c8acbf292..54e3c64f69d 100644 --- a/react-components/tsconfig.json +++ b/react-components/tsconfig.json @@ -12,8 +12,19 @@ "outDir": "dist", "isolatedModules": true, "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "noImplicitOverride": true }, - "include": ["src/**/*", "stories/**/*", "tests/**/*", "vite.config.ts", "playwright.config.ts"], - "exclude": ["node_modules", "dist", "storybook-static"] -} + "include": [ + "src/**/*", + "stories/**/*", + "tests/**/*", + "vite.config.ts", + "playwright.config.ts" + ], + "exclude": [ + "node_modules", + "dist", + "storybook-static" + ] +} \ No newline at end of file