diff --git a/examples-experimental/potree/package.json b/examples-experimental/potree/package.json index f8a535387d..51324db85d 100644 --- a/examples-experimental/potree/package.json +++ b/examples-experimental/potree/package.json @@ -17,8 +17,8 @@ "@deck.gl/layers": "^9.0.32", "@deck.gl/mesh-layers": "^9.0.32", "@deck.gl/react": "^9.0.32", - "@loaders.gl/potree": "4.4.0-alpha.0", - "@loaders.gl/loader-utils": "4.4.0-alpha.0", + "@loaders.gl/potree": "4.4.0-alpha.1", + "@loaders.gl/loader-utils": "4.4.0-alpha.1", "maplibre-gl": "^4.7.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/examples-experimental/potree/src/app.tsx b/examples-experimental/potree/src/app.tsx index 97717c80a0..7e7d803bb1 100644 --- a/examples-experimental/potree/src/app.tsx +++ b/examples-experimental/potree/src/app.tsx @@ -1,11 +1,11 @@ -import React, {useState, useEffect} from 'react'; +import React, {useState} from 'react'; import {createRoot} from 'react-dom/client'; -import Map from 'react-map-gl/maplibre'; +import {Map} from 'react-map-gl/maplibre'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; -import DeckGL from '@deck.gl/react'; +import {DeckGL} from '@deck.gl/react'; import {MapViewState} from '@deck.gl/core'; import {PotreeSource} from '@loaders.gl/potree'; @@ -14,14 +14,14 @@ import {PotreeTile3DLayer} from './potree-tile-3d-layer'; export const TRANSITION_DURAITON = 4000; const INITIAL_VIEW_STATE = { - longitude: -90, - latitude: 34, + longitude: 5.9822, + latitude: 51.805, pitch: 0, maxPitch: 90, bearing: 0, minZoom: 2, maxZoom: 30, - zoom: 3 + zoom: 15 }; export default function App() { @@ -30,6 +30,7 @@ export default function App() { function renderLayers() { const layers = new PotreeTile3DLayer({ data: 'https://raw.githubusercontent.com/visgl/deck.gl-data/refs/heads/master/formats/potree/1.8/3dm_32_291_5744_1_nw-converted', + pointSize: 5, source: PotreeSource }); return [layers]; @@ -41,8 +42,7 @@ export default function App() { diff --git a/examples-experimental/potree/src/pointcloud-tileset.ts b/examples-experimental/potree/src/pointcloud-tileset.ts index 6ea29daef3..7cee88b563 100644 --- a/examples-experimental/potree/src/pointcloud-tileset.ts +++ b/examples-experimental/potree/src/pointcloud-tileset.ts @@ -1,5 +1,7 @@ import {Vector3} from '@math.gl/core'; import {DataSource} from '@loaders.gl/loader-utils'; +import {POTreeNode} from '@loaders.gl/potree'; +import {PotreeTraverser} from './potree-traverser'; /** Deck.gl Viewport instance type. * We can't import it from Deck.gl to avoid circular reference */ @@ -19,24 +21,35 @@ export type Viewport = { }; type PointcloudTilesetProps = { - /** Delay time before the tileset traversal. It prevents traversal requests spam.*/ - debounceTime: number; - }; - - const DEFAULT_PROPS: PointcloudTilesetProps = { - debounceTime: 0, - }; + /** Delay time before the tileset traversal. It prevents traversal requests spam.*/ + debounceTime: number; + + onTileLoaded?: (node: POTreeNode) => void; +}; + +const DEFAULT_PROPS: PointcloudTilesetProps = { + debounceTime: 0 +}; export class PointcloudTileset { private frameNumber: number = 0; private lastUpdatedVieports: Viewport[] | Viewport | null = null; private updatePromise: Promise | null = null; + private _selectedNodes: POTreeNode[] = []; + private traverser: PotreeTraverser; public options: PointcloudTilesetProps; - constructor(public dataSource: DataSource, options?: Partial) { - // PUBLIC MEMBERS + constructor( + public dataSource: DataSource, + options?: Partial + ) { this.options = {...DEFAULT_PROPS, ...options}; + this.traverser = new PotreeTraverser(); + + this.dataSource.init().then(() => { + this.traverser.root = this.dataSource.root; + }); } get isReady() { @@ -45,7 +58,7 @@ export class PointcloudTileset { } get tiles() { - return []; + return this._selectedNodes; } get isLoaded() { @@ -61,12 +74,42 @@ export class PointcloudTileset { if (!this.updatePromise) { this.updatePromise = new Promise((resolve) => { setTimeout(() => { - console.log("update viewport"); - resolve(this.frameNumber++); + if (this.lastUpdatedVieports) { + this.doUpdate(this.lastUpdatedVieports); + } + + resolve(this.frameNumber); this.updatePromise = null; }, this.options.debounceTime); }); } return this.updatePromise; } + + doUpdate(viewports: Viewport[] | Viewport): void { + const preparedViewports = viewports instanceof Array ? viewports : [viewports]; + + this.frameNumber++; + this._selectedNodes = this.traverser.traverse(preparedViewports); + this.loadNodesContent(this.traverser.nodesToLoad); + } + + loadNodesContent(nodes: POTreeNode[]) { + for (const node of nodes) { + this.loadNodeContent(node); + } + } + + async loadNodeContent(node: POTreeNode) { + node.isContentLoading = true; + node.content = await this.dataSource.loadNodeContent(node.name); + this.onTileLoaded(node); + node.isContentLoading = false; + } + + onTileLoaded(node: POTreeNode) { + if (this.options.onTileLoaded) { + this.options.onTileLoaded(node); + } + } } diff --git a/examples-experimental/potree/src/potree-tile-3d-layer.ts b/examples-experimental/potree/src/potree-tile-3d-layer.ts index 846439984c..d43562c2de 100644 --- a/examples-experimental/potree/src/potree-tile-3d-layer.ts +++ b/examples-experimental/potree/src/potree-tile-3d-layer.ts @@ -1,8 +1,8 @@ import {Tile3DLayer, Tile3DLayerProps} from '@deck.gl/geo-layers'; -import {UpdateParameters} from '@deck.gl/core/typed'; -import {Viewport} from '@deck.gl/core'; +import {Viewport, UpdateParameters, COORDINATE_SYSTEM} from '@deck.gl/core'; import {Source} from '@loaders.gl/loader-utils'; import {PointcloudTileset} from './pointcloud-tileset'; +import { PointCloudLayer } from '@deck.gl/layers'; export type PotreeTile3DLayerProps = { source: Source; @@ -76,4 +76,54 @@ export class PotreeTile3DLayer< } }); } + + private _makePointCloudLayer( + tileHeader: Tile3D, + oldLayer?: PointCloudLayer + ): PointCloudLayer | null { + const { + attributes, + pointCount, + constantRGBA, + cartographicOrigin, + modelMatrix, + coordinateSystem = COORDINATE_SYSTEM.METER_OFFSETS + } = tileHeader.content; + const {positions, normals, colors} = attributes; + + if (!positions) { + return null; + } + const data = (oldLayer && oldLayer.props.data) || { + header: { + vertexCount: pointCount + }, + attributes: { + POSITION: positions, + NORMAL: normals, + COLOR_0: colors + } + }; + + const {pointSize, getPointColor} = this.props; + const SubLayerClass = this.getSubLayerClass('pointcloud', PointCloudLayer); + return new SubLayerClass( + { + pointSize + }, + this.getSubLayerProps({ + id: 'pointcloud' + }), + { + id: `${this.id}-pointcloud-${tileHeader.id}`, + tile: tileHeader, + data, + coordinateSystem, + coordinateOrigin: cartographicOrigin, + modelMatrix, + getColor: constantRGBA || getPointColor, + _offset: 0 + } + ); + } } diff --git a/examples-experimental/potree/src/potree-traverser.ts b/examples-experimental/potree/src/potree-traverser.ts new file mode 100644 index 0000000000..62dd96a854 --- /dev/null +++ b/examples-experimental/potree/src/potree-traverser.ts @@ -0,0 +1,37 @@ +import {POTreeNode} from "@loaders.gl/potree"; + +export class PotreeTraverser { + root?: POTreeNode; + nodesToLoad: POTreeNode[] = []; + + traverse(viewports: {id: string}[]) { + if (!this.root) { + return []; + } + const viewportIds = viewports.map(item => item.id); + + this.nodesToLoad = []; + const result: POTreeNode[] = []; + const stack: POTreeNode[] = [this.root]; + + while (stack.length > 0) { + const node = stack.pop(); + + if (!node) { + // eslint-disable-next-line no-continue + continue; + } + + for (const child of node?.children ?? []) { + stack.push(child); + } + node.selected = true; + node.viewportIds = viewportIds; + result.push(node); + if (!node.content && !node.isContentLoading) { + this.nodesToLoad.push(node); + } + } + return result; + } +} \ No newline at end of file diff --git a/examples-experimental/potree/tsconfig.json b/examples-experimental/potree/tsconfig.json index e4a0bcf5e2..8cd945d3c7 100644 --- a/examples-experimental/potree/tsconfig.json +++ b/examples-experimental/potree/tsconfig.json @@ -2,5 +2,9 @@ "compilerOptions": { "jsx": "react-jsx" }, - "include": ["./app.js"] + "include": ["./app.js"], + "baseUrl": "../..", + "paths": { + "@loaders.gl/potree": ["modules/potree/src"] + } } diff --git a/examples-experimental/potree/vite.config.ts b/examples-experimental/potree/vite.config.ts index 0dfc6a2510..58f7bc79a6 100644 --- a/examples-experimental/potree/vite.config.ts +++ b/examples-experimental/potree/vite.config.ts @@ -17,6 +17,6 @@ const getAliases = async (frameworkName, frameworkRootDir) => { // https://vitejs.dev/config/ export default defineConfig(async () => ({ - resolve: {alias: await getAliases('@loaders.gl', `${__dirname}/../../..`)}, + resolve: {alias: await getAliases('@loaders.gl', `${__dirname}/../..`)}, server: {open: true} })); diff --git a/modules/potree/package.json b/modules/potree/package.json index 72323c6bc7..59945ebfbe 100644 --- a/modules/potree/package.json +++ b/modules/potree/package.json @@ -47,7 +47,8 @@ "@loaders.gl/las": "4.4.0-alpha.1", "@loaders.gl/math": "4.4.0-alpha.1", "@loaders.gl/schema": "4.4.0-alpha.1", - "@math.gl/core": "^4.1.0" + "@math.gl/core": "^4.1.0", + "@math.gl/proj4": "^4.1.0" }, "peerDependencies": { "@loaders.gl/core": "4.4.0-alpha.0" diff --git a/modules/potree/src/index.ts b/modules/potree/src/index.ts index 15cb6b7dbe..1e8bf05466 100644 --- a/modules/potree/src/index.ts +++ b/modules/potree/src/index.ts @@ -2,3 +2,5 @@ export {PotreeLoader} from './potree-loader'; export {PotreeHierarchyChunkLoader} from './potree-hierarchy-chunk-loader'; export {PotreeBinLoader} from './potree-bin-loader'; export {PotreeSource} from './potree-source'; + +export {type POTreeNode} from './parsers/parse-potree-hierarchy-chunk'; diff --git a/modules/potree/src/lib/potree-node-source.ts b/modules/potree/src/lib/potree-node-source.ts index e4ec79762b..6eb49aab3c 100644 --- a/modules/potree/src/lib/potree-node-source.ts +++ b/modules/potree/src/lib/potree-node-source.ts @@ -2,16 +2,45 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors +import type {PotreeSourceOptions} from '../potree-source'; import {load} from '@loaders.gl/core'; import {Mesh} from '@loaders.gl/schema'; import {DataSource, resolvePath} from '@loaders.gl/loader-utils'; import {LASLoader} from '@loaders.gl/las'; -import {PotreeMetadata} from '../types/potree-metadata'; +import {PotreeBoundingBox, PotreeMetadata} from '../types/potree-metadata'; import {POTreeNode} from '../parsers/parse-potree-hierarchy-chunk'; import {PotreeHierarchyChunkLoader} from '../potree-hierarchy-chunk-loader'; import {PotreeLoader} from '../potree-loader'; import {parseVersion} from '../utils/parse-version'; -import type {PotreeSourceOptions} from '../potree-source'; +import {Proj4Projection} from '@math.gl/proj4'; + +// https://github.com/visgl/deck.gl/blob/9548f43cba2234a1f4877b6b17f6c88eb35b2e08/modules/core/src/lib/constants.js#L27 +// Describes the format of positions +export enum COORDINATE_SYSTEM { + /** + * `LNGLAT` if rendering into a geospatial viewport, `CARTESIAN` otherwise + */ + DEFAULT = -1, + /** + * Positions are interpreted as [lng, lat, elevation] + * lng lat are degrees, elevation is meters. distances as meters. + */ + LNGLAT = 1, + /** + * Positions are interpreted as meter offsets, distances as meters + */ + METER_OFFSETS = 2, + /** + * Positions are interpreted as lng lat offsets: [deltaLng, deltaLat, elevation] + * deltaLng, deltaLat are delta degrees, elevation is meters. + * distances as meters. + */ + LNGLAT_OFFSETS = 3, + /** + * Non-geospatial + */ + CARTESIAN = 0 +} /** * A Potree data source @@ -28,6 +57,8 @@ export class PotreeNodesSource extends DataSource { root: POTreeNode | null = null; /** Is data source ready to use after initial loading */ isReady = false; + /** The data set minimum bounding box */ + boundingBox?: PotreeBoundingBox; private initPromise: Promise | null = null; @@ -51,6 +82,8 @@ export class PotreeNodesSource extends DataSource { return; } this.metadata = await load(`${this.baseUrl}/cloud.js`, PotreeLoader); + this.parseBoundingVolume(); + await this.loadHierarchy(); this.isReady = true; } @@ -84,36 +117,86 @@ export class PotreeNodesSource extends DataSource { /** * Load octree node content - * @param path array of numbers between 0-7 specifying successive octree divisions. + * @param nodeName name of a node, string of numbers in range 0..7 * @return node content geometry or null if the node doesn't exist */ - async loadNodeContent(path: number[]): Promise { + // eslint-disable-next-line max-statements + async loadNodeContent(nodeName: string): Promise { await this.initPromise; if (!this.isSupported()) { return null; } - const isAvailable = await this.isNodeAvailable(path); + const isAvailable = await this.isNodeAvailable(nodeName); if (isAvailable) { - return load( - `${this.baseUrl}/${this.metadata?.octreeDir}/r/r${path.join( - '' - )}.${this.getContentExtension()}`, + const result = await load( + `${this.baseUrl}/${this.metadata?.octreeDir}/r/r${nodeName}.${this.getContentExtension()}`, LASLoader ); + + if (result) { + let projection; + if (this.metadata?.projection) { + projection = new Proj4Projection({ + from: this.metadata.projection, + to: 'WGS84' + }); + } + + result.cartographicOrigin = [0, 0, 0]; + const tileBoundingBox = result.header?.boundingBox; + if (tileBoundingBox) { + const [minXOriginal, minYOriginal, minZ] = tileBoundingBox[0]; + const [maxXOriginal, maxYOriginal, maxZ] = tileBoundingBox[1]; + let minX = minXOriginal; + let minY = minYOriginal; + let maxX = maxXOriginal; + let maxY = maxYOriginal; + if (projection) { + [minX, minY] = projection.project([minX, minY]); + [maxX, maxY] = projection.project([maxX, maxY]); + } + result.cartographicOrigin = [ + minX + (maxX - minX) / 2, + minY + (maxY - minY) / 2, + minZ + (maxZ - minZ) / 2 + ]; + } + + const position = result.attributes.POSITION.value as Float32Array; + for (let i = 0; i < (result.header?.vertexCount ?? 0); i++) { + let vertex = position.slice(i * 3, i * 3 + 2); + if (projection) { + vertex = projection.project(Array.from(vertex)); + } + + const offsets = [ + vertex[0] - result.cartographicOrigin[0], + vertex[1] - result.cartographicOrigin[1], + position[i * 3 + 2] - result.cartographicOrigin[2] + ]; + position.set(offsets, i * 3); + } + result.attributes.positions = result.attributes.POSITION; + result.attributes.colors = result.attributes.COLOR_0; + result.attributes.normals = result.attributes.NORMAL; + + result.coordinateSystem = COORDINATE_SYSTEM.LNGLAT_OFFSETS; + return result; + } } return null; } /** * Check if a node exists in the octree - * @param path array of numbers between 0-7 specifying successive octree divisions + * @param nodeName name of a node, string of numbers in range 0..7 * @returns true - the node does exist, false - the nodes doesn't exist */ - async isNodeAvailable(path: number[]): Promise { + async isNodeAvailable(nodeName: string): Promise { if (this.metadata?.hierarchy) { - return this.metadata.hierarchy.findIndex((item) => item[0] === `r${path.join()}`) !== -1; + return this.metadata.hierarchy.findIndex((item) => item[0] === `r${nodeName}`) !== -1; } if (!this.root) { @@ -122,8 +205,8 @@ export class PotreeNodesSource extends DataSource { let currentParent = this.root; let name = ''; let result = true; - for (const nodeLevel of path) { - const newName = `${name}${nodeLevel}`; + for (const char of nodeName) { + const newName = `${name}${char}`; const node = currentParent.children.find((child) => child.name === newName); if (node) { currentParent = node; @@ -159,4 +242,30 @@ export class PotreeNodesSource extends DataSource { this.baseUrl = this.baseUrl.substring(0, -1); } } + + private parseBoundingVolume(): void { + if (this.metadata?.projection && this.metadata.tightBoundingBox) { + const projection = new Proj4Projection({ + from: this.metadata.projection, + to: 'WGS84' + }); + + const {lx, ly, ux, uy} = this.metadata.tightBoundingBox; + const lCoord = [lx, ly]; + const wgs84LCood = projection.project(lCoord); + + const uCoord = [ux, uy]; + const wgs84UCood = projection.project(uCoord); + + this.boundingBox = { + ...this.metadata.tightBoundingBox, + lx: wgs84LCood[0], + ly: wgs84LCood[1], + ux: wgs84UCood[0], + uy: wgs84UCood[1] + }; + } else { + this.boundingBox = this.metadata?.tightBoundingBox; + } + } } diff --git a/modules/potree/src/parsers/parse-potree-hierarchy-chunk.ts b/modules/potree/src/parsers/parse-potree-hierarchy-chunk.ts index 6d685038fc..3cb4f34069 100644 --- a/modules/potree/src/parsers/parse-potree-hierarchy-chunk.ts +++ b/modules/potree/src/parsers/parse-potree-hierarchy-chunk.ts @@ -66,6 +66,8 @@ export type POTreeTileHeader = { /** Hierarchical potree node structure */ export type POTreeNode = { + id: string; + type: 'pointcloud'; /** Index data */ header: POTreeTileHeader; /** Human readable name */ @@ -82,6 +84,14 @@ export type POTreeNode = { children: POTreeNode[]; /** All children including unavailable */ childrenByIndex: POTreeNode[]; + /** Is tile selected for rendering */ + selected: boolean; + /** Points content data */ + content?: unknown; + /** Is content loading */ + isContentLoading?: boolean; + /** Viewport Ids */ + viewportIds: unknown[]; }; /** @@ -178,6 +188,8 @@ function buildHierarchy(flatNodes: POTreeNode[], options: {spacing?: number} = { node.children = []; node.childrenByIndex = new Array(8).fill(null); node.spacing = (options?.spacing || 0) / Math.pow(2, level); + node.type = 'pointcloud'; + node.id = node.name; // tileHeader.boundingVolume = Utils.createChildAABB(parentNode.boundingBox, index); if (parentNode) { diff --git a/modules/potree/test/potree-source.spec.ts b/modules/potree/test/potree-source.spec.ts index f2ec681f65..68da219d8b 100644 --- a/modules/potree/test/potree-source.spec.ts +++ b/modules/potree/test/potree-source.spec.ts @@ -23,7 +23,7 @@ test('PotreeSource#loadNodeContent - should return null for unsupported source', const DS = PotreeSource; const source = DS.createDataSource(POTREE_BIN_URL, {}); - const existingNodeContent = await source.loadNodeContent([3, 6, 0]); + const existingNodeContent = await source.loadNodeContent('360'); t.equals(existingNodeContent, null); t.end(); @@ -37,7 +37,7 @@ test('PotreeSource#loadNodeContent', async (t) => { t.ok(source.isSupported()); - const existingNodeContent = await source.loadNodeContent([2, 4, 6]); + const existingNodeContent = await source.loadNodeContent('246'); t.equals(existingNodeContent?.header?.vertexCount, 9933); t.end(); diff --git a/yarn.lock b/yarn.lock index 29df162903..c76bf64d7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5265,6 +5265,7 @@ __metadata: "@loaders.gl/math": "npm:4.4.0-alpha.1" "@loaders.gl/schema": "npm:4.4.0-alpha.1" "@math.gl/core": "npm:^4.1.0" + "@math.gl/proj4": "npm:^4.1.0" peerDependencies: "@loaders.gl/core": 4.4.0-alpha.0 languageName: unknown