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 6c3f3a3cafa..4678e21a5ed 100644 Binary files a/react-components/tests/visual-tests/__screenshots__/CadModelContainer.stories.spec.ts/cad-model-container-storybook-1.png and b/react-components/tests/visual-tests/__screenshots__/CadModelContainer.stories.spec.ts/cad-model-container-storybook-1.png differ 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