diff --git a/src/apps/illumination/ngv-main-illumination.ts b/src/apps/illumination/ngv-main-illumination.ts index 00a77c5..afee3dc 100644 --- a/src/apps/illumination/ngv-main-illumination.ts +++ b/src/apps/illumination/ngv-main-illumination.ts @@ -6,6 +6,7 @@ import {type CesiumWidget} from '@cesium/engine'; import type {IIlluminationConfig} from './ingv-config-illumination.js'; import '../../plugins/cesium/ngv-plugin-cesium-widget.js'; +import type {ViewerInitializedDetails} from '../../plugins/cesium/ngv-plugin-cesium-widget.js'; @customElement('ngv-main-illumination') export class NgvMainIllumination extends LitElement { @@ -23,6 +24,7 @@ export class NgvMainIllumination extends LitElement { `; updated(): void { + if (!this.viewer?.clock) return; this.viewer.clock.currentTime = this.date; console.log(this.date.toString()); } @@ -32,8 +34,8 @@ export class NgvMainIllumination extends LitElement {
) => { - this.viewer = evt.detail; + @viewerInitialized=${(evt: CustomEvent) => { + this.viewer = evt.detail.viewer; this.updated(); }} > diff --git a/src/apps/permits/demoPermitConfig.ts b/src/apps/permits/demoPermitConfig.ts index c9d7bc8..0829e9e 100644 --- a/src/apps/permits/demoPermitConfig.ts +++ b/src/apps/permits/demoPermitConfig.ts @@ -43,6 +43,9 @@ export const config: IPermitsConfig = { widgetOptions: { scene3DOnly: true, }, + globeOptions: { + depthTestAgainstTerrain: true, + }, }, }, }; diff --git a/src/apps/permits/index.ts b/src/apps/permits/index.ts index f86c982..f925f43 100644 --- a/src/apps/permits/index.ts +++ b/src/apps/permits/index.ts @@ -1,6 +1,6 @@ import type {HTMLTemplateResult} from 'lit'; import {html} from 'lit'; -import {customElement} from 'lit/decorators.js'; +import {customElement, state} from 'lit/decorators.js'; import '../../structure/ngv-structure-app.js'; @@ -11,42 +11,31 @@ import {ABaseApp} from '../../structure/BaseApp.js'; import type {IPermitsConfig} from './ingv-config-permits.js'; import '../../plugins/cesium/ngv-plugin-cesium-widget'; -import type {CesiumWidget, Model} from '@cesium/engine'; +import '../../plugins/cesium/ngv-plugin-cesium-upload'; +import '../../plugins/cesium/ngv-plugin-cesium-model-interact'; +import type {CesiumWidget, DataSourceCollection} from '@cesium/engine'; -import { - Math as CesiumMath, - Ellipsoid, - HeadingPitchRoll, - Transforms, -} from '@cesium/engine'; +import {PrimitiveCollection} from '@cesium/engine'; +import type {ViewerInitializedDetails} from '../../plugins/cesium/ngv-plugin-cesium-widget.js'; @customElement('ngv-app-permits') @localized() export class NgvAppPermits extends ABaseApp { + @state() private viewer: CesiumWidget; + private uploadedModelsCollection: PrimitiveCollection = + new PrimitiveCollection(); + private dataSourceCollection: DataSourceCollection; - constructor() { - super(() => import('./demoPermitConfig.js')); - } - - modelCallback(name: string, model: Model): void { - // This position the model where the camera is - console.log('positioning', name); - const positionClone = this.viewer.camera.position.clone(); + private storeOptions = { + localStoreKey: 'permits-localStoreModels', + indexDbName: 'permits-uploadedModelsStore', + }; - const fixedFrameTransform = Transforms.localFrameToFixedFrameGenerator( - 'north', - 'west', - ); + private collections: ViewerInitializedDetails['primitiveCollections']; - const modelOrientation = [90, 0, 0]; - const modelMatrix = Transforms.headingPitchRollToFixedFrame( - positionClone, - new HeadingPitchRoll(...modelOrientation.map(CesiumMath.toRadians)), - Ellipsoid.WGS84, - fixedFrameTransform, - ); - model.modelMatrix = modelMatrix; + constructor() { + super(() => import('./demoPermitConfig.js')); } render(): HTMLTemplateResult { @@ -56,11 +45,44 @@ export class NgvAppPermits extends ABaseApp { } return html` +
+ ${this.viewer + ? html` + +
+ + + ` + : ''} +
+ ) => { - this.viewer = evt.detail; + @viewerInitialized=${(evt: CustomEvent) => { + this.viewer = evt.detail.viewer; + this.viewer.scene.primitives.add(this.uploadedModelsCollection); + this.dataSourceCollection = evt.detail.dataSourceCollection; + this.collections = evt.detail.primitiveCollections; }} >
diff --git a/src/catalogs/demoCatalog.ts b/src/catalogs/demoCatalog.ts index 9b23a64..9645de3 100644 --- a/src/catalogs/demoCatalog.ts +++ b/src/catalogs/demoCatalog.ts @@ -12,6 +12,9 @@ export const catalog: INGVCatalog = { credit: 'test', }, }, + position: [6.628484, 46.5], + height: 0, + rotation: 0, }, sofa: { type: 'model', @@ -19,6 +22,9 @@ export const catalog: INGVCatalog = { url: 'https://raw.GithubUserContent.com/KhronosGroup/glTF-Sample-Assets/main/./Models/SheenWoodLeatherSofa/glTF-Binary/SheenWoodLeatherSofa.glb', credit: 'Khonos', }, + position: [6.628484, 46.5], + height: 0, + rotation: 0, }, // to complete }, diff --git a/src/interfaces/cesium/ingv-cesium-context.ts b/src/interfaces/cesium/ingv-cesium-context.ts index 22da76e..bf6089c 100644 --- a/src/interfaces/cesium/ingv-cesium-context.ts +++ b/src/interfaces/cesium/ingv-cesium-context.ts @@ -1,4 +1,4 @@ -import type {CesiumWidget} from '@cesium/engine'; +import type {CesiumWidget, Globe} from '@cesium/engine'; import type {INGVCatalog} from './ingv-catalog.js'; export interface IngvCesiumContext { @@ -32,4 +32,5 @@ export interface IngvCesiumContext { }; }; widgetOptions?: ConstructorParameters[1]; + globeOptions?: Partial; } diff --git a/src/interfaces/cesium/ingv-layers.ts b/src/interfaces/cesium/ingv-layers.ts index a80d6e2..75525a8 100644 --- a/src/interfaces/cesium/ingv-layers.ts +++ b/src/interfaces/cesium/ingv-layers.ts @@ -6,6 +6,7 @@ import type { } from '@cesium/engine'; import type {CesiumTerrainProvider, Model} from '@cesium/engine'; +import type {Cartesian3} from '@cesium/engine'; export type INGVCesiumImageryTypes = | INGVCesiumWMSImagery @@ -18,7 +19,7 @@ export type INGVCesiumAllTypes = | INGVCesiumImageryTypes; export type INGVCesiumAllPrimitiveTypes = - | INGVCesiumModel + | INGVCesiumModelConfig | INGVIFC | INGVCesium3DTiles; @@ -29,17 +30,30 @@ export interface INGVCesium3DTiles { options?: ConstructorParameters[0]; } -export interface INGVCesiumModel { +export interface INGVCesiumModelConfig { type: 'model'; options?: Parameters[0]; + position?: [number, number]; + height?: number; + rotation?: number; +} + +export interface INGVCesiumModel extends Model { + id: { + dimensions?: Cartesian3; + name: string; + }; } export interface INGVIFC { type: 'ifc'; url: string; options?: { - modelOptions: Omit; + modelOptions: Omit; }; + position: [number, number]; + height: number; + rotation: number; } export interface INGVCesiumTerrain { diff --git a/src/plugins/cesium/interactionHelpers.ts b/src/plugins/cesium/interactionHelpers.ts new file mode 100644 index 0000000..5e9b82c --- /dev/null +++ b/src/plugins/cesium/interactionHelpers.ts @@ -0,0 +1,507 @@ +import type {CustomDataSource, Model, Scene} from '@cesium/engine'; +import { + ArcType, + Axis, + BoundingSphere, + CallbackProperty, + Cartesian2, + Cartesian3, + Cartographic, + Color, + Ellipsoid, + HeadingPitchRoll, + IntersectionTests, + Matrix3, + Matrix4, + Plane, + Quaternion, + Ray, + TranslationRotationScale, +} from '@cesium/engine'; +import type {INGVCesiumModel} from '../../interfaces/cesium/ingv-layers.js'; + +export type PlaneColorOptions = { + material?: Color; + outline?: boolean; + outlineColor?: Color; +}; + +export type EdgeStyleOptions = { + width?: number; + material?: Color; +}; + +export type CornerPointStyleOptions = { + radiusPx?: number; + material?: Color; +}; + +export type BBoxStyles = { + planeColorOptions?: PlaneColorOptions; + edgeStyleOptions?: EdgeStyleOptions; + cornerPointStyleOptions: CornerPointStyleOptions; +}; + +const DefaultPlaneColorOptions: PlaneColorOptions = { + material: Color.RED.withAlpha(0.1), + outline: true, + outlineColor: Color.WHITE, +}; + +const DefaultEdgeStyles: EdgeStyleOptions = { + width: 10, + material: Color.WHITE.withAlpha(0.3), +}; + +const DefaultCornerPointStyles: CornerPointStyleOptions = { + radiusPx: 10, + material: Color.BROWN, +}; + +const SIDE_PLANES: Plane[] = [ + new Plane(new Cartesian3(0, 0, 1), 0.5), + new Plane(new Cartesian3(0, 0, -1), 0.5), + new Plane(new Cartesian3(0, 1, 0), 0.5), + new Plane(new Cartesian3(0, -1, 0), 0.5), + new Plane(new Cartesian3(1, 0, 0), 0.5), + new Plane(new Cartesian3(-1, 0, 0), 0.5), +]; + +const CORNER_POINT_VECTORS = [ + new Cartesian3(0.5, 0.5, 0.5), + new Cartesian3(0.5, -0.5, 0.5), + new Cartesian3(-0.5, -0.5, 0.5), + new Cartesian3(-0.5, 0.5, 0.5), +]; + +const LOCAL_EDGES: [Cartesian3, Cartesian3][] = []; +CORNER_POINT_VECTORS.forEach((vector, i) => { + const upPoint = vector; + const downPoint = Cartesian3.clone(upPoint, new Cartesian3()); + downPoint.z *= -1; + const nextUpPoint = CORNER_POINT_VECTORS[(i + 1) % 4]; + const nextDownPoint = Cartesian3.clone(nextUpPoint, new Cartesian3()); + nextDownPoint.z *= -1; + const verticalEdge: [Cartesian3, Cartesian3] = [downPoint, upPoint]; + // const topEdge: [Cartesian3, Cartesian3] = [nextUpPoint, upPoint]; + // const bottomEdge: [Cartesian3, Cartesian3] = [nextDownPoint, downPoint]; + LOCAL_EDGES.push(verticalEdge); +}); + +const scaleScratch = new Cartesian3(); +export function getScaleFromMatrix(matrix: Matrix4): Cartesian3 { + return Matrix4.getScale(matrix, scaleScratch); +} + +const dimensionsScratch = new Cartesian3(); +function getScaledDimensions(model: INGVCesiumModel): Cartesian3 { + Cartesian3.clone(model.id.dimensions, dimensionsScratch); + Cartesian3.multiplyComponents( + getScaleFromMatrix(model.modelMatrix), + dimensionsScratch, + dimensionsScratch, + ); + return dimensionsScratch; +} + +const scratchRotationMatrix = new Matrix3(); +const scratchRotationQuaternion = new Quaternion(); +export function getRotationQuaternionFromMatrix(matrix: Matrix4): Quaternion { + return Quaternion.fromRotationMatrix( + Matrix4.getRotation(matrix, scratchRotationMatrix), + scratchRotationQuaternion, + ); +} + +const scratchTranslation = new Cartesian3(); +export function getTranslationFromMatrix(matrix: Matrix4): Cartesian3 { + return Matrix4.getTranslation(matrix, scratchTranslation); +} + +const scratchTranslationRotationDimensionsMatrix = new Matrix4(); +export function getTranslationRotationDimensionsMatrix( + model: INGVCesiumModel, + result = scratchTranslationRotationDimensionsMatrix, +): Matrix4 { + return Matrix4.fromTranslationRotationScale( + new TranslationRotationScale( + getTranslationFromMatrix(model.modelMatrix), + getRotationQuaternionFromMatrix(model.modelMatrix), + getScaledDimensions(model), + ), + result, + ); +} + +const scratchTranslationRotationScaleMatrix = new Matrix4(); +export function getTranslationRotationScaleMatrix( + matrix: Matrix4, + result = scratchTranslationRotationScaleMatrix, +): Matrix4 { + return Matrix4.fromTranslationRotationScale( + new TranslationRotationScale( + getTranslationFromMatrix(matrix), + getRotationQuaternionFromMatrix(matrix), + getScaleFromMatrix(matrix), + ), + result, + ); +} + +const centerDiffScratch = new Cartesian3(); +export function getModelCenterDiff(model: Model): Cartesian3 { + return Cartesian3.subtract( + model.boundingSphere.center, + getTranslationFromMatrix(model.modelMatrix), + centerDiffScratch, + ); +} + +const scaleMatrixScratch = new Matrix4(); +const planeScaleScratch = new Cartesian3(); +function getPlaneScale(model: INGVCesiumModel) { + const dimensions = getScaledDimensions(model); + Cartesian3.fromArray( + [dimensions.x, dimensions.y, dimensions.z], + 0, + planeScaleScratch, + ); + + return Matrix4.fromScale(planeScaleScratch, scaleMatrixScratch); +} + +const planeDimensionsScratch = new Cartesian2(); +function getPlaneDimensions(model: INGVCesiumModel, normalAxis: Axis) { + const dimensions = getScaledDimensions(model); + let dimensionsArray: number[] = []; + + if (normalAxis === Axis.X) { + dimensionsArray = [dimensions.y, dimensions.z]; + } else if (normalAxis === Axis.Y) { + dimensionsArray = [dimensions.x, dimensions.z]; + } else if (normalAxis === Axis.Z) { + dimensionsArray = [dimensions.x, dimensions.y]; + } + + return Cartesian2.fromArray(dimensionsArray, 0, planeDimensionsScratch); +} + +export function createPlaneEntity( + dataSource: CustomDataSource, + plane: Plane, + model: INGVCesiumModel, + colorOptions: PlaneColorOptions = DefaultPlaneColorOptions, +): void { + const normalAxis: Axis = plane.normal.x + ? Axis.X + : plane.normal.y + ? Axis.Y + : Axis.Z; + + dataSource.entities.add({ + position: new CallbackProperty(() => model.boundingSphere.center, false), + orientation: new CallbackProperty( + () => getRotationQuaternionFromMatrix(model.modelMatrix), + false, + ), + plane: { + plane: new CallbackProperty( + () => Plane.transform(plane, getPlaneScale(model)), + false, + ), + dimensions: new CallbackProperty( + () => getPlaneDimensions(model, normalAxis), + false, + ), + ...colorOptions, + }, + }); +} +export function createEdge( + dataSource: CustomDataSource, + model: INGVCesiumModel, + edge: Cartesian3[], + styles: EdgeStyleOptions = DefaultEdgeStyles, +): void { + const positions = [new Cartesian3(), new Cartesian3()]; + dataSource.entities.add({ + polyline: { + show: true, + positions: new CallbackProperty(() => { + const matrix = getTranslationRotationDimensionsMatrix(model); + Matrix4.multiplyByPoint(matrix, edge[0], positions[0]); + Matrix4.multiplyByPoint(matrix, edge[1], positions[1]); + const centerDiff = getModelCenterDiff(model); + Cartesian3.add(positions[0], centerDiff, positions[0]); + Cartesian3.add(positions[1], centerDiff, positions[1]); + return positions; + }, false), + width: styles.width, + material: styles.material, + arcType: ArcType.NONE, + }, + }); +} + +export function createCornerPoint( + dataSource: CustomDataSource, + model: INGVCesiumModel, + edges: Cartesian3[], + scene: Scene, + styles: CornerPointStyleOptions = DefaultCornerPointStyles, +): void { + const position = new Cartesian3(); + const boundingSphere = new BoundingSphere(); + edges.forEach((localEdge) => { + dataSource.entities.add({ + position: new CallbackProperty(() => { + const matrix = getTranslationRotationDimensionsMatrix(model); + Matrix4.multiplyByPoint(matrix, localEdge, position); + const centerDiff = getModelCenterDiff(model); + Cartesian3.add(position, centerDiff, position); + boundingSphere.center = position; + return position; + }, false), + ellipsoid: { + show: true, + radii: new CallbackProperty(() => { + const pixelSize = getPixelSize(scene, boundingSphere); + const worldRadius = pixelSize * styles.radiusPx; + return new Cartesian3(worldRadius, worldRadius, worldRadius); + }, false), + material: styles.material, + }, + }); + }); +} + +export function showModelBBox( + dataSources: { + topDownPlanesDataSource?: CustomDataSource; + sidePlanesDataSource?: CustomDataSource; + edgeLinesDataSource?: CustomDataSource; + cornerPointsDataSource?: CustomDataSource; + }, + model: INGVCesiumModel, + scene: Scene, + styles: BBoxStyles = { + planeColorOptions: DefaultPlaneColorOptions, + edgeStyleOptions: DefaultEdgeStyles, + cornerPointStyleOptions: DefaultCornerPointStyles, + }, +): void { + const topDownPlanesDataSource = dataSources.topDownPlanesDataSource; + const sidePlanesDataSource = dataSources.sidePlanesDataSource; + if (topDownPlanesDataSource || sidePlanesDataSource) { + SIDE_PLANES.forEach((plane) => { + const normalAxis: Axis = plane.normal.x + ? Axis.X + : plane.normal.y + ? Axis.Y + : Axis.Z; + + const dataSource = + normalAxis === Axis.Z ? topDownPlanesDataSource : sidePlanesDataSource; + if (dataSource) + createPlaneEntity(dataSource, plane, model, styles.planeColorOptions); + }); + } + if (dataSources.edgeLinesDataSource) { + LOCAL_EDGES.forEach((edge) => + createEdge( + dataSources.edgeLinesDataSource, + model, + edge, + styles.edgeStyleOptions, + ), + ); + } + if (dataSources.cornerPointsDataSource) { + LOCAL_EDGES.forEach((edge) => + createCornerPoint( + dataSources.cornerPointsDataSource, + model, + edge, + scene, + styles.cornerPointStyleOptions, + ), + ); + } +} + +const scratchQuaternionRotate = new Quaternion(); +const scratchMatrix3Rotate = new Matrix3(); +const scratchHpr = new HeadingPitchRoll(); +export function rotate( + startPosition: Cartesian2, + endPosition: Cartesian2, + matrixToRotate: Matrix4, +): void { + const dx = endPosition.x - startPosition.x; + const sensitivity = 0.5; + const heading = -dx * sensitivity; + HeadingPitchRoll.fromDegrees(heading, 0, 0, scratchHpr); + + Matrix3.fromQuaternion( + Quaternion.fromHeadingPitchRoll(scratchHpr, scratchQuaternionRotate), + scratchMatrix3Rotate, + ); + + Matrix4.multiplyByMatrix3( + matrixToRotate, + scratchMatrix3Rotate, + matrixToRotate, + ); +} + +const scratchScale = new Cartesian3(); +const scratchScaleMatrix = new Matrix4(); +// todo use axis to have correct scale direction +export function scale( + startPosition: Cartesian2, + endPosition: Cartesian2, + matrix: Matrix4, +): void { + const dx = endPosition.x - startPosition.x; + const sensitivity = 0.01; + const scaleAmount = 1 + dx * sensitivity; + + Matrix4.fromScale( + Cartesian3.fromArray( + [scaleAmount, scaleAmount, scaleAmount], + 0, + scratchScale, + ), + scratchScaleMatrix, + ); + Matrix4.multiply(matrix, scratchScaleMatrix, matrix); +} + +const scratchPickedPositionCart = new Cartographic(); +const scratchPickedPosition = new Cartesian3(); +const scratchUpDirection = new Cartesian3(); +const scratchTop2d = new Cartesian2(); +const scratchBottom2d = new Cartesian2(); +const scratchAxis2D = new Cartesian2(); +const scratchMouseMoveVector = new Cartesian2(); +export function getVerticalMoveVector( + scene: Scene, + pickedPosition: Cartesian3, + endPosition: Cartesian2, + model: INGVCesiumModel, + result: Cartesian3 = new Cartesian3(), +): Cartesian3 { + const cartPickedPosition = Cartographic.fromCartesian( + pickedPosition, + Ellipsoid.default, + scratchPickedPositionCart, + ); + pickedPosition.clone(scratchPickedPosition); + const bottomPos = Cartesian3.fromRadians( + cartPickedPosition.longitude, + cartPickedPosition.latitude, + cartPickedPosition.height - model.id.dimensions.y, + ); + scene.cartesianToCanvasCoordinates(scratchPickedPosition, scratchTop2d); + scene.cartesianToCanvasCoordinates(bottomPos, scratchBottom2d); + Cartesian2.subtract(scratchTop2d, scratchBottom2d, scratchAxis2D); + Cartesian2.subtract(endPosition, scratchTop2d, scratchMouseMoveVector); + const scalar2d = + Cartesian2.dot(scratchMouseMoveVector, scratchAxis2D) / + Cartesian2.dot(scratchAxis2D, scratchAxis2D); + + const scalar3d = + getPixelSize(scene, model.boundingSphere) * + scalar2d * + model.id.dimensions.y; + + Cartesian3.normalize( + getTranslationFromMatrix(model.modelMatrix), + scratchUpDirection, + ); + return Cartesian3.multiplyByScalar(scratchUpDirection, scalar3d, result); +} + +const scratchCameraRay = new Ray(); +const scratchRayPlane = new Cartesian3(); +export function getHorizontalMoveVector( + scene: Scene, + pickedPosition: Cartesian3, + endPosition: Cartesian2, + movePlane: Plane, + result: Cartesian3 = new Cartesian3(), +): Cartesian3 | undefined { + const cameraRay = scene.camera.getPickRay(endPosition, scratchCameraRay); + if (!cameraRay) { + return undefined; + } + const nextPosition = IntersectionTests.rayPlane( + cameraRay, + movePlane, + scratchRayPlane, + ); + + if (!nextPosition) { + return undefined; + } + + return Cartesian3.subtract(nextPosition, pickedPosition, result); +} + +export function getPixelSize( + scene: Scene, + boundingSphere: BoundingSphere, +): number { + return scene.camera.getPixelSize( + boundingSphere, + scene.drawingBufferWidth, + scene.drawingBufferHeight, + ); +} + +type GltfJson = { + bufferViews: {byteOffset: number; byteLength: number}[]; + accessors: Record; + meshes: { + primitives: { + attributes: { + POSITION: number; + }; + }[]; + }[]; +}; + +export function getDimensions(model: Model): Cartesian3 { + // @ts-expect-error loader is not part of API + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + const json: GltfJson = model.loader.gltfJson; + + const diameter = model.boundingSphere.radius * 2; + const min = new Cartesian3(diameter, diameter, diameter); + const max = new Cartesian3( + -Number.MAX_VALUE, + -Number.MAX_VALUE, + -Number.MAX_VALUE, + ); + + json.meshes.forEach(function (mesh) { + mesh.primitives.forEach(function (primitive) { + const positionAccessor = json.accessors[primitive.attributes.POSITION]; + + if (positionAccessor) { + if (positionAccessor.min) { + min.x = Math.min(min.x, positionAccessor.min[1]); + min.y = Math.min(min.y, positionAccessor.min[0]); + min.z = Math.min(min.z, positionAccessor.min[2]); + } + if (positionAccessor.max) { + max.x = Math.max(max.x, positionAccessor.max[1]); + max.y = Math.max(max.y, positionAccessor.max[0]); + max.z = Math.max(max.z, positionAccessor.max[2]); + } + } + }); + }); + + return Cartesian3.subtract(max, min, new Cartesian3()); +} diff --git a/src/plugins/cesium/localStore.ts b/src/plugins/cesium/localStore.ts new file mode 100644 index 0000000..33334d4 --- /dev/null +++ b/src/plugins/cesium/localStore.ts @@ -0,0 +1,152 @@ +// todo Just for easier testing. Better place should be find and structure improved. + +import {Cartesian3, Matrix3, Matrix4, Quaternion} from '@cesium/engine'; +import type {INGVCesiumModel} from '../../interfaces/cesium/ingv-layers.js'; + +export function storeBlobInIndexedDB( + dbName: string, + blob: Blob, + name: string, +): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 2); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains('files')) { + db.createObjectStore('files'); + } + }; + + request.onsuccess = () => { + const db = request.result; + const transaction = db.transaction('files', 'readwrite'); + const store = transaction.objectStore('files'); + store.put(blob, name); + transaction.oncomplete = () => { + console.log('Blob stored successfully'); + resolve(); + }; + transaction.onerror = () => { + reject(new Error('Error storing blob')); + }; + }; + + request.onerror = (event) => console.error('IndexedDB error:', event); + }); +} + +export function getBlobFromIndexedDB( + dbName: string, + name: string, +): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 2); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains('files')) { + db.createObjectStore('files'); + } + }; + + request.onsuccess = () => { + const db = request.result; + const transaction = db.transaction('files', 'readonly'); + const store = transaction.objectStore('files'); + const getRequest = >store.get(name); + + getRequest.onsuccess = () => { + const blob = getRequest.result; + if (blob) { + resolve(blob); + } else { + reject(new Error('Blob not found')); + } + }; + + getRequest.onerror = () => + reject(new Error('Error retrieving blob from IndexedDB')); + }; + + request.onerror = () => reject(new Error('Error opening IndexedDB')); + }); +} + +export function deleteFromIndexedDB( + dbName: string, + name: string, +): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 2); + + request.onsuccess = () => { + const db = request.result; + const transaction = db.transaction('files', 'readwrite'); + const store = transaction.objectStore('files'); + const deleteRequest = store.delete(name); + + deleteRequest.onsuccess = () => { + console.log(`Item with key "${name}" deleted successfully`); + resolve(); + }; + + deleteRequest.onerror = () => { + console.error('Error deleting item from IndexedDB'); + reject(new Error('Error deleting item from IndexedDB')); + }; + }; + + request.onerror = () => { + console.error('Error opening IndexedDB'); + reject(new Error('Error opening IndexedDB')); + }; + }); +} + +export type StoredModel = { + name: string; + dimensions: number[]; + translation: number[]; + rotation: number[]; + scale: number[]; +}; + +export function updateModelsInLocalStore( + storeKey: string, + models: INGVCesiumModel[], +): void { + const localStoreModels: StoredModel[] = []; + models.forEach((model) => { + const translation = Matrix4.getTranslation( + model.modelMatrix, + new Cartesian3(), + ); + const scale = Matrix4.getScale(model.modelMatrix, new Cartesian3()); + const rotation = Quaternion.fromRotationMatrix( + Matrix4.getRotation(model.modelMatrix, new Matrix3()), + ); + localStoreModels.push({ + name: model.id.name, + dimensions: [ + model.id.dimensions.x, + model.id.dimensions.y, + model.id.dimensions.z, + ], + translation: [translation.x, translation.y, translation.z], + rotation: [rotation.x, rotation.y, rotation.z, rotation.w], + scale: [scale.x, scale.y, scale.z], + }); + }); + localStorage.setItem(storeKey, JSON.stringify(localStoreModels)); +} + +export function getStoredModels(storeKey: string): StoredModel[] { + if (!localStorage.getItem(storeKey)) return []; + try { + return JSON.parse(localStorage.getItem(storeKey)); + } catch (e) { + console.error('Not possible to parse models from local storage', e); + return []; + } +} diff --git a/src/plugins/cesium/ngv-cesium-factories.ts b/src/plugins/cesium/ngv-cesium-factories.ts index 349d243..1e1c70b 100644 --- a/src/plugins/cesium/ngv-cesium-factories.ts +++ b/src/plugins/cesium/ngv-cesium-factories.ts @@ -1,19 +1,26 @@ -import type {ImageryProvider} from '@cesium/engine'; +import {type ImageryProvider, PrimitiveCollection} from '@cesium/engine'; import { Ion, Math as CesiumMath, CesiumWidget, Cartesian3, Model, + DataSourceCollection, + DataSourceDisplay, + Cartographic, + HeadingPitchRoll, + Transforms, + Ellipsoid, } from '@cesium/engine'; import type { INGVCesium3DTiles, - INGVCesiumModel, + INGVCesiumModelConfig, INGVCesiumAllTypes, INGVCesiumImageryTypes, INGVCesiumTerrain, INGVIFC, + INGVCesiumModel, } from '../../interfaces/cesium/ingv-layers.js'; import { Cesium3DTileset, @@ -24,6 +31,7 @@ import { } from '@cesium/engine'; import type {IngvCesiumContext} from '../../interfaces/cesium/ingv-cesium-context.js'; import type {INGVCatalog} from '../../interfaces/cesium/ingv-catalog.js'; +import {getDimensions} from './interactionHelpers.js'; function withExtra(options: T, extra: Record): T { if (!extra) { @@ -51,10 +59,16 @@ export async function instantiateTerrain( } export async function instantiateModel( - config: INGVCesiumModel, + config: INGVCesiumModelConfig, extraOptions?: Record, -): Promise { - return Model.fromGltfAsync(withExtra(config.options, extraOptions)); +): Promise { + const model: INGVCesiumModel = await Model.fromGltfAsync( + withExtra(config.options, extraOptions), + ); + model.readyEvent.addEventListener(() => { + model.id.dimensions = getDimensions(model); + }); + return model; } export async function instantiate3dTileset( @@ -151,7 +165,7 @@ export function is3dTilesetConfig( export function isModelConfig( config: INGVCesiumAllTypes, -): config is INGVCesiumModel { +): config is INGVCesiumModelConfig { return config?.type === 'model'; } @@ -202,7 +216,14 @@ export async function initCesiumWidget( container: HTMLDivElement, cesiumContext: IngvCesiumContext, modelCallback: (name: string, model: Model) => void, -): Promise { +): Promise<{ + viewer: CesiumWidget; + dataSourceCollection: DataSourceCollection; + primitiveCollections: { + models: PrimitiveCollection; + tiles3d: PrimitiveCollection; + }; +}> { modelCallback = modelCallback || (() => { @@ -248,6 +269,29 @@ export async function initCesiumWidget( Object.assign({}, cesiumContext.widgetOptions), ); + if (cesiumContext.globeOptions) { + Object.assign(viewer.scene.globe, cesiumContext.globeOptions); + } + + const primitiveCollections = { + models: new PrimitiveCollection(), + tiles3d: new PrimitiveCollection(), + }; + + viewer.scene.primitives.add(primitiveCollections.models); + viewer.scene.primitives.add(primitiveCollections.tiles3d); + + const dataSourceCollection = new DataSourceCollection(); + const dataSourceDisplay = new DataSourceDisplay({ + scene: viewer.scene, + dataSourceCollection: dataSourceCollection, + }); + const clock = viewer.clock; + // todo: check if OK + clock.onTick.addEventListener(() => { + dataSourceDisplay.update(clock.currentTime); + }); + const stuffToDo: Promise[] = []; if (cesiumContext.layers.terrain) { const name = cesiumContext.layers.terrain; @@ -272,14 +316,14 @@ export async function initCesiumWidget( stuffToDo.push( instantiate3dTileset(config, cesiumContext.layerOptions[name]).then( (tileset) => { - viewer.scene.primitives.add(tileset); + primitiveCollections.tiles3d.add(tileset); }, ), ); }); - const modelPromises = cesiumContext.layers.models?.map(async (name) => { - let config = resolvedLayers[name]; + const modelPromises = cesiumContext.layers.models?.map(async (path) => { + let config = resolvedLayers[path]; let toRevokeUrl: string; if (isIFCConfig(config)) { @@ -300,16 +344,27 @@ export async function initCesiumWidget( console.log('IFC transformed to glTF', metadata, coordinationMatrix); const glbBlob = new Blob([glb]); toRevokeUrl = URL.createObjectURL(glbBlob); - const modelConfig: INGVCesiumModel = { + const modelConfig: INGVCesiumModelConfig = { type: 'model', options: Object.assign({}, modelOptions, { url: toRevokeUrl, + id: { + name: ifcUrl, + }, }), + height: config.height, + position: config.position, + rotation: config.rotation, }; config = modelConfig; + } else if (isModelConfig(config)) { + config = { + ...config, + options: {...config.options, id: {name: config.options.url}}, + }; } if (isModelConfig(config)) { - const bmConfig: Omit = { + const bmConfig: Omit = { scene: viewer.scene, gltfCallback(gltf) { // FIXME: here we can enable animations, ... @@ -318,16 +373,29 @@ export async function initCesiumWidget( }, // heightReference: HeightReference.CLAMP_TO_GROUND, }; + const modelMatrix = Transforms.headingPitchRollToFixedFrame( + Cartographic.toCartesian( + Cartographic.fromDegrees( + config.position[0], + config.position[1], + config.height, + ), + ), + new HeadingPitchRoll(CesiumMath.toRadians(config.rotation)), + Ellipsoid.WGS84, + Transforms.localFrameToFixedFrameGenerator('north', 'west'), + ); stuffToDo.push( instantiateModel( config, - Object.assign(bmConfig, cesiumContext.layerOptions[name]), + Object.assign(bmConfig, cesiumContext.layerOptions[path], { + modelMatrix, + }), ) .then( (model) => { - console.log('Got model!', config); - modelCallback(name, model); - viewer.scene.primitives.add(model); + modelCallback(path, model); + primitiveCollections.models.add(model); }, (e) => { console.error('o', e); @@ -374,5 +442,5 @@ export async function initCesiumWidget( duration: 0, }); - return viewer; + return {viewer, dataSourceCollection, primitiveCollections}; } diff --git a/src/plugins/cesium/ngv-plugin-cesium-model-interact.ts b/src/plugins/cesium/ngv-plugin-cesium-model-interact.ts new file mode 100644 index 0000000..0a369e9 --- /dev/null +++ b/src/plugins/cesium/ngv-plugin-cesium-model-interact.ts @@ -0,0 +1,453 @@ +import {html, LitElement} from 'lit'; +import type {HTMLTemplateResult, PropertyValues} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import type { + CesiumWidget, + DataSource, + PrimitiveCollection, + DataSourceCollection, + Cartesian2, +} from '@cesium/engine'; +import { + Model, + Cartesian3, + Color, + Matrix4, + Plane, + ScreenSpaceEventHandler, + ScreenSpaceEventType, + Quaternion, + CustomDataSource, + Ellipsoid, + TranslationRotationScale, + Entity, + ColorMaterialProperty, +} from '@cesium/engine'; +import {instantiateModel} from './ngv-cesium-factories.js'; +import type {StoredModel} from './localStore.js'; +import { + deleteFromIndexedDB, + getBlobFromIndexedDB, + getStoredModels, + updateModelsInLocalStore, +} from './localStore.js'; +import '../ui/ngv-layer-details.js'; +import '../ui/ngv-layers-list.js'; +import type {BBoxStyles} from './interactionHelpers.js'; +import { + getHorizontalMoveVector, + getTranslationFromMatrix, + getVerticalMoveVector, + rotate, + scale, + showModelBBox, +} from './interactionHelpers.js'; +import type {INGVCesiumModel} from '../../interfaces/cesium/ingv-layers.js'; + +type GrabType = 'side' | 'top' | 'edge' | 'corner' | undefined; + +@customElement('ngv-plugin-cesium-model-interact') +export class NgvPluginCesiumModelInteract extends LitElement { + @property({type: Object}) + private viewer: CesiumWidget; + @property({type: Object}) + private primitiveCollection: PrimitiveCollection; + @property({type: Object}) + private dataSourceCollection: DataSourceCollection; + @property({type: Object}) + private bboxStyle: BBoxStyles | undefined; + @property({type: Object}) + private storeOptions?: { + localStoreKey: string; + indexDbName: string; + }; + @property({type: Object}) + private options?: { + listTitle: string; + }; + @state() + private cursor: + | 'default' + | 'move' + | 'pointer' + | 'ns-resize' + | 'ew-resize' + | 'nesw-resize' = 'default'; + @state() + private chosenModel: INGVCesiumModel | undefined; + @state() + private position: Cartesian3 = new Cartesian3(); + @state() + private models: INGVCesiumModel[] = []; + private eventHandler: ScreenSpaceEventHandler | undefined; + private sidePlanesDataSource: DataSource | undefined; + private topDownPlanesDataSource: DataSource | undefined; + private edgeLinesDataSource: DataSource | undefined; + private cornerPointsDataSource: DataSource | undefined; + private moveStart: Cartesian3 = new Cartesian3(); + private moveStep: Cartesian3 = new Cartesian3(); + private pickedPointOffset: Cartesian3 = new Cartesian3(); + private dragStart: boolean = false; + private movePlane: Plane | undefined; + private grabType: GrabType; + private hoveredEdge: Entity | undefined; + + initEvents(): void { + this.eventHandler = new ScreenSpaceEventHandler(this.viewer.canvas); + this.eventHandler.setInputAction( + (evt: ScreenSpaceEventHandler.MotionEvent) => this.onMouseMove(evt), + ScreenSpaceEventType.MOUSE_MOVE, + ); + this.eventHandler.setInputAction( + (evt: ScreenSpaceEventHandler.PositionedEvent) => this.onClick(evt), + ScreenSpaceEventType.LEFT_CLICK, + ); + this.eventHandler.setInputAction( + (evt: ScreenSpaceEventHandler.PositionedEvent) => this.onLeftDown(evt), + ScreenSpaceEventType.LEFT_DOWN, + ); + this.eventHandler.setInputAction( + () => this.onLeftUp(), + ScreenSpaceEventType.LEFT_UP, + ); + + this.primitiveCollection.primitiveAdded.addEventListener(() => { + this.onPrimitivesChanged(); + }); + this.primitiveCollection.primitiveRemoved.addEventListener( + (p: INGVCesiumModel) => { + if (this.storeOptions) { + deleteFromIndexedDB(this.storeOptions.indexDbName, p.id.name) + .then(() => this.onPrimitivesChanged()) + .catch((e) => console.error(e)); + } else { + this.onPrimitivesChanged(); + } + }, + ); + } + + removeEvents(): void { + if (this.cursor !== 'default') { + this.viewer.canvas.style.cursor = 'default'; + } + if (this.eventHandler) { + this.eventHandler.destroy(); + this.eventHandler = null; + } + } + + onPrimitivesChanged(): void { + this.models = []; + for (let i = 0; i < this.primitiveCollection.length; i++) { + const model = this.primitiveCollection.get(i) as INGVCesiumModel; + if (model instanceof Model) { + this.models.push(model); + } + } + if (this.storeOptions) { + updateModelsInLocalStore(this.storeOptions.localStoreKey, this.models); + } + } + + onClick(evt: ScreenSpaceEventHandler.PositionedEvent): void { + const model: Model | undefined = this.pickModel(evt.position); + if (model) { + if (!this.chosenModel) { + this.chosenModel = model; + Matrix4.getTranslation(this.chosenModel.modelMatrix, this.position); + showModelBBox( + { + topDownPlanesDataSource: this.topDownPlanesDataSource, + sidePlanesDataSource: this.sidePlanesDataSource, + edgeLinesDataSource: this.edgeLinesDataSource, + cornerPointsDataSource: this.cornerPointsDataSource, + }, + this.chosenModel, + this.viewer.scene, + this.bboxStyle, + ); + } + } + } + + onLeftDown(evt: ScreenSpaceEventHandler.PositionedEvent): void { + this.grabType = this.pickGrabType(evt.position); + if (this.grabType) { + this.viewer.scene.screenSpaceCameraController.enableInputs = false; + this.viewer.scene.pickPosition(evt.position, this.moveStart); + this.dragStart = true; + + const normal = Ellipsoid.WGS84.geodeticSurfaceNormal(this.moveStart); + this.movePlane = Plane.fromPointNormal(this.moveStart, normal); + } + } + onLeftUp(): void { + if (this.grabType) { + this.viewer.scene.screenSpaceCameraController.enableInputs = true; + this.grabType = undefined; + } + } + + onMouseMove(evt: ScreenSpaceEventHandler.MotionEvent): void { + if (this.grabType && this.chosenModel) { + const endPosition = this.viewer.scene.pickPosition(evt.endPosition); + if (!endPosition) return; + + if (this.grabType === 'edge') { + rotate( + evt.startPosition, + evt.endPosition, + this.chosenModel.modelMatrix, + ); + return; + } + if (this.grabType === 'corner') { + scale(evt.startPosition, evt.endPosition, this.chosenModel.modelMatrix); + return; + } + if (this.dragStart) { + Cartesian3.subtract( + endPosition, + getTranslationFromMatrix(this.chosenModel.modelMatrix), + this.pickedPointOffset, + ); + this.dragStart = false; + } + + const pickedPosition = Cartesian3.add( + this.position, + this.pickedPointOffset, + new Cartesian3(), + ); + + if (this.grabType === 'top') { + getVerticalMoveVector( + this.viewer.scene, + pickedPosition, + evt.endPosition, + this.chosenModel, + this.moveStep, + ); + } else if (this.grabType === 'side') { + getHorizontalMoveVector( + this.viewer.scene, + pickedPosition, + evt.endPosition, + this.movePlane, + this.moveStep, + ); + } + + Cartesian3.add(this.position, this.moveStep, this.position); + + Matrix4.setTranslation( + this.chosenModel.modelMatrix, + this.position, + this.chosenModel.modelMatrix, + ); + + return; + } + this.updateCursor(evt.endPosition); + } + + updateCursor(position: Cartesian2): void { + const model: Model | undefined = this.pickModel(position); + const isSidePlane = !!this.pickEntity(position, this.sidePlanesDataSource); + const isTopPlane = !!this.pickEntity( + position, + this.topDownPlanesDataSource, + ); + const edgeEntity = this.pickEntity(position, this.edgeLinesDataSource); + const isCorner = !!this.pickEntity(position, this.cornerPointsDataSource); + if (model && !this.chosenModel) { + if (this.cursor !== 'pointer') { + this.viewer.canvas.style.cursor = this.cursor = 'pointer'; + } + } else if (isSidePlane) { + if (this.cursor !== 'move') { + this.viewer.canvas.style.cursor = this.cursor = 'move'; + } + } else if (isTopPlane) { + if (this.cursor !== 'ns-resize') { + this.viewer.canvas.style.cursor = this.cursor = 'ns-resize'; + } + } else if (isCorner) { + if (this.cursor !== 'nesw-resize') { + this.viewer.canvas.style.cursor = this.cursor = 'nesw-resize'; + } + } else if (edgeEntity) { + if (this.cursor !== 'ew-resize') { + this.viewer.canvas.style.cursor = this.cursor = 'ew-resize'; + } + if (!this.hoveredEdge) { + this.hoveredEdge = edgeEntity; + this.hoveredEdge.polyline.material = new ColorMaterialProperty( + Color.WHITE.withAlpha(0.9), + ); + } + } else if (this.cursor !== 'default') { + this.viewer.canvas.style.cursor = this.cursor = 'default'; + } + if (this.hoveredEdge && !edgeEntity) { + this.hoveredEdge.polyline.material = new ColorMaterialProperty( + Color.WHITE.withAlpha(0.3), + ); + this.hoveredEdge = undefined; + } + } + + pickModel(position: Cartesian2): Model | undefined { + const pickedObject: {primitive: Model | undefined} = < + {primitive: Model | undefined} + >this.viewer.scene.pick(position); + return pickedObject?.primitive && + this.primitiveCollection.contains(pickedObject.primitive) + ? pickedObject.primitive + : undefined; + } + + pickEntity(position: Cartesian2, dataSource: DataSource): Entity | undefined { + const obj: {id: Entity | undefined} = <{id: Entity | undefined}>( + this.viewer.scene.pick(position) + ); + return obj?.id && + obj.id instanceof Entity && + dataSource.entities.contains(obj.id) + ? obj?.id + : undefined; + } + + pickGrabType(position: Cartesian2): GrabType | undefined { + const pickedObject: {id: Entity | undefined} = <{id: Entity | undefined}>( + this.viewer.scene.pick(position) + ); + if (!pickedObject?.id?.id) return undefined; + if (this.sidePlanesDataSource.entities.contains(pickedObject.id)) { + return 'side'; + } else if ( + this.topDownPlanesDataSource.entities.contains(pickedObject.id) + ) { + return 'top'; + } else if (this.edgeLinesDataSource.entities.contains(pickedObject.id)) { + return 'edge'; + } else if (this.cornerPointsDataSource.entities.contains(pickedObject.id)) { + return 'corner'; + } + return undefined; + } + + initDataSources(): void { + this.dataSourceCollection + .add(new CustomDataSource()) + .then((dataSource) => (this.sidePlanesDataSource = dataSource)) + .catch((e) => console.error(e)); + this.dataSourceCollection + .add(new CustomDataSource()) + .then((dataSource) => (this.topDownPlanesDataSource = dataSource)) + .catch((e) => console.error(e)); + this.dataSourceCollection + .add(new CustomDataSource()) + .then((dataSource) => (this.edgeLinesDataSource = dataSource)) + .catch((e) => console.error(e)); + this.dataSourceCollection + .add(new CustomDataSource()) + .then((dataSource) => (this.cornerPointsDataSource = dataSource)) + .catch((e) => console.error(e)); + } + + async initModelsAndEvents(): Promise { + const models = this.storeOptions + ? getStoredModels(this.storeOptions.localStoreKey) + : undefined; + if (models?.length) { + await Promise.all( + models.map(async (m: StoredModel) => { + const blob = await getBlobFromIndexedDB( + this.storeOptions.indexDbName, + m.name, + ); + const model = await instantiateModel({ + type: 'model', + options: { + url: URL.createObjectURL(blob), + scene: this.viewer.scene, + modelMatrix: Matrix4.fromTranslationRotationScale( + new TranslationRotationScale( + new Cartesian3(...m.translation), + new Quaternion(...m.rotation), + new Cartesian3(...m.scale), + ), + ), + id: { + name: m.name, + dimensions: new Cartesian3(...Object.values(m.dimensions)), + }, + }, + }); + this.primitiveCollection.add(model); + }), + ); + this.onPrimitivesChanged(); + this.initEvents(); + this.viewer.scene.requestRender(); + } else { + this.initEvents(); + } + } + + firstUpdated(_changedProperties: PropertyValues): void { + this.initModelsAndEvents().catch((e) => console.error(e)); + this.initDataSources(); + super.firstUpdated(_changedProperties); + } + + protected shouldUpdate(): boolean { + return !!this.viewer && !!this.primitiveCollection; + } + render(): HTMLTemplateResult | string { + if (!this.chosenModel && !this.models?.length) return ''; + return this.chosenModel + ? html` ` + : html` { + return {name: m.id.name}; + })} + @remove="${(evt: {detail: number}) => { + const model = this.primitiveCollection.get( + evt.detail, + ) as INGVCesiumModel; + if (model) this.primitiveCollection.remove(model); + }}" + @zoom="${(evt: {detail: number}) => { + const model = this.primitiveCollection.get( + evt.detail, + ) as INGVCesiumModel; + this.viewer.camera.flyToBoundingSphere(model.boundingSphere, { + duration: 2, + }); + }}" + >`; + } + + disconnectedCallback(): void { + this.removeEvents(); + super.disconnectedCallback(); + } +} diff --git a/src/plugins/cesium/ngv-plugin-cesium-upload.ts b/src/plugins/cesium/ngv-plugin-cesium-upload.ts new file mode 100644 index 0000000..d1bbd1f --- /dev/null +++ b/src/plugins/cesium/ngv-plugin-cesium-upload.ts @@ -0,0 +1,137 @@ +import type {CesiumWidget, PrimitiveCollection} from '@cesium/engine'; +import { + Cartesian3, + Cartographic, + HeadingPitchRoll, + Math as CesiumMath, + Matrix4, + Quaternion, + ScreenSpaceEventHandler, + ScreenSpaceEventType, + Transforms, + TranslationRotationScale, +} from '@cesium/engine'; +import type {HTMLTemplateResult} from 'lit'; +import {html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import '../ui/ngv-upload.js'; +import {instantiateModel} from './ngv-cesium-factories.js'; +import type {FileUploadDetails} from '../ui/ngv-upload.js'; +import {storeBlobInIndexedDB, updateModelsInLocalStore} from './localStore.js'; +import type {INGVCesiumModel} from '../../interfaces/cesium/ingv-layers.js'; + +const cartographicScratch = new Cartographic(); + +@customElement('ngv-plugin-cesium-upload') +export class NgvPluginCesiumUpload extends LitElement { + @property({type: Object}) + public viewer: CesiumWidget; + @property({type: Object}) + public primitiveCollection: PrimitiveCollection; + @property({type: Object}) + public storeOptions?: { + localStoreKey: string; + indexDbName: string; + }; + private eventHandler: ScreenSpaceEventHandler | null = null; + private uploadedModel: INGVCesiumModel | undefined; + + async upload(fileDetails: FileUploadDetails): Promise { + const response = await fetch(fileDetails.url); + const arrayBuffer = await response.arrayBuffer(); + + const modelMatrix = Matrix4.fromTranslationRotationScale( + new TranslationRotationScale( + Cartesian3.ZERO, + Quaternion.fromHeadingPitchRoll( + new HeadingPitchRoll(CesiumMath.toRadians(90), 0, 0), + ), + ), + ); + + this.uploadedModel = await instantiateModel({ + type: 'model', + options: { + url: fileDetails.url, + scene: this.viewer.scene, + modelMatrix, + id: { + name: fileDetails.name, + dimensions: Cartesian3.ZERO, + }, + }, + }); + if (this.storeOptions) { + await storeBlobInIndexedDB( + this.storeOptions.indexDbName, + new Blob([arrayBuffer]), + fileDetails.name, + ); + } + this.primitiveCollection.add(this.uploadedModel); + this.viewer.scene.requestRender(); + this.showControls(); + } + + showControls(): void { + this.eventHandler = new ScreenSpaceEventHandler(this.viewer.canvas); + this.eventHandler.setInputAction( + (evt: ScreenSpaceEventHandler.MotionEvent) => this.onMouseMove(evt), + ScreenSpaceEventType.MOUSE_MOVE, + ); + this.eventHandler.setInputAction( + this.onClick.bind(this), + ScreenSpaceEventType.LEFT_CLICK, + ); + this.viewer.canvas.style.cursor = 'move'; + } + + onClick(): void { + this.viewer.canvas.style.cursor = 'default'; + this.eventHandler.destroy(); + this.eventHandler = null; + const models: INGVCesiumModel[] = []; + for (let i = 0; i < this.primitiveCollection.length; i++) { + models.push(this.primitiveCollection.get(i)); + } + if (this.storeOptions) { + updateModelsInLocalStore(this.storeOptions.localStoreKey, models); + } + } + + onMouseMove(event: ScreenSpaceEventHandler.MotionEvent): void { + const position = this.viewer.scene.pickPosition(event.endPosition); + const cart = Cartographic.fromCartesian( + position, + this.viewer.scene.ellipsoid, + cartographicScratch, + ); + const altitude = this.viewer.scene.globe.getHeight(cart); + cart.height = altitude || 0; + Cartographic.toCartesian(cart, this.viewer.scene.ellipsoid, position); + + this.uploadedModel.modelMatrix = + Transforms.eastNorthUpToFixedFrame(position); + } + + protected shouldUpdate(): boolean { + return !!this.viewer && !!this.primitiveCollection; + } + + render(): HTMLTemplateResult { + return html` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ngv-plugin-cesium-upload': NgvPluginCesiumUpload; + } +} diff --git a/src/plugins/cesium/ngv-plugin-cesium-widget.ts b/src/plugins/cesium/ngv-plugin-cesium-widget.ts index 7bc098a..20ca80d 100644 --- a/src/plugins/cesium/ngv-plugin-cesium-widget.ts +++ b/src/plugins/cesium/ngv-plugin-cesium-widget.ts @@ -5,12 +5,27 @@ import {customElement, property, query} from 'lit/decorators.js'; // @ts-expect-error Vite specific ?inline parameter import style from '@cesium/engine/Source/Widget/CesiumWidget.css?inline'; import type {IngvCesiumContext} from '../../interfaces/cesium/ingv-cesium-context.js'; -import type {CesiumWidget, Model} from '@cesium/engine'; +import type { + CesiumWidget, + DataSourceCollection, + Model, + PrimitiveCollection, +} from '@cesium/engine'; import {initCesiumWidget} from './ngv-cesium-factories.js'; +export type ViewerInitializedDetails = { + viewer: CesiumWidget; + dataSourceCollection: DataSourceCollection; + primitiveCollections: { + models: PrimitiveCollection; + tiles3d: PrimitiveCollection; + }; +}; + @customElement('ngv-plugin-cesium-widget') export class NgvPluginCesiumWidget extends LitElement { public viewer: CesiumWidget; + public dataSourceCollection: DataSourceCollection; static styles = [ unsafeCSS(style), @@ -41,14 +56,21 @@ export class NgvPluginCesiumWidget extends LitElement { private element: HTMLDivElement; private async initCesiumViewer(): Promise { - this.viewer = await initCesiumWidget( - this.element, - this.cesiumContext, - this.modelCallback, - ); + const {viewer, dataSourceCollection, primitiveCollections} = + await initCesiumWidget( + this.element, + this.cesiumContext, + this.modelCallback, + ); + this.viewer = viewer; + this.dataSourceCollection = dataSourceCollection; this.dispatchEvent( - new CustomEvent('viewerInitialized', { - detail: this.viewer, + new CustomEvent('viewerInitialized', { + detail: { + viewer, + dataSourceCollection, + primitiveCollections, + }, }), ); } diff --git a/src/plugins/ui/ngv-layer-details.ts b/src/plugins/ui/ngv-layer-details.ts new file mode 100644 index 0000000..1d90dcc --- /dev/null +++ b/src/plugins/ui/ngv-layer-details.ts @@ -0,0 +1,65 @@ +import {css, html, LitElement} from 'lit'; +import type {HTMLTemplateResult} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +export type LayerDetails = { + name: string; +}; + +@customElement('ngv-layer-details') +export class NgvLayerDetails extends LitElement { + @property({type: Object}) + private layer: LayerDetails; + + static styles = css` + .info { + background-color: white; + display: flex; + flex-direction: column; + z-index: 1; + margin-left: auto; + margin-right: auto; + padding: 10px; + gap: 10px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.16); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05); + } + + button, + input[type='text'] { + border-radius: 4px; + padding: 0 16px; + height: 40px; + cursor: pointer; + background-color: white; + border: 1px solid rgba(0, 0, 0, 0.16); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05); + transition: background-color 200ms; + } + + input[type='text'] { + cursor: text; + } + `; + + render(): HTMLTemplateResult | string { + if (!this.layer) return ''; + return html`
+ ${this.layer.name} + +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ngv-layer-details': NgvLayerDetails; + } +} diff --git a/src/plugins/ui/ngv-layers-list.ts b/src/plugins/ui/ngv-layers-list.ts new file mode 100644 index 0000000..a5cfa40 --- /dev/null +++ b/src/plugins/ui/ngv-layers-list.ts @@ -0,0 +1,99 @@ +import {css, html, LitElement} from 'lit'; +import type {HTMLTemplateResult} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +export type LayerListItem = { + name: string; +}; + +export type LayerListOptions = { + title?: string; + showDeleteBtns?: boolean; + showZoomBtns?: boolean; +}; + +@customElement('ngv-layers-list') +export class NgvLayersList extends LitElement { + @property({type: Object}) + private layers: LayerListItem[]; + @property({type: Object}) + private options?: LayerListOptions; + + static styles = css` + .list { + background-color: white; + display: flex; + flex-direction: column; + z-index: 1; + margin-left: auto; + margin-right: auto; + padding: 10px; + gap: 10px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.16); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05); + } + + .item { + text-overflow: ellipsis; + display: flex; + align-items: center; + column-gap: 10px; + } + + .item span { + overflow: hidden; + text-overflow: ellipsis; + } + + button { + border-radius: 4px; + padding: 0 16px; + height: 40px; + cursor: pointer; + background-color: white; + border: 1px solid rgba(0, 0, 0, 0.16); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05); + transition: background-color 200ms; + } + `; + + render(): HTMLTemplateResult | string { + if (!this.layers?.length) return ''; + return html`

${this.options.title}

+
+ ${this.layers.map( + (l, i) => + html`
+ ${this.options?.showZoomBtns + ? html`` + : ''} + ${l.name} + ${this.options?.showDeleteBtns + ? html`` + : ''} +
`, + )} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ngv-layers-list': NgvLayersList; + } +} diff --git a/src/plugins/ui/ngv-upload.ts b/src/plugins/ui/ngv-upload.ts new file mode 100644 index 0000000..903cdb3 --- /dev/null +++ b/src/plugins/ui/ngv-upload.ts @@ -0,0 +1,137 @@ +import {customElement, property, query, state} from 'lit/decorators.js'; +import type {HTMLTemplateResult} from 'lit'; +import {css, html, LitElement} from 'lit'; +import {msg} from '@lit/localize'; + +export type NgvUploadOptions = { + accept?: string; + mainBtnText?: string; + urlInput?: boolean; + urlPlaceholderText?: string; + fileInput?: boolean; + uploadBtnText?: string; +}; + +export type FileUploadDetails = { + url: string; + name: string; +}; + +@customElement('ngv-upload') +export class NgvUpload extends LitElement { + @property({type: Object}) options: NgvUploadOptions; + @state() showPopup = false; + @state() fileDetails: FileUploadDetails | undefined; + @query('input[type="file"]') fileInput: HTMLInputElement; + + static styles = css` + .upload-popup { + background-color: white; + display: flex; + flex-direction: column; + margin-left: auto; + margin-right: auto; + padding: 10px; + gap: 10px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.16); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05); + } + + button, + input[type='text'], + input[type='file']::file-selector-button { + border-radius: 4px; + padding: 0 16px; + height: 40px; + cursor: pointer; + background-color: white; + border: 1px solid rgba(0, 0, 0, 0.16); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05); + margin-right: 16px; + transition: background-color 200ms; + } + + input[type='text'] { + cursor: text; + } + + input[type='file'] { + background-color: #f3f4f6; + cursor: pointer; + } + + input[type='file']::file-selector-button:active { + background-color: #e5e7eb; + } + `; + + async onFileUpload(file: File): Promise { + const arrayBufer = await file.arrayBuffer(); + const blob = new Blob([arrayBufer]); + this.fileDetails = { + url: URL.createObjectURL(blob), + name: file.name, + }; + } + + upload(): void { + this.dispatchEvent( + new CustomEvent('uploaded', { + detail: this.fileDetails, + }), + ); + this.fileInput.value = ''; + this.showPopup = false; + } + + override willUpdate(): void { + let options: NgvUploadOptions = { + mainBtnText: msg('Upload'), + uploadBtnText: msg('Upload'), + urlInput: true, + fileInput: true, + urlPlaceholderText: msg('Put file URL here'), + accept: '*/*', + }; + if (this.options) { + options = {...options, ...this.options}; + } + this.options = options; + } + + render(): HTMLTemplateResult { + return html`
+ { + const target = e.target; + if (!target || !target.files?.length) return; + await this.onFileUpload(target.files[0]); + }} + /> + + +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ngv-upload': NgvUpload; + } +} diff --git a/src/structure/ngv-structure-header.ts b/src/structure/ngv-structure-header.ts index 66da786..d5029ae 100644 --- a/src/structure/ngv-structure-header.ts +++ b/src/structure/ngv-structure-header.ts @@ -32,10 +32,13 @@ export class NgvStructureHeader extends LitElement { .searchContext=${headerConfig.searchContext} >` : ''} - - + ${this.config.authContext + ? html` + + ` + : ''}