diff --git a/viewer/api-entry-points/core.ts b/viewer/api-entry-points/core.ts index 4b89e6f8638..13384f28d32 100644 --- a/viewer/api-entry-points/core.ts +++ b/viewer/api-entry-points/core.ts @@ -116,4 +116,11 @@ export { Image360AnnotationFilterOptions } from '../packages/360-images'; -export { OverlayCollection, OverlayInfo, Overlay3D, DefaultOverlay3DContentType } from '../packages/3d-overlays'; +export { + OverlayCollection, + Overlay3DCollection, + Overlay3DCollectionOptions, + OverlayInfo, + Overlay3D, + DefaultOverlay3DContentType +} from '../packages/3d-overlays'; diff --git a/viewer/packages/3d-overlays/index.ts b/viewer/packages/3d-overlays/index.ts index 91a0a8831af..c931c2bfc11 100644 --- a/viewer/packages/3d-overlays/index.ts +++ b/viewer/packages/3d-overlays/index.ts @@ -1,7 +1,7 @@ /*! * Copyright 2023 Cognite AS */ -export { Overlay3DCollection } from './src/Overlay3DCollection'; +export { Overlay3DCollection, Overlay3DCollectionOptions } from './src/Overlay3DCollection'; export { OverlayPointsObject, OverlayPointsParameters } from './src/OverlayPointsObject'; export { Overlay3DIcon } from './src/Overlay3DIcon'; export { Overlay3D } from './src/Overlay3D'; diff --git a/viewer/packages/3d-overlays/src/CameraChangeThrottler.ts b/viewer/packages/3d-overlays/src/CameraChangeThrottler.ts new file mode 100644 index 00000000000..7894cde50a2 --- /dev/null +++ b/viewer/packages/3d-overlays/src/CameraChangeThrottler.ts @@ -0,0 +1,27 @@ +/*! + * Copyright 2024 Cognite AS + */ +import throttle from 'lodash/throttle'; +import { Camera, Matrix4 } from 'three'; + +export type Callback = () => void; + +export class CameraChangeThrottler { + private readonly _prevCameraMatrix: Matrix4 = new Matrix4(); + + public call: (camera: Camera, callback: Callback) => void; + + constructor(wait: number = 500) { + this.call = throttle((camera: Camera, callback: Callback) => this._call(camera, callback), wait); + } + + private _call(camera: Camera, callback: Callback) { + if (camera.matrix.equals(this._prevCameraMatrix)) { + return; + } + + callback(); + + this._prevCameraMatrix.copy(camera.matrix); + } +} diff --git a/viewer/packages/3d-overlays/src/IconOctree.ts b/viewer/packages/3d-overlays/src/IconOctree.ts index d8afb7b36b4..12d3ec8aed8 100644 --- a/viewer/packages/3d-overlays/src/IconOctree.ts +++ b/viewer/packages/3d-overlays/src/IconOctree.ts @@ -9,20 +9,21 @@ import pullAll from 'lodash/pullAll'; import { Node, PointOctant, PointOctree } from 'sparse-octree'; import { Box3, Matrix4, Vector3 } from 'three'; import { Overlay3DIcon } from './Overlay3DIcon'; +import { DefaultOverlay3DContentType } from './OverlayCollection'; type NodeMetadata = { icon: Overlay3DIcon; level: number; }; -export class IconOctree extends PointOctree { +export class IconOctree extends PointOctree> { private readonly _nodeCenters: Map; - public static getMinimalOctreeBoundsFromIcons(icons: Overlay3DIcon[]): Box3 { + public static getMinimalOctreeBoundsFromIcons(icons: Overlay3DIcon[]): Box3 { return new Box3().setFromPoints(icons.map(icon => icon.getPosition())); } - constructor(icons: Overlay3DIcon[], bounds: Box3, maxLeafSize: number) { + constructor(icons: Overlay3DIcon[], bounds: Box3, maxLeafSize: number) { super(bounds.min, bounds.max, 0, maxLeafSize); icons.forEach(icon => this.set(icon.getPosition(), icon)); this.filterEmptyLeaves(); diff --git a/viewer/packages/3d-overlays/src/Overlay3DCollection.ts b/viewer/packages/3d-overlays/src/Overlay3DCollection.ts index 98750427944..86434470c70 100644 --- a/viewer/packages/3d-overlays/src/Overlay3DCollection.ts +++ b/viewer/packages/3d-overlays/src/Overlay3DCollection.ts @@ -2,20 +2,40 @@ * Copyright 2023 Cognite AS */ -import { CanvasTexture, Color, Texture, Object3D, type Camera } from 'three'; +import { CanvasTexture, Color, Texture, Object3D, Camera, Vector2, Raycaster, WebGLRenderer, Scene } from 'three'; import { Overlay3DIcon } from './Overlay3DIcon'; import { Overlay3D } from './Overlay3D'; import { OverlayPointsObject } from './OverlayPointsObject'; import { IconOctree } from './IconOctree'; import { DefaultOverlay3DContentType, OverlayCollection, OverlayInfo } from './OverlayCollection'; +import minBy from 'lodash/minBy'; +import { CameraChangeThrottler } from './CameraChangeThrottler'; +/** + * Constructor options for the Overlay3DCollection + */ export type Overlay3DCollectionOptions = { + /** + * The texture to display as icons in this collection + */ overlayTexture?: Texture; + /** + * A texture mask for marking what pixels are transparent in the supplied overlayTexture + */ overlayTextureMask?: Texture; + /** + * The maximum display size of each icon in pixels + */ maxPointSize?: number; + /** + * The default color to apply to overlay icons without a color on their own + */ defaultOverlayColor?: Color; }; +/** + * A collection of overlay icons with associated data + */ export class Overlay3DCollection extends Object3D implements OverlayCollection @@ -33,9 +53,11 @@ export class Overlay3DCollection private readonly _iconRadius = 0.4; private _overlays: Overlay3DIcon[]; //@ts-ignore Will be removed when clustering is added. - private _octree: IconOctree; + private _octree: IconOctree; + private readonly _rayCaster = new Raycaster(); + private readonly _cameraChangeDebouncer = new CameraChangeThrottler(); - constructor(overlayInfos?: OverlayInfo[], options?: Overlay3DCollectionOptions) { + constructor(overlayInfos: OverlayInfo[], options?: Overlay3DCollectionOptions) { super(); this.defaultOverlayColor = options?.defaultOverlayColor ?? this.defaultOverlayColor; @@ -46,30 +68,42 @@ export class Overlay3DCollection mask: options?.overlayTextureMask ?? (options?.overlayTexture ? undefined : defaultOverlayTextures.mask) }; - this._overlayPoints = new OverlayPointsObject(overlayInfos ? overlayInfos.length : this.DefaultMaxPoints, { - spriteTexture: this._sharedTextures.color, - maskTexture: this._sharedTextures.mask, - minPixelSize: this.MinPixelSize, - maxPixelSize: options?.maxPointSize ?? this.MaxPixelSize, - radius: this._iconRadius - }); - - this._overlays = this.initializeOverlay3DIcons(overlayInfos ?? []); + this._overlayPoints = new OverlayPointsObject( + overlayInfos ? overlayInfos.length : this.DefaultMaxPoints, + { + spriteTexture: this._sharedTextures.color, + maskTexture: this._sharedTextures.mask, + minPixelSize: this.MinPixelSize, + maxPixelSize: options?.maxPointSize ?? this.MaxPixelSize, + radius: this._iconRadius + }, + (...args) => this.onBeforeRenderDelegate(...args) + ); + + this._overlays = this.initializeOverlay3DIcons(overlayInfos); this.add(this._overlayPoints); - this.updatePointsObject(); this._octree = this.rebuildOctree(); } + /** + * Set whether this collection is visible or not + */ setVisibility(visibility: boolean): void { this._overlayPoints.visible = visibility; } + /** + * Get the overlay icons contained in this collection + */ getOverlays(): Overlay3D[] { return this._overlays; } + /** + * Add more overlays into this collection + */ public addOverlays(overlayInfos: OverlayInfo[]): Overlay3D[] { if (overlayInfos.length + this._overlays.length > this.DefaultMaxPoints) throw new Error('Cannot add more than ' + this.DefaultMaxPoints + ' points'); @@ -83,14 +117,24 @@ export class Overlay3DCollection return newIcons; } - sortOverlaysRelativeToCamera(camera: Camera): void { + private readonly onBeforeRenderDelegate: Object3D['onBeforeRender'] = ( + _renderer: WebGLRenderer, + _scene: Scene, + camera: Camera + ) => { + this._cameraChangeDebouncer.call(camera, () => this.sortOverlaysRelativeToCamera(camera)); + }; + + private sortOverlaysRelativeToCamera(camera: Camera): void { this._overlays = this._overlays.sort((a, b) => { return b.getPosition().distanceToSquared(camera.position) - a.getPosition().distanceToSquared(camera.position); }); - this.updatePointsObject(); } + /** + * Remove the listed overlays from this collection + */ public removeOverlays(overlays: Overlay3D[]): void { this._overlays = this._overlays.filter(overlay => !overlays.includes(overlay)); @@ -98,6 +142,9 @@ export class Overlay3DCollection this._octree = this.rebuildOctree(); } + /** + * Clean up all icons in this collection + */ public removeAllOverlays(): void { this._overlays = []; @@ -105,11 +152,24 @@ export class Overlay3DCollection this._octree = this.rebuildOctree(); } - private rebuildOctree(): IconOctree { - const icons = this._overlays as Overlay3DIcon[]; + /** + * Run intersection on icons in this collection. Returns the closest hit + */ + public intersectOverlays(normalizedCoordinates: Vector2, camera: Camera): Overlay3D | undefined { + this._rayCaster.setFromCamera(normalizedCoordinates.clone(), camera); + + const intersections = this._overlays.filter(icon => { + return icon.getVisible() && icon.intersect(this._rayCaster.ray) !== null; + }); + + return minBy(intersections, a => a.getPosition().clone().sub(this._rayCaster.ray.origin).length()); + } + + private rebuildOctree(): IconOctree { + const icons = this._overlays; const octreeBounds = IconOctree.getMinimalOctreeBoundsFromIcons(icons); - return new IconOctree(icons, octreeBounds, 2); + return new IconOctree(icons, octreeBounds, 2); } private updatePointsObject(): void { @@ -142,6 +202,9 @@ export class Overlay3DCollection }); } + /** + * Dispose this collection and icons with all associated resources + */ public dispose(): void { this._overlays.forEach(overlay => overlay.dispose()); diff --git a/viewer/packages/3d-overlays/src/OverlayPointsObject.ts b/viewer/packages/3d-overlays/src/OverlayPointsObject.ts index c9740686882..41a28ac4a8a 100644 --- a/viewer/packages/3d-overlays/src/OverlayPointsObject.ts +++ b/viewer/packages/3d-overlays/src/OverlayPointsObject.ts @@ -13,6 +13,7 @@ import { Group, LessEqualDepth, Matrix4, + Object3D, Points, RawShaderMaterial, ShaderMaterial, @@ -43,9 +44,14 @@ export class OverlayPointsObject extends Group { private readonly _colorBuffer: Float32Array; private readonly _colorAttribute: BufferAttribute; private readonly _points: { frontPoints: Points; backPoints: Points }; + private readonly _onBeforeRender?: Object3D['onBeforeRender']; private _modelTransform: Matrix4; - constructor(maxNumberOfPoints: number, materialParameters: OverlayPointsParameters) { + constructor( + maxNumberOfPoints: number, + materialParameters: OverlayPointsParameters, + onBeforeRender?: Object3D['onBeforeRender'] + ) { super(); const geometry = new BufferGeometry(); this._positionBuffer = new Float32Array(maxNumberOfPoints * 3); @@ -101,6 +107,7 @@ export class OverlayPointsObject extends Group { this._geometry = geometry; this._frontMaterial = frontMaterial; this._points = { frontPoints, backPoints }; + this._onBeforeRender = onBeforeRender; } public setPoints(points: Vector3[], colors?: Color[]): void { @@ -163,7 +170,8 @@ export class OverlayPointsObject extends Group { private initializePoints(geometry: BufferGeometry, frontMaterial: ShaderMaterial): Points { const frontPoints = createPoints(geometry, frontMaterial); - frontPoints.onBeforeRender = renderer => { + frontPoints.onBeforeRender = (renderer, ...rest) => { + this._onBeforeRender?.(renderer, ...rest); setUniforms(renderer, frontMaterial); }; diff --git a/viewer/packages/tools/src/Overlay3D/Overlay3DTool.ts b/viewer/packages/tools/src/Overlay3D/Overlay3DTool.ts index 9e8b324f69a..fda23e762d4 100644 --- a/viewer/packages/tools/src/Overlay3D/Overlay3DTool.ts +++ b/viewer/packages/tools/src/Overlay3D/Overlay3DTool.ts @@ -18,6 +18,7 @@ import { OverlayCollection, Overlay3D } from '@reveal/3d-overlays'; +import { TextOverlay } from './TextOverlay'; import { Cognite3DViewerToolBase } from '../Cognite3DViewerToolBase'; /** @@ -80,16 +81,12 @@ export type OverlayCollectionOptions = { */ export class Overlay3DTool extends Cognite3DViewerToolBase { private readonly _viewer: Cognite3DViewer; - private readonly _textOverlay: HTMLElement; private readonly _defaultOverlayColor: THREE.Color = new THREE.Color('#fbe50b'); - private readonly _defaultTextOverlayToCursorOffset = 20; - private readonly _temporaryVec = new THREE.Vector2(); - private readonly _raycaster = new THREE.Raycaster(); private _overlayCollections: Overlay3DCollection[] = []; private _isVisible = true; - private _textOverlayVisible = false; + private readonly _textOverlay: TextOverlay; private readonly _events = { hover: new EventTrigger>(), @@ -102,9 +99,7 @@ export class Overlay3DTool extends Co this._viewer = viewer; this._defaultOverlayColor = toolParameters?.defaultOverlayColor ?? this._defaultOverlayColor; - - this._textOverlay = this.createTextOverlay('', this._defaultTextOverlayToCursorOffset); - viewer.domElement.appendChild(this._textOverlay); + this._textOverlay = new TextOverlay(viewer.domElement); viewer.canvas.addEventListener('mousemove', this.onMouseMove); @@ -122,16 +117,12 @@ export class Overlay3DTool extends Co ): OverlayCollection { const { _viewer: viewer } = this; - const points = new Overlay3DCollection(overlays, { + const points = new Overlay3DCollection(overlays ?? [], { defaultOverlayColor: options?.defaultOverlayColor ?? this._defaultOverlayColor, overlayTexture: options?.overlayTexture, overlayTextureMask: options?.overlayTextureMask }); - viewer.on('cameraChange', () => { - points.sortOverlaysRelativeToCamera(viewer.cameraManager.getCamera()); - }); - this._overlayCollections.push(points); viewer.addObject3D(points); @@ -186,15 +177,14 @@ export class Overlay3DTool extends Co * Default is false. */ setTextOverlayVisible(visible: boolean): void { - this._textOverlayVisible = visible; - this._textOverlay.style.opacity = visible ? '1' : '0'; + this._textOverlay.setTextOverlayEnabled(visible); } /** * Gets whether text overlay is visible. */ getTextOverlayVisible(): boolean { - return this._textOverlayVisible; + return this._textOverlay.getTextOverlayEnabled(); } /** @@ -272,15 +262,16 @@ export class Overlay3DTool extends Co const { _textOverlay: textOverlay } = this; const intersectedOverlay = this.intersectPointsMarkers({ offsetX: event.offsetX, offsetY: event.offsetY }); + + this._textOverlay.reset(); + if (intersectedOverlay) { - this.positionTextOverlay(event); + this._textOverlay.positionTextOverlay(event); this._events.hover.fire({ targetOverlay: intersectedOverlay, mousePosition: event, - htmlTextOverlay: textOverlay + htmlTextOverlay: textOverlay.textOverlay }); - } else { - textOverlay.style.opacity = '0'; } }; @@ -289,88 +280,46 @@ export class Overlay3DTool extends Co if (intersectedOverlay) { this._events.click.fire({ targetOverlay: intersectedOverlay, - htmlTextOverlay: this._textOverlay, + htmlTextOverlay: this._textOverlay.textOverlay, mousePosition: { offsetX: event.offsetX, offsetY: event.offsetY } }); } }; - private positionTextOverlay(event: MouseEvent): void { - const { _textOverlay, _textOverlayVisible } = this; - _textOverlay.style.left = `${event.offsetX}px`; - _textOverlay.style.top = `${event.offsetY}px`; - _textOverlay.style.opacity = _textOverlayVisible ? '1' : '0'; - } - private intersectPointsMarkers(mouseCoords: { offsetX: number; offsetY: number }): Overlay3DIcon | null { - const { _viewer, _raycaster, _temporaryVec } = this; + const { _viewer } = this; - const pixelCoords = getNormalizedPixelCoordinates(_viewer.domElement, mouseCoords.offsetX, mouseCoords.offsetY); + const normalizedCoordinates = getNormalizedPixelCoordinates( + _viewer.domElement, + mouseCoords.offsetX, + mouseCoords.offsetY + ); const camera = _viewer.cameraManager.getCamera(); const cameraDirection = camera.getWorldDirection(new THREE.Vector3()); - _raycaster.setFromCamera(_temporaryVec.copy(pixelCoords), camera); - let intersection: [Overlay3DIcon, THREE.Vector3][] = []; + const intersections: [Overlay3D, THREE.Vector3][] = []; for (const points of this._overlayCollections) { - for (const icon of points.getOverlays() as Overlay3DIcon[]) { - if (icon.getVisible()) { - const inter = icon.intersect(_raycaster.ray); - if (inter) { - intersection.push([icon, inter]); - icon.updateAdaptiveScale({ - camera, - renderSize: _viewer.renderParameters.renderSize, - domElement: _viewer.canvas - }); - } - } + const intersection = points.intersectOverlays(normalizedCoordinates, camera); + if (intersection !== undefined) { + intersections.push([intersection, intersection.getPosition().clone()]); } } - intersection = intersection.filter(([icon, _]) => icon.intersect(_raycaster.ray) !== null); - - intersection = intersection + const sortedIntersections = intersections .map( ([icon, intersection]) => - [icon, intersection.sub(_raycaster.ray.origin)] as [Overlay3DIcon, THREE.Vector3] + [icon, intersection.sub(camera.position)] as [Overlay3DIcon, THREE.Vector3] ) .filter(([, intersection]) => intersection.dot(cameraDirection) > 0) .sort((a, b) => a[1].length() - b[1].length()); - if (intersection.length > 0) { - const intersectedOverlay = intersection[0][0]; + if (sortedIntersections.length > 0) { + const intersectedOverlay = sortedIntersections[0][0]; return intersectedOverlay; } return null; } - - private createTextOverlay(text: string, horizontalOffset: number): HTMLElement { - const textOverlay = document.createElement('div'); - textOverlay.innerText = text; - textOverlay.setAttribute('class', 'text-overlay'); - textOverlay.style.cssText = ` - position: absolute; - - /* Anchor to the center of the element and ignore events */ - transform: translate(${horizontalOffset}px, -50%); - touch-action: none; - user-select: none; - - padding: 7px; - max-width: 200px; - color: #fff; - background: #232323da; - border-radius: 5px; - border: '#ffffff22 solid 2px; - opacity: 0; - transition: opacity 0.3s; - opacity: 0; - z-index: 10; - `; - - return textOverlay; - } } diff --git a/viewer/packages/tools/src/Overlay3D/TextOverlay.ts b/viewer/packages/tools/src/Overlay3D/TextOverlay.ts new file mode 100644 index 00000000000..8c0c221f791 --- /dev/null +++ b/viewer/packages/tools/src/Overlay3D/TextOverlay.ts @@ -0,0 +1,78 @@ +/*! + * Copyright 2024 Cognite AS + */ +export class TextOverlay { + private readonly _textOverlay: HTMLElement; + private readonly _defaultTextOverlayToCursorOffset = 20; + private _textOverlayVisible = false; + + constructor(domElement: HTMLElement) { + this._textOverlay = this.createTextOverlay('', this._defaultTextOverlayToCursorOffset); + domElement.appendChild(this._textOverlay); + } + + get textOverlay(): HTMLElement { + return this._textOverlay; + } + + /** + * Sets whether text overlay is visible. + * Default is false. + */ + setTextOverlayEnabled(visible: boolean): void { + this._textOverlayVisible = visible; + this._textOverlay.style.opacity = visible ? '1' : '0'; + } + + /** + * Resets (i.e. hides) text overlay before recalculating position/visibility + */ + reset(): void { + this._textOverlay.style.opacity = '0'; + } + + /** + * Gets whether text overlay is visible. + */ + getTextOverlayEnabled(): boolean { + return this._textOverlayVisible; + } + + dispose(): void { + this._textOverlay.remove(); + } + + public positionTextOverlay(event: MouseEvent): void { + const { _textOverlay, _textOverlayVisible } = this; + _textOverlay.style.left = `${event.offsetX}px`; + _textOverlay.style.top = `${event.offsetY}px`; + _textOverlay.style.opacity = _textOverlayVisible ? '1' : '0'; + } + + private createTextOverlay(text: string, horizontalOffset: number): HTMLElement { + const textOverlay = document.createElement('div'); + textOverlay.innerText = text; + textOverlay.setAttribute('class', 'text-overlay'); + textOverlay.style.cssText = ` + position: absolute; + + /* Anchor to the center of the element and ignore events */ + transform: translate(${horizontalOffset}px, -50%); + touch-action: none; + user-select: none; + + padding: 7px; + max-width: 200px; + color: #fff; + background: #232323da; + border-radius: 5px; + border: '#ffffff22 solid 2px; + opacity: 0; + transition: opacity 0.3s; + opacity: 0; + z-index: 10; + `; + + return textOverlay; + } +} diff --git a/viewer/reveal.api.md b/viewer/reveal.api.md index f14e83b8764..c5c83bec9e8 100644 --- a/viewer/reveal.api.md +++ b/viewer/reveal.api.md @@ -9,6 +9,7 @@ import { AnnotationsAssetRef } from '@cognite/sdk'; import { AnnotationsCogniteAnnotationTypesImagesAssetLink } from '@cognite/sdk'; import { AnnotationStatus } from '@cognite/sdk'; import { Box3 } from 'three'; +import { Camera } from 'three'; import { CogniteClient } from '@cognite/sdk'; import { CogniteInternalId } from '@cognite/sdk'; import { Color } from 'three'; @@ -23,6 +24,7 @@ import { PerspectiveCamera } from 'three'; import { Plane } from 'three'; import { Quaternion } from 'three'; import { Raycaster } from 'three'; +import { Texture } from 'three'; import * as THREE from 'three'; import { Vector2 } from 'three'; import { Vector3 } from 'three'; @@ -1575,6 +1577,26 @@ export interface Overlay3D { setVisible(visible: boolean): void; } +// @public +export class Overlay3DCollection extends Object3D implements OverlayCollection { + constructor(overlayInfos: OverlayInfo[], options?: Overlay3DCollectionOptions); + addOverlays(overlayInfos: OverlayInfo[]): Overlay3D[]; + dispose(): void; + getOverlays(): Overlay3D[]; + intersectOverlays(normalizedCoordinates: Vector2, camera: Camera): Overlay3D | undefined; + removeAllOverlays(): void; + removeOverlays(overlays: Overlay3D[]): void; + setVisibility(visibility: boolean): void; +} + +// @public +export type Overlay3DCollectionOptions = { + overlayTexture?: Texture; + overlayTextureMask?: Texture; + maxPointSize?: number; + defaultOverlayColor?: Color; +}; + // @public export class Overlay3DTool extends Cognite3DViewerToolBase { constructor(viewer: Cognite3DViewer, toolParameters?: Overlay3DToolParameters);