From bfb00673d4fb5eabef34df25cf3245c5c0c90003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Wed, 1 Oct 2025 11:34:47 +0200 Subject: [PATCH 01/43] refactor(Tiles3D): address FIXME --- src/entities/Tiles3D.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/entities/Tiles3D.ts b/src/entities/Tiles3D.ts index 94dfad16de..5cca7f51e8 100644 --- a/src/entities/Tiles3D.ts +++ b/src/entities/Tiles3D.ts @@ -287,12 +287,6 @@ export default class Tiles3D new GLTFExtensionsPlugin({ dracoLoader, ktxLoader, - // FIXME the following parameters are optional but the .d.ts file makes them mandatory - // https://github.com/NASA-AMMOS/3DTilesRendererJS/pull/908 - metadata: true, - rtc: true, - autoDispose: true, - plugins: [], }), ); -- GitLab From a044d2ce2f159519c5c15909fb0b14569c1b14c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Wed, 1 Oct 2025 11:35:18 +0200 Subject: [PATCH 02/43] refactor(EntityInspector): remove monkey-patched traverseOnce --- src/gui/EntityInspector.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/gui/EntityInspector.ts b/src/gui/EntityInspector.ts index 5d56e8c6de..ea07feecf9 100644 --- a/src/gui/EntityInspector.ts +++ b/src/gui/EntityInspector.ts @@ -5,8 +5,9 @@ */ import type GUI from 'lil-gui'; +import type { Object3D } from 'three'; -import { Color, Object3D, Plane, PlaneHelper, Vector3, type ColorRepresentation } from 'three'; +import { Color, Plane, PlaneHelper, Vector3, type ColorRepresentation } from 'three'; import type HasDefaultPointOfView from '../core/HasDefaultPointOfView'; import type Instance from '../core/Instance'; @@ -30,9 +31,8 @@ const _tempArray: Object3D[] = []; * * @param callback - The callback to call for each visited object. */ -// @ts-expect-error monkey patching // FIXME -Object3D.prototype.traverseOnce = function traverseOnce(callback: (obj: Object3D) => void): void { - this.traverse((o: Object3D) => _tempArray.push(o)); +function traverseOnce(root: Object3D, callback: (obj: Object3D) => void): void { + root.traverse((o: Object3D) => _tempArray.push(o)); while (_tempArray.length > 0) { const obj = _tempArray.pop(); @@ -40,7 +40,7 @@ Object3D.prototype.traverseOnce = function traverseOnce(callback: (obj: Object3D callback(obj); } } -}; +} class ClippingPlanePanel extends Panel { public entity: Entity3D; @@ -293,8 +293,7 @@ class EntityInspector extends Panel { const color = new Color(this.boundingBoxColor); // by default, adds axis-oriented bounding boxes to each object in the hierarchy. // custom implementations may override this to have a different behaviour. - // @ts-expect-error traverseOnce() is monkey patched - this.rootObject.traverseOnce(obj => this.addOrRemoveBoundingBox(obj, visible, color)); + traverseOnce(this.rootObject, obj => this.addOrRemoveBoundingBox(obj, visible, color)); this.notify(this.entity); } -- GitLab From 906fbd02a4ad1825bce9e6634d2a32b6358094fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Thu, 26 Jun 2025 11:53:31 +0200 Subject: [PATCH 03/43] fix(Layer): change the target state after applying the texture to it --- src/core/layer/Layer.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/layer/Layer.ts b/src/core/layer/Layer.ts index 43b8c1b5db..6c3638a8fc 100644 --- a/src/core/layer/Layer.ts +++ b/src/core/layer/Layer.ts @@ -1384,17 +1384,17 @@ abstract class Layer< target.textureIsFinal = isLastRender; - if (isLastRender) { - this.setTargetState(target, TargetState.Complete); - } else { - this.setTargetState(target, TargetState.Pending); - } - target.paintCount++; const texture = nonNull(target.renderTarget).object.texture; this.applyTextureToNode({ texture, pitch }, target, isLastRender); this.instance.notifyChange(this); + + if (isLastRender) { + this.setTargetState(target, TargetState.Complete); + } else { + this.setTargetState(target, TargetState.Pending); + } } private setTargetState(target: Target, state: TargetState): void { -- GitLab From b36a96f4665e0bb93515cdac2cbb6a78cdbb9e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Tue, 24 Jun 2025 11:27:50 +0200 Subject: [PATCH 04/43] feat(Map): add events for tile creation and deletion --- src/entities/Map.ts | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/entities/Map.ts b/src/entities/Map.ts index aeac6ecf5a..c3e37e3b34 100644 --- a/src/entities/Map.ts +++ b/src/entities/Map.ts @@ -18,6 +18,7 @@ import { type Camera, type ColorRepresentation, type Intersection, + type Mesh, type Object3D, type Side, type TextureDataType, @@ -92,6 +93,20 @@ import PlanarTileVolume from './tiles/PlanarTileVolume'; import TileIndex, { type NeighbourList } from './tiles/TileIndex'; import TileMesh, { isTileMesh } from './tiles/TileMesh'; +/** + * Interface for Map tiles. + */ +export interface Tile extends Mesh { + /** + * The level of detail (LOD) of the tile. LOD 0 means the tile is a root tile. + */ + lod: number; + /** + * The geographic extent of the tile. If the tile has LOD 0, then it is the same as the extent of its parent map. + */ + extent: Extent; +} + /** * A function that allows subdivision of the specified tile. * If the function returns `true`, the node can be subdivided. @@ -100,18 +115,7 @@ export type MapSubdivisionStrategy = ( /** * The tile to subdivide. */ - tile: Readonly< - Object3D & { - /** - * The level of detail (LOD) of the tile. LOD 0 means the tile is a root tile. - */ - lod: number; - /** - * The geographic extent of the tile. - */ - extent: Extent; - } - >, + tile: Readonly, context: { entity: Readonly; layers: readonly Readonly[] }, ) => boolean; @@ -384,6 +388,10 @@ export interface MapEventMap extends Entity3DEventMap { 'elevation-changed': { extent: Extent }; /** Fires when all tiles are painted */ 'paint-complete': unknown; + /** Fires when a tile is created. */ + 'tile-created': { tile: Tile }; + /** Fires when a tile is deleted. */ + 'tile-deleted': { tile: Tile }; } export type MapConstructorOptions = { @@ -1229,6 +1237,8 @@ class Map this.updateObject(tile); + this.dispatchEvent({ type: 'tile-created', tile }); + this.onObjectCreated(tile); if (parent) { @@ -1868,6 +1878,7 @@ class Map private disposeTile(tile: TileMesh): void { tile.traverseTiles(desc => { desc.dispose(); + this.dispatchEvent({ type: 'tile-deleted', tile: desc }); this._allTiles.delete(desc); }); } -- GitLab From c37cba3a898f647abde1e748d2556500d49ece8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Tue, 24 Jun 2025 18:05:42 +0200 Subject: [PATCH 05/43] feat(PromiseUtils): add batch() to split computations in several slices --- src/utils/PromiseUtils.ts | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/utils/PromiseUtils.ts b/src/utils/PromiseUtils.ts index 15f647a33b..44be1707ee 100644 --- a/src/utils/PromiseUtils.ts +++ b/src/utils/PromiseUtils.ts @@ -14,6 +14,10 @@ function delay(duration: number): Promise { return new Promise(resolve => setTimeout(resolve, duration)); } +function nextFrame(): Promise { + return new Promise(resolve => requestAnimationFrame(_ => resolve())); +} + export enum PromiseStatus { Fullfilled = 'fulfilled', Rejected = 'rejected', @@ -35,8 +39,61 @@ function abortError(): Error { return new AbortError(); } +const SLICE_DURATION_MILLISECONDS = 4; + +function batch( + items: I[], + transformer: (obj: I, index: number) => O | null, + options?: { + outputItems?: O[]; + start?: number; + signal?: AbortSignal; + }, +): Promise { + const result: O[] = options?.outputItems ?? []; + + const processSlice = (start: number) => { + const begin = performance.now(); + + for (let i = start; i < items.length; i++) { + const input = items[i]; + const output = transformer(input, i); + + if (output != null) { + result.push(output); + } + + const elapsed = performance.now() - begin; + + if (elapsed > SLICE_DURATION_MILLISECONDS) { + const nextStart = i + 1; + return Promise.resolve(nextStart); + } + } + + options?.signal?.throwIfAborted(); + + return Promise.resolve(undefined); + }; + + return processSlice(options?.start ?? 0).then(async nextStart => { + if (nextStart != null) { + options?.signal?.throwIfAborted(); + + await batch(items, transformer, { + outputItems: result, + signal: options?.signal, + start: nextStart, + }); + } + return result; + }); +} + export default { delay, PromiseStatus, abortError, + nextFrame, + batch, }; -- GitLab From 7ddee67f51db7fa35b31f7590e107232b8d2f93b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Thu, 26 Jun 2025 11:50:09 +0200 Subject: [PATCH 06/43] feat(Map): add elevation-loaded event when elevation is loaded on a tile --- src/core/layer/Layer.ts | 5 +++-- src/entities/Map.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/core/layer/Layer.ts b/src/core/layer/Layer.ts index 6c3638a8fc..6d64a97f00 100644 --- a/src/core/layer/Layer.ts +++ b/src/core/layer/Layer.ts @@ -247,7 +247,8 @@ export interface LayerEvents { /** * Fires when a node has been completed. */ - 'node-complete': { node: LayerNode }; + // eslint-disable-next-line no-use-before-define + 'node-complete': { node: LayerNode; layer: Layer }; } export interface LayerOptions { @@ -1405,7 +1406,7 @@ abstract class Layer< target.state = state; if (state === TargetState.Complete) { - this.dispatchEvent({ type: 'node-complete', node: target.node }); + this.dispatchEvent({ type: 'node-complete', node: target.node, layer: this }); } } diff --git a/src/entities/Map.ts b/src/entities/Map.ts index c3e37e3b34..5e15769e88 100644 --- a/src/entities/Map.ts +++ b/src/entities/Map.ts @@ -40,6 +40,7 @@ import type ColorLayer from '../core/layer/ColorLayer'; import type ElevationLayer from '../core/layer/ElevationLayer'; import type HasLayers from '../core/layer/HasLayers'; import type Layer from '../core/layer/Layer'; +import type { LayerEvents } from '../core/layer/Layer'; import type MemoryUsage from '../core/MemoryUsage'; import type Pickable from '../core/picking/Pickable'; import type PickableFeatures from '../core/picking/PickableFeatures'; @@ -386,6 +387,8 @@ export interface MapEventMap extends Entity3DEventMap { 'layer-removed': { layer: Layer }; /** Fires when elevation data has changed on a specific extent of the map. */ 'elevation-changed': { extent: Extent }; + /** Fires when (final, non-interim) elevation data has been loaded for a specific tile */ + 'elevation-loaded': { tile: Tile }; /** Fires when all tiles are painted */ 'paint-complete': unknown; /** Fires when a tile is created. */ @@ -608,7 +611,7 @@ class Map private readonly _layerIds: Set = new Set(); private readonly _materialOptions: MaterialOptions; private readonly _subdivisionStrategy: MapSubdivisionStrategy; - private readonly _onNodeComplete: () => void; + private readonly _onNodeComplete: (e: LayerEvents['node-complete']) => void; private _paintCompleteTimeout: NodeJS.Timeout | null = null; private _geometryBuilder: TileGeometryBuilder | null = null; @@ -696,11 +699,15 @@ class Map return this._layers.some(l => l.loading); } - private onNodeComplete(): void { + private onNodeComplete(e: LayerEvents['node-complete']): void { if (this._paintCompleteTimeout) { clearTimeout(this._paintCompleteTimeout); } + if (isElevationLayer(e.layer)) { + this.dispatchEvent({ type: 'elevation-loaded', tile: e.node as unknown as Tile }); + } + this._paintCompleteTimeout = setTimeout(this.evaluatePaintComplete.bind(this), 500); } -- GitLab From 683623bb04efd8d7fc486036d84940f1c39f739e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Thu, 26 Jun 2025 11:51:21 +0200 Subject: [PATCH 07/43] feat(ElevationProvider): add getElevationFast() --- src/core/ElevationProvider.ts | 27 ++++++++++++++++++++++ src/core/geographic/Extent.ts | 30 ++++++++++++++++++++---- src/entities/Map.ts | 42 +++++++++++++++++++++++++++++++++- src/entities/tiles/TileMesh.ts | 30 +++++++++++++++++++++++- 4 files changed, 123 insertions(+), 6 deletions(-) diff --git a/src/core/ElevationProvider.ts b/src/core/ElevationProvider.ts index 284392678e..a0d5fc9228 100644 --- a/src/core/ElevationProvider.ts +++ b/src/core/ElevationProvider.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: MIT */ +import type ElevationSample from './ElevationSample'; import type GetElevationOptions from './GetElevationOptions'; import type GetElevationResult from './GetElevationResult'; @@ -13,6 +14,12 @@ import type GetElevationResult from './GetElevationResult'; * Note: to combine multiple providers into one, you can use the {@link aggregateElevationProviders} function. */ export default interface ElevationProvider { + /** + * Returns the elevation at the specified coordinates, without any coordinate conversion. + * @param x - The X coordinate of the location to sample, in the same coordinate system as this elevation provider. + * @param y - The Y coordinate of the location to sample, in the same coordinate system as this elevation provider. + */ + getElevationFast(x: number, y: number): ElevationSample | undefined; /** * Sample the elevation at the specified coordinate. * @@ -35,6 +42,26 @@ class AggregateProvider implements ElevationProvider { this._providers = providers; } + public getElevationFast(x: number, y: number): ElevationSample | undefined { + const samples: ElevationSample[] = []; + // Accumulate elevation samples from all providers. + for (let i = 0; i < this._providers.length; i++) { + const provider = this._providers[i]; + const sample = provider.getElevationFast(x, y); + if (sample) { + samples.push(sample); + } + } + + if (samples.length > 0) { + samples.sort((a, b) => a.resolution - b.resolution); + + return samples[0]; + } + + return undefined; + } + public getElevation( options: GetElevationOptions, result?: GetElevationResult, diff --git a/src/core/geographic/Extent.ts b/src/core/geographic/Extent.ts index a02fcc7cbd..f401d74a92 100644 --- a/src/core/geographic/Extent.ts +++ b/src/core/geographic/Extent.ts @@ -465,6 +465,24 @@ class Extent { return result; } + getQuadrant(x: number, y: number): 0 | 1 | 2 | 3 { + const dims = this.dimensions(tmpXY); + const midX = this.west + dims.width / 2; + const midY = this.south + dims.height / 2; + + if (x < midX) { + if (y < midY) { + return 0; + } + return 1; + } else { + if (y < midY) { + return 3; + } + return 2; + } + } + /** * Sets the target with the width and height of this extent. * The `x` property will be set with the width, @@ -498,11 +516,15 @@ class Extent { c.latitude >= this.south - epsilon ); } + return this.isXYInside(c.x, c.y, epsilon); + } + + isXYInside(x: number, y: number, epsilon = 0): boolean { return ( - c.x <= this.east + epsilon && - c.x >= this.west - epsilon && - c.y <= this.north + epsilon && - c.y >= this.south - epsilon + x <= this.east + epsilon && + x >= this.west - epsilon && + y <= this.north + epsilon && + y >= this.south - epsilon ); } diff --git a/src/entities/Map.ts b/src/entities/Map.ts index 5e15769e88..a7001dc211 100644 --- a/src/entities/Map.ts +++ b/src/entities/Map.ts @@ -30,7 +30,7 @@ import type Context from '../core/Context'; import type ContourLineOptions from '../core/ContourLineOptions'; import type ElevationProvider from '../core/ElevationProvider'; import type ElevationRange from '../core/ElevationRange'; -import type CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; +import type ElevationSample from '../core/ElevationSample'; import type Extent from '../core/geographic/Extent'; import type GetElevationOptions from '../core/GetElevationOptions'; import type GetElevationResult from '../core/GetElevationResult'; @@ -55,6 +55,7 @@ import type { TileGeometryBuilder } from './tiles/TileGeometry'; import type TileVolume from './tiles/TileVolume'; import { defaultColorimetryOptions } from '../core/ColorimetryOptions'; +import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; import Coordinates from '../core/geographic/Coordinates'; import { isColorLayer } from '../core/layer/ColorLayer'; import { isElevationLayer } from '../core/layer/ElevationLayer'; @@ -192,6 +193,7 @@ const tmpVector = new Vector3(); const tmpBox3 = new Box3(); const tempNDC = new Vector2(); const tempDims = new Vector2(); +const tempElevationCoords = new Coordinates(CoordinateSystem.epsg4326, 0, 0); const tempCanvasCoords = new Vector2(); const tmpSseSizes: [number, number] = [0, 0]; const tmpIntersectList: Intersection[] = []; @@ -1952,6 +1954,44 @@ class Map return { min: 0, max: 0 }; } + private getRootTileThatContainsXY(x: number, y: number): TileMesh { + for (const root of this._rootTiles) { + if (root.extent.isXYInside(x, y)) { + return root; + } + } + + throw new Error('no root tile contains the coordinate'); + } + + public getElevationFast(x: number, y: number): ElevationSample | undefined { + const elevationLayer = this.getElevationLayers()[0]; + + if (!elevationLayer.visible) { + return undefined; + } + + if (!this.extent.isXYInside(x, y)) { + return undefined; + } + + const root = this.getRootTileThatContainsXY(x, y); + const leaf = root.getLeafThatContains(x, y); + + tempElevationCoords.set(this.instance.coordinateSystem, x, y); + + const result = leaf?.getElevation({ coordinates: tempElevationCoords }); + + if (result == null) { + return undefined; + } + + return { + ...result, + source: this, + } satisfies ElevationSample; + } + /** * Sample the elevation at the specified coordinate. * diff --git a/src/entities/tiles/TileMesh.ts b/src/entities/tiles/TileMesh.ts index 04f3633366..df669bedae 100644 --- a/src/entities/tiles/TileMesh.ts +++ b/src/entities/tiles/TileMesh.ts @@ -121,6 +121,13 @@ class TileMesh private _skirtDepth: number | undefined; private _minmax: { min: number; max: number } = { min: -Infinity, max: +Infinity }; private _shouldUpdateHeightMap = false; + // eslint-disable-next-line no-use-before-define + private _childTiles: [TileMesh | null, TileMesh | null, TileMesh | null, TileMesh | null] = [ + null, + null, + null, + null, + ]; private readonly _helpers: { root: Group | null; @@ -139,7 +146,7 @@ class TileMesh } | null = null; public disposed = false; - public isLeaf = false; + public isLeaf = true; public getMemoryUsage(context: GetMemoryUsageContext): void { this.material?.getMemoryUsage(context); @@ -475,6 +482,12 @@ class TileMesh tile.updateMatrix(); tile.updateMatrixWorld(); + const center = tile.extent.centerAsVector2(tempVec2); + const quadrant = this.extent.getQuadrant(center.x, center.y); + + this._childTiles[quadrant] = tile; + this.isLeaf = false; + if (this._heightMap) { const heightMap = this._heightMap.payload; const inheritedHeightMap = heightMap.clone(); @@ -842,6 +855,7 @@ class TileMesh const childTiles = this.children.filter(c => isTileMesh(c)) as TileMesh[]; childTiles.forEach(c => c.dispose()); this.remove(...childTiles); + this.isLeaf = true; return childTiles; } @@ -938,6 +952,20 @@ class TileMesh callbackFn(this.customDistanceMaterial); } + public getLeafThatContains(x: number, y: number): TileMesh | undefined { + if (!this.extent.isXYInside(x, y)) { + throw new Error('this tile does not contain the coordinates'); + } + + if (this.isLeaf) { + return this; + } + + const quadrant = this.extent.getQuadrant(x, y); + + return this._childTiles[quadrant]?.getLeafThatContains(x, y); + } + public dispose(): void { if (this.disposed) { return; -- GitLab From 44ae6790f28232de6fc607d3ccebaaf2e1367ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Tue, 24 Jun 2025 18:07:36 +0200 Subject: [PATCH 08/43] feat: introduce FeatureSource to fetch features --- src/sources/FeatureSource.ts | 58 ++++++++++ src/sources/FileFeatureSource.ts | 121 ++++++++++++++++++++ src/sources/StaticFeatureSource.ts | 103 +++++++++++++++++ src/sources/StreamableFeatureSource.ts | 113 ++++++++++++++++++ src/sources/api.ts | 6 + src/sources/features/processor.ts | 94 +++++++++++++++ test/unit/sources/FileFeatureSource.test.ts | 34 ++++++ 7 files changed, 529 insertions(+) create mode 100644 src/sources/FeatureSource.ts create mode 100644 src/sources/FileFeatureSource.ts create mode 100644 src/sources/StaticFeatureSource.ts create mode 100644 src/sources/StreamableFeatureSource.ts create mode 100644 src/sources/features/processor.ts create mode 100644 test/unit/sources/FileFeatureSource.test.ts diff --git a/src/sources/FeatureSource.ts b/src/sources/FeatureSource.ts new file mode 100644 index 0000000000..02c1c9c60b --- /dev/null +++ b/src/sources/FeatureSource.ts @@ -0,0 +1,58 @@ +import type { Feature } from 'ol'; +import { EventDispatcher } from 'three'; +import type Extent from '../core/geographic/Extent'; +import type CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; + +export type GetFeatureResult = { + features: Readonly; +}; + +export type GetFeatureRequest = { + extent: Extent; + signal?: AbortSignal; +}; + +export interface FeatureSourceEventMap { + updated: unknown; +} + +export interface FeatureSource extends EventDispatcher { + initialize(options: { targetProjection: CoordinateSystem }): Promise; + getFeatures(request: GetFeatureRequest): Promise; +} + +export abstract class FeatureSourceBase + extends EventDispatcher + implements FeatureSource +{ + abstract readonly type: string; + + protected _targetProjection: CoordinateSystem | null = null; + protected _initialized = false; + + constructor() { + super(); + } + + initialize(options: { targetProjection: CoordinateSystem }): Promise { + this._targetProjection = options.targetProjection; + + this._initialized = true; + return Promise.resolve(); + } + + /** + * Raises an event to reload the source. + */ + update() { + this.dispatchEvent({ type: 'updated' }); + } + + protected throwIfNotInitialized() { + if (!this._initialized) { + throw new Error('this source has not been initialized'); + } + } + + abstract getFeatures(request: GetFeatureRequest): Promise; +} diff --git a/src/sources/FileFeatureSource.ts b/src/sources/FileFeatureSource.ts new file mode 100644 index 0000000000..a316d699ab --- /dev/null +++ b/src/sources/FileFeatureSource.ts @@ -0,0 +1,121 @@ +import type { Feature } from 'ol'; +import type FeatureFormat from 'ol/format/Feature'; +import type { Type } from 'ol/format/Feature'; +import type { Geometry } from 'ol/geom'; +import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; +import Fetcher from '../utils/Fetcher'; +import { nonNull } from '../utils/tsutils'; +import { FeatureSourceBase, type GetFeatureRequest, type GetFeatureResult } from './FeatureSource'; +import { filterByExtent, processFeatures } from './features/processor'; + +export type Getter = (url: string, type: Type) => Promise; + +const defaultGetter: Getter = (url, type) => { + switch (type) { + case 'arraybuffer': + return Fetcher.arrayBuffer(url); + case 'json': + return Fetcher.json(url); + case 'text': + return Fetcher.text(url); + case 'xml': + return Fetcher.xml(url); + } +}; + +export default class FileFeatureSource extends FeatureSourceBase { + readonly isFileFeatureSource = true as const; + readonly type = 'FileFeatureSource' as const; + + private readonly _format: FeatureFormat; + private _features: Feature[] | null = null; + private _loadFeaturePromise: Promise[]> | null = null; + private _getter: Getter; + private _url: string; + private _sourceProjection?: CoordinateSystem; + private _abortController: AbortController | null = null; + + constructor(params: { + format: FeatureFormat; + url: string; + getter?: Getter; + sourceProjection?: CoordinateSystem; + }) { + super(); + this._format = params.format; + this._url = params.url; + this._sourceProjection = params.sourceProjection; + this._getter = params.getter ?? defaultGetter; + } + + private loadFeatures() { + this.throwIfNotInitialized(); + + this._abortController?.abort(); + this._abortController = new AbortController(); + + if (this._features != null) { + return this._features; + } + + if (this._loadFeaturePromise != null) { + return this._loadFeaturePromise; + } + + this._loadFeaturePromise = this.loadFeaturesOnce(this._abortController.signal); + + return this._loadFeaturePromise; + } + + private async loadFeaturesOnce(signal: AbortSignal): Promise[]> { + signal.throwIfAborted(); + + const data = await this._getter(this._url, this._format.getType()); + + signal.throwIfAborted(); + + if (!this._sourceProjection) { + const dataProjection = this._format.readProjection(data); + if (dataProjection) { + this._sourceProjection = CoordinateSystem.fromSrid(dataProjection?.getCode()); + } else { + this._sourceProjection = CoordinateSystem.epsg4326; + } + } + + const features = this._format.readFeatures(data) as Feature[]; + + const targetProjection = nonNull(this._targetProjection, 'this source is not initialized'); + const sourceProjection = nonNull(this._sourceProjection); + + const actualFeatures = await processFeatures(features, sourceProjection, targetProjection); + + if (!signal.aborted) { + this._features = actualFeatures; + } + + return actualFeatures; + } + + async getFeatures(request: GetFeatureRequest): Promise { + request.signal?.throwIfAborted(); + + const features = await this.loadFeatures(); + + request.signal?.throwIfAborted(); + + const filtered = await filterByExtent(features, request.extent, { signal: request.signal }); + + request.signal?.throwIfAborted(); + + return { features: filtered }; + } + + reload() { + this._features = null; + this._loadFeaturePromise = null; + this._abortController?.abort(); + + this.dispatchEvent({ type: 'updated' }); + } +} diff --git a/src/sources/StaticFeatureSource.ts b/src/sources/StaticFeatureSource.ts new file mode 100644 index 0000000000..e0d92245e5 --- /dev/null +++ b/src/sources/StaticFeatureSource.ts @@ -0,0 +1,103 @@ +import type { Feature } from 'ol'; +import type { GetFeatureRequest, GetFeatureResult } from './FeatureSource'; +import { FeatureSourceBase } from './FeatureSource'; +import { filterByExtent } from './features/processor'; + +export default class StaticFeatureSource extends FeatureSourceBase { + readonly isStaticFeatureSource = true as const; + override readonly type = 'StaticFeatureSource' as const; + + private readonly _features: Set = new Set(); + + /** + * Returns a copy of the features contained in this source. + */ + get features(): Readonly { + return [...this._features]; + } + + constructor() { + super(); + } + + /** + * Adds a single feature. + * + * Note: if you want to add multiple features at once, use {@link addFeatures} for better performance. + */ + addFeature(feature: Feature) { + this._features.add(feature); + + this.update(); + } + + /** + * Removes a single feature. + * + * Note: if you want to remove multiple features at once, use {@link removeFeatures} for better performance. + * + * @returns `true` if the feature feature was actually removed, `false` otherwise. + */ + removeFeature(feature: Feature): boolean { + if (this._features.delete(feature)) { + this.update(); + return true; + } + + return false; + } + + /** + * Adds multiple features. + */ + addFeatures(features: Iterable) { + for (const feature of features) { + this._features.add(feature); + } + + this.update(); + } + + /** + * Removes multiple features. + * + * @returns `true` if at least one feature was actually removed, `false` otherwise. + */ + removeFeatures(features: Iterable): boolean { + let actuallyRemoved = false; + for (const feature of features) { + if (this._features.delete(feature)) { + actuallyRemoved = true; + } + } + + if (actuallyRemoved) { + this.update(); + return true; + } + + return false; + } + + /** + * Removes all features. + */ + clear() { + if (this._features.size > 0) { + this._features.clear(); + this.update(); + } + } + + override async getFeatures(request: GetFeatureRequest): Promise { + const filtered = await filterByExtent([...this._features], request.extent, { + signal: request.signal, + }); + + const result: GetFeatureResult = { + features: filtered, + }; + + return result; + } +} diff --git a/src/sources/StreamableFeatureSource.ts b/src/sources/StreamableFeatureSource.ts new file mode 100644 index 0000000000..e8d20a121b --- /dev/null +++ b/src/sources/StreamableFeatureSource.ts @@ -0,0 +1,113 @@ +import type { Feature } from 'ol'; +import type FeatureFormat from 'ol/format/Feature'; +import type { Type } from 'ol/format/Feature'; +import GeoJSON from 'ol/format/GeoJSON'; +import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; +import type Extent from '../core/geographic/Extent'; +import Fetcher from '../utils/Fetcher'; +import { nonNull } from '../utils/tsutils'; +import { processFeatures } from './features/processor'; +import { FeatureSourceBase, type GetFeatureRequest, type GetFeatureResult } from './FeatureSource'; + +export type QueryBuilder = (params: { + extent: Extent; + sourceProjection: CoordinateSystem; +}) => URL | undefined; + +export const ogcApiFeaturesBuilder: (serverUrl: string, collection: string) => QueryBuilder = ( + serverUrl, + collection, +) => { + return params => { + const url = new URL(`/collections/${collection}/items.json`, serverUrl); + + const bbox = params.extent.as(params.sourceProjection); + + url.searchParams.append('bbox', `${bbox.west},${bbox.south},${bbox.east},${bbox.north}`); + url.searchParams.append('limit', '1000'); + + return url; + }; +}; + +export type Getter = (url: string, type: Type) => Promise; + +export const defaultGetter: Getter = (url, type) => { + switch (type) { + case 'arraybuffer': + return Fetcher.arrayBuffer(url); + case 'json': + return Fetcher.json(url); + case 'text': + return Fetcher.text(url); + case 'xml': + return Fetcher.xml(url); + } +}; + +export default class StreamableFeatureSource extends FeatureSourceBase { + readonly isStreamableFeatureSource = true as const; + readonly type = 'StreamableFeatureSource' as const; + + private readonly _queryBuilder: QueryBuilder; + private readonly _format: FeatureFormat; + private readonly _getter: Getter; + private readonly _sourceProjection: CoordinateSystem; + + constructor(params: { + /** + * The query builder. + */ + queryBuilder: QueryBuilder; + /** + * The format of the features. + * @defaultValue {@link GeoJSON} + */ + format?: FeatureFormat; + getter?: Getter; + sourceProjection?: CoordinateSystem; + }) { + super(); + this._queryBuilder = params.queryBuilder; + this._format = params.format ?? new GeoJSON(); + this._getter = params.getter ?? defaultGetter; + // TODO assume EPSG:4326 ? + this._sourceProjection = params.sourceProjection ?? CoordinateSystem.epsg4326; + } + + async getFeatures(request: GetFeatureRequest): Promise { + this.throwIfNotInitialized(); + + const url = this._queryBuilder({ + extent: request.extent, + sourceProjection: this._sourceProjection, + }); + + if (!url) { + return { + features: [], + }; + } + + const data = await this._getter(url.toString(), this._format.getType()); + + const features = this._format.readFeatures(data) as Feature[]; + + const targetProjection = nonNull(this._targetProjection, 'this source is not initialized'); + const sourceProjection = nonNull(this._sourceProjection); + + const getFeatureId = (feature: Feature) => { + return ( + feature.getId() ?? feature.get('id') ?? feature.get('fid') ?? feature.get('ogc_fid') + ); + }; + + const actualFeatures = await processFeatures(features, sourceProjection, targetProjection, { + getFeatureId, + }); + + return { + features: actualFeatures, + }; + } +} diff --git a/src/sources/api.ts b/src/sources/api.ts index 51a9ea5d1c..68b23205f5 100644 --- a/src/sources/api.ts +++ b/src/sources/api.ts @@ -10,6 +10,8 @@ import AggregatePointCloudSource, { AggregatePointCloudSourceOptions, } from './AggregatePointCloudSource'; import COPCSource, { COPCSourceOptions } from './COPCSource'; +import { FeatureSource, GetFeatureRequest, GetFeatureResult } from './FeatureSource'; +import FileFeatureSource from './FileFeatureSource'; import GeoTIFFSource, { type ChannelMapping, type GeoTIFFCacheOptions, @@ -58,9 +60,13 @@ export { COPCSource, COPCSourceOptions, CustomContainsFn, + FeatureSource, + FileFeatureSource, GeoTIFFCacheOptions, GeoTIFFSource, GeoTIFFSourceOptions, + GetFeatureRequest, + GetFeatureResult, GetImageOptions, GetNodeDataOptions, ImageResponse, diff --git a/src/sources/features/processor.ts b/src/sources/features/processor.ts new file mode 100644 index 0000000000..398430b0ca --- /dev/null +++ b/src/sources/features/processor.ts @@ -0,0 +1,94 @@ +import type { Feature } from 'ol'; +import type { Extent as OLExtent } from 'ol/extent'; +import type { Geometry } from 'ol/geom'; +import type CoordinateSystem from '../../core/geographic/coordinate-system/CoordinateSystem'; +import type Extent from '../../core/geographic/Extent'; +import OpenLayersUtils from '../../utils/OpenLayersUtils'; +import PromiseUtils from '../../utils/PromiseUtils'; + +export async function processFeatures( + features: Feature[], + sourceProjection: CoordinateSystem, + targetProjection: CoordinateSystem, + optionalProcessings?: { + getFeatureId?: (feature: Feature) => number | string; + transformer?: (feature: Feature, geometry: Geometry) => void; + }, +): Promise[]> { + // Since everything happens in the main frame, we split the computation + // into several slices that are executed over time. + await PromiseUtils.nextFrame(); + + const shouldReproject = sourceProjection.id !== targetProjection.id; + + const tmpExtent = [0, 0, 0, 0]; + + const transformer = (feature: Feature, index: number) => { + const id = + optionalProcessings?.getFeatureId != null + ? optionalProcessings.getFeatureId(feature) + : index; + + feature.setId(id); + + const geometry = feature.getGeometry(); + + // We ignore features without geometry as they cannot be represented. + if (geometry) { + if (shouldReproject) { + // Reproject geometry + geometry.transform(sourceProjection.id, targetProjection.id); + } + + // Pre-compute extent to speedup ulterior computations + geometry.getExtent(tmpExtent); + + if (optionalProcessings?.transformer) { + optionalProcessings.transformer(feature, geometry); + } + + return feature; + } + + return null; + }; + + // Process the features in batched slices + const actualFeatures = await PromiseUtils.batch(features, transformer); + + return actualFeatures; +} + +export function intersects(feature: Feature, olExtent: OLExtent): boolean { + const geom = feature.getGeometry(); + + if (!geom) { + return false; + } + + if (geom.intersectsExtent(olExtent)) { + return true; + } + + return false; +} + +export async function filterByExtent( + features: Feature[], + extent: Extent, + options?: { signal?: AbortSignal }, +): Promise { + const olExtent = OpenLayersUtils.toOLExtent(extent); + + const filter = (feature: Feature) => { + if (intersects(feature, olExtent)) { + return feature; + } + + return null; + }; + + const filtered = await PromiseUtils.batch(features, filter, { signal: options?.signal }); + + return filtered; +} diff --git a/test/unit/sources/FileFeatureSource.test.ts b/test/unit/sources/FileFeatureSource.test.ts new file mode 100644 index 0000000000..15e8b4526c --- /dev/null +++ b/test/unit/sources/FileFeatureSource.test.ts @@ -0,0 +1,34 @@ +import type FeatureFormat from 'ol/format/Feature'; + +import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem'; +import Extent from '@giro3d/giro3d/core/geographic/Extent'; +import FileFeatureSource from '@giro3d/giro3d/sources/FileFeatureSource'; +import { Projection } from 'ol/proj'; + +describe('FileFeatureSource', () => { + describe('getFeatures', () => { + it('should load the file', async () => { + // @ts-expect-error incomplete implementation + const format: FeatureFormat = { + getType: () => 'json', + readProjection: () => new Projection({ code: 'EPSG:4326' }), + readFeatures: () => [], + }; + + const data = ''; + const getter = jest.fn(() => Promise.resolve(data)); + + const source = new FileFeatureSource({ + url: 'foo', + format, + getter, + }); + + await source.initialize({ targetProjection: CoordinateSystem.epsg4326 }); + + await source.getFeatures({ extent: Extent.WGS84 }); + + expect(getter).toHaveBeenCalledWith('foo', 'json'); + }); + }); +}); -- GitLab From 91b7e37bb6ee2f1455dbf83c1f77f635b7780be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Tue, 24 Jun 2025 14:59:35 +0200 Subject: [PATCH 09/43] feat: introduce DrapedFeatureCollection --- examples/draped_feature_collection.html | 7 + examples/draped_feature_collection.js | 279 ++++++++++ src/core/FeatureTypes.ts | 48 ++ src/entities/DrapedFeatureCollection.ts | 701 ++++++++++++++++++++++++ 4 files changed, 1035 insertions(+) create mode 100644 examples/draped_feature_collection.html create mode 100644 examples/draped_feature_collection.js create mode 100644 src/entities/DrapedFeatureCollection.ts diff --git a/examples/draped_feature_collection.html b/examples/draped_feature_collection.html new file mode 100644 index 0000000000..4b08107304 --- /dev/null +++ b/examples/draped_feature_collection.html @@ -0,0 +1,7 @@ +--- +title: Draped feature collection +shortdesc: TODO +longdesc: TODO +attribution: © IGN +tags: [wfs, wmts, ign, map] +--- diff --git a/examples/draped_feature_collection.js b/examples/draped_feature_collection.js new file mode 100644 index 0000000000..4c02bc551b --- /dev/null +++ b/examples/draped_feature_collection.js @@ -0,0 +1,279 @@ +import { AmbientLight, Color, DirectionalLight, DoubleSide, MathUtils, Vector3 } from 'three'; +import { MapControls } from 'three/examples/jsm/controls/MapControls.js'; + +import GeoJSON from 'ol/format/GeoJSON.js'; +import VectorSource from 'ol/source/Vector.js'; + +import Instance from '@giro3d/giro3d/core/Instance.js'; +import Extent from '@giro3d/giro3d/core/geographic/Extent.js'; +import ColorLayer from '@giro3d/giro3d/core/layer/ColorLayer.js'; +import ElevationLayer from '@giro3d/giro3d/core/layer/ElevationLayer.js'; +import WmtsSource from '@giro3d/giro3d/sources/WmtsSource.js'; +import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem.js'; +import DrapedFeatureCollection from '@giro3d/giro3d/entities/DrapedFeatureCollection.js'; +import Giro3dMap from '@giro3d/giro3d/entities/Map.js'; +import BilFormat from '@giro3d/giro3d/formats/BilFormat.js'; +import Inspector from '@giro3d/giro3d/gui/Inspector.js'; +import FileFeatureSource from '@giro3d/giro3d/sources/FileFeatureSource.js'; +import StreamableFeatureSource, { + ogcApiFeaturesBuilder, +} from '@giro3d/giro3d/sources/StreamableFeatureSource.js'; + +import StatusBar from './widgets/StatusBar.js'; + +Instance.registerCRS( + 'EPSG:2154', + '+proj=lcc +lat_0=46.5 +lon_0=3 +lat_1=49 +lat_2=44 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs', +); +Instance.registerCRS( + 'IGNF:WGS84G', + 'GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]', +); + +const SKY_COLOR = new Color(0xf1e9c6); + +const instance = new Instance({ + target: 'view', + crs: CoordinateSystem.fromEpsg(2154), + backgroundColor: SKY_COLOR, +}); + +const extent = new Extent( + CoordinateSystem.fromEpsg(2154), + -111629.52, + 1275028.84, + 5976033.79, + 7230161.64, +); + +// create a map +const map = new Giro3dMap({ + extent, + backgroundColor: '#304f66', + lighting: { + enabled: true, + elevationLayersOnly: true, + }, + side: DoubleSide, +}); + +instance.add(map); + +const noDataValue = -1000; + +const url = 'https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetCapabilities'; + +// Let's build the elevation layer from the WMTS capabilities +WmtsSource.fromCapabilities(url, { + layer: 'ELEVATION.ELEVATIONGRIDCOVERAGE.HIGHRES', + format: new BilFormat(), + noDataValue, +}) + .then(elevationWmts => { + map.addLayer( + new ElevationLayer({ + name: 'elevation', + extent: map.extent, + // We don't need the full resolution of terrain + // because we are not using any shading. This will save a lot of memory + // and make the terrain faster to load. + resolutionFactor: 1, + minmax: { min: 0, max: 5000 }, + noDataOptions: { + replaceNoData: false, + }, + source: elevationWmts, + }), + ); + }) + .catch(console.error); + +// Let's build the color layer from the WMTS capabilities +WmtsSource.fromCapabilities(url, { + layer: 'GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2', +}) + .then(orthophotoWmts => { + map.addLayer( + new ColorLayer({ + name: 'color', + resolutionFactor: 1, + extent: map.extent, + source: orthophotoWmts, + }), + ); + }) + .catch(console.error); + +const geojson = new FileFeatureSource({ + format: new GeoJSON(), + // url: 'http://localhost:14000/vectors/geojson/points.geojson', + // url: 'http://localhost:14000/vectors/geojson/grenoble_linestring.geojson', + // url: 'http://localhost:14000/vectors/geojson/grenoble_polygon.geojson', + // url: 'http://localhost:14000/vectors/geojson/grenoble_batiments.geojson', + // url: 'http://localhost:14002/collections/public.cadastre/items.json', + url: 'http://localhost:14002/collections/public.cadastre/items.json?limit=500', + sourceProjection: CoordinateSystem.epsg4326, +}); + +const communes = new StreamableFeatureSource({ + queryBuilder: ogcApiFeaturesBuilder('http://localhost:14002/', 'public.cadastre'), + sourceProjection: CoordinateSystem.epsg4326, +}); + +const hydrants = new StreamableFeatureSource({ + queryBuilder: ogcApiFeaturesBuilder('http://localhost:14002/', 'public.hydrants_sdis_64'), + sourceProjection: CoordinateSystem.epsg4326, +}); + +const bdTopoIgn = new StreamableFeatureSource({ + sourceProjection: CoordinateSystem.fromEpsg(2154), + queryBuilder: params => { + const url = new URL('https://data.geopf.fr/wfs/ows'); + + url.searchParams.append('SERVICE', 'WFS'); + url.searchParams.append('VERSION', '2.0.0'); + url.searchParams.append('request', 'GetFeature'); + url.searchParams.append('typename', 'BDTOPO_V3:batiment'); + url.searchParams.append('outputFormat', 'application/json'); + url.searchParams.append('SRSNAME', 'EPSG:2154'); + url.searchParams.append('startIndex', '0'); + + const extent = params.extent.as(CoordinateSystem.fromEpsg(2154)); + + url.searchParams.append( + 'bbox', + `${extent.west},${extent.south},${extent.east},${extent.north},EPSG:2154`, + ); + + return url; + }, +}); + +const bdTopoLocal = new StreamableFeatureSource({ + sourceProjection: CoordinateSystem.epsg4326, + queryBuilder: ogcApiFeaturesBuilder('http://localhost:14002/', 'public.batiment_038_isere'), +}); + +const hoverColor = new Color('yellow'); + +const bdTopoStyle = feature => { + const properties = feature.getProperties(); + let fillColor = '#FFFFFF'; + + const hovered = properties.hovered ?? false; + const clicked = properties.clicked ?? false; + + switch (properties.usage1) { + case 'Industriel': + fillColor = '#f0bb41'; + break; + case 'Agricole': + fillColor = '#96ff0d'; + break; + case 'Religieux': + fillColor = '#41b5f0'; + break; + case 'Sportif': + fillColor = '#ff0d45'; + break; + case 'Résidentiel': + fillColor = '#cec8be'; + break; + case 'Commercial et services': + fillColor = '#d8ffd4'; + break; + } + + const fill = clicked + ? 'yellow' + : hovered + ? new Color(fillColor).lerp(hoverColor, 0.2) // Let's use a slightly brighter color for hover + : fillColor; + + return { + fill: { + color: fill, + shading: true, + }, + stroke: { + color: clicked ? 'yellow' : hovered ? 'white' : 'black', + lineWidth: clicked ? 5 : undefined, + }, + }; +}; + +// Let's compute the extrusion offset of building polygons to give them walls. +const extrusionOffsetCallback = feature => { + const properties = feature.getProperties(); + const buildingHeight = properties['hauteur']; + const extrusionOffset = buildingHeight; + + if (Number.isNaN(extrusionOffset)) { + return null; + } + return extrusionOffset; +}; + +const entity = new DrapedFeatureCollection({ + source: bdTopoLocal, + minLod: 10, + drapingMode: 'per-feature', + extrusionOffset: extrusionOffsetCallback, + style: bdTopoStyle, + // style: feature => ({ + // // stroke: { + // // color: 'yellow', + // // lineWidth: 2, + // // lineWidthUnits: 'pixels', + // // depthTest: false, + // // renderOrder: 1, + // // }, + // fill: { + // // renderOrder: 1, + // color: new Color().setHSL(MathUtils.randFloat(0, 1), 1, 0.5), + // shading: true, + // // depthTest: false, + // }, + // point: { + // pointSize: 92, + // depthTest: true, + // image: 'http://localhost:14000/images/pin.png', + // }, + // }), +}); + +instance.add(entity).then(() => { + entity.attach(map); +}); + +// Add a sunlight +const sun = new DirectionalLight('#ffffff', 2); +sun.position.set(1, 0, 1).normalize(); +sun.updateMatrixWorld(true); +instance.scene.add(sun); + +// We can look below the floor, so let's light also a bit there +const sun2 = new DirectionalLight('#ffffff', 0.5); +sun2.position.set(0, 1, 1); +sun2.updateMatrixWorld(); +instance.scene.add(sun2); + +// Add an ambient light +const ambientLight = new AmbientLight(0xffffff, 0.2); +instance.scene.add(ambientLight); + +instance.view.camera.position.set(913349.2364044407, 6456426.459171033, 1706.0108044011636); + +const lookAt = new Vector3(913896, 6459191, 200); +instance.view.camera.lookAt(lookAt); + +const controls = new MapControls(instance.view.camera, instance.domElement); +controls.enableDamping = true; +controls.dampingFactor = 0.4; +controls.target.copy(lookAt); +controls.saveState(); +instance.view.setControls(controls); + +Inspector.attach('inspector', instance); + +StatusBar.bind(instance); diff --git a/src/core/FeatureTypes.ts b/src/core/FeatureTypes.ts index b1a3616973..a94583ce8a 100644 --- a/src/core/FeatureTypes.ts +++ b/src/core/FeatureTypes.ts @@ -5,6 +5,18 @@ */ import type Feature from 'ol/Feature'; +import type { + Circle, + Geometry, + LinearRing, + LineString, + MultiLineString, + MultiPoint, + MultiPolygon, + Point, + Polygon, + SimpleGeometry, +} from 'ol/geom'; import type { Color, ColorRepresentation, Material, SpriteMaterial, Texture } from 'three'; import type { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; @@ -22,6 +34,42 @@ function hasUUID(obj: unknown): obj is { uuid: string } { return 'uuid' in obj && typeof obj.uuid === 'string'; } +export type GeometryFn = (geom: T) => O; + +export function mapGeometry( + geom: SimpleGeometry, + callbacks: { + processPoint?: GeometryFn; + processPolygon?: GeometryFn; + processLineString?: GeometryFn; + processMultiPoint?: GeometryFn; + processLinearRing?: GeometryFn; + processMultiLineString?: GeometryFn; + processMultiPolygon?: GeometryFn; + processCircle?: GeometryFn; + fallback?: GeometryFn; + }, +) { + switch (geom.getType()) { + case 'Point': + return callbacks.processPoint?.(geom as Point); + case 'LineString': + return callbacks.processLineString?.(geom as LineString); + case 'LinearRing': + return callbacks.processLinearRing?.(geom as LinearRing); + case 'Polygon': + return callbacks.processPolygon?.(geom as Polygon); + case 'MultiPoint': + return callbacks.processMultiPoint?.(geom as MultiPoint); + case 'MultiLineString': + return callbacks.processMultiLineString?.(geom as MultiLineString); + case 'MultiPolygon': + return callbacks.processMultiPolygon?.(geom as MultiPolygon); + case 'Circle': + return callbacks.processCircle?.(geom as Circle); + } +} + /** * The units used to define line width. If `"pixels"`, the line has a constant width expressed in * pixels. If `"world"`, the line has a variable apparent width expressed in CRS units, depending on diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts new file mode 100644 index 0000000000..c350eef179 --- /dev/null +++ b/src/entities/DrapedFeatureCollection.ts @@ -0,0 +1,701 @@ +import type Feature from 'ol/Feature'; + +import { Box3, Group, Sphere, Vector3 } from 'three'; + +import GUI from 'lil-gui'; +import { type Coordinate } from 'ol/coordinate'; +import { getCenter } from 'ol/extent'; +import type { Circle, Point, SimpleGeometry } from 'ol/geom'; +import { LineString, MultiLineString, MultiPolygon, Polygon } from 'ol/geom'; +import type ElevationProvider from '../core/ElevationProvider'; +import { + FeatureExtrusionOffset, + FeatureExtrusionOffsetCallback, + mapGeometry, + type FeatureStyle, + type FeatureStyleCallback, + type LineMaterialGenerator, + type PointMaterialGenerator, + type SurfaceMaterialGenerator, +} from '../core/FeatureTypes'; +import type Extent from '../core/geographic/Extent'; +import type HasDefaultPointOfView from '../core/HasDefaultPointOfView'; +import Instance from '../core/Instance'; +import type PointOfView from '../core/PointOfView'; +import EntityInspector from '../gui/EntityInspector'; +import EntityPanel from '../gui/EntityPanel'; +import type { + LineOptions, + PointOptions, + PolygonOptions, +} from '../renderer/geometries/GeometryConverter'; +import GeometryConverter from '../renderer/geometries/GeometryConverter'; +import type SimpleGeometryMesh from '../renderer/geometries/SimpleGeometryMesh'; +import { computeDistanceToFitSphere, computeZoomToFitSphere } from '../renderer/View'; +import type { FeatureSource } from '../sources/FeatureSource'; +import OLUtils from '../utils/OpenLayersUtils'; +import { isOrthographicCamera, isPerspectiveCamera } from '../utils/predicates'; +import { nonNull } from '../utils/tsutils'; +import Entity3D from './Entity3D'; +import type { MeshUserData } from './FeatureCollection'; +import type MapEntity from './Map'; +import type { MapEventMap, Tile } from './Map'; + +const tmpSphere = new Sphere(); + +/** + * How the geometry should be draped on the terrain: + * - `per-feature`: the same elevation offset is applied to the entire feature. + * Suitable for level geometries, such as buildings, lakes, etc. + * - `per-vertex`: the elevation is applied to each vertex independently. Suitable for + * lines that must follow the terrain, such as roads. + * - `none`: no draping is done, the elevation of the feature is used as is. Suitable for + * geometries that should not be draped on the terrain, such as flight paths or flying objects. + * + * Note: that `Point` geometries, having only one coordinate, will automatically use the `per-feature` mode. + */ +export type DrapingMode = 'per-feature' | 'per-vertex' | 'none'; + +/** + * A function to determine the {@link DrapingMode} for each feature. + */ +export type DrapingModeFunction = (feature: Feature) => DrapingMode; + +/** + * This callback can be used to generate elevation for a given OpenLayer + * [Feature](https://openlayers.org/en/latest/apidoc/module-ol_Feature-Feature.html) (typically from its properties). + * + * - If a single number is returned, it will be used for all vertices in the geometry. + * - If an array is returned, each value will be used to determine the height of the corresponding vertex in the geometry. + * Note that the cardinality of the array must be the same as the number of vertices in the geometry. + */ +export type DrapedFeatureElevationCallback = ( + feature: Feature, + elevationProvider: ElevationProvider, +) => { geometry: SimpleGeometry; verticalOffset: number; mustReplace: boolean }; + +function cloneAsXYZIfRequired( + geometry: G, +): G { + if (geometry.getLayout() === 'XYZ') { + // No need to clone. + return geometry; + } + + const stride = geometry.getStride(); + + const vertexCount = geometry.getFlatCoordinates().length / stride; + const flat = new Array(vertexCount * 3); + + switch (geometry.getType()) { + case 'LineString': + return new LineString(flat, 'XYZ') as G; + case 'Polygon': { + const ends = (geometry as Polygon).getEnds().map(end => (end / stride) * 3); + return new Polygon(flat, 'XYZ', ends) as G; + } + case 'MultiLineString': { + const ends = (geometry as MultiLineString).getEnds().map(end => (end / stride) * 3); + return new MultiLineString(flat, 'XYZ', ends) as G; + } + case 'MultiPolygon': { + const endss = (geometry as MultiPolygon) + .getEndss() + .map(ends => ends.map(end => (end / stride) * 3)); + return new MultiPolygon(flat, 'XYZ', endss) as G; + } + } + + throw new Error(); +} + +function getFeatureElevation( + feature: Feature, + geometry: SimpleGeometry, + provider: ElevationProvider, +) { + let center: Coordinate; + + if (geometry.getType() === 'Point') { + center = (geometry as Point).getCoordinates(); + } else if (geometry.getType() === 'Circle') { + center = (geometry as Circle).getCenter(); + } else { + center = getCenter(geometry.getExtent()); + } + + const [x, y] = center; + + const sample = provider.getElevationFast(x, y); + + return sample?.elevation ?? 0; +} + +function applyPerVertexDraping( + geometry: Polygon | LineString | MultiLineString | MultiPolygon, + provider: ElevationProvider, +) { + const coordinates = geometry.getFlatCoordinates(); + const stride = geometry.getStride(); + + // We have to possibly clone the geometry because OpenLayers does + // not allow changing the layout of an existing geometry, leading to issues. + const clone = cloneAsXYZIfRequired(geometry.clone()); + const coordinateCount = coordinates.length / stride; + const xyz = new Array(coordinateCount * 3); + + let k = 0; + + for (let i = 0; i < coordinates.length; i += stride) { + const x = coordinates[i + 0]; + const y = coordinates[i + 1]; + + const sample = provider.getElevationFast(x, y); + + const z = sample?.elevation ?? 0; + + xyz[k + 0] = x; + xyz[k + 1] = y; + xyz[k + 2] = z; + + k += 3; + } + + clone.setFlatCoordinates('XYZ', xyz); + + return clone; +} + +export const defaultElevationCallback: DrapedFeatureElevationCallback = (feature, provider) => { + let result: ReturnType; + + const perFeature: (geometry: SimpleGeometry) => void = geometry => { + let center: Coordinate; + + if (geometry.getType() === 'Point') { + center = (geometry as Point).getCoordinates(); + } else if (geometry.getType() === 'Circle') { + center = (geometry as Circle).getCenter(); + } else { + center = getCenter(geometry.getExtent()); + } + + const [x, y] = center; + + const sample = provider.getElevationFast(x, y); + + result = { + verticalOffset: sample?.elevation ?? 0, + geometry, + mustReplace: false, + }; + }; + + const perVertex: ( + geometry: Polygon | LineString | MultiLineString | MultiPolygon, + ) => void = geometry => { + const coordinates = geometry.getFlatCoordinates(); + const stride = geometry.getStride(); + + // We have to possibly clone the geometry because OpenLayers does + // not allow changing the layout of an existing geometry, leading to issues. + const clone = cloneAsXYZIfRequired(geometry.clone()); + const coordinateCount = coordinates.length / stride; + const xyz = new Array(coordinateCount * 3); + + let k = 0; + + for (let i = 0; i < coordinates.length; i += stride) { + const x = coordinates[i + 0]; + const y = coordinates[i + 1]; + + const sample = provider.getElevationFast(x, y); + + const z = sample?.elevation ?? 0; + + xyz[k + 0] = x; + xyz[k + 1] = y; + xyz[k + 2] = z; + + k += 3; + } + + clone.setFlatCoordinates('XYZ', xyz); + + // TODO nous avons besoin d'une méthode pour savoir s'il faut remplacer le mesh ou non + result = { + verticalOffset: 0, + geometry: clone, + mustReplace: true, + }; + }; + + const geometry = nonNull(feature.getGeometry(), 'feature has no geometry') as SimpleGeometry; + + mapGeometry(geometry, { + processPoint: perFeature, + processCircle: perFeature, + processPolygon: perVertex, + processLineString: perVertex, + processMultiLineString: perVertex, + processMultiPolygon: perVertex, + }); + + // TODO + // @ts-expect-error TODO + return result; +}; + +export type DrapedFeatureCollectionOptions = { + /** + * The data source. + */ + source: FeatureSource; + /** + * The minimum tile LOD (level of detail) to display the features. + * If zero, then features are always displayed, since root tiles have LOD zero. + * @defaultValue 0 + */ + minLod?: number; + /** + * How is draping computed for each feature. + */ + drapingMode?: DrapingMode | DrapingModeFunction; + /** + * An style or a callback returning a style to style the individual features. + * If an object is used, the informations it contains will be used to style every + * feature the same way. If a function is provided, it will be called with the feature. + * This allows to individually style each feature. + */ + style?: FeatureStyle | FeatureStyleCallback; + /** + * If set, this will cause 2D features to be extruded of the corresponding amount. + * If a single value is given, it will be used for all the vertices of every feature. + * If an array is given, each extruded vertex will use the corresponding value. + * If a callback is given, it allows to extrude each feature individually. + */ + extrusionOffset?: FeatureExtrusionOffset | FeatureExtrusionOffsetCallback; + /** + * The elevation callback to compute elevations for a feature. + * @defaultValue {@link defaultElevationCallback} + */ + elevationCallback?: DrapedFeatureElevationCallback; + /** + * An optional material generator for shaded surfaces. + */ + shadedSurfaceMaterialGenerator?: SurfaceMaterialGenerator; + /** + * An optional material generator for unshaded surfaces. + */ + unshadedSurfaceMaterialGenerator?: SurfaceMaterialGenerator; + /** + * An optional material generator for lines. + */ + lineMaterialGenerator?: LineMaterialGenerator; + /** + * An optional material generator for points. + */ + pointMaterialGenerator?: PointMaterialGenerator; +}; + +function getStableFeatureId(feature: Feature): string { + const existing = feature.getId(); + if (existing != null) { + return existing.toString(); + } + const fid = feature.get('fid'); + if (fid != null) { + return `${fid}`; + } + + throw new Error('not implemented'); +} + +type EventHandler = (e: T) => void; + +export default class DrapedFeatureCollection extends Entity3D { + override type = 'DrapedFeatureCollection' as const; + readonly isDrapedFeatureCollection = true as const; + + private _map: MapEntity | null = null; + + private readonly _drapingMode: DrapingMode | DrapingModeFunction; + private readonly _elevationCallback: DrapedFeatureElevationCallback; + private readonly _geometryConverter: GeometryConverter; + private readonly _activeTiles = new Map(); + private readonly _extrusionCallback: + | FeatureExtrusionOffset + | FeatureExtrusionOffsetCallback + | undefined; + private readonly _features: Map< + string, + { + feature: Feature; + originalZ: number; + extent: Extent; + mesh: SimpleGeometryMesh | undefined; + } + > = new Map(); + private readonly _source: FeatureSource; + private readonly _eventHandlers: { + onTileCreated: EventHandler; + onTileDeleted: EventHandler; + onElevationLoaded: EventHandler; + }; + private readonly _style: FeatureStyle | FeatureStyleCallback | undefined; + + get loadedFeatures() { + return this._features.size; + } + + private _shouldCleanup = false; + private _sortedTiles: Tile[] | null = null; + private _minLod = 0; + + get minLod() { + return this._minLod; + } + + set minLod(v: number) { + this._minLod = v >= 0 ? v : 0; + } + + constructor(options: DrapedFeatureCollectionOptions) { + super(new Group()); + + this._drapingMode = options.drapingMode ?? 'per-vertex'; + this._extrusionCallback = options.extrusionOffset; + this._source = options.source; + this._minLod = options.minLod ?? this._minLod; + this._elevationCallback = options.elevationCallback ?? defaultElevationCallback; + + this._eventHandlers = { + onTileCreated: this.onTileCreated.bind(this), + onTileDeleted: this.onTileDeleted.bind(this), + onElevationLoaded: this.onElevationLoaded.bind(this), + }; + + this._geometryConverter = new GeometryConverter({ + shadedSurfaceMaterialGenerator: options.shadedSurfaceMaterialGenerator, + unshadedSurfaceMaterialGenerator: options.unshadedSurfaceMaterialGenerator, + lineMaterialGenerator: options.lineMaterialGenerator, + pointMaterialGenerator: options.pointMaterialGenerator, + }); + this._style = options.style; + this._geometryConverter.addEventListener('texture-loaded', () => this.notifyChange(this)); + } + + override async preprocess() { + await this._source.initialize({ targetProjection: this.instance.coordinateSystem }); + } + + attach(map: MapEntity): this { + if (this._map != null) { + throw new Error('a map is already attached to this entity'); + } + + this._map = map; + + map.addEventListener('tile-created', this._eventHandlers.onTileCreated); + map.addEventListener('tile-deleted', this._eventHandlers.onTileDeleted); + map.addEventListener('elevation-loaded', this._eventHandlers.onElevationLoaded); + + // TODO register trop tôt avant que l'élévation soit prête + // map.traverseTiles(tile => { + // this.registerTile(tile); + // }); + + return this; + } + + private getSortedTiles() { + if (this._sortedTiles == null) { + this._sortedTiles = [...this._activeTiles.values()]; + this._sortedTiles.sort((t0, t1) => t0.lod - t1.lod); + } + + return this._sortedTiles; + } + + detach(map: MapEntity): this { + map.traverseTiles(tile => { + this.unregisterTile(tile); + }); + + return this; + } + + private onTileCreated(_: MapEventMap['tile-created']) { + // TODO + // this.registerTile(tile); + } + + private onTileDeleted({ tile }: MapEventMap['tile-deleted']) { + this.unregisterTile(tile); + } + + private onElevationLoaded({ tile }: MapEventMap['elevation-loaded']) { + this.registerTile(tile, true); + } + + private registerTile(tile: Tile, forceRecreateMeshes = false) { + if (!this._activeTiles.has(tile.id) || forceRecreateMeshes) { + this._activeTiles.set(tile.id, tile); + this._sortedTiles = null; + + if (tile.lod >= this._minLod) { + this.loadFeaturesOnExtent(tile.extent).then(features => { + this.loadMeshes(features); + }); + } + } + } + + private loadMeshes(features: Readonly) { + for (const feature of features) { + const id = getStableFeatureId(feature); + + const geometry = feature.getGeometry(); + + if (geometry) { + if (!this._features.has(id)) { + const extent = OLUtils.fromOLExtent( + geometry.getExtent(), + this.instance.coordinateSystem, + ); + + this._features.set(id, { feature, mesh: undefined, originalZ: 0, extent }); + } + + this.loadFeatureMesh(feature, id); + } + } + + this.notifyChange(); + } + + private getPointOptions(style?: FeatureStyle): PointOptions { + const pointStyle = style?.point; + + return { + color: pointStyle?.color, + pointSize: pointStyle?.pointSize, + renderOrder: pointStyle?.renderOrder, + sizeAttenuation: pointStyle?.sizeAttenuation, + depthTest: pointStyle?.depthTest, + image: pointStyle?.image, + opacity: pointStyle?.opacity, + }; + } + + private getPolygonOptions(feature: Feature, style?: FeatureStyle): PolygonOptions { + let extrusionOffset: FeatureExtrusionOffset | undefined = undefined; + if (this._extrusionCallback != null) { + extrusionOffset = + typeof this._extrusionCallback === 'function' + ? this._extrusionCallback(feature) + : this._extrusionCallback; + } + + return { + fill: style?.fill, + stroke: style?.stroke, + extrusionOffset, + }; + } + + // TODO gérer l'élévation sur les lignes + private getLineOptions(style?: FeatureStyle): LineOptions { + return { + ...style?.stroke, + }; + } + + private createMesh(feature: Feature, geometry: SimpleGeometry): SimpleGeometryMesh | undefined { + let style: FeatureStyle | undefined = undefined; + if (typeof this._style === 'function') { + style = this._style(feature); + } else { + style = this._style; + } + + const converter = this._geometryConverter; + + const result = mapGeometry(geometry, { + processPoint: p => converter.build(p, this.getPointOptions(style)), + processPolygon: p => converter.build(p, this.getPolygonOptions(feature, style)), + processLineString: p => converter.build(p, this.getLineOptions(style)), + processMultiPolygon: p => converter.build(p, this.getPolygonOptions(feature, style)), + // TODO faire le reste: + }); + + return result; + } + + private getDrapingMode(feature: Feature): DrapingMode { + if (typeof this._drapingMode === 'function') { + return this._drapingMode(feature); + } + + return this._drapingMode; + } + + private loadFeatureMesh(feature: Feature, id: string) { + // TODO filter non compatible geometries + const geometry = feature.getGeometry() as SimpleGeometry | undefined; + + if (geometry) { + const drapingMode = this.getDrapingMode(feature); + + let actualGeometry: SimpleGeometry = geometry; + let shouldReplaceMesh = false; + let verticalOffset = 0; + + if ( + drapingMode === 'per-feature' || + (drapingMode === 'per-vertex' && geometry.getType() === 'Point') + ) { + // Note that point is necessarily per feature, since there is only one vertex + actualGeometry = geometry; + verticalOffset = getFeatureElevation(feature, geometry, nonNull(this._map)); + } else if (drapingMode === 'per-vertex') { + shouldReplaceMesh = true; + // TODO support multipoint ? + actualGeometry = applyPerVertexDraping(geometry, this._map); + } + + // // TODO doit-on supporter plusieurs maps ? + // // TODO Callback de récupération de l'élévation + // const processed = this._elevationCallback(feature, this._maps[0]); + + const existing = nonNull(this._features.get(id)); + + // We have to entirely recreate the mesh because + // the vertices will have different elevations + if (shouldReplaceMesh && existing.mesh) { + existing.mesh.dispose(); + existing.mesh.removeFromParent(); + existing.mesh = undefined; + } + + // The mesh needs to be (re)created + if (existing.mesh == null) { + const newMesh = this.createMesh(feature, actualGeometry); + existing.originalZ = newMesh?.position.z ?? 0; + existing.mesh = newMesh; + } + + const mesh = existing.mesh; + + if (mesh) { + mesh.name = id; + + this.object3d.add(mesh); + + // When a single elevation value is applied to the entire mesh, + // then we can simply translate the Mesh itself, rather than recreate it. + if (verticalOffset !== 0) { + mesh.position.setZ(existing.originalZ + verticalOffset); + mesh.updateMatrix(); + } + + mesh.updateMatrixWorld(true); + } + } + } + + private unregisterTile(tile: Tile) { + const actuallyDeleted = this._activeTiles.delete(tile.id); + + if (actuallyDeleted) { + this._sortedTiles = null; + this._shouldCleanup = true; + this.notifyChange(this); + } + } + + private async loadFeaturesOnExtent(extent: Extent): Promise { + const result = await this._source.getFeatures({ extent }); + + return result.features; + } + + override postUpdate(): void { + if (this._shouldCleanup) { + this._shouldCleanup = false; + + this.cleanup(); + } + } + + cleanup() { + const sorted = this.getSortedTiles(); + const features = [...this._features.values()]; + + for (const block of features) { + let stillUsed = false; + for (const tile of sorted) { + if (tile.lod >= this._minLod && tile.extent.intersectsExtent(block.extent)) { + stillUsed = true; + break; + } + } + + if (!stillUsed && block.mesh) { + block.mesh.dispose(); + block.mesh.removeFromParent(); + block.mesh = undefined; + } + } + } + + override getDefaultPointOfView({ + camera, + }: Parameters[0]): ReturnType< + HasDefaultPointOfView['getDefaultPointOfView'] + > { + const bounds = new Box3().setFromObject(this.object3d); + const sphere = bounds.getBoundingSphere(tmpSphere); + + let orthographicZoom = 1; + let distance: number; + + if (isOrthographicCamera(camera)) { + orthographicZoom = computeZoomToFitSphere(camera, sphere.radius); + // In orthographic camera, the actual distance has no effect on the size + // of objects, but it does have an effect on clipping planes. + // Let's compute a reasonable distance to put the camera. + distance = sphere.radius; + } else if (isPerspectiveCamera(camera)) { + distance = computeDistanceToFitSphere(camera, sphere.radius); + } else { + return null; + } + + // To avoid a perfectly vertical camera axis that would cause a gimbal lock. + const VERTICAL_OFFSET = 0.01; + const origin = new Vector3(sphere.center.x, sphere.center.y - VERTICAL_OFFSET, distance); + const target = sphere.center; + + const result: PointOfView = { origin, target, orthographicZoom }; + + return Object.freeze(result); + } + + override dispose() { + this._geometryConverter.dispose({ disposeMaterials: true, disposeTextures: true }); + this.traverseMeshes(mesh => { + mesh.geometry.dispose(); + }); + } +} + +class DrapedFeatureCollectionInspector extends EntityInspector { + constructor(gui: GUI, instance: Instance, entity: DrapedFeatureCollection) { + super(gui, instance, entity); + + this.addController(entity, 'loadedFeatures'); + } +} + +EntityPanel.registerInspector('DrapedFeatureCollection', DrapedFeatureCollectionInspector); -- GitLab From f2bac5b6e36c6de36848737e3c7048614244dc7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Sun, 29 Jun 2025 11:18:51 +0200 Subject: [PATCH 10/43] feat(FeatureSource): add update event --- examples/draped_feature_collection.js | 60 +++++++++++++------------ src/entities/DrapedFeatureCollection.ts | 41 +++++++++++++---- src/sources/FeatureSource.ts | 8 ++-- src/sources/FileFeatureSource.ts | 5 ++- src/sources/StaticFeatureSource.ts | 36 +++++++++++++-- src/sources/StreamableFeatureSource.ts | 5 ++- 6 files changed, 108 insertions(+), 47 deletions(-) diff --git a/examples/draped_feature_collection.js b/examples/draped_feature_collection.js index 4c02bc551b..abdeee4fd9 100644 --- a/examples/draped_feature_collection.js +++ b/examples/draped_feature_collection.js @@ -14,12 +14,15 @@ import DrapedFeatureCollection from '@giro3d/giro3d/entities/DrapedFeatureCollec import Giro3dMap from '@giro3d/giro3d/entities/Map.js'; import BilFormat from '@giro3d/giro3d/formats/BilFormat.js'; import Inspector from '@giro3d/giro3d/gui/Inspector.js'; +import StaticFeatureSource from '@giro3d/giro3d/sources/StaticFeatureSource.js'; import FileFeatureSource from '@giro3d/giro3d/sources/FileFeatureSource.js'; import StreamableFeatureSource, { ogcApiFeaturesBuilder, } from '@giro3d/giro3d/sources/StreamableFeatureSource.js'; import StatusBar from './widgets/StatusBar.js'; +import { Feature } from 'ol'; +import { Point } from 'ol/geom.js'; Instance.registerCRS( 'EPSG:2154', @@ -149,11 +152,6 @@ const bdTopoIgn = new StreamableFeatureSource({ }, }); -const bdTopoLocal = new StreamableFeatureSource({ - sourceProjection: CoordinateSystem.epsg4326, - queryBuilder: ogcApiFeaturesBuilder('http://localhost:14002/', 'public.batiment_038_isere'), -}); - const hoverColor = new Color('yellow'); const bdTopoStyle = feature => { @@ -214,36 +212,40 @@ const extrusionOffsetCallback = feature => { return extrusionOffset; }; +const sources = { + static: new StaticFeatureSource({ + coordinateSystem: CoordinateSystem.fromEpsg(2154), + }), + batiment: new StreamableFeatureSource({ + sourceProjection: CoordinateSystem.epsg4326, + queryBuilder: ogcApiFeaturesBuilder('http://localhost:14002/', 'public.batiment_038_isere'), + }), +}; + +const pointStyle = { + point: { + pointSize: 48, + depthTest: false, + image: 'http://localhost:14000/images/pin.png', + }, +}; + const entity = new DrapedFeatureCollection({ - source: bdTopoLocal, - minLod: 10, + source: sources['static'], + minLod: 0, drapingMode: 'per-feature', - extrusionOffset: extrusionOffsetCallback, - style: bdTopoStyle, - // style: feature => ({ - // // stroke: { - // // color: 'yellow', - // // lineWidth: 2, - // // lineWidthUnits: 'pixels', - // // depthTest: false, - // // renderOrder: 1, - // // }, - // fill: { - // // renderOrder: 1, - // color: new Color().setHSL(MathUtils.randFloat(0, 1), 1, 0.5), - // shading: true, - // // depthTest: false, - // }, - // point: { - // pointSize: 92, - // depthTest: true, - // image: 'http://localhost:14000/images/pin.png', - // }, - // }), + // extrusionOffset: extrusionOffsetCallback, + style: pointStyle, }); instance.add(entity).then(() => { entity.attach(map); + + setInterval(() => { + const x = MathUtils.lerp(map.extent.west, map.extent.east, MathUtils.randFloat(0.3, 0.7)); + const y = MathUtils.lerp(map.extent.south, map.extent.north, MathUtils.randFloat(0.3, 0.7)); + sources['static'].addFeature(new Feature(new Point([x, y]))); + }, 500); }); // Add a sunlight diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index c350eef179..8d5b647fb0 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -2,15 +2,14 @@ import type Feature from 'ol/Feature'; import { Box3, Group, Sphere, Vector3 } from 'three'; -import GUI from 'lil-gui'; +import type GUI from 'lil-gui'; import { type Coordinate } from 'ol/coordinate'; import { getCenter } from 'ol/extent'; import type { Circle, Point, SimpleGeometry } from 'ol/geom'; import { LineString, MultiLineString, MultiPolygon, Polygon } from 'ol/geom'; import type ElevationProvider from '../core/ElevationProvider'; +import type { FeatureExtrusionOffset, FeatureExtrusionOffsetCallback } from '../core/FeatureTypes'; import { - FeatureExtrusionOffset, - FeatureExtrusionOffsetCallback, mapGeometry, type FeatureStyle, type FeatureStyleCallback, @@ -20,7 +19,7 @@ import { } from '../core/FeatureTypes'; import type Extent from '../core/geographic/Extent'; import type HasDefaultPointOfView from '../core/HasDefaultPointOfView'; -import Instance from '../core/Instance'; +import type Instance from '../core/Instance'; import type PointOfView from '../core/PointOfView'; import EntityInspector from '../gui/EntityInspector'; import EntityPanel from '../gui/EntityPanel'; @@ -32,7 +31,7 @@ import type { import GeometryConverter from '../renderer/geometries/GeometryConverter'; import type SimpleGeometryMesh from '../renderer/geometries/SimpleGeometryMesh'; import { computeDistanceToFitSphere, computeZoomToFitSphere } from '../renderer/View'; -import type { FeatureSource } from '../sources/FeatureSource'; +import type { FeatureSource, FeatureSourceEventMap } from '../sources/FeatureSource'; import OLUtils from '../utils/OpenLayersUtils'; import { isOrthographicCamera, isPerspectiveCamera } from '../utils/predicates'; import { nonNull } from '../utils/tsutils'; @@ -341,6 +340,8 @@ export default class DrapedFeatureCollection extends Entity3D { onTileCreated: EventHandler; onTileDeleted: EventHandler; onElevationLoaded: EventHandler; + onSourceUpdated: EventHandler; + onTextureLoaded: () => void; }; private readonly _style: FeatureStyle | FeatureStyleCallback | undefined; @@ -366,6 +367,7 @@ export default class DrapedFeatureCollection extends Entity3D { this._drapingMode = options.drapingMode ?? 'per-vertex'; this._extrusionCallback = options.extrusionOffset; this._source = options.source; + this._style = options.style; this._minLod = options.minLod ?? this._minLod; this._elevationCallback = options.elevationCallback ?? defaultElevationCallback; @@ -373,6 +375,8 @@ export default class DrapedFeatureCollection extends Entity3D { onTileCreated: this.onTileCreated.bind(this), onTileDeleted: this.onTileDeleted.bind(this), onElevationLoaded: this.onElevationLoaded.bind(this), + onTextureLoaded: this.notifyChange.bind(this), + onSourceUpdated: this.onSourceUpdated.bind(this), }; this._geometryConverter = new GeometryConverter({ @@ -381,12 +385,30 @@ export default class DrapedFeatureCollection extends Entity3D { lineMaterialGenerator: options.lineMaterialGenerator, pointMaterialGenerator: options.pointMaterialGenerator, }); - this._style = options.style; - this._geometryConverter.addEventListener('texture-loaded', () => this.notifyChange(this)); + + this._geometryConverter.addEventListener( + 'texture-loaded', + this._eventHandlers.onTextureLoaded, + ); + + this._source.addEventListener('updated', this._eventHandlers.onSourceUpdated); + } + + private onSourceUpdated() { + this._features.forEach(v => { + v.mesh?.dispose(); + v.mesh?.removeFromParent(); + }); + + this._features.clear(); + + for (const tile of [...this._activeTiles.values()]) { + this.registerTile(tile, true); + } } override async preprocess() { - await this._source.initialize({ targetProjection: this.instance.coordinateSystem }); + await this._source.initialize({ targetCoordinateSystem: this.instance.coordinateSystem }); } attach(map: MapEntity): this { @@ -561,6 +583,7 @@ export default class DrapedFeatureCollection extends Entity3D { } else if (drapingMode === 'per-vertex') { shouldReplaceMesh = true; // TODO support multipoint ? + // @ts-expect-error cast actualGeometry = applyPerVertexDraping(geometry, this._map); } @@ -596,9 +619,9 @@ export default class DrapedFeatureCollection extends Entity3D { // then we can simply translate the Mesh itself, rather than recreate it. if (verticalOffset !== 0) { mesh.position.setZ(existing.originalZ + verticalOffset); - mesh.updateMatrix(); } + mesh.updateMatrix(); mesh.updateMatrixWorld(true); } } diff --git a/src/sources/FeatureSource.ts b/src/sources/FeatureSource.ts index 02c1c9c60b..89460dee69 100644 --- a/src/sources/FeatureSource.ts +++ b/src/sources/FeatureSource.ts @@ -17,7 +17,7 @@ export interface FeatureSourceEventMap { } export interface FeatureSource extends EventDispatcher { - initialize(options: { targetProjection: CoordinateSystem }): Promise; + initialize(options: { targetCoordinateSystem: CoordinateSystem }): Promise; getFeatures(request: GetFeatureRequest): Promise; } @@ -27,15 +27,15 @@ export abstract class FeatureSourceBase { abstract readonly type: string; - protected _targetProjection: CoordinateSystem | null = null; + protected _targetCoordinateSystem: CoordinateSystem | null = null; protected _initialized = false; constructor() { super(); } - initialize(options: { targetProjection: CoordinateSystem }): Promise { - this._targetProjection = options.targetProjection; + initialize(options: { targetCoordinateSystem: CoordinateSystem }): Promise { + this._targetCoordinateSystem = options.targetCoordinateSystem; this._initialized = true; return Promise.resolve(); diff --git a/src/sources/FileFeatureSource.ts b/src/sources/FileFeatureSource.ts index a316d699ab..93f2ea0809 100644 --- a/src/sources/FileFeatureSource.ts +++ b/src/sources/FileFeatureSource.ts @@ -85,7 +85,10 @@ export default class FileFeatureSource extends FeatureSourceBase { const features = this._format.readFeatures(data) as Feature[]; - const targetProjection = nonNull(this._targetProjection, 'this source is not initialized'); + const targetProjection = nonNull( + this._targetCoordinateSystem, + 'this source is not initialized', + ); const sourceProjection = nonNull(this._sourceProjection); const actualFeatures = await processFeatures(features, sourceProjection, targetProjection); diff --git a/src/sources/StaticFeatureSource.ts b/src/sources/StaticFeatureSource.ts index e0d92245e5..289ef8bc48 100644 --- a/src/sources/StaticFeatureSource.ts +++ b/src/sources/StaticFeatureSource.ts @@ -1,13 +1,29 @@ import type { Feature } from 'ol'; +import { MathUtils } from 'three'; +import type CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; +import { nonNull } from '../utils/tsutils'; import type { GetFeatureRequest, GetFeatureResult } from './FeatureSource'; import { FeatureSourceBase } from './FeatureSource'; import { filterByExtent } from './features/processor'; +function preprocess(feature: Feature, src: CoordinateSystem, dst: CoordinateSystem) { + if (feature.getId() == null) { + feature.setId(MathUtils.generateUUID()); + } + + if (src.id !== dst.id) { + feature.getGeometry()?.transform(src.id, dst.id); + } + + return feature; +} + export default class StaticFeatureSource extends FeatureSourceBase { readonly isStaticFeatureSource = true as const; override readonly type = 'StaticFeatureSource' as const; private readonly _features: Set = new Set(); + private readonly _coordinateSystem: CoordinateSystem; /** * Returns a copy of the features contained in this source. @@ -16,8 +32,14 @@ export default class StaticFeatureSource extends FeatureSourceBase { return [...this._features]; } - constructor() { + constructor(options: { features?: Feature[]; coordinateSystem: CoordinateSystem }) { super(); + + this._coordinateSystem = options.coordinateSystem; + + if (options.features) { + this._features = new Set(options.features); + } } /** @@ -26,7 +48,11 @@ export default class StaticFeatureSource extends FeatureSourceBase { * Note: if you want to add multiple features at once, use {@link addFeatures} for better performance. */ addFeature(feature: Feature) { - this._features.add(feature); + this.throwIfNotInitialized(); + + this._features.add( + preprocess(feature, this._coordinateSystem, nonNull(this._targetCoordinateSystem)), + ); this.update(); } @@ -51,8 +77,12 @@ export default class StaticFeatureSource extends FeatureSourceBase { * Adds multiple features. */ addFeatures(features: Iterable) { + this.throwIfNotInitialized(); + for (const feature of features) { - this._features.add(feature); + this._features.add( + preprocess(feature, this._coordinateSystem, nonNull(this._targetCoordinateSystem)), + ); } this.update(); diff --git a/src/sources/StreamableFeatureSource.ts b/src/sources/StreamableFeatureSource.ts index e8d20a121b..03fba31d93 100644 --- a/src/sources/StreamableFeatureSource.ts +++ b/src/sources/StreamableFeatureSource.ts @@ -93,7 +93,10 @@ export default class StreamableFeatureSource extends FeatureSourceBase { const features = this._format.readFeatures(data) as Feature[]; - const targetProjection = nonNull(this._targetProjection, 'this source is not initialized'); + const targetProjection = nonNull( + this._targetCoordinateSystem, + 'this source is not initialized', + ); const sourceProjection = nonNull(this._sourceProjection); const getFeatureId = (feature: Feature) => { -- GitLab From ab34b2710f5cb7715b34965f7200154482c7124e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Wed, 9 Jul 2025 16:26:59 +0200 Subject: [PATCH 11/43] test(StaticFeatureSource): add tests --- src/sources/StaticFeatureSource.ts | 59 ++++- test/unit/sources/StaticFeatureSource.test.ts | 224 ++++++++++++++++++ 2 files changed, 273 insertions(+), 10 deletions(-) create mode 100644 test/unit/sources/StaticFeatureSource.test.ts diff --git a/src/sources/StaticFeatureSource.ts b/src/sources/StaticFeatureSource.ts index 289ef8bc48..97134dbd1c 100644 --- a/src/sources/StaticFeatureSource.ts +++ b/src/sources/StaticFeatureSource.ts @@ -18,27 +18,46 @@ function preprocess(feature: Feature, src: CoordinateSystem, dst: CoordinateSyst return feature; } +/** + * A feature source that does not read from any remote source, but + * instead acts as a container for features added by the user. + * + * Note: when features are added to this source, they might be transformed to match the target + * coordinate system, as well as assigning them unique IDs. + */ export default class StaticFeatureSource extends FeatureSourceBase { readonly isStaticFeatureSource = true as const; override readonly type = 'StaticFeatureSource' as const; + private readonly _initialFeatures: Feature[] | undefined = undefined; private readonly _features: Set = new Set(); private readonly _coordinateSystem: CoordinateSystem; /** * Returns a copy of the features contained in this source. + * + * Note: this property returns an empty array if the source is not yet initialized. */ get features(): Readonly { return [...this._features]; } - constructor(options: { features?: Feature[]; coordinateSystem: CoordinateSystem }) { + constructor(options: { + /** + * The initial features in this source. + */ + features?: Feature[]; + /** + * The coordinate system of features contained in this source. + */ + coordinateSystem: CoordinateSystem; + }) { super(); this._coordinateSystem = options.coordinateSystem; if (options.features) { - this._features = new Set(options.features); + this._initialFeatures = [...options.features]; } } @@ -50,9 +69,7 @@ export default class StaticFeatureSource extends FeatureSourceBase { addFeature(feature: Feature) { this.throwIfNotInitialized(); - this._features.add( - preprocess(feature, this._coordinateSystem, nonNull(this._targetCoordinateSystem)), - ); + this.doAddFeatures(feature); this.update(); } @@ -79,11 +96,7 @@ export default class StaticFeatureSource extends FeatureSourceBase { addFeatures(features: Iterable) { this.throwIfNotInitialized(); - for (const feature of features) { - this._features.add( - preprocess(feature, this._coordinateSystem, nonNull(this._targetCoordinateSystem)), - ); - } + this.doAddFeatures([...features]); this.update(); } @@ -95,6 +108,7 @@ export default class StaticFeatureSource extends FeatureSourceBase { */ removeFeatures(features: Iterable): boolean { let actuallyRemoved = false; + for (const feature of features) { if (this._features.delete(feature)) { actuallyRemoved = true; @@ -119,6 +133,31 @@ export default class StaticFeatureSource extends FeatureSourceBase { } } + private doAddFeatures(features: Feature | Feature[]) { + if (Array.isArray(features)) { + features.forEach(f => { + preprocess(f, this._coordinateSystem, nonNull(this._targetCoordinateSystem)); + this._features.add(f); + }); + } else { + preprocess(features, this._coordinateSystem, nonNull(this._targetCoordinateSystem)); + this._features.add(features); + } + } + + override async initialize(options: { + targetCoordinateSystem: CoordinateSystem; + }): Promise { + await super.initialize(options); + + // Let's prepare the features that were added during construction. + // We couldn't do that before since the target coordinate system was not known. + if (this._initialFeatures) { + this.doAddFeatures(this._initialFeatures); + this._initialFeatures.length = 0; + } + } + override async getFeatures(request: GetFeatureRequest): Promise { const filtered = await filterByExtent([...this._features], request.extent, { signal: request.signal, diff --git a/test/unit/sources/StaticFeatureSource.test.ts b/test/unit/sources/StaticFeatureSource.test.ts new file mode 100644 index 0000000000..02fa4ba3a5 --- /dev/null +++ b/test/unit/sources/StaticFeatureSource.test.ts @@ -0,0 +1,224 @@ +import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem'; +import Extent from '@giro3d/giro3d/core/geographic/Extent'; +import StaticFeatureSource from '@giro3d/giro3d/sources/StaticFeatureSource'; +import { Feature } from 'ol'; +import { Point } from 'ol/geom'; + +describe('StaticFeatureSource', () => { + let sourceWithTransformation: StaticFeatureSource; + let sourceWithoutTransformation: StaticFeatureSource; + + beforeEach(async () => { + sourceWithTransformation = new StaticFeatureSource({ + coordinateSystem: CoordinateSystem.epsg4326, + }); + + sourceWithoutTransformation = new StaticFeatureSource({ + coordinateSystem: CoordinateSystem.epsg4326, + }); + + await sourceWithTransformation.initialize({ + targetCoordinateSystem: CoordinateSystem.epsg3857, + }); + await sourceWithoutTransformation.initialize({ + targetCoordinateSystem: CoordinateSystem.epsg4326, + }); + }); + + describe('addFeature', () => { + it('should throw if not initialized', () => { + const source = new StaticFeatureSource({ coordinateSystem: CoordinateSystem.epsg4326 }); + + expect(() => source.addFeature(new Feature())).toThrow( + /this source has not been initialized/, + ); + }); + + it('should transform the geometry if the source and target coordinate systems differ', () => { + const geometry = new Point([3.24, 45.23]); + const feature = new Feature(geometry); + + sourceWithTransformation.addFeature(feature); + + expect(sourceWithTransformation.features).toEqual([feature]); + + const [x, y] = geometry.getCoordinates(); + + expect(x).toBeCloseTo(360675.15); + expect(y).toBeCloseTo(5657803.247); + }); + }); + + describe('constructor', () => { + it('should honot the list of features passed', async () => { + const features = [new Feature(new Point([0, 0]))]; + + const source = new StaticFeatureSource({ + features, + coordinateSystem: CoordinateSystem.epsg4326, + }); + + await source.initialize({ targetCoordinateSystem: CoordinateSystem.epsg4326 }); + + expect(source.features).toHaveLength(1); + expect(source.features[0]).toBe(features[0]); + }); + }); + + describe('addFeatures', () => { + it('should throw if not initialized', () => { + const source = new StaticFeatureSource({ coordinateSystem: CoordinateSystem.epsg4326 }); + + expect(() => source.addFeatures([])).toThrow(/this source has not been initialized/); + }); + + it('should assign a unique ID to each feature', () => { + const f0 = new Feature(new Point([0, 0])); + const f1 = new Feature(new Point([0, 0])); + + expect(f0.getId()).toBeUndefined(); + expect(f1.getId()).toBeUndefined(); + + sourceWithTransformation.addFeatures([f0, f1]); + + expect(sourceWithTransformation.features).toEqual([f0, f1]); + + expect(f0.getId()).toBeDefined(); + expect(f1.getId()).toBeDefined(); + }); + + it('should transform the geometry if the source and target coordinate systems differ', () => { + const geometry0 = new Point([3.24, 45.23]); + const geometry1 = new Point([3.24, 45.23]); + + sourceWithTransformation.addFeatures([new Feature(geometry0), new Feature(geometry1)]); + + const [x0, y0] = geometry0.getCoordinates(); + + expect(x0).toBeCloseTo(360675.15); + expect(y0).toBeCloseTo(5657803.247); + + const [x1, y1] = geometry1.getCoordinates(); + + expect(x1).toBeCloseTo(360675.15); + expect(y1).toBeCloseTo(5657803.247); + }); + }); + + describe('clear', () => { + it('should raise the update event if some features were present', () => { + const listener = jest.fn(); + + sourceWithTransformation.addEventListener('updated', listener); + + sourceWithTransformation.clear(); + + expect(listener).not.toHaveBeenCalled(); + + sourceWithTransformation.addFeature(new Feature(new Point([0, 0]))); + + sourceWithTransformation.clear(); + + expect(sourceWithTransformation.features).toHaveLength(0); + + expect(listener).toHaveBeenCalled(); + }); + }); + + describe('removeFeature', () => { + it('should return true if the feature was actually removed', () => { + const feature = new Feature(new Point([3.24, 45.23])); + + sourceWithTransformation.addFeature(feature); + + expect(sourceWithTransformation.removeFeature(new Feature())).toEqual(false); + expect(sourceWithTransformation.removeFeature(feature)).toEqual(true); + }); + + it('should raise the update event if the feature was actually removed', () => { + const feature = new Feature(new Point([3.24, 45.23])); + + sourceWithTransformation.addFeature(feature); + + const listener = jest.fn(); + sourceWithTransformation.addEventListener('updated', listener); + + sourceWithTransformation.removeFeature(feature); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + + describe('removeFeatures', () => { + it('should return true if the feature was actually removed', () => { + const feature = new Feature(new Point([3.24, 45.23])); + + sourceWithTransformation.addFeature(feature); + + expect(sourceWithTransformation.removeFeatures([new Feature()])).toEqual(false); + expect(sourceWithTransformation.removeFeatures([feature])).toEqual(true); + + expect(sourceWithTransformation.features).toHaveLength(0); + }); + + it('should raise the update event if the feature was actually removed', () => { + const feature = new Feature(new Point([3.24, 45.23])); + + sourceWithTransformation.addFeature(feature); + + const listener = jest.fn(); + sourceWithTransformation.addEventListener('updated', listener); + + sourceWithTransformation.removeFeatures([feature]); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + + describe('getFeatures', () => { + it('should return an empty array if no features are present', async () => { + const result = await sourceWithoutTransformation.getFeatures({ extent: Extent.WGS84 }); + + expect(result.features).toHaveLength(0); + }); + + it('should return all features that intersect the requested extent', async () => { + const sw = new Feature(new Point([-1, -1])); + const nw = new Feature(new Point([-1, 1])); + const ne = new Feature(new Point([1, 1])); + const se = new Feature(new Point([1, -1])); + + sourceWithoutTransformation.addFeatures([sw, nw, ne, se]); + + const fullExtent = await sourceWithoutTransformation.getFeatures({ + extent: Extent.WGS84, + }); + + expect(fullExtent.features).toEqual([sw, nw, ne, se]); + + const westernHemisphere = await sourceWithoutTransformation.getFeatures({ + extent: new Extent(CoordinateSystem.epsg4326, -180, 0, -90, +90), + }); + + expect(westernHemisphere.features).toEqual([sw, nw]); + + const easternHemisphere = await sourceWithoutTransformation.getFeatures({ + extent: new Extent(CoordinateSystem.epsg4326, 0, +180, -90, +90), + }); + + expect(easternHemisphere.features).toEqual([ne, se]); + + const northernHemisphere = await sourceWithoutTransformation.getFeatures({ + extent: new Extent(CoordinateSystem.epsg4326, -180, +180, 0, +90), + }); + + expect(northernHemisphere.features).toEqual([nw, ne]); + + const southernHemisphere = await sourceWithoutTransformation.getFeatures({ + extent: new Extent(CoordinateSystem.epsg4326, -180, +180, -90, 0), + }); + + expect(southernHemisphere.features).toEqual([sw, se]); + }); + }); +}); -- GitLab From 62baad91857b30ecaf62255fda04b0d32fd618b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Wed, 9 Jul 2025 17:32:39 +0200 Subject: [PATCH 12/43] feat(FeatureSource): update --- src/sources/AggregateFeatureSource.ts | 53 ++++++++++++ src/sources/FeatureSource.ts | 35 +++++++- src/sources/FileFeatureSource.ts | 72 ++++++++++------ src/sources/StaticFeatureSource.ts | 29 ++++--- src/sources/StreamableFeatureSource.ts | 86 +++++++++++++------ src/sources/api.ts | 32 ++++++- test/unit/sources/FileFeatureSource.test.ts | 20 ++++- .../sources/StreamableFeatureSource.test.ts | 46 ++++++++++ 8 files changed, 301 insertions(+), 72 deletions(-) create mode 100644 src/sources/AggregateFeatureSource.ts create mode 100644 test/unit/sources/StreamableFeatureSource.test.ts diff --git a/src/sources/AggregateFeatureSource.ts b/src/sources/AggregateFeatureSource.ts new file mode 100644 index 0000000000..9581afc981 --- /dev/null +++ b/src/sources/AggregateFeatureSource.ts @@ -0,0 +1,53 @@ +import type { Feature } from 'ol'; +import type CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; +import type { FeatureSource, GetFeatureRequest, GetFeatureResult } from './FeatureSource'; +import { FeatureSourceBase } from './FeatureSource'; + +export interface AggregateFeatureSourceOptions { + sources: FeatureSource[]; +} + +export default class AggregateFeatureSource extends FeatureSourceBase { + override readonly type = 'AggregateFeatureSource' as const; + readonly isAggregateFeatureSource = true as const; + + private readonly _sources: FeatureSource[]; + + constructor(params: AggregateFeatureSourceOptions) { + super(); + + this._sources = [...params.sources]; + } + + /** + * The sources in this source. + */ + get sources(): Readonly { + return [...this._sources]; + } + + override async getFeatures(request: GetFeatureRequest): Promise { + const result: Feature[] = []; + + const promises: Promise[] = []; + + for (const source of this._sources) { + const promise = source.getFeatures(request); + promises.push(promise); + } + + const promiseResults = await Promise.all(promises); + + promiseResults.forEach(r => result.push(...r.features)); + + return { features: result } satisfies GetFeatureResult; + } + + override async initialize(options: { + targetCoordinateSystem: CoordinateSystem; + }): Promise { + await super.initialize(options); + + this.sources.forEach(source => source.initialize(options)); + } +} diff --git a/src/sources/FeatureSource.ts b/src/sources/FeatureSource.ts index 89460dee69..dd09a2fe8a 100644 --- a/src/sources/FeatureSource.ts +++ b/src/sources/FeatureSource.ts @@ -4,20 +4,52 @@ import type Extent from '../core/geographic/Extent'; import type CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; export type GetFeatureResult = { + /** + * The resulting features. + */ features: Readonly; }; export type GetFeatureRequest = { + /** + * The extent of the request. The source will make the best effort to return features + * limited to this extent, but may return more features. + */ extent: Extent; + /** + * Optional abort signal to cancel the feature request. + */ signal?: AbortSignal; }; export interface FeatureSourceEventMap { + /** + * Raised when the content of the feature source have changed. + */ updated: unknown; } +/** + * Interface for feature sources. + * + * Note: to implement a feature source, you can use the {@link FeatureSourceBase} base class for convenience. + */ export interface FeatureSource extends EventDispatcher { - initialize(options: { targetCoordinateSystem: CoordinateSystem }): Promise; + /** + * Initializes the source. The source is not useable before being initialized. + */ + initialize(options: { + /** + * The coordinate system of features that are generated by this source. + * The source is responsible for reprojecting features if their original + * CRS is different from the target. + */ + targetCoordinateSystem: CoordinateSystem; + }): Promise; + /** + * Gets the features matching the request. + * @param request - The feature request. + */ getFeatures(request: GetFeatureRequest): Promise; } @@ -38,6 +70,7 @@ export abstract class FeatureSourceBase this._targetCoordinateSystem = options.targetCoordinateSystem; this._initialized = true; + return Promise.resolve(); } diff --git a/src/sources/FileFeatureSource.ts b/src/sources/FileFeatureSource.ts index 93f2ea0809..d4bcd13010 100644 --- a/src/sources/FileFeatureSource.ts +++ b/src/sources/FileFeatureSource.ts @@ -1,6 +1,7 @@ import type { Feature } from 'ol'; import type FeatureFormat from 'ol/format/Feature'; import type { Type } from 'ol/format/Feature'; +import GeoJSON from 'ol/format/GeoJSON'; import type { Geometry } from 'ol/geom'; import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; import Fetcher from '../utils/Fetcher'; @@ -23,6 +24,33 @@ const defaultGetter: Getter = (url, type) => { } }; +export interface FileFeatureSourceOptions { + /** + * The URL to the remote file. + */ + url: string; + /** + * The format to parse the file. + * @defaultValue {@link GeoJSON} + */ + format?: FeatureFormat; + /** + * A function to retrieve the file remotely. + * If not specified, will use standard fetch functions to download the file. + * Mostly useful for unit testing. + */ + getter?: Getter; + /** + * The coordinate system of the features in this source. + * 1. If not provided, will attempt to read it from the file. + * 2. If the file does not contain coordinate system information, will assume EPSG:4326. + */ + sourceCoordinateSystem?: CoordinateSystem; +} + +/** + * Loads features from a remote file (such as GeoJSON, GPX, etc.) + */ export default class FileFeatureSource extends FeatureSourceBase { readonly isFileFeatureSource = true as const; readonly type = 'FileFeatureSource' as const; @@ -32,28 +60,20 @@ export default class FileFeatureSource extends FeatureSourceBase { private _loadFeaturePromise: Promise[]> | null = null; private _getter: Getter; private _url: string; - private _sourceProjection?: CoordinateSystem; - private _abortController: AbortController | null = null; - - constructor(params: { - format: FeatureFormat; - url: string; - getter?: Getter; - sourceProjection?: CoordinateSystem; - }) { + private _sourceCoordinateSystem?: CoordinateSystem; + + constructor(params: FileFeatureSourceOptions) { super(); - this._format = params.format; + + this._format = params.format ?? new GeoJSON(); this._url = params.url; - this._sourceProjection = params.sourceProjection; + this._sourceCoordinateSystem = params.sourceCoordinateSystem; this._getter = params.getter ?? defaultGetter; } private loadFeatures() { this.throwIfNotInitialized(); - this._abortController?.abort(); - this._abortController = new AbortController(); - if (this._features != null) { return this._features; } @@ -62,24 +82,20 @@ export default class FileFeatureSource extends FeatureSourceBase { return this._loadFeaturePromise; } - this._loadFeaturePromise = this.loadFeaturesOnce(this._abortController.signal); + this._loadFeaturePromise = this.loadFeaturesOnce(); return this._loadFeaturePromise; } - private async loadFeaturesOnce(signal: AbortSignal): Promise[]> { - signal.throwIfAborted(); - + private async loadFeaturesOnce(): Promise[]> { const data = await this._getter(this._url, this._format.getType()); - signal.throwIfAborted(); - - if (!this._sourceProjection) { + if (!this._sourceCoordinateSystem) { const dataProjection = this._format.readProjection(data); if (dataProjection) { - this._sourceProjection = CoordinateSystem.fromSrid(dataProjection?.getCode()); + this._sourceCoordinateSystem = CoordinateSystem.fromSrid(dataProjection?.getCode()); } else { - this._sourceProjection = CoordinateSystem.epsg4326; + this._sourceCoordinateSystem = CoordinateSystem.epsg4326; } } @@ -89,13 +105,11 @@ export default class FileFeatureSource extends FeatureSourceBase { this._targetCoordinateSystem, 'this source is not initialized', ); - const sourceProjection = nonNull(this._sourceProjection); + const sourceProjection = nonNull(this._sourceCoordinateSystem); const actualFeatures = await processFeatures(features, sourceProjection, targetProjection); - if (!signal.aborted) { - this._features = actualFeatures; - } + this._features = actualFeatures; return actualFeatures; } @@ -114,10 +128,12 @@ export default class FileFeatureSource extends FeatureSourceBase { return { features: filtered }; } + /** + * Deletes the already loaded features, and dispatch an event to reload the features. + */ reload() { this._features = null; this._loadFeaturePromise = null; - this._abortController?.abort(); this.dispatchEvent({ type: 'updated' }); } diff --git a/src/sources/StaticFeatureSource.ts b/src/sources/StaticFeatureSource.ts index 97134dbd1c..d09aa2703b 100644 --- a/src/sources/StaticFeatureSource.ts +++ b/src/sources/StaticFeatureSource.ts @@ -1,6 +1,6 @@ import type { Feature } from 'ol'; import { MathUtils } from 'three'; -import type CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; +import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; import { nonNull } from '../utils/tsutils'; import type { GetFeatureRequest, GetFeatureResult } from './FeatureSource'; import { FeatureSourceBase } from './FeatureSource'; @@ -18,6 +18,18 @@ function preprocess(feature: Feature, src: CoordinateSystem, dst: CoordinateSyst return feature; } +export interface StaticFeaturesSourceOptions { + /** + * The initial features in this source. + */ + features?: Feature[]; + /** + * The coordinate system of features contained in this source. + * @defaultValue {@link CoordinateSystem.epsg4326} + */ + coordinateSystem?: CoordinateSystem; +} + /** * A feature source that does not read from any remote source, but * instead acts as a container for features added by the user. @@ -42,21 +54,12 @@ export default class StaticFeatureSource extends FeatureSourceBase { return [...this._features]; } - constructor(options: { - /** - * The initial features in this source. - */ - features?: Feature[]; - /** - * The coordinate system of features contained in this source. - */ - coordinateSystem: CoordinateSystem; - }) { + constructor(options?: StaticFeaturesSourceOptions) { super(); - this._coordinateSystem = options.coordinateSystem; + this._coordinateSystem = options?.coordinateSystem ?? CoordinateSystem.epsg4326; - if (options.features) { + if (options?.features) { this._initialFeatures = [...options.features]; } } diff --git a/src/sources/StreamableFeatureSource.ts b/src/sources/StreamableFeatureSource.ts index 03fba31d93..3eacc0f04e 100644 --- a/src/sources/StreamableFeatureSource.ts +++ b/src/sources/StreamableFeatureSource.ts @@ -9,22 +9,53 @@ import { nonNull } from '../utils/tsutils'; import { processFeatures } from './features/processor'; import { FeatureSourceBase, type GetFeatureRequest, type GetFeatureResult } from './FeatureSource'; -export type QueryBuilder = (params: { +/** + * A function to build URLs used to query features from the remote source. + * @returns The URL of the query, or `undefined`, if the query should not be made at all. + */ +export type StreamableFeatureSourceQueryBuilder = (params: { extent: Extent; - sourceProjection: CoordinateSystem; + sourceCoordinateSystem: CoordinateSystem; }) => URL | undefined; -export const ogcApiFeaturesBuilder: (serverUrl: string, collection: string) => QueryBuilder = ( - serverUrl, - collection, -) => { +/** + * A query builder to fetch data from an OGC API Features service. + * @param serviceUrl - The base URL to the service. + * @param collection - The name of the feature collection. + * @param options - Optional parameters to customize the query. + */ +export const ogcApiFeaturesBuilder: ( + serverUrl: string, + collection: string, + options?: { + /** + * The limit of features to retrieve with each query. + * @defaultValue 1000 + */ + limit?: number; + /** + * Additional parameters to pass to the query, such as CQL filter, etc, + * with the exception of the `limit` (passed with the `limit` option) + * and `bbox` parameters (dynamically computed for each query). + */ + params?: Record; + }, +) => StreamableFeatureSourceQueryBuilder = (serviceUrl, collection, opts) => { return params => { - const url = new URL(`/collections/${collection}/items.json`, serverUrl); + const url = new URL(`/collections/${collection}/items.json`, serviceUrl); - const bbox = params.extent.as(params.sourceProjection); + const bbox = params.extent.as(params.sourceCoordinateSystem); url.searchParams.append('bbox', `${bbox.west},${bbox.south},${bbox.east},${bbox.north}`); - url.searchParams.append('limit', '1000'); + + const limit = opts?.limit ?? 1000; + url.searchParams.append('limit', limit.toString()); + + if (opts?.params) { + for (const [key, value] of Object.entries(opts.params)) { + url.searchParams.append(key, value); + } + } return url; }; @@ -45,34 +76,39 @@ export const defaultGetter: Getter = (url, type) => { } }; +export interface StreamableFeatureSourceOptions { + /** + * The query builder. + */ + queryBuilder: StreamableFeatureSourceQueryBuilder; + /** + * The format of the features. + * @defaultValue {@link GeoJSON} + */ + format?: FeatureFormat; + getter?: Getter; + sourceCoordinateSystem?: CoordinateSystem; +} + +/** + * A feature source that supports streaming features (e.g OGC API Features, etc) + */ export default class StreamableFeatureSource extends FeatureSourceBase { readonly isStreamableFeatureSource = true as const; readonly type = 'StreamableFeatureSource' as const; - private readonly _queryBuilder: QueryBuilder; + private readonly _queryBuilder: StreamableFeatureSourceQueryBuilder; private readonly _format: FeatureFormat; private readonly _getter: Getter; private readonly _sourceProjection: CoordinateSystem; - constructor(params: { - /** - * The query builder. - */ - queryBuilder: QueryBuilder; - /** - * The format of the features. - * @defaultValue {@link GeoJSON} - */ - format?: FeatureFormat; - getter?: Getter; - sourceProjection?: CoordinateSystem; - }) { + constructor(params: StreamableFeatureSourceOptions) { super(); this._queryBuilder = params.queryBuilder; this._format = params.format ?? new GeoJSON(); this._getter = params.getter ?? defaultGetter; // TODO assume EPSG:4326 ? - this._sourceProjection = params.sourceProjection ?? CoordinateSystem.epsg4326; + this._sourceProjection = params.sourceCoordinateSystem ?? CoordinateSystem.epsg4326; } async getFeatures(request: GetFeatureRequest): Promise { @@ -80,7 +116,7 @@ export default class StreamableFeatureSource extends FeatureSourceBase { const url = this._queryBuilder({ extent: request.extent, - sourceProjection: this._sourceProjection, + sourceCoordinateSystem: this._sourceProjection, }); if (!url) { diff --git a/src/sources/api.ts b/src/sources/api.ts index 68b23205f5..e4216f467b 100644 --- a/src/sources/api.ts +++ b/src/sources/api.ts @@ -4,14 +4,21 @@ * SPDX-License-Identifier: MIT */ +import AggregateFeatureSource, { AggregateFeatureSourceOptions } from './AggregateFeatureSource'; // import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; import AggregateImageSource from './AggregateImageSource'; import AggregatePointCloudSource, { AggregatePointCloudSourceOptions, } from './AggregatePointCloudSource'; import COPCSource, { COPCSourceOptions } from './COPCSource'; -import { FeatureSource, GetFeatureRequest, GetFeatureResult } from './FeatureSource'; -import FileFeatureSource from './FileFeatureSource'; +import { + FeatureSource, + FeatureSourceBase, + FeatureSourceEventMap, + GetFeatureRequest, + GetFeatureResult, +} from './FeatureSource'; +import FileFeatureSource, { FileFeatureSourceOptions } from './FileFeatureSource'; import GeoTIFFSource, { type ChannelMapping, type GeoTIFFCacheOptions, @@ -38,10 +45,16 @@ import { PointCloudSourceEventMap, } from './PointCloudSource'; import PotreeSource, { PotreeSourceOptions } from './PotreeSource'; +import StaticFeatureSource, { StaticFeaturesSourceOptions } from './StaticFeatureSource'; import StaticImageSource, { type StaticImageSourceEvents, type StaticImageSourceOptions, } from './StaticImageSource'; +import StreamableFeatureSource, { + StreamableFeatureSourceOptions, + StreamableFeatureSourceQueryBuilder, + ogcApiFeaturesBuilder, +} from './StreamableFeatureSource'; import TiledImageSource, { type TiledImageSourceOptions } from './TiledImageSource'; import VectorSource, { type VectorSourceOptions } from './VectorSource'; import VectorTileSource, { type VectorTileSourceOptions } from './VectorTileSource'; @@ -53,15 +66,20 @@ import WmtsSource, { type WmtsFromCapabilitiesOptions, type WmtsSourceOptions } */ export { AggregateImageSource, + AggregateFeatureSource, + AggregateFeatureSourceOptions, AggregatePointCloudSource, AggregatePointCloudSourceOptions, - ChannelMapping, // CoordinateSystem, COPCSource, COPCSourceOptions, + ChannelMapping, CustomContainsFn, FeatureSource, + FeatureSourceBase, + FeatureSourceEventMap, FileFeatureSource, + FileFeatureSourceOptions, GeoTIFFCacheOptions, GeoTIFFSource, GeoTIFFSourceOptions, @@ -74,7 +92,6 @@ export { ImageSource, ImageSourceEvents, ImageSourceOptions, - las, LASSource, LASSourceOptions, PointCloudAttribute, @@ -86,9 +103,14 @@ export { PointCloudSourceEventMap, PotreeSource, PotreeSourceOptions, + StaticFeatureSource, + StaticFeaturesSourceOptions, StaticImageSource, StaticImageSourceEvents, StaticImageSourceOptions, + StreamableFeatureSource, + StreamableFeatureSourceOptions, + StreamableFeatureSourceQueryBuilder, TiledImageSource, TiledImageSourceOptions, VectorSource, @@ -100,4 +122,6 @@ export { WmtsFromCapabilitiesOptions, WmtsSource, WmtsSourceOptions, + las, + ogcApiFeaturesBuilder, }; diff --git a/test/unit/sources/FileFeatureSource.test.ts b/test/unit/sources/FileFeatureSource.test.ts index 15e8b4526c..bc7f6fc462 100644 --- a/test/unit/sources/FileFeatureSource.test.ts +++ b/test/unit/sources/FileFeatureSource.test.ts @@ -6,6 +6,24 @@ import FileFeatureSource from '@giro3d/giro3d/sources/FileFeatureSource'; import { Projection } from 'ol/proj'; describe('FileFeatureSource', () => { + describe('reload', () => { + it('should dispatch the updated event', () => { + const source = new FileFeatureSource({ + url: 'foo', + }); + + const listener = jest.fn(); + + source.addEventListener('updated', listener); + + expect(listener).not.toHaveBeenCalled(); + + source.reload(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + describe('getFeatures', () => { it('should load the file', async () => { // @ts-expect-error incomplete implementation @@ -24,7 +42,7 @@ describe('FileFeatureSource', () => { getter, }); - await source.initialize({ targetProjection: CoordinateSystem.epsg4326 }); + await source.initialize({ targetCoordinateSystem: CoordinateSystem.epsg4326 }); await source.getFeatures({ extent: Extent.WGS84 }); diff --git a/test/unit/sources/StreamableFeatureSource.test.ts b/test/unit/sources/StreamableFeatureSource.test.ts new file mode 100644 index 0000000000..5ee24c913b --- /dev/null +++ b/test/unit/sources/StreamableFeatureSource.test.ts @@ -0,0 +1,46 @@ +import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem'; +import Extent from '@giro3d/giro3d/core/geographic/Extent'; +import StreamableFeatureSource from '@giro3d/giro3d/sources/StreamableFeatureSource'; + +const queryBuilder = jest.fn(); +const getter = jest.fn(); + +let source: StreamableFeatureSource; + +beforeEach(async () => { + getter.mockReset().mockReturnValue(JSON.stringify({ type: 'FeatureCollection', features: [] })); + queryBuilder.mockReset().mockReturnValue(new URL('http://example.com')); + + source = new StreamableFeatureSource({ + sourceCoordinateSystem: CoordinateSystem.epsg4326, + queryBuilder, + getter, + }); + + await source.initialize({ targetCoordinateSystem: CoordinateSystem.epsg4326 }); +}); + +describe('getFeatures', () => { + it('should use the provided query builder', async () => { + const extent = Extent.WGS84; + + await source.getFeatures({ extent }); + + expect(queryBuilder).toHaveBeenCalledWith({ + extent, + sourceCoordinateSystem: CoordinateSystem.epsg4326, + }); + + expect(getter).toHaveBeenCalledWith('http://example.com/', 'json'); + }); + + it('should drop the query if the query builder returns undefined', async () => { + const extent = Extent.WGS84; + + queryBuilder.mockReturnValue(undefined); + + await source.getFeatures({ extent }); + + expect(getter).not.toHaveBeenCalled(); + }); +}); -- GitLab From c1cda93c9650554ba265b9a99943bbf5d6fbca63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Wed, 9 Jul 2025 18:02:30 +0200 Subject: [PATCH 13/43] docs(draped_feature_collection): update --- examples/draped_feature_collection.js | 105 ++++++++++++++++++++------ 1 file changed, 80 insertions(+), 25 deletions(-) diff --git a/examples/draped_feature_collection.js b/examples/draped_feature_collection.js index abdeee4fd9..e9a558d45d 100644 --- a/examples/draped_feature_collection.js +++ b/examples/draped_feature_collection.js @@ -80,7 +80,7 @@ WmtsSource.fromCapabilities(url, { // We don't need the full resolution of terrain // because we are not using any shading. This will save a lot of memory // and make the terrain faster to load. - resolutionFactor: 1, + resolutionFactor: 1 / 2, minmax: { min: 0, max: 5000 }, noDataOptions: { replaceNoData: false, @@ -115,7 +115,7 @@ const geojson = new FileFeatureSource({ // url: 'http://localhost:14000/vectors/geojson/grenoble_batiments.geojson', // url: 'http://localhost:14002/collections/public.cadastre/items.json', url: 'http://localhost:14002/collections/public.cadastre/items.json?limit=500', - sourceProjection: CoordinateSystem.epsg4326, + sourceCoordinateSystem: CoordinateSystem.epsg4326, }); const communes = new StreamableFeatureSource({ @@ -201,7 +201,7 @@ const bdTopoStyle = feature => { }; // Let's compute the extrusion offset of building polygons to give them walls. -const extrusionOffsetCallback = feature => { +const bdTopoExtrusionOffset = feature => { const properties = feature.getProperties(); const buildingHeight = properties['hauteur']; const extrusionOffset = buildingHeight; @@ -212,40 +212,95 @@ const extrusionOffsetCallback = feature => { return extrusionOffset; }; -const sources = { - static: new StaticFeatureSource({ - coordinateSystem: CoordinateSystem.fromEpsg(2154), - }), - batiment: new StreamableFeatureSource({ - sourceProjection: CoordinateSystem.epsg4326, - queryBuilder: ogcApiFeaturesBuilder('http://localhost:14002/', 'public.batiment_038_isere'), - }), -}; - const pointStyle = { point: { - pointSize: 48, - depthTest: false, + pointSize: 96, + depthTest: true, image: 'http://localhost:14000/images/pin.png', }, }; +/** + * @type {Record} + */ +const sources = { + static: { + minLod: 0, + drapingMode: 'per-feature', + style: pointStyle, + source: new StaticFeatureSource({ + coordinateSystem: CoordinateSystem.fromEpsg(2154), + }), + }, + hydrants: { + source: hydrants, + style: pointStyle, + drapingMode: 'per-feature', + minLod: 2, + }, + communes: { + source: communes, + minLod: 5, + drapingMode: 'per-vertex', + style: { + stroke: { + color: 'black', + lineWidth: 4, + depthTest: false, + renderOrder: 2, + }, + }, + }, + batiment: { + minLod: 12, + drapingMode: 'per-feature', + extrusionOffset: bdTopoExtrusionOffset, + source: new StreamableFeatureSource({ + sourceProjection: CoordinateSystem.epsg4326, + queryBuilder: ogcApiFeaturesBuilder( + 'http://localhost:14002/', + 'public.batiment_038_isere', + ), + }), + style: bdTopoStyle, + }, +}; + +const data = sources.hydrants; + const entity = new DrapedFeatureCollection({ - source: sources['static'], - minLod: 0, - drapingMode: 'per-feature', - // extrusionOffset: extrusionOffsetCallback, - style: pointStyle, + source: data.source, + minLod: data.minLod, + drapingMode: data.drapingMode, + extrusionOffset: data.extrusionOffset, + style: data.style, }); instance.add(entity).then(() => { entity.attach(map); - setInterval(() => { - const x = MathUtils.lerp(map.extent.west, map.extent.east, MathUtils.randFloat(0.3, 0.7)); - const y = MathUtils.lerp(map.extent.south, map.extent.north, MathUtils.randFloat(0.3, 0.7)); - sources['static'].addFeature(new Feature(new Point([x, y]))); - }, 500); + if (data === sources.static) { + setInterval(() => { + const x = MathUtils.lerp( + map.extent.west, + map.extent.east, + MathUtils.randFloat(0.3, 0.7), + ); + const y = MathUtils.lerp( + map.extent.south, + map.extent.north, + MathUtils.randFloat(0.3, 0.7), + ); + // @ts-expect-error casting + sources['static'].source.addFeature(new Feature(new Point([x, y]))); + }, 500); + } }); // Add a sunlight -- GitLab From dd29ac2bef6321165d629371131e3da74caf2f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Wed, 9 Jul 2025 23:03:02 +0200 Subject: [PATCH 14/43] feat(DrapedFeatureCollection): update --- src/entities/DrapedFeatureCollection.ts | 104 ++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index 8d5b647fb0..cdb94bb895 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -1,5 +1,7 @@ import type Feature from 'ol/Feature'; +import type { Object3D } from 'three'; + import { Box3, Group, Sphere, Vector3 } from 'three'; import type GUI from 'lil-gui'; @@ -29,7 +31,11 @@ import type { PolygonOptions, } from '../renderer/geometries/GeometryConverter'; import GeometryConverter from '../renderer/geometries/GeometryConverter'; +import { isLineStringMesh } from '../renderer/geometries/LineStringMesh'; +import { isPointMesh } from '../renderer/geometries/PointMesh'; import type SimpleGeometryMesh from '../renderer/geometries/SimpleGeometryMesh'; +import { isSimpleGeometryMesh } from '../renderer/geometries/SimpleGeometryMesh'; +import { isSurfaceMesh } from '../renderer/geometries/SurfaceMesh'; import { computeDistanceToFitSphere, computeZoomToFitSphere } from '../renderer/View'; import type { FeatureSource, FeatureSourceEventMap } from '../sources/FeatureSource'; import OLUtils from '../utils/OpenLayersUtils'; @@ -73,6 +79,9 @@ export type DrapedFeatureElevationCallback = ( elevationProvider: ElevationProvider, ) => { geometry: SimpleGeometry; verticalOffset: number; mustReplace: boolean }; +/** + * Either returns the same geometry if it already has a XYZ layout, or create an equivalent geometry in the XYZ layout. + */ function cloneAsXYZIfRequired( geometry: G, ): G { @@ -312,6 +321,11 @@ function getStableFeatureId(feature: Feature): string { type EventHandler = (e: T) => void; +type ObjectOptions = { + castShadow: boolean; + receiveShadow: boolean; +}; + export default class DrapedFeatureCollection extends Entity3D { override type = 'DrapedFeatureCollection' as const; readonly isDrapedFeatureCollection = true as const; @@ -322,6 +336,11 @@ export default class DrapedFeatureCollection extends Entity3D { private readonly _elevationCallback: DrapedFeatureElevationCallback; private readonly _geometryConverter: GeometryConverter; private readonly _activeTiles = new Map(); + private readonly _objectOptions: ObjectOptions = { + castShadow: false, + receiveShadow: false, + }; + private readonly _extrusionCallback: | FeatureExtrusionOffset | FeatureExtrusionOffsetCallback @@ -394,6 +413,53 @@ export default class DrapedFeatureCollection extends Entity3D { this._source.addEventListener('updated', this._eventHandlers.onSourceUpdated); } + traverseGeometries(callback: (geom: SimpleGeometryMesh) => void) { + this.traverse(obj => { + if (isSimpleGeometryMesh(obj)) { + callback(obj); + } + }); + } + + private updateObjectOption(key: K, value: ObjectOptions[K]) { + if (this._objectOptions[key] !== value) { + this._objectOptions[key] = value; + this.traverseGeometries(mesh => { + mesh.traverse(obj => { + obj.castShadow = this._objectOptions.castShadow; + obj.receiveShadow = this._objectOptions.receiveShadow; + }); + }); + this.notifyChange(this); + } + } + + /** + * Toggles the `.castShadow` property on objects generated by this entity. + * + * Note: shadow maps require normal attributes on objects. + */ + get castShadow() { + return this._objectOptions.castShadow; + } + + set castShadow(v: boolean) { + this.updateObjectOption('castShadow', v); + } + + /** + * Toggles the `.receiveShadow` property on objects generated by this entity. + * + * Note: shadow maps require normal attributes on objects. + */ + get receiveShadow() { + return this._objectOptions.receiveShadow; + } + + set receiveShadow(v: boolean) { + this.updateObjectOption('receiveShadow', v); + } + private onSourceUpdated() { this._features.forEach(v => { v.mesh?.dispose(); @@ -496,6 +562,21 @@ export default class DrapedFeatureCollection extends Entity3D { this.notifyChange(); } + private prepare( + mesh: SimpleGeometryMesh, + feature: Feature, + style: FeatureStyle | undefined, + ) { + mesh.traverse(obj => { + obj.userData.feature = feature; + obj.userData.style = style; + obj.castShadow = this._objectOptions.castShadow; + obj.receiveShadow = this._objectOptions.receiveShadow; + + this.assignRenderOrder(obj); + }); + } + private getPointOptions(style?: FeatureStyle): PointOptions { const pointStyle = style?.point; @@ -551,9 +632,32 @@ export default class DrapedFeatureCollection extends Entity3D { // TODO faire le reste: }); + if (result) { + this.prepare(result, feature, style); + } + return result; } + // We override this because the render order of the features depends on their style, + // so we have to cumulate that with the render order of the entity. + protected override assignRenderOrder(obj: Object3D) { + const renderOrder = this.renderOrder; + + // Note that the final render order of the mesh is the sum of + // the entity's render order and the style's render order(s). + if (isSurfaceMesh(obj)) { + const relativeRenderOrder = obj.userData.style?.fill?.renderOrder ?? 0; + obj.renderOrder = renderOrder + relativeRenderOrder; + } else if (isLineStringMesh(obj)) { + const relativeRenderOrder = obj.userData.style?.stroke?.renderOrder ?? 0; + obj.renderOrder = renderOrder + relativeRenderOrder; + } else if (isPointMesh(obj)) { + const relativeRenderOrder = obj.userData.style?.point?.renderOrder ?? 0; + obj.renderOrder = renderOrder + relativeRenderOrder; + } + } + private getDrapingMode(feature: Feature): DrapingMode { if (typeof this._drapingMode === 'function') { return this._drapingMode(feature); -- GitLab From d21b9d63219e45eace4ee9f3b0130a4dd40f14fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Thu, 10 Jul 2025 13:33:34 +0200 Subject: [PATCH 15/43] feat(StreamableFeatureSource): add WFS query builder --- src/sources/StreamableFeatureSource.ts | 49 ++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/src/sources/StreamableFeatureSource.ts b/src/sources/StreamableFeatureSource.ts index 3eacc0f04e..e22473a403 100644 --- a/src/sources/StreamableFeatureSource.ts +++ b/src/sources/StreamableFeatureSource.ts @@ -46,14 +46,57 @@ export const ogcApiFeaturesBuilder: ( const bbox = params.extent.as(params.sourceCoordinateSystem); - url.searchParams.append('bbox', `${bbox.west},${bbox.south},${bbox.east},${bbox.north}`); + url.searchParams.set('bbox', `${bbox.west},${bbox.south},${bbox.east},${bbox.north}`); const limit = opts?.limit ?? 1000; - url.searchParams.append('limit', limit.toString()); + url.searchParams.set('limit', limit.toString()); if (opts?.params) { for (const [key, value] of Object.entries(opts.params)) { - url.searchParams.append(key, value); + url.searchParams.set(key, value); + } + } + + return url; + }; +}; + +/** + * A query builder to fetch data from an WFS service. + * @param serviceUrl - The base URL to the service. + * @param typename - The name of the feature collection. + * @param options - Optional parameters to customize the query. + */ +export const wfsBuilder: ( + serverUrl: string, + typename: string, + options?: { + /** + * Additional parameters to pass to the query, with the exception + * of the `bbox` parameter (dynamically computed for each query). + */ + params?: Record; + }, +) => StreamableFeatureSourceQueryBuilder = (serviceUrl, typename, opts) => { + return params => { + const url = new URL(serviceUrl); + + url.searchParams.set('SERVICE', 'WFS'); + url.searchParams.set('VERSION', '2.0.0'); + url.searchParams.set('request', 'GetFeature'); + url.searchParams.set('typename', typename); + url.searchParams.set('outputFormat', 'application/json'); + // url.searchParams.set('startIndex', '0'); + url.searchParams.set('SRSNAME', params.sourceCoordinateSystem.id); + const bbox = params.extent.as(params.sourceCoordinateSystem); + url.searchParams.set( + 'bbox', + `${bbox.west},${bbox.south},${bbox.east},${bbox.north},${params.sourceCoordinateSystem.id}`, + ); + + if (opts?.params) { + for (const [key, value] of Object.entries(opts.params)) { + url.searchParams.set(key, value); } } -- GitLab From bebd71e3ab84d646fc84111af49d82001878eb19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Thu, 10 Jul 2025 13:34:56 +0200 Subject: [PATCH 16/43] feat(DrapedFeatureCollection): update --- examples/draped_feature_collection.js | 8 +- src/entities/DrapedFeatureCollection.ts | 138 ++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 14 deletions(-) diff --git a/examples/draped_feature_collection.js b/examples/draped_feature_collection.js index e9a558d45d..d96dac3e78 100644 --- a/examples/draped_feature_collection.js +++ b/examples/draped_feature_collection.js @@ -120,16 +120,16 @@ const geojson = new FileFeatureSource({ const communes = new StreamableFeatureSource({ queryBuilder: ogcApiFeaturesBuilder('http://localhost:14002/', 'public.cadastre'), - sourceProjection: CoordinateSystem.epsg4326, + sourceCoordinateSystem: CoordinateSystem.epsg4326, }); const hydrants = new StreamableFeatureSource({ queryBuilder: ogcApiFeaturesBuilder('http://localhost:14002/', 'public.hydrants_sdis_64'), - sourceProjection: CoordinateSystem.epsg4326, + sourceCoordinateSystem: CoordinateSystem.epsg4326, }); const bdTopoIgn = new StreamableFeatureSource({ - sourceProjection: CoordinateSystem.fromEpsg(2154), + sourceCoordinateSystem: CoordinateSystem.fromEpsg(2154), queryBuilder: params => { const url = new URL('https://data.geopf.fr/wfs/ows'); @@ -262,7 +262,7 @@ const sources = { drapingMode: 'per-feature', extrusionOffset: bdTopoExtrusionOffset, source: new StreamableFeatureSource({ - sourceProjection: CoordinateSystem.epsg4326, + sourceCoordinateSystem: CoordinateSystem.epsg4326, queryBuilder: ogcApiFeaturesBuilder( 'http://localhost:14002/', 'public.batiment_038_isere', diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index cdb94bb895..32fd2ccac3 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -26,15 +26,22 @@ import type PointOfView from '../core/PointOfView'; import EntityInspector from '../gui/EntityInspector'; import EntityPanel from '../gui/EntityPanel'; import type { + BaseOptions, LineOptions, PointOptions, PolygonOptions, } from '../renderer/geometries/GeometryConverter'; import GeometryConverter from '../renderer/geometries/GeometryConverter'; +import type LineStringMesh from '../renderer/geometries/LineStringMesh'; import { isLineStringMesh } from '../renderer/geometries/LineStringMesh'; +import type MultiLineStringMesh from '../renderer/geometries/MultiLineStringMesh'; +import { isMultiPolygonMesh } from '../renderer/geometries/MultiPolygonMesh'; +import type PointMesh from '../renderer/geometries/PointMesh'; import { isPointMesh } from '../renderer/geometries/PointMesh'; +import { isPolygonMesh } from '../renderer/geometries/PolygonMesh'; import type SimpleGeometryMesh from '../renderer/geometries/SimpleGeometryMesh'; import { isSimpleGeometryMesh } from '../renderer/geometries/SimpleGeometryMesh'; +import type SurfaceMesh from '../renderer/geometries/SurfaceMesh'; import { isSurfaceMesh } from '../renderer/geometries/SurfaceMesh'; import { computeDistanceToFitSphere, computeZoomToFitSphere } from '../renderer/View'; import type { FeatureSource, FeatureSourceEventMap } from '../sources/FeatureSource'; @@ -117,6 +124,19 @@ function cloneAsXYZIfRequired | null { + let current = obj; + + while (isSimpleGeometryMesh(current.parent)) { + current = current.parent; + } + + if (isSimpleGeometryMesh(current)) { + return current; + } + return null; +} + function getFeatureElevation( feature: Feature, geometry: SimpleGeometry, @@ -421,6 +441,98 @@ export default class DrapedFeatureCollection extends Entity3D { }); } + /** + * Updates the styles of the given objects, or all objects if unspecified. + * @param objects - The objects to update. + */ + updateStyles(objects?: (SimpleGeometryMesh | SurfaceMesh)[]) { + if (objects != null) { + objects.forEach(obj => { + if (obj.userData.parentEntity === this) { + this.updateStyle(getRootMesh(obj)); + } + }); + } else { + this._features.forEach(v => { + if (v.mesh) { + this.updateStyle(v.mesh); + } + }); + } + + // Make sure new materials have the correct opacity + this.updateOpacity(); + + this.notifyChange(this); + } + + private updateStyle(obj: SimpleGeometryMesh | null) { + if (!obj) { + return; + } + + const feature = obj.userData.feature as Feature; + const style = this.getStyle(feature); + + const commonOptions: BaseOptions = { + origin: obj.position, + // TODO + // ignoreZ: draping === 'none', + }; + + switch (obj.type) { + case 'PointMesh': + this._geometryConverter.updatePointMesh(obj as PointMesh, { + ...commonOptions, + ...style?.point, + }); + break; + case 'PolygonMesh': + case 'MultiPolygonMesh': + { + // TODO + // const elevation = + // typeof this._elevation === 'function' + // ? this._elevation(feature) + // : this._elevation; + + const extrusionOffset = this.getExtrusionOffset(feature); + + const options = { + ...commonOptions, + ...style, + extrusionOffset, + // elevation, // TODO + }; + if (isPolygonMesh(obj)) { + this._geometryConverter.updatePolygonMesh(obj, options); + } else if (isMultiPolygonMesh(obj)) { + this._geometryConverter.updateMultiPolygonMesh(obj, options); + } + } + break; + case 'LineStringMesh': + this._geometryConverter.updateLineStringMesh(obj as LineStringMesh, { + ...commonOptions, + ...style?.stroke, + }); + break; + case 'MultiLineStringMesh': + this._geometryConverter.updateMultiLineStringMesh( + obj as MultiLineStringMesh, + { + ...commonOptions, + ...style?.stroke, + }, + ); + break; + } + + // Since changing the style of the feature might create additional objects, + // we have to use this method again. + this.prepare(obj, feature, style); + } + private updateObjectOption(key: K, value: ObjectOptions[K]) { if (this._objectOptions[key] !== value) { this._objectOptions[key] = value; @@ -513,9 +625,8 @@ export default class DrapedFeatureCollection extends Entity3D { return this; } - private onTileCreated(_: MapEventMap['tile-created']) { - // TODO - // this.registerTile(tile); + private onTileCreated({ tile }: MapEventMap['tile-created']) { + this.registerTile(tile); } private onTileDeleted({ tile }: MapEventMap['tile-deleted']) { @@ -591,7 +702,7 @@ export default class DrapedFeatureCollection extends Entity3D { }; } - private getPolygonOptions(feature: Feature, style?: FeatureStyle): PolygonOptions { + private getExtrusionOffset(feature: Feature) { let extrusionOffset: FeatureExtrusionOffset | undefined = undefined; if (this._extrusionCallback != null) { extrusionOffset = @@ -600,10 +711,14 @@ export default class DrapedFeatureCollection extends Entity3D { : this._extrusionCallback; } + return extrusionOffset; + } + + private getPolygonOptions(feature: Feature, style?: FeatureStyle): PolygonOptions { return { fill: style?.fill, stroke: style?.stroke, - extrusionOffset, + extrusionOffset: this.getExtrusionOffset(feature), }; } @@ -614,13 +729,15 @@ export default class DrapedFeatureCollection extends Entity3D { }; } - private createMesh(feature: Feature, geometry: SimpleGeometry): SimpleGeometryMesh | undefined { - let style: FeatureStyle | undefined = undefined; + private getStyle(feature: Feature): FeatureStyle | undefined { if (typeof this._style === 'function') { - style = this._style(feature); - } else { - style = this._style; + return this._style(feature); } + return this._style; + } + + private createMesh(feature: Feature, geometry: SimpleGeometry): SimpleGeometryMesh | undefined { + const style = this.getStyle(feature); const converter = this._geometryConverter; @@ -629,6 +746,7 @@ export default class DrapedFeatureCollection extends Entity3D { processPolygon: p => converter.build(p, this.getPolygonOptions(feature, style)), processLineString: p => converter.build(p, this.getLineOptions(style)), processMultiPolygon: p => converter.build(p, this.getPolygonOptions(feature, style)), + processMultiLineString: p => converter.build(p, this.getLineOptions(style)), // TODO faire le reste: }); -- GitLab From 9165b2a52db1184c6b746bc1f3043504ab9ff896 Mon Sep 17 00:00:00 2001 From: Sandro Mani Date: Tue, 16 Sep 2025 10:44:46 +0200 Subject: [PATCH 17/43] feat(StreamableFeatureSource): add feature cache --- src/sources/StreamableFeatureSource.ts | 93 +++++++++++++++++++------- 1 file changed, 67 insertions(+), 26 deletions(-) diff --git a/src/sources/StreamableFeatureSource.ts b/src/sources/StreamableFeatureSource.ts index e22473a403..e6ba6b0087 100644 --- a/src/sources/StreamableFeatureSource.ts +++ b/src/sources/StreamableFeatureSource.ts @@ -3,7 +3,7 @@ import type FeatureFormat from 'ol/format/Feature'; import type { Type } from 'ol/format/Feature'; import GeoJSON from 'ol/format/GeoJSON'; import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; -import type Extent from '../core/geographic/Extent'; +import Extent from '../core/geographic/Extent'; import Fetcher from '../utils/Fetcher'; import { nonNull } from '../utils/tsutils'; import { processFeatures } from './features/processor'; @@ -131,6 +131,11 @@ export interface StreamableFeatureSourceOptions { format?: FeatureFormat; getter?: Getter; sourceCoordinateSystem?: CoordinateSystem; + /** + * The cache tile size. + * @defaultValue 1000 + */ + cacheTileSize: number; } /** @@ -144,52 +149,88 @@ export default class StreamableFeatureSource extends FeatureSourceBase { private readonly _format: FeatureFormat; private readonly _getter: Getter; private readonly _sourceProjection: CoordinateSystem; + private readonly _cacheTileSize: number; + private readonly _featureTileCache: Record; constructor(params: StreamableFeatureSourceOptions) { super(); this._queryBuilder = params.queryBuilder; this._format = params.format ?? new GeoJSON(); this._getter = params.getter ?? defaultGetter; + this._cacheTileSize = params.cacheTileSize ?? 1000; // TODO assume EPSG:4326 ? this._sourceProjection = params.sourceCoordinateSystem ?? CoordinateSystem.epsg4326; + + this._featureTileCache = {}; } async getFeatures(request: GetFeatureRequest): Promise { this.throwIfNotInitialized(); - const url = this._queryBuilder({ - extent: request.extent, - sourceCoordinateSystem: this._sourceProjection, - }); + // Get tiles which need to be fetched + let west = request.extent.west; + let east = request.extent.east; + let south = request.extent.south; + let north = request.extent.north; - if (!url) { - return { - features: [], - }; - } + const xmin = Math.floor(west / this._cacheTileSize); + const xmax = Math.ceil((east + 1) / this._cacheTileSize); + const ymin = Math.floor(south / this._cacheTileSize); + const ymax = Math.ceil((north + 1) / this._cacheTileSize); - const data = await this._getter(url.toString(), this._format.getType()); + const features = []; - const features = this._format.readFeatures(data) as Feature[]; + for (let x = xmin; x < xmax; ++x) { + for (let y = ymin; y < ymax; ++y) { - const targetProjection = nonNull( - this._targetCoordinateSystem, - 'this source is not initialized', - ); - const sourceProjection = nonNull(this._sourceProjection); + const key = `${x}/${y}`; - const getFeatureId = (feature: Feature) => { - return ( - feature.getId() ?? feature.get('id') ?? feature.get('fid') ?? feature.get('ogc_fid') - ); - }; + if (!(key in this._featureTileCache)) { + + const tileExtent = new Extent(request.extent.crs, + x * this._cacheTileSize, (x + 1) * this._cacheTileSize, + y * this._cacheTileSize, (y + 1) * this._cacheTileSize, + ); + const url = this._queryBuilder({ + extent: tileExtent, + sourceCoordinateSystem: this._sourceProjection, + }); + + if (!url) { + this._featureTileCache[key] = []; + continue; + } + + const data = await this._getter(url.toString(), this._format.getType()); - const actualFeatures = await processFeatures(features, sourceProjection, targetProjection, { - getFeatureId, - }); + const features = this._format.readFeatures(data) as Feature[]; + + const targetProjection = nonNull( + this._targetCoordinateSystem, + 'this source is not initialized', + ); + const sourceProjection = nonNull(this._sourceProjection); + + const getFeatureId = (feature: Feature) => { + return ( + feature.getId() ?? feature.get('id') ?? feature.get('fid') ?? feature.get('ogc_fid') + ); + }; + + const actualFeatures = await processFeatures(features, sourceProjection, targetProjection, { + getFeatureId, + }); + + this._featureTileCache[key] = actualFeatures; + + } + + features.push(...this._featureTileCache[key]); + } + } return { - features: actualFeatures, + features: features, }; } } -- GitLab From 886c8b475809d9a54c22f1ef486c3b0041eadab7 Mon Sep 17 00:00:00 2001 From: Sandro Mani Date: Tue, 16 Sep 2025 10:45:03 +0200 Subject: [PATCH 18/43] feat(StreamableFeatureSource): allow specifying maxExtent --- src/sources/StreamableFeatureSource.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/sources/StreamableFeatureSource.ts b/src/sources/StreamableFeatureSource.ts index e6ba6b0087..2ea300a9eb 100644 --- a/src/sources/StreamableFeatureSource.ts +++ b/src/sources/StreamableFeatureSource.ts @@ -136,6 +136,7 @@ export interface StreamableFeatureSourceOptions { * @defaultValue 1000 */ cacheTileSize: number; + maxExtent?: Extent; } /** @@ -151,12 +152,14 @@ export default class StreamableFeatureSource extends FeatureSourceBase { private readonly _sourceProjection: CoordinateSystem; private readonly _cacheTileSize: number; private readonly _featureTileCache: Record; + private readonly _maxExtent: Extent|null; constructor(params: StreamableFeatureSourceOptions) { super(); this._queryBuilder = params.queryBuilder; this._format = params.format ?? new GeoJSON(); this._getter = params.getter ?? defaultGetter; + this._maxExtent = params.maxExtent ?? null; this._cacheTileSize = params.cacheTileSize ?? 1000; // TODO assume EPSG:4326 ? this._sourceProjection = params.sourceCoordinateSystem ?? CoordinateSystem.epsg4326; @@ -172,6 +175,12 @@ export default class StreamableFeatureSource extends FeatureSourceBase { let east = request.extent.east; let south = request.extent.south; let north = request.extent.north; + if (this._maxExtent) { + west = Math.max(west, this._maxExtent.west); + east = Math.min(east, this._maxExtent.east); + south = Math.max(south, this._maxExtent.south); + north = Math.min(north, this._maxExtent.north); + } const xmin = Math.floor(west / this._cacheTileSize); const xmax = Math.ceil((east + 1) / this._cacheTileSize); -- GitLab From c04176305c3e980724095055718547c6a5080233 Mon Sep 17 00:00:00 2001 From: Sandro Mani Date: Tue, 16 Sep 2025 10:46:16 +0200 Subject: [PATCH 19/43] fix(DrapedFeatureCollection): remove event listeners when detaching from map --- src/entities/DrapedFeatureCollection.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index 32fd2ccac3..47615cd89e 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -617,11 +617,20 @@ export default class DrapedFeatureCollection extends Entity3D { return this._sortedTiles; } - detach(map: MapEntity): this { - map.traverseTiles(tile => { + detach(): this { + if (this._map == null) { + throw new Error('no map is attached to this entity'); + } + + this._map.removeEventListener('tile-created', this._eventHandlers.onTileCreated); + this._map.removeEventListener('tile-deleted', this._eventHandlers.onTileDeleted); + this._map.removeEventListener('elevation-loaded', this._eventHandlers.onElevationLoaded); + + this._map.traverseTiles(tile => { this.unregisterTile(tile); }); + this._map = null; return this; } -- GitLab From 247a5e4eaffa4925eead6b7669354686d3104cdc Mon Sep 17 00:00:00 2001 From: Sandro Mani Date: Tue, 16 Sep 2025 10:47:10 +0200 Subject: [PATCH 20/43] fix(DrapedFeatureCollection): detach when disposing --- src/entities/DrapedFeatureCollection.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index 47615cd89e..885f6bffda 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -937,6 +937,7 @@ export default class DrapedFeatureCollection extends Entity3D { } override dispose() { + this.detach(); this._geometryConverter.dispose({ disposeMaterials: true, disposeTextures: true }); this.traverseMeshes(mesh => { mesh.geometry.dispose(); -- GitLab From f9000d235cd21b395d261daf301b29121fbd18c0 Mon Sep 17 00:00:00 2001 From: Sandro Mani Date: Tue, 16 Sep 2025 10:52:10 +0200 Subject: [PATCH 21/43] fix(DrapedFeatureCollection): register all tiles when attaching to map --- src/entities/DrapedFeatureCollection.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index 885f6bffda..e71c67f111 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -601,9 +601,9 @@ export default class DrapedFeatureCollection extends Entity3D { map.addEventListener('elevation-loaded', this._eventHandlers.onElevationLoaded); // TODO register trop tôt avant que l'élévation soit prête - // map.traverseTiles(tile => { - // this.registerTile(tile); - // }); + map.traverseTiles(tile => { + this.registerTile(tile); + }); return this; } -- GitLab From f0005b47b911a071cb1131be3df4b67b630aef1f Mon Sep 17 00:00:00 2001 From: Sandro Mani Date: Tue, 16 Sep 2025 10:53:47 +0200 Subject: [PATCH 22/43] fix(DrapedFeatureCollection): don't resample extrusion offset from terrain If offset was already sampled on an equal or higher LOD terrain tile. --- src/entities/DrapedFeatureCollection.ts | 134 ++++++++++++------------ 1 file changed, 66 insertions(+), 68 deletions(-) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index e71c67f111..d649b65fcf 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -346,6 +346,14 @@ type ObjectOptions = { receiveShadow: boolean; }; +type FeaturesEntry = { + feature: Feature; + originalZ: number; + extent: Extent; + mesh: SimpleGeometryMesh | undefined; + sampledLod: number; +} + export default class DrapedFeatureCollection extends Entity3D { override type = 'DrapedFeatureCollection' as const; readonly isDrapedFeatureCollection = true as const; @@ -365,15 +373,7 @@ export default class DrapedFeatureCollection extends Entity3D { | FeatureExtrusionOffset | FeatureExtrusionOffsetCallback | undefined; - private readonly _features: Map< - string, - { - feature: Feature; - originalZ: number; - extent: Extent; - mesh: SimpleGeometryMesh | undefined; - } - > = new Map(); + private readonly _features: Map = new Map(); private readonly _source: FeatureSource; private readonly _eventHandlers: { onTileCreated: EventHandler; @@ -653,29 +653,33 @@ export default class DrapedFeatureCollection extends Entity3D { if (tile.lod >= this._minLod) { this.loadFeaturesOnExtent(tile.extent).then(features => { - this.loadMeshes(features); + this.loadMeshes(features, tile.lod); }); } } } - private loadMeshes(features: Readonly) { + private loadMeshes(features: Readonly, lod: number) { for (const feature of features) { - const id = getStableFeatureId(feature); const geometry = feature.getGeometry(); if (geometry) { + const id = getStableFeatureId(feature); if (!this._features.has(id)) { const extent = OLUtils.fromOLExtent( geometry.getExtent(), this.instance.coordinateSystem, ); - this._features.set(id, { feature, mesh: undefined, originalZ: 0, extent }); + this._features.set(id, { feature, mesh: undefined, originalZ: 0, extent, sampledLod: lod }); } + const existing = nonNull(this._features.get(id)); - this.loadFeatureMesh(feature, id); + if (!existing.mesh || existing.sampledLod < lod) { + this.loadFeatureMesh(id, existing); + existing.sampledLod = lod; + } } } @@ -793,68 +797,62 @@ export default class DrapedFeatureCollection extends Entity3D { return this._drapingMode; } - private loadFeatureMesh(feature: Feature, id: string) { + private loadFeatureMesh(id: string, existing: FeaturesEntry) { // TODO filter non compatible geometries - const geometry = feature.getGeometry() as SimpleGeometry | undefined; - - if (geometry) { - const drapingMode = this.getDrapingMode(feature); - - let actualGeometry: SimpleGeometry = geometry; - let shouldReplaceMesh = false; - let verticalOffset = 0; - - if ( - drapingMode === 'per-feature' || - (drapingMode === 'per-vertex' && geometry.getType() === 'Point') - ) { - // Note that point is necessarily per feature, since there is only one vertex - actualGeometry = geometry; - verticalOffset = getFeatureElevation(feature, geometry, nonNull(this._map)); - } else if (drapingMode === 'per-vertex') { - shouldReplaceMesh = true; - // TODO support multipoint ? - // @ts-expect-error cast - actualGeometry = applyPerVertexDraping(geometry, this._map); - } - - // // TODO doit-on supporter plusieurs maps ? - // // TODO Callback de récupération de l'élévation - // const processed = this._elevationCallback(feature, this._maps[0]); + const geometry = existing.feature.getGeometry() as SimpleGeometry; + + const drapingMode = this.getDrapingMode(existing.feature); + + let actualGeometry: SimpleGeometry = geometry; + let shouldReplaceMesh = false; + let verticalOffset = 0; + + if ( + drapingMode === 'per-feature' || + (drapingMode === 'per-vertex' && geometry.getType() === 'Point') + ) { + // Note that point is necessarily per feature, since there is only one vertex + actualGeometry = geometry; + verticalOffset = getFeatureElevation(existing.feature, geometry, nonNull(this._map)); + } else if (drapingMode === 'per-vertex') { + shouldReplaceMesh = true; + // TODO support multipoint ? + // @ts-expect-error cast + actualGeometry = applyPerVertexDraping(geometry, this._map); + } - const existing = nonNull(this._features.get(id)); + // // TODO doit-on supporter plusieurs maps ? + // // TODO Callback de récupération de l'élévation + // const processed = this._elevationCallback(feature, this._maps[0]); - // We have to entirely recreate the mesh because - // the vertices will have different elevations - if (shouldReplaceMesh && existing.mesh) { - existing.mesh.dispose(); - existing.mesh.removeFromParent(); - existing.mesh = undefined; - } + // We have to entirely recreate the mesh because + // the vertices will have different elevations + if (shouldReplaceMesh && existing.mesh) { + existing.mesh.dispose(); + existing.mesh.removeFromParent(); + existing.mesh = undefined; + } - // The mesh needs to be (re)created - if (existing.mesh == null) { - const newMesh = this.createMesh(feature, actualGeometry); - existing.originalZ = newMesh?.position.z ?? 0; + // The mesh needs to be (re)created + if (existing.mesh == undefined) { + const newMesh = this.createMesh(existing.feature, actualGeometry); + existing.originalZ = newMesh?.position.z ?? 0; + if (newMesh) { existing.mesh = newMesh; + existing.mesh.name = id; + this.object3d.add(existing.mesh); } + } - const mesh = existing.mesh; - - if (mesh) { - mesh.name = id; - - this.object3d.add(mesh); - - // When a single elevation value is applied to the entire mesh, - // then we can simply translate the Mesh itself, rather than recreate it. - if (verticalOffset !== 0) { - mesh.position.setZ(existing.originalZ + verticalOffset); - } - - mesh.updateMatrix(); - mesh.updateMatrixWorld(true); + if (existing.mesh) { + // When a single elevation value is applied to the entire mesh, + // then we can simply translate the Mesh itself, rather than recreate it. + if (verticalOffset !== 0) { + existing.mesh.position.setZ(existing.originalZ + verticalOffset); } + + existing.mesh.updateMatrix(); + existing.mesh.updateMatrixWorld(true); } } -- GitLab From d783dc9a1a220a5b9db2fe110052f5f65c8af628 Mon Sep 17 00:00:00 2001 From: Sandro Mani Date: Tue, 16 Sep 2025 10:54:47 +0200 Subject: [PATCH 23/43] fix(DrapedFeatureCollection): don't register new tiles if invisible Ensure tiles are registered when visibility changes back to visible --- src/entities/DrapedFeatureCollection.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index d649b65fcf..93910b2947 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -634,6 +634,15 @@ export default class DrapedFeatureCollection extends Entity3D { return this; } + override updateVisibility() { + super.updateVisibility(); + if (this.visible && this._map) { + this._map.traverseTiles(tile => { + this.registerTile(tile); + }); + } + } + private onTileCreated({ tile }: MapEventMap['tile-created']) { this.registerTile(tile); } @@ -647,6 +656,9 @@ export default class DrapedFeatureCollection extends Entity3D { } private registerTile(tile: Tile, forceRecreateMeshes = false) { + if (!this.visible) { + return; + } if (!this._activeTiles.has(tile.id) || forceRecreateMeshes) { this._activeTiles.set(tile.id, tile); this._sortedTiles = null; -- GitLab From 3a986ade03faacc712a01c2a121158b82d76cc04 Mon Sep 17 00:00:00 2001 From: Sandro Mani Date: Tue, 16 Sep 2025 10:55:39 +0200 Subject: [PATCH 24/43] fix(DrapedFeatureCollection): only load mesh if tile is still active after loading features --- src/entities/DrapedFeatureCollection.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index 93910b2947..0e4bbb705f 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -665,7 +665,9 @@ export default class DrapedFeatureCollection extends Entity3D { if (tile.lod >= this._minLod) { this.loadFeaturesOnExtent(tile.extent).then(features => { - this.loadMeshes(features, tile.lod); + if (this._activeTiles.has(tile.id)) { + this.loadMeshes(features, tile.lod); + } }); } } -- GitLab From c846080afb99d9de51c40082adafed62d74d3d43 Mon Sep 17 00:00:00 2001 From: Sandro Mani Date: Wed, 17 Sep 2025 16:43:16 +0200 Subject: [PATCH 25/43] feat(StreamableFeatureSource): introduce FeatureGetter to control get feature strategy --- src/sources/StreamableFeatureSource.ts | 199 +++++++++++++++---------- 1 file changed, 119 insertions(+), 80 deletions(-) diff --git a/src/sources/StreamableFeatureSource.ts b/src/sources/StreamableFeatureSource.ts index 2ea300a9eb..66cd541d6e 100644 --- a/src/sources/StreamableFeatureSource.ts +++ b/src/sources/StreamableFeatureSource.ts @@ -2,6 +2,7 @@ import type { Feature } from 'ol'; import type FeatureFormat from 'ol/format/Feature'; import type { Type } from 'ol/format/Feature'; import GeoJSON from 'ol/format/GeoJSON'; +import {Cache, CacheConfiguration} from '../core/Cache'; import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; import Extent from '../core/geographic/Extent'; import Fetcher from '../utils/Fetcher'; @@ -131,61 +132,79 @@ export interface StreamableFeatureSourceOptions { format?: FeatureFormat; getter?: Getter; sourceCoordinateSystem?: CoordinateSystem; - /** - * The cache tile size. - * @defaultValue 1000 - */ - cacheTileSize: number; maxExtent?: Extent; } /** - * A feature source that supports streaming features (e.g OGC API Features, etc) + * Interface for StreamableFeatureSource feature getter. */ -export default class StreamableFeatureSource extends FeatureSourceBase { - readonly isStreamableFeatureSource = true as const; - readonly type = 'StreamableFeatureSource' as const; +export interface FeatureGetter { + getFeatures(extent: Extent, options: StreamableFeatureSourceOptions, targetCoordinateSystem: CoordinateSystem): Promise; +}; - private readonly _queryBuilder: StreamableFeatureSourceQueryBuilder; - private readonly _format: FeatureFormat; - private readonly _getter: Getter; - private readonly _sourceProjection: CoordinateSystem; - private readonly _cacheTileSize: number; - private readonly _featureTileCache: Record; - private readonly _maxExtent: Extent|null; +export abstract class FeatureGetterBase implements FeatureGetter { + abstract getFeatures(extent: Extent, options: StreamableFeatureSourceOptions, targetCoordinateSystem: CoordinateSystem): Promise; - constructor(params: StreamableFeatureSourceOptions) { - super(); - this._queryBuilder = params.queryBuilder; - this._format = params.format ?? new GeoJSON(); - this._getter = params.getter ?? defaultGetter; - this._maxExtent = params.maxExtent ?? null; - this._cacheTileSize = params.cacheTileSize ?? 1000; - // TODO assume EPSG:4326 ? - this._sourceProjection = params.sourceCoordinateSystem ?? CoordinateSystem.epsg4326; - - this._featureTileCache = {}; - } + protected async _fetchFeatures(extent: Extent, options: StreamableFeatureSourceOptions, targetCoordinateSystem: CoordinateSystem): Promise + { + const sourceCoordinateSystem = nonNull(options.sourceCoordinateSystem); + const getter = nonNull(options.getter); + const format = nonNull(options.format); - async getFeatures(request: GetFeatureRequest): Promise { - this.throwIfNotInitialized(); + const url = options.queryBuilder({ + extent: extent, + sourceCoordinateSystem: sourceCoordinateSystem + }); - // Get tiles which need to be fetched - let west = request.extent.west; - let east = request.extent.east; - let south = request.extent.south; - let north = request.extent.north; - if (this._maxExtent) { - west = Math.max(west, this._maxExtent.west); - east = Math.min(east, this._maxExtent.east); - south = Math.max(south, this._maxExtent.south); - north = Math.min(north, this._maxExtent.north); + if (!url) { + return []; } - const xmin = Math.floor(west / this._cacheTileSize); - const xmax = Math.ceil((east + 1) / this._cacheTileSize); - const ymin = Math.floor(south / this._cacheTileSize); - const ymax = Math.ceil((north + 1) / this._cacheTileSize); + const data = await getter(url.toString(), format.getType()); + + const features = format.readFeatures(data) as Feature[]; + + const getFeatureId = (feature: Feature) => { + return ( + feature.getId() ?? feature.get('id') ?? feature.get('fid') ?? feature.get('ogc_fid') + ); + }; + + return await processFeatures(features, sourceCoordinateSystem, targetCoordinateSystem, { + getFeatureId, + }); + } +} + +/** + * The default StreamableFeatureSource feature getter. + * Directly queries the features from the datasource. + */ +export class DefaultFeatureGetter extends FeatureGetterBase { + async getFeatures(extent: Extent, options: StreamableFeatureSourceOptions, targetCoordinateSystem: CoordinateSystem): Promise { + return await this._fetchFeatures(extent, options, targetCoordinateSystem); + } +} + +/** + * Cached/tiled StreamableFeatureSource feature getter. + * Queries the features from the datasource in tiles and caches the tiles. + */ +export class CachedTiledFeatureGetter extends FeatureGetterBase { + + private readonly _tileSize: number; + private readonly _cache: Cache; + + constructor(tileSize: number = 1000, cacheConfig?: CacheConfiguration) { + super(); + this._tileSize = tileSize; + this._cache = new Cache(cacheConfig ?? {ttl: 600}); + } + async getFeatures(extent: Extent, options: StreamableFeatureSourceOptions, targetCoordinateSystem: CoordinateSystem): Promise { + const xmin = Math.floor(extent.west / this._tileSize); + const xmax = Math.ceil((extent.east + 1) / this._tileSize); + const ymin = Math.floor(extent.south / this._tileSize); + const ymax = Math.ceil((extent.north + 1) / this._tileSize); const features = []; @@ -194,52 +213,72 @@ export default class StreamableFeatureSource extends FeatureSourceBase { const key = `${x}/${y}`; - if (!(key in this._featureTileCache)) { + let tileFeatures = this._cache.get(key) as Feature[]; + if (tileFeatures === undefined) { - const tileExtent = new Extent(request.extent.crs, - x * this._cacheTileSize, (x + 1) * this._cacheTileSize, - y * this._cacheTileSize, (y + 1) * this._cacheTileSize, + const tileExtent = new Extent(extent.crs, + x * this._tileSize, (x + 1) * this._tileSize, + y * this._tileSize, (y + 1) * this._tileSize, ); - const url = this._queryBuilder({ - extent: tileExtent, - sourceCoordinateSystem: this._sourceProjection, - }); - - if (!url) { - this._featureTileCache[key] = []; - continue; - } - - const data = await this._getter(url.toString(), this._format.getType()); - const features = this._format.readFeatures(data) as Feature[]; + tileFeatures = await super._fetchFeatures(tileExtent, options, targetCoordinateSystem); + this._cache.set(key, features); - const targetProjection = nonNull( - this._targetCoordinateSystem, - 'this source is not initialized', - ); - const sourceProjection = nonNull(this._sourceProjection); + } + features.push(...tileFeatures); + } + } + return features; + } +} - const getFeatureId = (feature: Feature) => { - return ( - feature.getId() ?? feature.get('id') ?? feature.get('fid') ?? feature.get('ogc_fid') - ); - }; +/** + * A feature source that supports streaming features (e.g OGC API Features, etc) + */ +export default class StreamableFeatureSource extends FeatureSourceBase { + readonly isStreamableFeatureSource = true as const; + readonly type = 'StreamableFeatureSource' as const; - const actualFeatures = await processFeatures(features, sourceProjection, targetProjection, { - getFeatureId, - }); + private readonly _options: StreamableFeatureSourceOptions; + private readonly _featureGetter: FeatureGetter; - this._featureTileCache[key] = actualFeatures; + constructor(params: StreamableFeatureSourceOptions, featureGetter: FeatureGetter|null = null) { + super(); + this._options = { + queryBuilder: params.queryBuilder, + format: params.format ?? new GeoJSON(), + getter: params.getter ?? defaultGetter, + maxExtent: params.maxExtent, + // TODO assume EPSG:4326 ? + sourceCoordinateSystem: params.sourceCoordinateSystem ?? CoordinateSystem.epsg4326 + }; + this._featureGetter = featureGetter ?? new DefaultFeatureGetter(); + } - } + async getFeatures(request: GetFeatureRequest): Promise { + this.throwIfNotInitialized(); - features.push(...this._featureTileCache[key]); - } + let west = request.extent.west; + let east = request.extent.east; + let south = request.extent.south; + let north = request.extent.north; + if (this._options.maxExtent) { + west = Math.max(west, this._options.maxExtent.west); + east = Math.min(east, this._options.maxExtent.east); + south = Math.max(south, this._options.maxExtent.south); + north = Math.min(north, this._options.maxExtent.north); + } + if (west >= east || south >= north) { + // Empty extent + return {features: []}; } - return { - features: features, - }; + const features = await this._featureGetter.getFeatures( + new Extent(request.extent.crs, {east, north, south, west}), + this._options, + nonNull(this._targetCoordinateSystem) + ); + + return {features: features,}; } } -- GitLab From 33f024ff6fb40b62a0dff8531b0bb304319c5e91 Mon Sep 17 00:00:00 2001 From: Sandro Mani Date: Wed, 17 Sep 2025 16:45:33 +0200 Subject: [PATCH 26/43] fix(GeometryConverter): preserve origin when rebuilding mesh --- src/entities/DrapedFeatureCollection.ts | 4 +--- src/renderer/geometries/GeometryConverter.ts | 3 ++- src/renderer/geometries/LineStringMesh.ts | 4 +++- src/renderer/geometries/MultiLineStringMesh.ts | 4 ++++ src/renderer/geometries/MultiPointMesh.ts | 4 ++++ src/renderer/geometries/MultiPolygonMesh.ts | 4 ++++ src/renderer/geometries/PointMesh.ts | 10 +++++++++- src/renderer/geometries/PolygonMesh.ts | 2 ++ src/renderer/geometries/SimpleGeometryMesh.ts | 3 ++- 9 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index 0e4bbb705f..ded3359c2e 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -475,9 +475,7 @@ export default class DrapedFeatureCollection extends Entity3D { const style = this.getStyle(feature); const commonOptions: BaseOptions = { - origin: obj.position, - // TODO - // ignoreZ: draping === 'none', + origin: obj.geometryOrigin }; switch (obj.type) { diff --git a/src/renderer/geometries/GeometryConverter.ts b/src/renderer/geometries/GeometryConverter.ts index f000395010..e6c890a8d3 100644 --- a/src/renderer/geometries/GeometryConverter.ts +++ b/src/renderer/geometries/GeometryConverter.ts @@ -662,8 +662,9 @@ export default class GeometryConverter< * @param object - The object to finalize. * @param options - Options */ - private finalize(object: Object3D, options: BaseOptions & BaseStyle): void { + private finalize(object: SimpleGeometryMesh, options: BaseOptions & BaseStyle): void { if (options.origin) { + object.geometryOrigin = options.origin; object.position.copy(options.origin); } diff --git a/src/renderer/geometries/LineStringMesh.ts b/src/renderer/geometries/LineStringMesh.ts index 91010709d0..b54651d5aa 100644 --- a/src/renderer/geometries/LineStringMesh.ts +++ b/src/renderer/geometries/LineStringMesh.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: MIT */ -import type { WebGLRenderer } from 'three'; +import type { WebGLRenderer, Vector3 } from 'three'; import type { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; import type { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; @@ -22,6 +22,8 @@ export default class LineStringMesh = {}; public set opacity(opacity: number) { diff --git a/src/renderer/geometries/MultiPointMesh.ts b/src/renderer/geometries/MultiPointMesh.ts index a5c1713543..c527f0076d 100644 --- a/src/renderer/geometries/MultiPointMesh.ts +++ b/src/renderer/geometries/MultiPointMesh.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: MIT */ +import type { Vector3 } from 'three'; + import { Object3D } from 'three'; import type PointMesh from './PointMesh'; @@ -20,6 +22,8 @@ export default class MultiPointMesh = {}; public constructor(points: PointMesh[]) { diff --git a/src/renderer/geometries/MultiPolygonMesh.ts b/src/renderer/geometries/MultiPolygonMesh.ts index 7311b5fa0d..6f1552b896 100644 --- a/src/renderer/geometries/MultiPolygonMesh.ts +++ b/src/renderer/geometries/MultiPolygonMesh.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: MIT */ +import type { Vector3 } from 'three'; + import { Object3D } from 'three'; import type PolygonMesh from './PolygonMesh'; @@ -20,6 +22,8 @@ export default class MultiPolygonMesh = {}; public set opacity(opacity: number) { diff --git a/src/renderer/geometries/PointMesh.ts b/src/renderer/geometries/PointMesh.ts index 84b497bb43..ac257bc785 100644 --- a/src/renderer/geometries/PointMesh.ts +++ b/src/renderer/geometries/PointMesh.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: MIT */ -import type { Camera, PerspectiveCamera, Scene, SpriteMaterial, WebGLRenderer } from 'three'; +import type { + Camera, + PerspectiveCamera, + Scene, + SpriteMaterial, + WebGLRenderer, + Vector3, +} from 'three'; import { MathUtils, Sprite } from 'three'; @@ -22,6 +29,7 @@ export default class PointMesh; @@ -29,6 +29,7 @@ interface SimpleGeometryMesh< > extends Object3D { isSimpleGeometryMesh: true; type: SimpleGeometryMeshTypes; + geometryOrigin: Vector3 | undefined; /** * Disposes the resources owned by this mesh. */ -- GitLab From c83bbf1b790a946926c0534d9a073a23c62058a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Tue, 30 Sep 2025 12:45:56 +0200 Subject: [PATCH 27/43] style(ESLint): fix linting issues --- examples/draped_feature_collection.js | 47 +++--- examples/wfs_mesh.js | 4 + src/core/FeatureTypes.ts | 2 +- src/core/geographic/Extent.ts | 4 +- src/core/layer/Layer.ts | 2 +- src/entities/DrapedFeatureCollection.ts | 154 ++++++++++-------- src/entities/tiles/TileMesh.ts | 2 +- src/renderer/geometries/GeometryConverter.ts | 1 - src/sources/AggregateFeatureSource.ts | 20 ++- src/sources/FeatureSource.ts | 22 ++- src/sources/FileFeatureSource.ts | 26 ++- src/sources/StaticFeatureSource.ts | 39 +++-- src/sources/StreamableFeatureSource.ts | 95 +++++++---- src/sources/features/processor.ts | 12 +- src/utils/PromiseUtils.ts | 2 +- test/unit/sources/FileFeatureSource.test.ts | 9 +- test/unit/sources/StaticFeatureSource.test.ts | 11 +- .../sources/StreamableFeatureSource.test.ts | 6 + 18 files changed, 289 insertions(+), 169 deletions(-) diff --git a/examples/draped_feature_collection.js b/examples/draped_feature_collection.js index d96dac3e78..63ba790409 100644 --- a/examples/draped_feature_collection.js +++ b/examples/draped_feature_collection.js @@ -1,28 +1,33 @@ -import { AmbientLight, Color, DirectionalLight, DoubleSide, MathUtils, Vector3 } from 'three'; -import { MapControls } from 'three/examples/jsm/controls/MapControls.js'; +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ +import { Feature } from 'ol'; import GeoJSON from 'ol/format/GeoJSON.js'; +import { Point } from 'ol/geom.js'; import VectorSource from 'ol/source/Vector.js'; +import { AmbientLight, Color, DirectionalLight, DoubleSide, MathUtils, Vector3 } from 'three'; +import { MapControls } from 'three/examples/jsm/controls/MapControls.js'; -import Instance from '@giro3d/giro3d/core/Instance.js'; +import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem.js'; import Extent from '@giro3d/giro3d/core/geographic/Extent.js'; +import Instance from '@giro3d/giro3d/core/Instance.js'; import ColorLayer from '@giro3d/giro3d/core/layer/ColorLayer.js'; import ElevationLayer from '@giro3d/giro3d/core/layer/ElevationLayer.js'; -import WmtsSource from '@giro3d/giro3d/sources/WmtsSource.js'; -import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem.js'; import DrapedFeatureCollection from '@giro3d/giro3d/entities/DrapedFeatureCollection.js'; import Giro3dMap from '@giro3d/giro3d/entities/Map.js'; import BilFormat from '@giro3d/giro3d/formats/BilFormat.js'; import Inspector from '@giro3d/giro3d/gui/Inspector.js'; -import StaticFeatureSource from '@giro3d/giro3d/sources/StaticFeatureSource.js'; import FileFeatureSource from '@giro3d/giro3d/sources/FileFeatureSource.js'; +import StaticFeatureSource from '@giro3d/giro3d/sources/StaticFeatureSource.js'; import StreamableFeatureSource, { ogcApiFeaturesBuilder, } from '@giro3d/giro3d/sources/StreamableFeatureSource.js'; +import WmtsSource from '@giro3d/giro3d/sources/WmtsSource.js'; import StatusBar from './widgets/StatusBar.js'; -import { Feature } from 'ol'; -import { Point } from 'ol/geom.js'; Instance.registerCRS( 'EPSG:2154', @@ -131,24 +136,24 @@ const hydrants = new StreamableFeatureSource({ const bdTopoIgn = new StreamableFeatureSource({ sourceCoordinateSystem: CoordinateSystem.fromEpsg(2154), queryBuilder: params => { - const url = new URL('https://data.geopf.fr/wfs/ows'); + const queryUrl = new URL('https://data.geopf.fr/wfs/ows'); - url.searchParams.append('SERVICE', 'WFS'); - url.searchParams.append('VERSION', '2.0.0'); - url.searchParams.append('request', 'GetFeature'); - url.searchParams.append('typename', 'BDTOPO_V3:batiment'); - url.searchParams.append('outputFormat', 'application/json'); - url.searchParams.append('SRSNAME', 'EPSG:2154'); - url.searchParams.append('startIndex', '0'); + queryUrl.searchParams.append('SERVICE', 'WFS'); + queryUrl.searchParams.append('VERSION', '2.0.0'); + queryUrl.searchParams.append('request', 'GetFeature'); + queryUrl.searchParams.append('typename', 'BDTOPO_V3:batiment'); + queryUrl.searchParams.append('outputFormat', 'application/json'); + queryUrl.searchParams.append('SRSNAME', 'EPSG:2154'); + queryUrl.searchParams.append('startIndex', '0'); - const extent = params.extent.as(CoordinateSystem.fromEpsg(2154)); + const queryExtent = params.extent.as(CoordinateSystem.fromEpsg(2154)); - url.searchParams.append( + queryUrl.searchParams.append( 'bbox', - `${extent.west},${extent.south},${extent.east},${extent.north},EPSG:2154`, + `${queryExtent.west},${queryExtent.south},${queryExtent.east},${queryExtent.north},EPSG:2154`, ); - return url; + return queryUrl; }, }); @@ -297,7 +302,7 @@ instance.add(entity).then(() => { map.extent.north, MathUtils.randFloat(0.3, 0.7), ); - // @ts-expect-error casting + // @ts-expect-error casting issue sources['static'].source.addFeature(new Feature(new Point([x, y]))); }, 500); } diff --git a/examples/wfs_mesh.js b/examples/wfs_mesh.js index 2019bb57f0..3af5892b00 100644 --- a/examples/wfs_mesh.js +++ b/examples/wfs_mesh.js @@ -17,9 +17,13 @@ import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/C import Extent from '@giro3d/giro3d/core/geographic/Extent.js'; import Instance from '@giro3d/giro3d/core/Instance.js'; import ColorLayer from '@giro3d/giro3d/core/layer/ColorLayer.js'; +import DrapedFeatureCollection from '@giro3d/giro3d/entities/DrapedFeatureCollection.js'; import FeatureCollection from '@giro3d/giro3d/entities/FeatureCollection.js'; import Map from '@giro3d/giro3d/entities/Map.js'; import Inspector from '@giro3d/giro3d/gui/Inspector.js'; +import StreamableFeatureSource, { + wfsBuilder, +} from '@giro3d/giro3d/sources/StreamableFeatureSource.js'; import WmtsSource from '@giro3d/giro3d/sources/WmtsSource.js'; import { bindToggle } from './widgets/bindToggle.js'; diff --git a/src/core/FeatureTypes.ts b/src/core/FeatureTypes.ts index a94583ce8a..95db61454d 100644 --- a/src/core/FeatureTypes.ts +++ b/src/core/FeatureTypes.ts @@ -49,7 +49,7 @@ export function mapGeometry( processCircle?: GeometryFn; fallback?: GeometryFn; }, -) { +): O | undefined { switch (geom.getType()) { case 'Point': return callbacks.processPoint?.(geom as Point); diff --git a/src/core/geographic/Extent.ts b/src/core/geographic/Extent.ts index f401d74a92..cf33295653 100644 --- a/src/core/geographic/Extent.ts +++ b/src/core/geographic/Extent.ts @@ -465,7 +465,7 @@ class Extent { return result; } - getQuadrant(x: number, y: number): 0 | 1 | 2 | 3 { + public getQuadrant(x: number, y: number): 0 | 1 | 2 | 3 { const dims = this.dimensions(tmpXY); const midX = this.west + dims.width / 2; const midY = this.south + dims.height / 2; @@ -519,7 +519,7 @@ class Extent { return this.isXYInside(c.x, c.y, epsilon); } - isXYInside(x: number, y: number, epsilon = 0): boolean { + public isXYInside(x: number, y: number, epsilon = 0): boolean { return ( x <= this.east + epsilon && x >= this.west - epsilon && diff --git a/src/core/layer/Layer.ts b/src/core/layer/Layer.ts index 6d64a97f00..0904744d14 100644 --- a/src/core/layer/Layer.ts +++ b/src/core/layer/Layer.ts @@ -247,7 +247,7 @@ export interface LayerEvents { /** * Fires when a node has been completed. */ - // eslint-disable-next-line no-use-before-define + 'node-complete': { node: LayerNode; layer: Layer }; } diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index ded3359c2e..94f2add85a 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -1,57 +1,63 @@ -import type Feature from 'ol/Feature'; +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ +import type GUI from 'lil-gui'; +import type Feature from 'ol/Feature'; +import type { Circle, Point, SimpleGeometry } from 'ol/geom'; import type { Object3D } from 'three'; -import { Box3, Group, Sphere, Vector3 } from 'three'; - -import type GUI from 'lil-gui'; import { type Coordinate } from 'ol/coordinate'; import { getCenter } from 'ol/extent'; -import type { Circle, Point, SimpleGeometry } from 'ol/geom'; import { LineString, MultiLineString, MultiPolygon, Polygon } from 'ol/geom'; +import { Box3, Group, Sphere, Vector3 } from 'three'; + import type ElevationProvider from '../core/ElevationProvider'; import type { FeatureExtrusionOffset, FeatureExtrusionOffsetCallback } from '../core/FeatureTypes'; -import { - mapGeometry, - type FeatureStyle, - type FeatureStyleCallback, - type LineMaterialGenerator, - type PointMaterialGenerator, - type SurfaceMaterialGenerator, -} from '../core/FeatureTypes'; import type Extent from '../core/geographic/Extent'; import type HasDefaultPointOfView from '../core/HasDefaultPointOfView'; import type Instance from '../core/Instance'; import type PointOfView from '../core/PointOfView'; -import EntityInspector from '../gui/EntityInspector'; -import EntityPanel from '../gui/EntityPanel'; import type { BaseOptions, LineOptions, PointOptions, PolygonOptions, } from '../renderer/geometries/GeometryConverter'; -import GeometryConverter from '../renderer/geometries/GeometryConverter'; import type LineStringMesh from '../renderer/geometries/LineStringMesh'; -import { isLineStringMesh } from '../renderer/geometries/LineStringMesh'; import type MultiLineStringMesh from '../renderer/geometries/MultiLineStringMesh'; -import { isMultiPolygonMesh } from '../renderer/geometries/MultiPolygonMesh'; import type PointMesh from '../renderer/geometries/PointMesh'; +import type SimpleGeometryMesh from '../renderer/geometries/SimpleGeometryMesh'; +import type SurfaceMesh from '../renderer/geometries/SurfaceMesh'; +import type { FeatureSource, FeatureSourceEventMap } from '../sources/FeatureSource'; +import type { MeshUserData } from './FeatureCollection'; +import type MapEntity from './Map'; +import type { MapEventMap, Tile } from './Map'; + +import { + mapGeometry, + type FeatureStyle, + type FeatureStyleCallback, + type LineMaterialGenerator, + type PointMaterialGenerator, + type SurfaceMaterialGenerator, +} from '../core/FeatureTypes'; +import EntityInspector from '../gui/EntityInspector'; +import EntityPanel from '../gui/EntityPanel'; +import GeometryConverter from '../renderer/geometries/GeometryConverter'; +import { isLineStringMesh } from '../renderer/geometries/LineStringMesh'; +import { isMultiPolygonMesh } from '../renderer/geometries/MultiPolygonMesh'; import { isPointMesh } from '../renderer/geometries/PointMesh'; import { isPolygonMesh } from '../renderer/geometries/PolygonMesh'; -import type SimpleGeometryMesh from '../renderer/geometries/SimpleGeometryMesh'; import { isSimpleGeometryMesh } from '../renderer/geometries/SimpleGeometryMesh'; -import type SurfaceMesh from '../renderer/geometries/SurfaceMesh'; import { isSurfaceMesh } from '../renderer/geometries/SurfaceMesh'; import { computeDistanceToFitSphere, computeZoomToFitSphere } from '../renderer/View'; -import type { FeatureSource, FeatureSourceEventMap } from '../sources/FeatureSource'; import OLUtils from '../utils/OpenLayersUtils'; import { isOrthographicCamera, isPerspectiveCamera } from '../utils/predicates'; import { nonNull } from '../utils/tsutils'; import Entity3D from './Entity3D'; -import type { MeshUserData } from './FeatureCollection'; -import type MapEntity from './Map'; -import type { MapEventMap, Tile } from './Map'; const tmpSphere = new Sphere(); @@ -141,7 +147,7 @@ function getFeatureElevation( feature: Feature, geometry: SimpleGeometry, provider: ElevationProvider, -) { +): number { let center: Coordinate; if (geometry.getType() === 'Point') { @@ -159,10 +165,10 @@ function getFeatureElevation( return sample?.elevation ?? 0; } -function applyPerVertexDraping( - geometry: Polygon | LineString | MultiLineString | MultiPolygon, +function applyPerVertexDraping( + geometry: G, provider: ElevationProvider, -) { +): G { const coordinates = geometry.getFlatCoordinates(); const stride = geometry.getStride(); @@ -191,7 +197,7 @@ function applyPerVertexDraping( clone.setFlatCoordinates('XYZ', xyz); - return clone; + return clone as G; } export const defaultElevationCallback: DrapedFeatureElevationCallback = (feature, provider) => { @@ -352,11 +358,11 @@ type FeaturesEntry = { extent: Extent; mesh: SimpleGeometryMesh | undefined; sampledLod: number; -} +}; export default class DrapedFeatureCollection extends Entity3D { - override type = 'DrapedFeatureCollection' as const; - readonly isDrapedFeatureCollection = true as const; + public override type = 'DrapedFeatureCollection' as const; + public readonly isDrapedFeatureCollection = true as const; private _map: MapEntity | null = null; @@ -384,7 +390,7 @@ export default class DrapedFeatureCollection extends Entity3D { }; private readonly _style: FeatureStyle | FeatureStyleCallback | undefined; - get loadedFeatures() { + public get loadedFeatures(): number { return this._features.size; } @@ -392,15 +398,15 @@ export default class DrapedFeatureCollection extends Entity3D { private _sortedTiles: Tile[] | null = null; private _minLod = 0; - get minLod() { + public get minLod(): number { return this._minLod; } - set minLod(v: number) { + public set minLod(v: number) { this._minLod = v >= 0 ? v : 0; } - constructor(options: DrapedFeatureCollectionOptions) { + public constructor(options: DrapedFeatureCollectionOptions) { super(new Group()); this._drapingMode = options.drapingMode ?? 'per-vertex'; @@ -433,7 +439,7 @@ export default class DrapedFeatureCollection extends Entity3D { this._source.addEventListener('updated', this._eventHandlers.onSourceUpdated); } - traverseGeometries(callback: (geom: SimpleGeometryMesh) => void) { + public traverseGeometries(callback: (geom: SimpleGeometryMesh) => void): void { this.traverse(obj => { if (isSimpleGeometryMesh(obj)) { callback(obj); @@ -445,7 +451,9 @@ export default class DrapedFeatureCollection extends Entity3D { * Updates the styles of the given objects, or all objects if unspecified. * @param objects - The objects to update. */ - updateStyles(objects?: (SimpleGeometryMesh | SurfaceMesh)[]) { + public updateStyles( + objects?: (SimpleGeometryMesh | SurfaceMesh)[], + ): void { if (objects != null) { objects.forEach(obj => { if (obj.userData.parentEntity === this) { @@ -466,7 +474,7 @@ export default class DrapedFeatureCollection extends Entity3D { this.notifyChange(this); } - private updateStyle(obj: SimpleGeometryMesh | null) { + private updateStyle(obj: SimpleGeometryMesh | null): void { if (!obj) { return; } @@ -475,7 +483,7 @@ export default class DrapedFeatureCollection extends Entity3D { const style = this.getStyle(feature); const commonOptions: BaseOptions = { - origin: obj.geometryOrigin + origin: obj.geometryOrigin, }; switch (obj.type) { @@ -531,7 +539,10 @@ export default class DrapedFeatureCollection extends Entity3D { this.prepare(obj, feature, style); } - private updateObjectOption(key: K, value: ObjectOptions[K]) { + private updateObjectOption( + key: K, + value: ObjectOptions[K], + ): void { if (this._objectOptions[key] !== value) { this._objectOptions[key] = value; this.traverseGeometries(mesh => { @@ -549,11 +560,11 @@ export default class DrapedFeatureCollection extends Entity3D { * * Note: shadow maps require normal attributes on objects. */ - get castShadow() { + public get castShadow(): boolean { return this._objectOptions.castShadow; } - set castShadow(v: boolean) { + public set castShadow(v: boolean) { this.updateObjectOption('castShadow', v); } @@ -562,15 +573,15 @@ export default class DrapedFeatureCollection extends Entity3D { * * Note: shadow maps require normal attributes on objects. */ - get receiveShadow() { + public get receiveShadow(): boolean { return this._objectOptions.receiveShadow; } - set receiveShadow(v: boolean) { + public set receiveShadow(v: boolean) { this.updateObjectOption('receiveShadow', v); } - private onSourceUpdated() { + private onSourceUpdated(): void { this._features.forEach(v => { v.mesh?.dispose(); v.mesh?.removeFromParent(); @@ -583,11 +594,11 @@ export default class DrapedFeatureCollection extends Entity3D { } } - override async preprocess() { + public override async preprocess(): Promise { await this._source.initialize({ targetCoordinateSystem: this.instance.coordinateSystem }); } - attach(map: MapEntity): this { + public attach(map: MapEntity): this { if (this._map != null) { throw new Error('a map is already attached to this entity'); } @@ -606,7 +617,7 @@ export default class DrapedFeatureCollection extends Entity3D { return this; } - private getSortedTiles() { + private getSortedTiles(): Tile[] { if (this._sortedTiles == null) { this._sortedTiles = [...this._activeTiles.values()]; this._sortedTiles.sort((t0, t1) => t0.lod - t1.lod); @@ -615,7 +626,7 @@ export default class DrapedFeatureCollection extends Entity3D { return this._sortedTiles; } - detach(): this { + public detach(): this { if (this._map == null) { throw new Error('no map is attached to this entity'); } @@ -632,7 +643,7 @@ export default class DrapedFeatureCollection extends Entity3D { return this; } - override updateVisibility() { + public override updateVisibility(): void { super.updateVisibility(); if (this.visible && this._map) { this._map.traverseTiles(tile => { @@ -641,19 +652,19 @@ export default class DrapedFeatureCollection extends Entity3D { } } - private onTileCreated({ tile }: MapEventMap['tile-created']) { + private onTileCreated({ tile }: MapEventMap['tile-created']): void { this.registerTile(tile); } - private onTileDeleted({ tile }: MapEventMap['tile-deleted']) { + private onTileDeleted({ tile }: MapEventMap['tile-deleted']): void { this.unregisterTile(tile); } - private onElevationLoaded({ tile }: MapEventMap['elevation-loaded']) { + private onElevationLoaded({ tile }: MapEventMap['elevation-loaded']): void { this.registerTile(tile, true); } - private registerTile(tile: Tile, forceRecreateMeshes = false) { + private registerTile(tile: Tile, forceRecreateMeshes = false): void { if (!this.visible) { return; } @@ -671,9 +682,8 @@ export default class DrapedFeatureCollection extends Entity3D { } } - private loadMeshes(features: Readonly, lod: number) { + private loadMeshes(features: Readonly, lod: number): void { for (const feature of features) { - const geometry = feature.getGeometry(); if (geometry) { @@ -684,7 +694,13 @@ export default class DrapedFeatureCollection extends Entity3D { this.instance.coordinateSystem, ); - this._features.set(id, { feature, mesh: undefined, originalZ: 0, extent, sampledLod: lod }); + this._features.set(id, { + feature, + mesh: undefined, + originalZ: 0, + extent, + sampledLod: lod, + }); } const existing = nonNull(this._features.get(id)); @@ -702,7 +718,7 @@ export default class DrapedFeatureCollection extends Entity3D { mesh: SimpleGeometryMesh, feature: Feature, style: FeatureStyle | undefined, - ) { + ): void { mesh.traverse(obj => { obj.userData.feature = feature; obj.userData.style = style; @@ -727,7 +743,7 @@ export default class DrapedFeatureCollection extends Entity3D { }; } - private getExtrusionOffset(feature: Feature) { + private getExtrusionOffset(feature: Feature): FeatureExtrusionOffset | undefined { let extrusionOffset: FeatureExtrusionOffset | undefined = undefined; if (this._extrusionCallback != null) { extrusionOffset = @@ -784,7 +800,7 @@ export default class DrapedFeatureCollection extends Entity3D { // We override this because the render order of the features depends on their style, // so we have to cumulate that with the render order of the entity. - protected override assignRenderOrder(obj: Object3D) { + protected override assignRenderOrder(obj: Object3D): void { const renderOrder = this.renderOrder; // Note that the final render order of the mesh is the sum of @@ -809,7 +825,7 @@ export default class DrapedFeatureCollection extends Entity3D { return this._drapingMode; } - private loadFeatureMesh(id: string, existing: FeaturesEntry) { + private loadFeatureMesh(id: string, existing: FeaturesEntry): void { // TODO filter non compatible geometries const geometry = existing.feature.getGeometry() as SimpleGeometry; @@ -846,7 +862,7 @@ export default class DrapedFeatureCollection extends Entity3D { } // The mesh needs to be (re)created - if (existing.mesh == undefined) { + if (existing.mesh === undefined) { const newMesh = this.createMesh(existing.feature, actualGeometry); existing.originalZ = newMesh?.position.z ?? 0; if (newMesh) { @@ -868,7 +884,7 @@ export default class DrapedFeatureCollection extends Entity3D { } } - private unregisterTile(tile: Tile) { + private unregisterTile(tile: Tile): void { const actuallyDeleted = this._activeTiles.delete(tile.id); if (actuallyDeleted) { @@ -884,7 +900,7 @@ export default class DrapedFeatureCollection extends Entity3D { return result.features; } - override postUpdate(): void { + public override postUpdate(): void { if (this._shouldCleanup) { this._shouldCleanup = false; @@ -892,7 +908,7 @@ export default class DrapedFeatureCollection extends Entity3D { } } - cleanup() { + public cleanup(): void { const sorted = this.getSortedTiles(); const features = [...this._features.values()]; @@ -913,7 +929,7 @@ export default class DrapedFeatureCollection extends Entity3D { } } - override getDefaultPointOfView({ + public override getDefaultPointOfView({ camera, }: Parameters[0]): ReturnType< HasDefaultPointOfView['getDefaultPointOfView'] @@ -946,7 +962,7 @@ export default class DrapedFeatureCollection extends Entity3D { return Object.freeze(result); } - override dispose() { + public override dispose(): void { this.detach(); this._geometryConverter.dispose({ disposeMaterials: true, disposeTextures: true }); this.traverseMeshes(mesh => { @@ -956,7 +972,7 @@ export default class DrapedFeatureCollection extends Entity3D { } class DrapedFeatureCollectionInspector extends EntityInspector { - constructor(gui: GUI, instance: Instance, entity: DrapedFeatureCollection) { + public constructor(gui: GUI, instance: Instance, entity: DrapedFeatureCollection) { super(gui, instance, entity); this.addController(entity, 'loadedFeatures'); diff --git a/src/entities/tiles/TileMesh.ts b/src/entities/tiles/TileMesh.ts index df669bedae..e2d54e68a6 100644 --- a/src/entities/tiles/TileMesh.ts +++ b/src/entities/tiles/TileMesh.ts @@ -121,7 +121,7 @@ class TileMesh private _skirtDepth: number | undefined; private _minmax: { min: number; max: number } = { min: -Infinity, max: +Infinity }; private _shouldUpdateHeightMap = false; - // eslint-disable-next-line no-use-before-define + private _childTiles: [TileMesh | null, TileMesh | null, TileMesh | null, TileMesh | null] = [ null, null, diff --git a/src/renderer/geometries/GeometryConverter.ts b/src/renderer/geometries/GeometryConverter.ts index e6c890a8d3..9858fceaf7 100644 --- a/src/renderer/geometries/GeometryConverter.ts +++ b/src/renderer/geometries/GeometryConverter.ts @@ -27,7 +27,6 @@ import { SRGBColorSpace, Vector3, type Material, - type Object3D, type Texture, } from 'three'; import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; diff --git a/src/sources/AggregateFeatureSource.ts b/src/sources/AggregateFeatureSource.ts index 9581afc981..9862c48b88 100644 --- a/src/sources/AggregateFeatureSource.ts +++ b/src/sources/AggregateFeatureSource.ts @@ -1,6 +1,14 @@ +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ + import type { Feature } from 'ol'; + import type CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; import type { FeatureSource, GetFeatureRequest, GetFeatureResult } from './FeatureSource'; + import { FeatureSourceBase } from './FeatureSource'; export interface AggregateFeatureSourceOptions { @@ -8,12 +16,12 @@ export interface AggregateFeatureSourceOptions { } export default class AggregateFeatureSource extends FeatureSourceBase { - override readonly type = 'AggregateFeatureSource' as const; - readonly isAggregateFeatureSource = true as const; + public override readonly type = 'AggregateFeatureSource' as const; + public readonly isAggregateFeatureSource = true as const; private readonly _sources: FeatureSource[]; - constructor(params: AggregateFeatureSourceOptions) { + public constructor(params: AggregateFeatureSourceOptions) { super(); this._sources = [...params.sources]; @@ -22,11 +30,11 @@ export default class AggregateFeatureSource extends FeatureSourceBase { /** * The sources in this source. */ - get sources(): Readonly { + public get sources(): Readonly { return [...this._sources]; } - override async getFeatures(request: GetFeatureRequest): Promise { + public override async getFeatures(request: GetFeatureRequest): Promise { const result: Feature[] = []; const promises: Promise[] = []; @@ -43,7 +51,7 @@ export default class AggregateFeatureSource extends FeatureSourceBase { return { features: result } satisfies GetFeatureResult; } - override async initialize(options: { + public override async initialize(options: { targetCoordinateSystem: CoordinateSystem; }): Promise { await super.initialize(options); diff --git a/src/sources/FeatureSource.ts b/src/sources/FeatureSource.ts index dd09a2fe8a..7339301307 100644 --- a/src/sources/FeatureSource.ts +++ b/src/sources/FeatureSource.ts @@ -1,7 +1,15 @@ +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ + import type { Feature } from 'ol'; + import { EventDispatcher } from 'three'; -import type Extent from '../core/geographic/Extent'; + import type CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; +import type Extent from '../core/geographic/Extent'; export type GetFeatureResult = { /** @@ -57,16 +65,16 @@ export abstract class FeatureSourceBase extends EventDispatcher implements FeatureSource { - abstract readonly type: string; + public abstract readonly type: string; protected _targetCoordinateSystem: CoordinateSystem | null = null; protected _initialized = false; - constructor() { + public constructor() { super(); } - initialize(options: { targetCoordinateSystem: CoordinateSystem }): Promise { + public initialize(options: { targetCoordinateSystem: CoordinateSystem }): Promise { this._targetCoordinateSystem = options.targetCoordinateSystem; this._initialized = true; @@ -77,15 +85,15 @@ export abstract class FeatureSourceBase /** * Raises an event to reload the source. */ - update() { + public update(): void { this.dispatchEvent({ type: 'updated' }); } - protected throwIfNotInitialized() { + protected throwIfNotInitialized(): void { if (!this._initialized) { throw new Error('this source has not been initialized'); } } - abstract getFeatures(request: GetFeatureRequest): Promise; + public abstract getFeatures(request: GetFeatureRequest): Promise; } diff --git a/src/sources/FileFeatureSource.ts b/src/sources/FileFeatureSource.ts index d4bcd13010..19ec615816 100644 --- a/src/sources/FileFeatureSource.ts +++ b/src/sources/FileFeatureSource.ts @@ -1,13 +1,21 @@ +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ + import type { Feature } from 'ol'; import type FeatureFormat from 'ol/format/Feature'; import type { Type } from 'ol/format/Feature'; -import GeoJSON from 'ol/format/GeoJSON'; import type { Geometry } from 'ol/geom'; + +import GeoJSON from 'ol/format/GeoJSON'; + import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; import Fetcher from '../utils/Fetcher'; import { nonNull } from '../utils/tsutils'; -import { FeatureSourceBase, type GetFeatureRequest, type GetFeatureResult } from './FeatureSource'; import { filterByExtent, processFeatures } from './features/processor'; +import { FeatureSourceBase, type GetFeatureRequest, type GetFeatureResult } from './FeatureSource'; export type Getter = (url: string, type: Type) => Promise; @@ -52,8 +60,8 @@ export interface FileFeatureSourceOptions { * Loads features from a remote file (such as GeoJSON, GPX, etc.) */ export default class FileFeatureSource extends FeatureSourceBase { - readonly isFileFeatureSource = true as const; - readonly type = 'FileFeatureSource' as const; + public readonly isFileFeatureSource = true as const; + public readonly type = 'FileFeatureSource' as const; private readonly _format: FeatureFormat; private _features: Feature[] | null = null; @@ -62,7 +70,7 @@ export default class FileFeatureSource extends FeatureSourceBase { private _url: string; private _sourceCoordinateSystem?: CoordinateSystem; - constructor(params: FileFeatureSourceOptions) { + public constructor(params: FileFeatureSourceOptions) { super(); this._format = params.format ?? new GeoJSON(); @@ -71,11 +79,11 @@ export default class FileFeatureSource extends FeatureSourceBase { this._getter = params.getter ?? defaultGetter; } - private loadFeatures() { + private loadFeatures(): Promise { this.throwIfNotInitialized(); if (this._features != null) { - return this._features; + return Promise.resolve(this._features); } if (this._loadFeaturePromise != null) { @@ -114,7 +122,7 @@ export default class FileFeatureSource extends FeatureSourceBase { return actualFeatures; } - async getFeatures(request: GetFeatureRequest): Promise { + public async getFeatures(request: GetFeatureRequest): Promise { request.signal?.throwIfAborted(); const features = await this.loadFeatures(); @@ -131,7 +139,7 @@ export default class FileFeatureSource extends FeatureSourceBase { /** * Deletes the already loaded features, and dispatch an event to reload the features. */ - reload() { + public reload(): void { this._features = null; this._loadFeaturePromise = null; diff --git a/src/sources/StaticFeatureSource.ts b/src/sources/StaticFeatureSource.ts index d09aa2703b..8a3e9ab0d6 100644 --- a/src/sources/StaticFeatureSource.ts +++ b/src/sources/StaticFeatureSource.ts @@ -1,12 +1,21 @@ +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ + import type { Feature } from 'ol'; + import { MathUtils } from 'three'; + +import type { GetFeatureRequest, GetFeatureResult } from './FeatureSource'; + import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; import { nonNull } from '../utils/tsutils'; -import type { GetFeatureRequest, GetFeatureResult } from './FeatureSource'; -import { FeatureSourceBase } from './FeatureSource'; import { filterByExtent } from './features/processor'; +import { FeatureSourceBase } from './FeatureSource'; -function preprocess(feature: Feature, src: CoordinateSystem, dst: CoordinateSystem) { +function preprocess(feature: Feature, src: CoordinateSystem, dst: CoordinateSystem): Feature { if (feature.getId() == null) { feature.setId(MathUtils.generateUUID()); } @@ -38,8 +47,8 @@ export interface StaticFeaturesSourceOptions { * coordinate system, as well as assigning them unique IDs. */ export default class StaticFeatureSource extends FeatureSourceBase { - readonly isStaticFeatureSource = true as const; - override readonly type = 'StaticFeatureSource' as const; + public readonly isStaticFeatureSource = true as const; + public override readonly type = 'StaticFeatureSource' as const; private readonly _initialFeatures: Feature[] | undefined = undefined; private readonly _features: Set = new Set(); @@ -50,11 +59,11 @@ export default class StaticFeatureSource extends FeatureSourceBase { * * Note: this property returns an empty array if the source is not yet initialized. */ - get features(): Readonly { + public get features(): Readonly { return [...this._features]; } - constructor(options?: StaticFeaturesSourceOptions) { + public constructor(options?: StaticFeaturesSourceOptions) { super(); this._coordinateSystem = options?.coordinateSystem ?? CoordinateSystem.epsg4326; @@ -69,7 +78,7 @@ export default class StaticFeatureSource extends FeatureSourceBase { * * Note: if you want to add multiple features at once, use {@link addFeatures} for better performance. */ - addFeature(feature: Feature) { + public addFeature(feature: Feature): void { this.throwIfNotInitialized(); this.doAddFeatures(feature); @@ -84,7 +93,7 @@ export default class StaticFeatureSource extends FeatureSourceBase { * * @returns `true` if the feature feature was actually removed, `false` otherwise. */ - removeFeature(feature: Feature): boolean { + public removeFeature(feature: Feature): boolean { if (this._features.delete(feature)) { this.update(); return true; @@ -96,7 +105,7 @@ export default class StaticFeatureSource extends FeatureSourceBase { /** * Adds multiple features. */ - addFeatures(features: Iterable) { + public addFeatures(features: Iterable): void { this.throwIfNotInitialized(); this.doAddFeatures([...features]); @@ -109,7 +118,7 @@ export default class StaticFeatureSource extends FeatureSourceBase { * * @returns `true` if at least one feature was actually removed, `false` otherwise. */ - removeFeatures(features: Iterable): boolean { + public removeFeatures(features: Iterable): boolean { let actuallyRemoved = false; for (const feature of features) { @@ -129,14 +138,14 @@ export default class StaticFeatureSource extends FeatureSourceBase { /** * Removes all features. */ - clear() { + public clear(): void { if (this._features.size > 0) { this._features.clear(); this.update(); } } - private doAddFeatures(features: Feature | Feature[]) { + private doAddFeatures(features: Feature | Feature[]): void { if (Array.isArray(features)) { features.forEach(f => { preprocess(f, this._coordinateSystem, nonNull(this._targetCoordinateSystem)); @@ -148,7 +157,7 @@ export default class StaticFeatureSource extends FeatureSourceBase { } } - override async initialize(options: { + public override async initialize(options: { targetCoordinateSystem: CoordinateSystem; }): Promise { await super.initialize(options); @@ -161,7 +170,7 @@ export default class StaticFeatureSource extends FeatureSourceBase { } } - override async getFeatures(request: GetFeatureRequest): Promise { + public override async getFeatures(request: GetFeatureRequest): Promise { const filtered = await filterByExtent([...this._features], request.extent, { signal: request.signal, }); diff --git a/src/sources/StreamableFeatureSource.ts b/src/sources/StreamableFeatureSource.ts index 66cd541d6e..b8c605d441 100644 --- a/src/sources/StreamableFeatureSource.ts +++ b/src/sources/StreamableFeatureSource.ts @@ -1,8 +1,18 @@ +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ + import type { Feature } from 'ol'; import type FeatureFormat from 'ol/format/Feature'; import type { Type } from 'ol/format/Feature'; + import GeoJSON from 'ol/format/GeoJSON'; -import {Cache, CacheConfiguration} from '../core/Cache'; + +import type { CacheConfiguration } from '../core/Cache'; + +import { Cache } from '../core/Cache'; import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; import Extent from '../core/geographic/Extent'; import Fetcher from '../utils/Fetcher'; @@ -139,21 +149,32 @@ export interface StreamableFeatureSourceOptions { * Interface for StreamableFeatureSource feature getter. */ export interface FeatureGetter { - getFeatures(extent: Extent, options: StreamableFeatureSourceOptions, targetCoordinateSystem: CoordinateSystem): Promise; -}; + getFeatures( + extent: Extent, + options: StreamableFeatureSourceOptions, + targetCoordinateSystem: CoordinateSystem, + ): Promise; +} export abstract class FeatureGetterBase implements FeatureGetter { - abstract getFeatures(extent: Extent, options: StreamableFeatureSourceOptions, targetCoordinateSystem: CoordinateSystem): Promise; - - protected async _fetchFeatures(extent: Extent, options: StreamableFeatureSourceOptions, targetCoordinateSystem: CoordinateSystem): Promise - { + public abstract getFeatures( + extent: Extent, + options: StreamableFeatureSourceOptions, + targetCoordinateSystem: CoordinateSystem, + ): Promise; + + protected async _fetchFeatures( + extent: Extent, + options: StreamableFeatureSourceOptions, + targetCoordinateSystem: CoordinateSystem, + ): Promise { const sourceCoordinateSystem = nonNull(options.sourceCoordinateSystem); const getter = nonNull(options.getter); const format = nonNull(options.format); const url = options.queryBuilder({ extent: extent, - sourceCoordinateSystem: sourceCoordinateSystem + sourceCoordinateSystem: sourceCoordinateSystem, }); if (!url) { @@ -164,7 +185,7 @@ export abstract class FeatureGetterBase implements FeatureGetter { const features = format.readFeatures(data) as Feature[]; - const getFeatureId = (feature: Feature) => { + const getFeatureId = (feature: Feature): number | string => { return ( feature.getId() ?? feature.get('id') ?? feature.get('fid') ?? feature.get('ogc_fid') ); @@ -181,7 +202,11 @@ export abstract class FeatureGetterBase implements FeatureGetter { * Directly queries the features from the datasource. */ export class DefaultFeatureGetter extends FeatureGetterBase { - async getFeatures(extent: Extent, options: StreamableFeatureSourceOptions, targetCoordinateSystem: CoordinateSystem): Promise { + public async getFeatures( + extent: Extent, + options: StreamableFeatureSourceOptions, + targetCoordinateSystem: CoordinateSystem, + ): Promise { return await this._fetchFeatures(extent, options, targetCoordinateSystem); } } @@ -191,16 +216,19 @@ export class DefaultFeatureGetter extends FeatureGetterBase { * Queries the features from the datasource in tiles and caches the tiles. */ export class CachedTiledFeatureGetter extends FeatureGetterBase { - private readonly _tileSize: number; private readonly _cache: Cache; - constructor(tileSize: number = 1000, cacheConfig?: CacheConfiguration) { + public constructor(tileSize: number = 1000, cacheConfig?: CacheConfiguration) { super(); this._tileSize = tileSize; - this._cache = new Cache(cacheConfig ?? {ttl: 600}); + this._cache = new Cache(cacheConfig ?? { ttl: 600 }); } - async getFeatures(extent: Extent, options: StreamableFeatureSourceOptions, targetCoordinateSystem: CoordinateSystem): Promise { + public async getFeatures( + extent: Extent, + options: StreamableFeatureSourceOptions, + targetCoordinateSystem: CoordinateSystem, + ): Promise { const xmin = Math.floor(extent.west / this._tileSize); const xmax = Math.ceil((extent.east + 1) / this._tileSize); const ymin = Math.floor(extent.south / this._tileSize); @@ -210,20 +238,24 @@ export class CachedTiledFeatureGetter extends FeatureGetterBase { for (let x = xmin; x < xmax; ++x) { for (let y = ymin; y < ymax; ++y) { - const key = `${x}/${y}`; let tileFeatures = this._cache.get(key) as Feature[]; if (tileFeatures === undefined) { - - const tileExtent = new Extent(extent.crs, - x * this._tileSize, (x + 1) * this._tileSize, - y * this._tileSize, (y + 1) * this._tileSize, + const tileExtent = new Extent( + extent.crs, + x * this._tileSize, + (x + 1) * this._tileSize, + y * this._tileSize, + (y + 1) * this._tileSize, ); - tileFeatures = await super._fetchFeatures(tileExtent, options, targetCoordinateSystem); + tileFeatures = await super._fetchFeatures( + tileExtent, + options, + targetCoordinateSystem, + ); this._cache.set(key, features); - } features.push(...tileFeatures); } @@ -236,13 +268,16 @@ export class CachedTiledFeatureGetter extends FeatureGetterBase { * A feature source that supports streaming features (e.g OGC API Features, etc) */ export default class StreamableFeatureSource extends FeatureSourceBase { - readonly isStreamableFeatureSource = true as const; - readonly type = 'StreamableFeatureSource' as const; + public readonly isStreamableFeatureSource = true as const; + public readonly type = 'StreamableFeatureSource' as const; private readonly _options: StreamableFeatureSourceOptions; private readonly _featureGetter: FeatureGetter; - constructor(params: StreamableFeatureSourceOptions, featureGetter: FeatureGetter|null = null) { + public constructor( + params: StreamableFeatureSourceOptions, + featureGetter: FeatureGetter | null = null, + ) { super(); this._options = { queryBuilder: params.queryBuilder, @@ -250,12 +285,12 @@ export default class StreamableFeatureSource extends FeatureSourceBase { getter: params.getter ?? defaultGetter, maxExtent: params.maxExtent, // TODO assume EPSG:4326 ? - sourceCoordinateSystem: params.sourceCoordinateSystem ?? CoordinateSystem.epsg4326 + sourceCoordinateSystem: params.sourceCoordinateSystem ?? CoordinateSystem.epsg4326, }; this._featureGetter = featureGetter ?? new DefaultFeatureGetter(); } - async getFeatures(request: GetFeatureRequest): Promise { + public async getFeatures(request: GetFeatureRequest): Promise { this.throwIfNotInitialized(); let west = request.extent.west; @@ -270,15 +305,15 @@ export default class StreamableFeatureSource extends FeatureSourceBase { } if (west >= east || south >= north) { // Empty extent - return {features: []}; + return { features: [] }; } const features = await this._featureGetter.getFeatures( - new Extent(request.extent.crs, {east, north, south, west}), + new Extent(request.extent.crs, { east, north, south, west }), this._options, - nonNull(this._targetCoordinateSystem) + nonNull(this._targetCoordinateSystem), ); - return {features: features,}; + return { features: features }; } } diff --git a/src/sources/features/processor.ts b/src/sources/features/processor.ts index 398430b0ca..b982b2483a 100644 --- a/src/sources/features/processor.ts +++ b/src/sources/features/processor.ts @@ -1,8 +1,16 @@ +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ + import type { Feature } from 'ol'; import type { Extent as OLExtent } from 'ol/extent'; import type { Geometry } from 'ol/geom'; + import type CoordinateSystem from '../../core/geographic/coordinate-system/CoordinateSystem'; import type Extent from '../../core/geographic/Extent'; + import OpenLayersUtils from '../../utils/OpenLayersUtils'; import PromiseUtils from '../../utils/PromiseUtils'; @@ -23,7 +31,7 @@ export async function processFeatures( const tmpExtent = [0, 0, 0, 0]; - const transformer = (feature: Feature, index: number) => { + const transformer = (feature: Feature, index: number): Feature | null => { const id = optionalProcessings?.getFeatureId != null ? optionalProcessings.getFeatureId(feature) @@ -80,7 +88,7 @@ export async function filterByExtent( ): Promise { const olExtent = OpenLayersUtils.toOLExtent(extent); - const filter = (feature: Feature) => { + const filter = (feature: Feature): Feature | null => { if (intersects(feature, olExtent)) { return feature; } diff --git a/src/utils/PromiseUtils.ts b/src/utils/PromiseUtils.ts index 44be1707ee..85fac9f0e3 100644 --- a/src/utils/PromiseUtils.ts +++ b/src/utils/PromiseUtils.ts @@ -52,7 +52,7 @@ function batch( ): Promise { const result: O[] = options?.outputItems ?? []; - const processSlice = (start: number) => { + const processSlice = (start: number): Promise => { const begin = performance.now(); for (let i = start; i < items.length; i++) { diff --git a/test/unit/sources/FileFeatureSource.test.ts b/test/unit/sources/FileFeatureSource.test.ts index bc7f6fc462..1e7cc82d05 100644 --- a/test/unit/sources/FileFeatureSource.test.ts +++ b/test/unit/sources/FileFeatureSource.test.ts @@ -1,9 +1,16 @@ +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ + import type FeatureFormat from 'ol/format/Feature'; +import { Projection } from 'ol/proj'; + import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem'; import Extent from '@giro3d/giro3d/core/geographic/Extent'; import FileFeatureSource from '@giro3d/giro3d/sources/FileFeatureSource'; -import { Projection } from 'ol/proj'; describe('FileFeatureSource', () => { describe('reload', () => { diff --git a/test/unit/sources/StaticFeatureSource.test.ts b/test/unit/sources/StaticFeatureSource.test.ts index 02fa4ba3a5..38e255b871 100644 --- a/test/unit/sources/StaticFeatureSource.test.ts +++ b/test/unit/sources/StaticFeatureSource.test.ts @@ -1,8 +1,15 @@ +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ + +import { Feature } from 'ol'; +import { Point } from 'ol/geom'; + import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem'; import Extent from '@giro3d/giro3d/core/geographic/Extent'; import StaticFeatureSource from '@giro3d/giro3d/sources/StaticFeatureSource'; -import { Feature } from 'ol'; -import { Point } from 'ol/geom'; describe('StaticFeatureSource', () => { let sourceWithTransformation: StaticFeatureSource; diff --git a/test/unit/sources/StreamableFeatureSource.test.ts b/test/unit/sources/StreamableFeatureSource.test.ts index 5ee24c913b..cebbd2022a 100644 --- a/test/unit/sources/StreamableFeatureSource.test.ts +++ b/test/unit/sources/StreamableFeatureSource.test.ts @@ -1,3 +1,9 @@ +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ + import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem'; import Extent from '@giro3d/giro3d/core/geographic/Extent'; import StreamableFeatureSource from '@giro3d/giro3d/sources/StreamableFeatureSource'; -- GitLab From 3cf49a4b303c3a069a6f50918720e3976f371959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Tue, 30 Sep 2025 14:24:54 +0200 Subject: [PATCH 28/43] refactor(DrapedFeatureCollection): abstract map behind MapLike interface --- src/entities/DrapedFeatureCollection.ts | 108 ++++++++++++++++-------- src/entities/api.ts | 4 + 2 files changed, 79 insertions(+), 33 deletions(-) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index 94f2add85a..c2309ab37b 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -6,8 +6,8 @@ import type GUI from 'lil-gui'; import type Feature from 'ol/Feature'; -import type { Circle, Point, SimpleGeometry } from 'ol/geom'; -import type { Object3D } from 'three'; +import type { Circle, Geometry, MultiPoint, Point, SimpleGeometry } from 'ol/geom'; +import type { EventDispatcher, Object3D } from 'three'; import { type Coordinate } from 'ol/coordinate'; import { getCenter } from 'ol/extent'; @@ -33,8 +33,7 @@ import type SimpleGeometryMesh from '../renderer/geometries/SimpleGeometryMesh'; import type SurfaceMesh from '../renderer/geometries/SurfaceMesh'; import type { FeatureSource, FeatureSourceEventMap } from '../sources/FeatureSource'; import type { MeshUserData } from './FeatureCollection'; -import type MapEntity from './Map'; -import type { MapEventMap, Tile } from './Map'; +import type { Tile } from './Map'; import { mapGeometry, @@ -61,6 +60,19 @@ import Entity3D from './Entity3D'; const tmpSphere = new Sphere(); +interface MapLikeEventMap { + 'elevation-loaded': { tile: Tile }; + 'tile-created': { tile: Tile }; + 'tile-deleted': { tile: Tile }; +} + +/** + * Map-like object to drape features onto. + */ +export interface MapLike extends ElevationProvider, EventDispatcher { + traverseTiles(callback: (tile: Tile) => void): void; +} + /** * How the geometry should be draped on the terrain: * - `per-feature`: the same elevation offset is applied to the entire feature. @@ -68,7 +80,8 @@ const tmpSphere = new Sphere(); * - `per-vertex`: the elevation is applied to each vertex independently. Suitable for * lines that must follow the terrain, such as roads. * - `none`: no draping is done, the elevation of the feature is used as is. Suitable for - * geometries that should not be draped on the terrain, such as flight paths or flying objects. + * geometries that should not be draped on the terrain, such as flight paths or flying objects, + * or for 3D geometries that already have a vertical elevation. * * Note: that `Point` geometries, having only one coordinate, will automatically use the `per-feature` mode. */ @@ -95,9 +108,9 @@ export type DrapedFeatureElevationCallback = ( /** * Either returns the same geometry if it already has a XYZ layout, or create an equivalent geometry in the XYZ layout. */ -function cloneAsXYZIfRequired( - geometry: G, -): G { +function cloneAsXYZIfRequired< + G extends Polygon | LineString | MultiPoint | MultiLineString | MultiPolygon, +>(geometry: G): G { if (geometry.getLayout() === 'XYZ') { // No need to clone. return geometry; @@ -143,11 +156,7 @@ function getRootMesh(obj: Object3D): SimpleGeometryMesh | null { return null; } -function getFeatureElevation( - feature: Feature, - geometry: SimpleGeometry, - provider: ElevationProvider, -): number { +function getFeatureElevation(geometry: SimpleGeometry, provider: ElevationProvider): number { let center: Coordinate; if (geometry.getType() === 'Point') { @@ -165,10 +174,25 @@ function getFeatureElevation( return sample?.elevation ?? 0; } -function applyPerVertexDraping( - geometry: G, - provider: ElevationProvider, -): G { +type SupportedGeometry = Point | Polygon | LineString | MultiLineString | MultiPolygon; + +function isGeometrySupported(g: Geometry): g is SupportedGeometry { + switch (g.getType()) { + case 'Point': + case 'LineString': + case 'Polygon': + case 'MultiPoint': + case 'MultiLineString': + case 'MultiPolygon': + return true; + default: + return false; + } +} + +function applyPerVertexDraping< + G extends Polygon | LineString | MultiPoint | MultiLineString | MultiPolygon, +>(geometry: G, provider: ElevationProvider): G { const coordinates = geometry.getFlatCoordinates(); const stride = geometry.getStride(); @@ -201,7 +225,7 @@ function applyPerVertexDraping { - let result: ReturnType; + let result: ReturnType | null = null; const perFeature: (geometry: SimpleGeometry) => void = geometry => { let center: Coordinate; @@ -275,8 +299,11 @@ export const defaultElevationCallback: DrapedFeatureElevationCallback = (feature processMultiPolygon: perVertex, }); - // TODO - // @ts-expect-error TODO + if (result == null) { + // Just to please the typechecker + throw new Error(); + } + return result; }; @@ -360,11 +387,15 @@ type FeaturesEntry = { sampledLod: number; }; +/** + * Loads 3D features from a {@link FeatureSource} and displays them on top + * of a map, by taking terrain into account. + */ export default class DrapedFeatureCollection extends Entity3D { public override type = 'DrapedFeatureCollection' as const; public readonly isDrapedFeatureCollection = true as const; - private _map: MapEntity | null = null; + private _map: MapLike | null = null; private readonly _drapingMode: DrapingMode | DrapingModeFunction; private readonly _elevationCallback: DrapedFeatureElevationCallback; @@ -382,9 +413,9 @@ export default class DrapedFeatureCollection extends Entity3D { private readonly _features: Map = new Map(); private readonly _source: FeatureSource; private readonly _eventHandlers: { - onTileCreated: EventHandler; - onTileDeleted: EventHandler; - onElevationLoaded: EventHandler; + onTileCreated: EventHandler; + onTileDeleted: EventHandler; + onElevationLoaded: EventHandler; onSourceUpdated: EventHandler; onTextureLoaded: () => void; }; @@ -598,7 +629,10 @@ export default class DrapedFeatureCollection extends Entity3D { await this._source.initialize({ targetCoordinateSystem: this.instance.coordinateSystem }); } - public attach(map: MapEntity): this { + /** + * Sets the draping target. + */ + public attach(map: MapLike): this { if (this._map != null) { throw new Error('a map is already attached to this entity'); } @@ -609,7 +643,6 @@ export default class DrapedFeatureCollection extends Entity3D { map.addEventListener('tile-deleted', this._eventHandlers.onTileDeleted); map.addEventListener('elevation-loaded', this._eventHandlers.onElevationLoaded); - // TODO register trop tôt avant que l'élévation soit prête map.traverseTiles(tile => { this.registerTile(tile); }); @@ -652,15 +685,15 @@ export default class DrapedFeatureCollection extends Entity3D { } } - private onTileCreated({ tile }: MapEventMap['tile-created']): void { + private onTileCreated({ tile }: MapLikeEventMap['tile-created']): void { this.registerTile(tile); } - private onTileDeleted({ tile }: MapEventMap['tile-deleted']): void { + private onTileDeleted({ tile }: MapLikeEventMap['tile-deleted']): void { this.unregisterTile(tile); } - private onElevationLoaded({ tile }: MapEventMap['elevation-loaded']): void { + private onElevationLoaded({ tile }: MapLikeEventMap['elevation-loaded']): void { this.registerTile(tile, true); } @@ -826,12 +859,21 @@ export default class DrapedFeatureCollection extends Entity3D { } private loadFeatureMesh(id: string, existing: FeaturesEntry): void { - // TODO filter non compatible geometries - const geometry = existing.feature.getGeometry() as SimpleGeometry; + const geometry = existing.feature.getGeometry(); + + if (geometry == null) { + console.warn(`No geometry for feature ${id}`); + return; + } + + if (!isGeometrySupported(geometry)) { + console.warn(`Unsupported geometry type for feature ${id} (${geometry.getType()})`); + return; + } const drapingMode = this.getDrapingMode(existing.feature); - let actualGeometry: SimpleGeometry = geometry; + let actualGeometry = geometry; let shouldReplaceMesh = false; let verticalOffset = 0; @@ -841,7 +883,7 @@ export default class DrapedFeatureCollection extends Entity3D { ) { // Note that point is necessarily per feature, since there is only one vertex actualGeometry = geometry; - verticalOffset = getFeatureElevation(existing.feature, geometry, nonNull(this._map)); + verticalOffset = getFeatureElevation(geometry, nonNull(this._map)); } else if (drapingMode === 'per-vertex') { shouldReplaceMesh = true; // TODO support multipoint ? diff --git a/src/entities/api.ts b/src/entities/api.ts index b65ae7d52e..1b21bac502 100644 --- a/src/entities/api.ts +++ b/src/entities/api.ts @@ -5,6 +5,8 @@ */ import type Atmosphere from './Atmosphere'; +import type DrapedFeatureCollection from './DrapedFeatureCollection'; +import type { MapLike as DrapedFeatureCollectionMapLike } from './DrapedFeatureCollection'; import type Globe from './Globe'; import type { GlobeConstructorOptions, @@ -63,9 +65,11 @@ export { DEFAULT_SUBDIVISION_THRESHOLD, DEFAULT_TILES3D_POINTCLOUD_ATTRIBUTE_MAPPING, Entity, + DrapedFeatureCollectionMapLike, Entity3D, Entity3DEventMap, EntityEventMap, + DrapedFeatureCollection, EntityUserData, FeatureCollection, Globe, -- GitLab From 58ed4569c85b2450a6fd567a90b7adabc5a5ebfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Tue, 30 Sep 2025 16:12:58 +0200 Subject: [PATCH 29/43] docs(draped_feature_collection): update example --- examples/draped_feature_collection.css | 4 + examples/draped_feature_collection.html | 49 ++- examples/draped_feature_collection.js | 294 +++++------------- .../screenshots/draped_feature_collection.jpg | Bin 0 -> 42753 bytes 4 files changed, 126 insertions(+), 221 deletions(-) create mode 100644 examples/draped_feature_collection.css create mode 100644 examples/screenshots/draped_feature_collection.jpg diff --git a/examples/draped_feature_collection.css b/examples/draped_feature_collection.css new file mode 100644 index 0000000000..60f4b8404f --- /dev/null +++ b/examples/draped_feature_collection.css @@ -0,0 +1,4 @@ +#view canvas { + background: rgb(132, 170, 182); + background: radial-gradient(circle, rgba(132, 170, 182, 1) 0%, rgba(37, 44, 48, 1) 100%); +} diff --git a/examples/draped_feature_collection.html b/examples/draped_feature_collection.html index 4b08107304..291fac8859 100644 --- a/examples/draped_feature_collection.html +++ b/examples/draped_feature_collection.html @@ -1,7 +1,50 @@ --- title: Draped feature collection -shortdesc: TODO -longdesc: TODO +shortdesc: Illustrates the DrapedFeatureCollection entity. +longdesc: The DrapedFeatureCollection entities loads vector features and display them on top of the terrain. attribution: © IGN -tags: [wfs, wmts, ign, map] +tags: [features, draping, ign, map] --- + +
+ > +
+
Parameters
+
+
+ +
+ + + +
+ +
+ Draping mode indicates how the geometry is deformed to conform to the + terrain. +
    +
  • + per-feature means that the feature's center is clamped to + the ground, preserving the geometry shape. Suitable for flat geometries + such as lakes. +
  • +
  • + per-vertex means that every vertex is clamped to the + ground, deforming the geometry. Suitable for features that should + conform to the terrain, such as roads. +
  • +
  • + none disables terrain conformation and preserves the + original geometry. For 2D geometries, their elevation remains at zero. + For 3D geometries, the Z coordinate of each vertex is preserved. +
  • +
+
+
+
+
+
diff --git a/examples/draped_feature_collection.js b/examples/draped_feature_collection.js index 63ba790409..5a414bf142 100644 --- a/examples/draped_feature_collection.js +++ b/examples/draped_feature_collection.js @@ -4,14 +4,11 @@ * SPDX-License-Identifier: MIT */ -import { Feature } from 'ol'; -import GeoJSON from 'ol/format/GeoJSON.js'; -import { Point } from 'ol/geom.js'; -import VectorSource from 'ol/source/Vector.js'; -import { AmbientLight, Color, DirectionalLight, DoubleSide, MathUtils, Vector3 } from 'three'; +import { AmbientLight, DirectionalLight, DoubleSide, Vector3 } from 'three'; import { MapControls } from 'three/examples/jsm/controls/MapControls.js'; import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem.js'; +import Coordinates from '@giro3d/giro3d/core/geographic/Coordinates.js'; import Extent from '@giro3d/giro3d/core/geographic/Extent.js'; import Instance from '@giro3d/giro3d/core/Instance.js'; import ColorLayer from '@giro3d/giro3d/core/layer/ColorLayer.js'; @@ -21,12 +18,9 @@ import Giro3dMap from '@giro3d/giro3d/entities/Map.js'; import BilFormat from '@giro3d/giro3d/formats/BilFormat.js'; import Inspector from '@giro3d/giro3d/gui/Inspector.js'; import FileFeatureSource from '@giro3d/giro3d/sources/FileFeatureSource.js'; -import StaticFeatureSource from '@giro3d/giro3d/sources/StaticFeatureSource.js'; -import StreamableFeatureSource, { - ogcApiFeaturesBuilder, -} from '@giro3d/giro3d/sources/StreamableFeatureSource.js'; import WmtsSource from '@giro3d/giro3d/sources/WmtsSource.js'; +import { bindDropDown } from './widgets/bindDropDown.js'; import StatusBar from './widgets/StatusBar.js'; Instance.registerCRS( @@ -38,21 +32,17 @@ Instance.registerCRS( 'GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]', ); -const SKY_COLOR = new Color(0xf1e9c6); +const coordinateSystem = CoordinateSystem.fromEpsg(2154); const instance = new Instance({ target: 'view', - crs: CoordinateSystem.fromEpsg(2154), - backgroundColor: SKY_COLOR, + crs: coordinateSystem, + backgroundColor: null, }); -const extent = new Extent( - CoordinateSystem.fromEpsg(2154), - -111629.52, - 1275028.84, - 5976033.79, - 7230161.64, -); +const mapCenter = new Coordinates(coordinateSystem, 870_623, 6_396_742); + +const extent = Extent.fromCenterAndSize(coordinateSystem, mapCenter, 20_000, 10_000); // create a map const map = new Giro3dMap({ @@ -71,6 +61,70 @@ const noDataValue = -1000; const url = 'https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetCapabilities'; +const featureSource = new FileFeatureSource({ + url: 'https://3d.oslandia.com/giro3d/vectors/Saou-syncline.geojson', +}); + +const style = { + stroke: { + color: 'yellow', + depthTest: false, + renderOrder: 999, + lineWidth: 3, + }, +}; + +const entities = { + 'per-vertex': new DrapedFeatureCollection({ + source: featureSource, + minLod: 0, + drapingMode: 'per-vertex', + style, + }), + 'per-feature': new DrapedFeatureCollection({ + source: featureSource, + minLod: 0, + drapingMode: 'per-feature', + style, + }), + none: new DrapedFeatureCollection({ + source: featureSource, + minLod: 0, + drapingMode: 'none', + style, + }), +}; + +function updateEntities(newMode) { + for (const key of Object.keys(entities)) { + entities[key].visible = newMode === key; + } + + instance.notifyChange(); +} + +const [_, currentMode] = bindDropDown('mode', updateEntities); + +function loadDrapedFeatures() { + entities['per-feature'].visible = false; + entities['per-vertex'].visible = false; + entities['none'].visible = false; + + instance.add(entities['per-vertex']).then(() => { + entities['per-vertex'].attach(map); + }); + instance.add(entities['none']).then(() => { + entities['none'].attach(map); + }); + instance.add(entities['per-feature']).then(() => { + entities['per-feature'].attach(map); + }); + + updateEntities(currentMode); +} + +loadDrapedFeatures(); + // Let's build the elevation layer from the WMTS capabilities WmtsSource.fromCapabilities(url, { layer: 'ELEVATION.ELEVATIONGRIDCOVERAGE.HIGHRES', @@ -98,7 +152,7 @@ WmtsSource.fromCapabilities(url, { // Let's build the color layer from the WMTS capabilities WmtsSource.fromCapabilities(url, { - layer: 'GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2', + layer: 'HR.ORTHOIMAGERY.ORTHOPHOTOS', }) .then(orthophotoWmts => { map.addLayer( @@ -112,202 +166,6 @@ WmtsSource.fromCapabilities(url, { }) .catch(console.error); -const geojson = new FileFeatureSource({ - format: new GeoJSON(), - // url: 'http://localhost:14000/vectors/geojson/points.geojson', - // url: 'http://localhost:14000/vectors/geojson/grenoble_linestring.geojson', - // url: 'http://localhost:14000/vectors/geojson/grenoble_polygon.geojson', - // url: 'http://localhost:14000/vectors/geojson/grenoble_batiments.geojson', - // url: 'http://localhost:14002/collections/public.cadastre/items.json', - url: 'http://localhost:14002/collections/public.cadastre/items.json?limit=500', - sourceCoordinateSystem: CoordinateSystem.epsg4326, -}); - -const communes = new StreamableFeatureSource({ - queryBuilder: ogcApiFeaturesBuilder('http://localhost:14002/', 'public.cadastre'), - sourceCoordinateSystem: CoordinateSystem.epsg4326, -}); - -const hydrants = new StreamableFeatureSource({ - queryBuilder: ogcApiFeaturesBuilder('http://localhost:14002/', 'public.hydrants_sdis_64'), - sourceCoordinateSystem: CoordinateSystem.epsg4326, -}); - -const bdTopoIgn = new StreamableFeatureSource({ - sourceCoordinateSystem: CoordinateSystem.fromEpsg(2154), - queryBuilder: params => { - const queryUrl = new URL('https://data.geopf.fr/wfs/ows'); - - queryUrl.searchParams.append('SERVICE', 'WFS'); - queryUrl.searchParams.append('VERSION', '2.0.0'); - queryUrl.searchParams.append('request', 'GetFeature'); - queryUrl.searchParams.append('typename', 'BDTOPO_V3:batiment'); - queryUrl.searchParams.append('outputFormat', 'application/json'); - queryUrl.searchParams.append('SRSNAME', 'EPSG:2154'); - queryUrl.searchParams.append('startIndex', '0'); - - const queryExtent = params.extent.as(CoordinateSystem.fromEpsg(2154)); - - queryUrl.searchParams.append( - 'bbox', - `${queryExtent.west},${queryExtent.south},${queryExtent.east},${queryExtent.north},EPSG:2154`, - ); - - return queryUrl; - }, -}); - -const hoverColor = new Color('yellow'); - -const bdTopoStyle = feature => { - const properties = feature.getProperties(); - let fillColor = '#FFFFFF'; - - const hovered = properties.hovered ?? false; - const clicked = properties.clicked ?? false; - - switch (properties.usage1) { - case 'Industriel': - fillColor = '#f0bb41'; - break; - case 'Agricole': - fillColor = '#96ff0d'; - break; - case 'Religieux': - fillColor = '#41b5f0'; - break; - case 'Sportif': - fillColor = '#ff0d45'; - break; - case 'Résidentiel': - fillColor = '#cec8be'; - break; - case 'Commercial et services': - fillColor = '#d8ffd4'; - break; - } - - const fill = clicked - ? 'yellow' - : hovered - ? new Color(fillColor).lerp(hoverColor, 0.2) // Let's use a slightly brighter color for hover - : fillColor; - - return { - fill: { - color: fill, - shading: true, - }, - stroke: { - color: clicked ? 'yellow' : hovered ? 'white' : 'black', - lineWidth: clicked ? 5 : undefined, - }, - }; -}; - -// Let's compute the extrusion offset of building polygons to give them walls. -const bdTopoExtrusionOffset = feature => { - const properties = feature.getProperties(); - const buildingHeight = properties['hauteur']; - const extrusionOffset = buildingHeight; - - if (Number.isNaN(extrusionOffset)) { - return null; - } - return extrusionOffset; -}; - -const pointStyle = { - point: { - pointSize: 96, - depthTest: true, - image: 'http://localhost:14000/images/pin.png', - }, -}; - -/** - * @type {Record} - */ -const sources = { - static: { - minLod: 0, - drapingMode: 'per-feature', - style: pointStyle, - source: new StaticFeatureSource({ - coordinateSystem: CoordinateSystem.fromEpsg(2154), - }), - }, - hydrants: { - source: hydrants, - style: pointStyle, - drapingMode: 'per-feature', - minLod: 2, - }, - communes: { - source: communes, - minLod: 5, - drapingMode: 'per-vertex', - style: { - stroke: { - color: 'black', - lineWidth: 4, - depthTest: false, - renderOrder: 2, - }, - }, - }, - batiment: { - minLod: 12, - drapingMode: 'per-feature', - extrusionOffset: bdTopoExtrusionOffset, - source: new StreamableFeatureSource({ - sourceCoordinateSystem: CoordinateSystem.epsg4326, - queryBuilder: ogcApiFeaturesBuilder( - 'http://localhost:14002/', - 'public.batiment_038_isere', - ), - }), - style: bdTopoStyle, - }, -}; - -const data = sources.hydrants; - -const entity = new DrapedFeatureCollection({ - source: data.source, - minLod: data.minLod, - drapingMode: data.drapingMode, - extrusionOffset: data.extrusionOffset, - style: data.style, -}); - -instance.add(entity).then(() => { - entity.attach(map); - - if (data === sources.static) { - setInterval(() => { - const x = MathUtils.lerp( - map.extent.west, - map.extent.east, - MathUtils.randFloat(0.3, 0.7), - ); - const y = MathUtils.lerp( - map.extent.south, - map.extent.north, - MathUtils.randFloat(0.3, 0.7), - ); - // @ts-expect-error casting issue - sources['static'].source.addFeature(new Feature(new Point([x, y]))); - }, 500); - } -}); - // Add a sunlight const sun = new DirectionalLight('#ffffff', 2); sun.position.set(1, 0, 1).normalize(); @@ -324,9 +182,9 @@ instance.scene.add(sun2); const ambientLight = new AmbientLight(0xffffff, 0.2); instance.scene.add(ambientLight); -instance.view.camera.position.set(913349.2364044407, 6456426.459171033, 1706.0108044011636); +instance.view.camera.position.set(mapCenter.x - 10000, mapCenter.y - 4000, 2000); -const lookAt = new Vector3(913896, 6459191, 200); +const lookAt = new Vector3(mapCenter.x, mapCenter.y, 100); instance.view.camera.lookAt(lookAt); const controls = new MapControls(instance.view.camera, instance.domElement); diff --git a/examples/screenshots/draped_feature_collection.jpg b/examples/screenshots/draped_feature_collection.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d826fe131d3926a30f22b7d5d159bd7e78e61bbc GIT binary patch literal 42753 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 literal 0 HcmV?d00001 -- GitLab From f6d8f7a27572369901de68dd28b7b7ba426a2f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Tue, 30 Sep 2025 16:14:53 +0200 Subject: [PATCH 30/43] refactor(StreamableFeatureSource): inject Cache instance --- src/sources/StreamableFeatureSource.ts | 31 ++++++++++++++------------ 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/sources/StreamableFeatureSource.ts b/src/sources/StreamableFeatureSource.ts index b8c605d441..7613695fd9 100644 --- a/src/sources/StreamableFeatureSource.ts +++ b/src/sources/StreamableFeatureSource.ts @@ -9,10 +9,11 @@ import type FeatureFormat from 'ol/format/Feature'; import type { Type } from 'ol/format/Feature'; import GeoJSON from 'ol/format/GeoJSON'; +import { MathUtils } from 'three'; -import type { CacheConfiguration } from '../core/Cache'; +import type { Cache } from '../core/Cache'; -import { Cache } from '../core/Cache'; +import { GlobalCache } from '../core/Cache'; import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; import Extent from '../core/geographic/Extent'; import Fetcher from '../utils/Fetcher'; @@ -141,6 +142,11 @@ export interface StreamableFeatureSourceOptions { */ format?: FeatureFormat; getter?: Getter; + featureGetter?: FeatureGetter; + /** + * The source coordinate system. + * @defaultValue EPSG:4326 + */ sourceCoordinateSystem?: CoordinateSystem; maxExtent?: Extent; } @@ -217,12 +223,13 @@ export class DefaultFeatureGetter extends FeatureGetterBase { */ export class CachedTiledFeatureGetter extends FeatureGetterBase { private readonly _tileSize: number; + private readonly _cacheKey = MathUtils.generateUUID(); private readonly _cache: Cache; - public constructor(tileSize: number = 1000, cacheConfig?: CacheConfiguration) { + public constructor(params?: { tileSize?: number; cache?: Cache }) { super(); - this._tileSize = tileSize; - this._cache = new Cache(cacheConfig ?? { ttl: 600 }); + this._tileSize = params?.tileSize ?? 1000; + this._cache = params?.cache ?? GlobalCache; } public async getFeatures( extent: Extent, @@ -238,7 +245,7 @@ export class CachedTiledFeatureGetter extends FeatureGetterBase { for (let x = xmin; x < xmax; ++x) { for (let y = ymin; y < ymax; ++y) { - const key = `${x}/${y}`; + const key = `${this._cacheKey}-${x}/${y}`; let tileFeatures = this._cache.get(key) as Feature[]; if (tileFeatures === undefined) { @@ -255,6 +262,7 @@ export class CachedTiledFeatureGetter extends FeatureGetterBase { options, targetCoordinateSystem, ); + this._cache.set(key, features); } features.push(...tileFeatures); @@ -272,22 +280,17 @@ export default class StreamableFeatureSource extends FeatureSourceBase { public readonly type = 'StreamableFeatureSource' as const; private readonly _options: StreamableFeatureSourceOptions; - private readonly _featureGetter: FeatureGetter; - public constructor( - params: StreamableFeatureSourceOptions, - featureGetter: FeatureGetter | null = null, - ) { + public constructor(params: StreamableFeatureSourceOptions) { super(); this._options = { queryBuilder: params.queryBuilder, format: params.format ?? new GeoJSON(), getter: params.getter ?? defaultGetter, + featureGetter: params.featureGetter ?? new DefaultFeatureGetter(), maxExtent: params.maxExtent, - // TODO assume EPSG:4326 ? sourceCoordinateSystem: params.sourceCoordinateSystem ?? CoordinateSystem.epsg4326, }; - this._featureGetter = featureGetter ?? new DefaultFeatureGetter(); } public async getFeatures(request: GetFeatureRequest): Promise { @@ -308,7 +311,7 @@ export default class StreamableFeatureSource extends FeatureSourceBase { return { features: [] }; } - const features = await this._featureGetter.getFeatures( + const features = await nonNull(this._options.featureGetter).getFeatures( new Extent(request.extent.crs, { east, north, south, west }), this._options, nonNull(this._targetCoordinateSystem), -- GitLab From 2a5daf8a0f96476dc00ea1f2747141bde23689af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Tue, 30 Sep 2025 16:46:31 +0200 Subject: [PATCH 31/43] test(FeatureSource): add tests --- .../sources/AggregateFeatureSource.test.ts | 95 ++++++ test/unit/sources/FileFeatureSource.test.ts | 65 ++-- test/unit/sources/StaticFeatureSource.test.ts | 285 +++++++++--------- .../sources/StreamableFeatureSource.test.ts | 6 +- 4 files changed, 273 insertions(+), 178 deletions(-) create mode 100644 test/unit/sources/AggregateFeatureSource.test.ts diff --git a/test/unit/sources/AggregateFeatureSource.test.ts b/test/unit/sources/AggregateFeatureSource.test.ts new file mode 100644 index 0000000000..fe027adc36 --- /dev/null +++ b/test/unit/sources/AggregateFeatureSource.test.ts @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ + +import { Feature } from 'ol'; +import { describe, expect, it, vitest } from 'vitest'; + +import type { FeatureSource } from '@giro3d/giro3d/sources/FeatureSource'; + +import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem'; +import Extent from '@giro3d/giro3d/core/geographic/Extent'; +import AggregateFeatureSource from '@giro3d/giro3d/sources/AggregateFeatureSource'; + +describe('initialize', () => { + it('should initialize all sub-sources', async () => { + // @ts-expect-error incomplete mock + const source1: FeatureSource = { + initialize: vitest.fn().mockReturnValue(Promise.resolve()), + }; + + // @ts-expect-error incomplete mock + const source2: FeatureSource = { + initialize: vitest.fn().mockReturnValue(Promise.resolve()), + }; + + const source = new AggregateFeatureSource({ + sources: [source1, source2], + }); + + await source.initialize({ + targetCoordinateSystem: CoordinateSystem.fromEpsg(4326), + }); + + expect(source1.initialize).toHaveBeenCalledExactlyOnceWith({ + targetCoordinateSystem: CoordinateSystem.fromEpsg(4326), + }); + + expect(source2.initialize).toHaveBeenCalledExactlyOnceWith({ + targetCoordinateSystem: CoordinateSystem.fromEpsg(4326), + }); + }); +}); + +describe('getFeatures', () => { + it('should query all sub-sources', async () => { + const feature1 = new Feature(); + const feature2 = new Feature(); + const feature3 = new Feature(); + const feature4 = new Feature(); + + // @ts-expect-error incomplete mock + const source1: FeatureSource = { + initialize: vitest.fn().mockReturnValue(Promise.resolve()), + getFeatures: vitest.fn().mockReturnValue({ features: [feature1, feature3] }), + }; + + // @ts-expect-error incomplete mock + const source2: FeatureSource = { + initialize: vitest.fn().mockReturnValue(Promise.resolve()), + getFeatures: vitest.fn().mockReturnValue({ features: [feature2, feature4] }), + }; + + const source = new AggregateFeatureSource({ + sources: [source1, source2], + }); + + await source.initialize({ + targetCoordinateSystem: CoordinateSystem.fromEpsg(4326), + }); + + const signal = new AbortController().signal; + const extent = new Extent(CoordinateSystem.fromEpsg(4325), { + west: 0, + east: 1, + north: 1, + south: 0, + }); + + const result = await source.getFeatures({ + extent, + signal, + }); + + expect(source1.getFeatures).toHaveBeenCalledExactlyOnceWith({ extent, signal }); + expect(source2.getFeatures).toHaveBeenCalledExactlyOnceWith({ extent, signal }); + + expect(result.features).toHaveLength(4); + expect(result.features).toContain(feature1); + expect(result.features).toContain(feature2); + expect(result.features).toContain(feature3); + expect(result.features).toContain(feature4); + }); +}); diff --git a/test/unit/sources/FileFeatureSource.test.ts b/test/unit/sources/FileFeatureSource.test.ts index 1e7cc82d05..5e2d766deb 100644 --- a/test/unit/sources/FileFeatureSource.test.ts +++ b/test/unit/sources/FileFeatureSource.test.ts @@ -7,53 +7,52 @@ import type FeatureFormat from 'ol/format/Feature'; import { Projection } from 'ol/proj'; +import { describe, expect, it, vitest } from 'vitest'; import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem'; import Extent from '@giro3d/giro3d/core/geographic/Extent'; import FileFeatureSource from '@giro3d/giro3d/sources/FileFeatureSource'; -describe('FileFeatureSource', () => { - describe('reload', () => { - it('should dispatch the updated event', () => { - const source = new FileFeatureSource({ - url: 'foo', - }); +describe('reload', () => { + it('should dispatch the updated event', () => { + const source = new FileFeatureSource({ + url: 'foo', + }); - const listener = jest.fn(); + const listener = vitest.fn(); - source.addEventListener('updated', listener); + source.addEventListener('updated', listener); - expect(listener).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); - source.reload(); + source.reload(); - expect(listener).toHaveBeenCalledTimes(1); - }); + expect(listener).toHaveBeenCalledTimes(1); }); +}); - describe('getFeatures', () => { - it('should load the file', async () => { - // @ts-expect-error incomplete implementation - const format: FeatureFormat = { - getType: () => 'json', - readProjection: () => new Projection({ code: 'EPSG:4326' }), - readFeatures: () => [], - }; - - const data = ''; - const getter = jest.fn(() => Promise.resolve(data)); - - const source = new FileFeatureSource({ - url: 'foo', - format, - getter, - }); +describe('getFeatures', () => { + it('should load the file', async () => { + // @ts-expect-error incomplete implementation + const format: FeatureFormat = { + getType: () => 'json', + readProjection: () => new Projection({ code: 'EPSG:4326' }), + readFeatures: () => [], + }; + + const data = ''; + const getter = vitest.fn(() => Promise.resolve(data)); + + const source = new FileFeatureSource({ + url: 'foo', + format, + getter, + }); - await source.initialize({ targetCoordinateSystem: CoordinateSystem.epsg4326 }); + await source.initialize({ targetCoordinateSystem: CoordinateSystem.epsg4326 }); - await source.getFeatures({ extent: Extent.WGS84 }); + await source.getFeatures({ extent: Extent.WGS84 }); - expect(getter).toHaveBeenCalledWith('foo', 'json'); - }); + expect(getter).toHaveBeenCalledWith('foo', 'json'); }); }); diff --git a/test/unit/sources/StaticFeatureSource.test.ts b/test/unit/sources/StaticFeatureSource.test.ts index 38e255b871..f875d289fe 100644 --- a/test/unit/sources/StaticFeatureSource.test.ts +++ b/test/unit/sources/StaticFeatureSource.test.ts @@ -6,226 +6,225 @@ import { Feature } from 'ol'; import { Point } from 'ol/geom'; +import { describe, beforeEach, it, expect, vitest } from 'vitest'; import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem'; import Extent from '@giro3d/giro3d/core/geographic/Extent'; import StaticFeatureSource from '@giro3d/giro3d/sources/StaticFeatureSource'; -describe('StaticFeatureSource', () => { - let sourceWithTransformation: StaticFeatureSource; - let sourceWithoutTransformation: StaticFeatureSource; +let sourceWithTransformation: StaticFeatureSource; +let sourceWithoutTransformation: StaticFeatureSource; - beforeEach(async () => { - sourceWithTransformation = new StaticFeatureSource({ - coordinateSystem: CoordinateSystem.epsg4326, - }); +beforeEach(async () => { + sourceWithTransformation = new StaticFeatureSource({ + coordinateSystem: CoordinateSystem.epsg4326, + }); - sourceWithoutTransformation = new StaticFeatureSource({ - coordinateSystem: CoordinateSystem.epsg4326, - }); + sourceWithoutTransformation = new StaticFeatureSource({ + coordinateSystem: CoordinateSystem.epsg4326, + }); - await sourceWithTransformation.initialize({ - targetCoordinateSystem: CoordinateSystem.epsg3857, - }); - await sourceWithoutTransformation.initialize({ - targetCoordinateSystem: CoordinateSystem.epsg4326, - }); + await sourceWithTransformation.initialize({ + targetCoordinateSystem: CoordinateSystem.epsg3857, + }); + await sourceWithoutTransformation.initialize({ + targetCoordinateSystem: CoordinateSystem.epsg4326, }); +}); - describe('addFeature', () => { - it('should throw if not initialized', () => { - const source = new StaticFeatureSource({ coordinateSystem: CoordinateSystem.epsg4326 }); +describe('addFeature', () => { + it('should throw if not initialized', () => { + const source = new StaticFeatureSource({ coordinateSystem: CoordinateSystem.epsg4326 }); - expect(() => source.addFeature(new Feature())).toThrow( - /this source has not been initialized/, - ); - }); + expect(() => source.addFeature(new Feature())).toThrow( + /this source has not been initialized/, + ); + }); - it('should transform the geometry if the source and target coordinate systems differ', () => { - const geometry = new Point([3.24, 45.23]); - const feature = new Feature(geometry); + it('should transform the geometry if the source and target coordinate systems differ', () => { + const geometry = new Point([3.24, 45.23]); + const feature = new Feature(geometry); - sourceWithTransformation.addFeature(feature); + sourceWithTransformation.addFeature(feature); - expect(sourceWithTransformation.features).toEqual([feature]); + expect(sourceWithTransformation.features).toEqual([feature]); - const [x, y] = geometry.getCoordinates(); + const [x, y] = geometry.getCoordinates(); - expect(x).toBeCloseTo(360675.15); - expect(y).toBeCloseTo(5657803.247); - }); + expect(x).toBeCloseTo(360675.15); + expect(y).toBeCloseTo(5657803.247); }); +}); - describe('constructor', () => { - it('should honot the list of features passed', async () => { - const features = [new Feature(new Point([0, 0]))]; +describe('constructor', () => { + it('should honot the list of features passed', async () => { + const features = [new Feature(new Point([0, 0]))]; - const source = new StaticFeatureSource({ - features, - coordinateSystem: CoordinateSystem.epsg4326, - }); + const source = new StaticFeatureSource({ + features, + coordinateSystem: CoordinateSystem.epsg4326, + }); - await source.initialize({ targetCoordinateSystem: CoordinateSystem.epsg4326 }); + await source.initialize({ targetCoordinateSystem: CoordinateSystem.epsg4326 }); - expect(source.features).toHaveLength(1); - expect(source.features[0]).toBe(features[0]); - }); + expect(source.features).toHaveLength(1); + expect(source.features[0]).toBe(features[0]); }); +}); - describe('addFeatures', () => { - it('should throw if not initialized', () => { - const source = new StaticFeatureSource({ coordinateSystem: CoordinateSystem.epsg4326 }); +describe('addFeatures', () => { + it('should throw if not initialized', () => { + const source = new StaticFeatureSource({ coordinateSystem: CoordinateSystem.epsg4326 }); - expect(() => source.addFeatures([])).toThrow(/this source has not been initialized/); - }); + expect(() => source.addFeatures([])).toThrow(/this source has not been initialized/); + }); - it('should assign a unique ID to each feature', () => { - const f0 = new Feature(new Point([0, 0])); - const f1 = new Feature(new Point([0, 0])); + it('should assign a unique ID to each feature', () => { + const f0 = new Feature(new Point([0, 0])); + const f1 = new Feature(new Point([0, 0])); - expect(f0.getId()).toBeUndefined(); - expect(f1.getId()).toBeUndefined(); + expect(f0.getId()).toBeUndefined(); + expect(f1.getId()).toBeUndefined(); - sourceWithTransformation.addFeatures([f0, f1]); + sourceWithTransformation.addFeatures([f0, f1]); - expect(sourceWithTransformation.features).toEqual([f0, f1]); + expect(sourceWithTransformation.features).toEqual([f0, f1]); - expect(f0.getId()).toBeDefined(); - expect(f1.getId()).toBeDefined(); - }); + expect(f0.getId()).toBeDefined(); + expect(f1.getId()).toBeDefined(); + }); - it('should transform the geometry if the source and target coordinate systems differ', () => { - const geometry0 = new Point([3.24, 45.23]); - const geometry1 = new Point([3.24, 45.23]); + it('should transform the geometry if the source and target coordinate systems differ', () => { + const geometry0 = new Point([3.24, 45.23]); + const geometry1 = new Point([3.24, 45.23]); - sourceWithTransformation.addFeatures([new Feature(geometry0), new Feature(geometry1)]); + sourceWithTransformation.addFeatures([new Feature(geometry0), new Feature(geometry1)]); - const [x0, y0] = geometry0.getCoordinates(); + const [x0, y0] = geometry0.getCoordinates(); - expect(x0).toBeCloseTo(360675.15); - expect(y0).toBeCloseTo(5657803.247); + expect(x0).toBeCloseTo(360675.15); + expect(y0).toBeCloseTo(5657803.247); - const [x1, y1] = geometry1.getCoordinates(); + const [x1, y1] = geometry1.getCoordinates(); - expect(x1).toBeCloseTo(360675.15); - expect(y1).toBeCloseTo(5657803.247); - }); + expect(x1).toBeCloseTo(360675.15); + expect(y1).toBeCloseTo(5657803.247); }); +}); - describe('clear', () => { - it('should raise the update event if some features were present', () => { - const listener = jest.fn(); +describe('clear', () => { + it('should raise the update event if some features were present', () => { + const listener = vitest.fn(); - sourceWithTransformation.addEventListener('updated', listener); + sourceWithTransformation.addEventListener('updated', listener); - sourceWithTransformation.clear(); + sourceWithTransformation.clear(); - expect(listener).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); - sourceWithTransformation.addFeature(new Feature(new Point([0, 0]))); + sourceWithTransformation.addFeature(new Feature(new Point([0, 0]))); - sourceWithTransformation.clear(); + sourceWithTransformation.clear(); - expect(sourceWithTransformation.features).toHaveLength(0); + expect(sourceWithTransformation.features).toHaveLength(0); - expect(listener).toHaveBeenCalled(); - }); + expect(listener).toHaveBeenCalled(); }); +}); - describe('removeFeature', () => { - it('should return true if the feature was actually removed', () => { - const feature = new Feature(new Point([3.24, 45.23])); +describe('removeFeature', () => { + it('should return true if the feature was actually removed', () => { + const feature = new Feature(new Point([3.24, 45.23])); - sourceWithTransformation.addFeature(feature); + sourceWithTransformation.addFeature(feature); - expect(sourceWithTransformation.removeFeature(new Feature())).toEqual(false); - expect(sourceWithTransformation.removeFeature(feature)).toEqual(true); - }); + expect(sourceWithTransformation.removeFeature(new Feature())).toEqual(false); + expect(sourceWithTransformation.removeFeature(feature)).toEqual(true); + }); - it('should raise the update event if the feature was actually removed', () => { - const feature = new Feature(new Point([3.24, 45.23])); + it('should raise the update event if the feature was actually removed', () => { + const feature = new Feature(new Point([3.24, 45.23])); - sourceWithTransformation.addFeature(feature); + sourceWithTransformation.addFeature(feature); - const listener = jest.fn(); - sourceWithTransformation.addEventListener('updated', listener); + const listener = vitest.fn(); + sourceWithTransformation.addEventListener('updated', listener); - sourceWithTransformation.removeFeature(feature); + sourceWithTransformation.removeFeature(feature); - expect(listener).toHaveBeenCalledTimes(1); - }); + expect(listener).toHaveBeenCalledTimes(1); }); +}); - describe('removeFeatures', () => { - it('should return true if the feature was actually removed', () => { - const feature = new Feature(new Point([3.24, 45.23])); +describe('removeFeatures', () => { + it('should return true if the feature was actually removed', () => { + const feature = new Feature(new Point([3.24, 45.23])); - sourceWithTransformation.addFeature(feature); + sourceWithTransformation.addFeature(feature); - expect(sourceWithTransformation.removeFeatures([new Feature()])).toEqual(false); - expect(sourceWithTransformation.removeFeatures([feature])).toEqual(true); + expect(sourceWithTransformation.removeFeatures([new Feature()])).toEqual(false); + expect(sourceWithTransformation.removeFeatures([feature])).toEqual(true); - expect(sourceWithTransformation.features).toHaveLength(0); - }); + expect(sourceWithTransformation.features).toHaveLength(0); + }); - it('should raise the update event if the feature was actually removed', () => { - const feature = new Feature(new Point([3.24, 45.23])); + it('should raise the update event if the feature was actually removed', () => { + const feature = new Feature(new Point([3.24, 45.23])); - sourceWithTransformation.addFeature(feature); + sourceWithTransformation.addFeature(feature); - const listener = jest.fn(); - sourceWithTransformation.addEventListener('updated', listener); + const listener = vitest.fn(); + sourceWithTransformation.addEventListener('updated', listener); - sourceWithTransformation.removeFeatures([feature]); + sourceWithTransformation.removeFeatures([feature]); - expect(listener).toHaveBeenCalledTimes(1); - }); + expect(listener).toHaveBeenCalledTimes(1); }); +}); - describe('getFeatures', () => { - it('should return an empty array if no features are present', async () => { - const result = await sourceWithoutTransformation.getFeatures({ extent: Extent.WGS84 }); +describe('getFeatures', () => { + it('should return an empty array if no features are present', async () => { + const result = await sourceWithoutTransformation.getFeatures({ extent: Extent.WGS84 }); - expect(result.features).toHaveLength(0); - }); - - it('should return all features that intersect the requested extent', async () => { - const sw = new Feature(new Point([-1, -1])); - const nw = new Feature(new Point([-1, 1])); - const ne = new Feature(new Point([1, 1])); - const se = new Feature(new Point([1, -1])); + expect(result.features).toHaveLength(0); + }); - sourceWithoutTransformation.addFeatures([sw, nw, ne, se]); + it('should return all features that intersect the requested extent', async () => { + const sw = new Feature(new Point([-1, -1])); + const nw = new Feature(new Point([-1, 1])); + const ne = new Feature(new Point([1, 1])); + const se = new Feature(new Point([1, -1])); - const fullExtent = await sourceWithoutTransformation.getFeatures({ - extent: Extent.WGS84, - }); + sourceWithoutTransformation.addFeatures([sw, nw, ne, se]); - expect(fullExtent.features).toEqual([sw, nw, ne, se]); + const fullExtent = await sourceWithoutTransformation.getFeatures({ + extent: Extent.WGS84, + }); - const westernHemisphere = await sourceWithoutTransformation.getFeatures({ - extent: new Extent(CoordinateSystem.epsg4326, -180, 0, -90, +90), - }); + expect(fullExtent.features).toEqual([sw, nw, ne, se]); - expect(westernHemisphere.features).toEqual([sw, nw]); + const westernHemisphere = await sourceWithoutTransformation.getFeatures({ + extent: new Extent(CoordinateSystem.epsg4326, -180, 0, -90, +90), + }); - const easternHemisphere = await sourceWithoutTransformation.getFeatures({ - extent: new Extent(CoordinateSystem.epsg4326, 0, +180, -90, +90), - }); + expect(westernHemisphere.features).toEqual([sw, nw]); - expect(easternHemisphere.features).toEqual([ne, se]); + const easternHemisphere = await sourceWithoutTransformation.getFeatures({ + extent: new Extent(CoordinateSystem.epsg4326, 0, +180, -90, +90), + }); - const northernHemisphere = await sourceWithoutTransformation.getFeatures({ - extent: new Extent(CoordinateSystem.epsg4326, -180, +180, 0, +90), - }); + expect(easternHemisphere.features).toEqual([ne, se]); - expect(northernHemisphere.features).toEqual([nw, ne]); + const northernHemisphere = await sourceWithoutTransformation.getFeatures({ + extent: new Extent(CoordinateSystem.epsg4326, -180, +180, 0, +90), + }); - const southernHemisphere = await sourceWithoutTransformation.getFeatures({ - extent: new Extent(CoordinateSystem.epsg4326, -180, +180, -90, 0), - }); + expect(northernHemisphere.features).toEqual([nw, ne]); - expect(southernHemisphere.features).toEqual([sw, se]); + const southernHemisphere = await sourceWithoutTransformation.getFeatures({ + extent: new Extent(CoordinateSystem.epsg4326, -180, +180, -90, 0), }); + + expect(southernHemisphere.features).toEqual([sw, se]); }); }); diff --git a/test/unit/sources/StreamableFeatureSource.test.ts b/test/unit/sources/StreamableFeatureSource.test.ts index cebbd2022a..24fc838228 100644 --- a/test/unit/sources/StreamableFeatureSource.test.ts +++ b/test/unit/sources/StreamableFeatureSource.test.ts @@ -4,12 +4,14 @@ * SPDX-License-Identifier: MIT */ +import { beforeEach, describe, expect, it, vitest } from 'vitest'; + import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem'; import Extent from '@giro3d/giro3d/core/geographic/Extent'; import StreamableFeatureSource from '@giro3d/giro3d/sources/StreamableFeatureSource'; -const queryBuilder = jest.fn(); -const getter = jest.fn(); +const queryBuilder = vitest.fn(); +const getter = vitest.fn(); let source: StreamableFeatureSource; -- GitLab From c6b32e7bd7346849b1604cb56c719491ed5aa2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Tue, 30 Sep 2025 16:57:13 +0200 Subject: [PATCH 32/43] docs(AggregateFeatureSource): add documentation --- src/sources/AggregateFeatureSource.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sources/AggregateFeatureSource.ts b/src/sources/AggregateFeatureSource.ts index 9862c48b88..b533c6a43b 100644 --- a/src/sources/AggregateFeatureSource.ts +++ b/src/sources/AggregateFeatureSource.ts @@ -15,6 +15,9 @@ export interface AggregateFeatureSourceOptions { sources: FeatureSource[]; } +/** + * A {@link FeatureSource} that aggregates multiple sub-sources behind a single interface. + */ export default class AggregateFeatureSource extends FeatureSourceBase { public override readonly type = 'AggregateFeatureSource' as const; public readonly isAggregateFeatureSource = true as const; -- GitLab From 3a413cbebb7d1eda151c5ca50941fdf268161c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Tue, 30 Sep 2025 16:57:49 +0200 Subject: [PATCH 33/43] refactor(StreamableFeatureSource): cleanup --- src/sources/StreamableFeatureSource.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/sources/StreamableFeatureSource.ts b/src/sources/StreamableFeatureSource.ts index 7613695fd9..06798e8b35 100644 --- a/src/sources/StreamableFeatureSource.ts +++ b/src/sources/StreamableFeatureSource.ts @@ -148,6 +148,10 @@ export interface StreamableFeatureSourceOptions { * @defaultValue EPSG:4326 */ sourceCoordinateSystem?: CoordinateSystem; + /** + * Limits the extent in which features are queried. If a feature requests is + * outside this extent, no query happens. + */ maxExtent?: Extent; } @@ -169,7 +173,7 @@ export abstract class FeatureGetterBase implements FeatureGetter { targetCoordinateSystem: CoordinateSystem, ): Promise; - protected async _fetchFeatures( + protected async fetchFeatures( extent: Extent, options: StreamableFeatureSourceOptions, targetCoordinateSystem: CoordinateSystem, @@ -213,7 +217,7 @@ export class DefaultFeatureGetter extends FeatureGetterBase { options: StreamableFeatureSourceOptions, targetCoordinateSystem: CoordinateSystem, ): Promise { - return await this._fetchFeatures(extent, options, targetCoordinateSystem); + return await this.fetchFeatures(extent, options, targetCoordinateSystem); } } @@ -231,6 +235,7 @@ export class CachedTiledFeatureGetter extends FeatureGetterBase { this._tileSize = params?.tileSize ?? 1000; this._cache = params?.cache ?? GlobalCache; } + public async getFeatures( extent: Extent, options: StreamableFeatureSourceOptions, @@ -257,7 +262,7 @@ export class CachedTiledFeatureGetter extends FeatureGetterBase { (y + 1) * this._tileSize, ); - tileFeatures = await super._fetchFeatures( + tileFeatures = await super.fetchFeatures( tileExtent, options, targetCoordinateSystem, @@ -273,7 +278,8 @@ export class CachedTiledFeatureGetter extends FeatureGetterBase { } /** - * A feature source that supports streaming features (e.g OGC API Features, etc) + * A feature source that supports streaming features from a + * remote server (e.g OGC API Features, etc) */ export default class StreamableFeatureSource extends FeatureSourceBase { public readonly isStreamableFeatureSource = true as const; @@ -300,12 +306,14 @@ export default class StreamableFeatureSource extends FeatureSourceBase { let east = request.extent.east; let south = request.extent.south; let north = request.extent.north; + if (this._options.maxExtent) { west = Math.max(west, this._options.maxExtent.west); east = Math.min(east, this._options.maxExtent.east); south = Math.max(south, this._options.maxExtent.south); north = Math.min(north, this._options.maxExtent.north); } + if (west >= east || south >= north) { // Empty extent return { features: [] }; -- GitLab From 72ddebd763589c821f738551075dda28403743fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Wed, 1 Oct 2025 11:47:17 +0200 Subject: [PATCH 34/43] refactor(DrapedFeatureCollection): cleanup --- src/entities/DrapedFeatureCollection.ts | 146 ++++-------------------- 1 file changed, 21 insertions(+), 125 deletions(-) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index c2309ab37b..88d8b04e90 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -92,19 +92,6 @@ export type DrapingMode = 'per-feature' | 'per-vertex' | 'none'; */ export type DrapingModeFunction = (feature: Feature) => DrapingMode; -/** - * This callback can be used to generate elevation for a given OpenLayer - * [Feature](https://openlayers.org/en/latest/apidoc/module-ol_Feature-Feature.html) (typically from its properties). - * - * - If a single number is returned, it will be used for all vertices in the geometry. - * - If an array is returned, each value will be used to determine the height of the corresponding vertex in the geometry. - * Note that the cardinality of the array must be the same as the number of vertices in the geometry. - */ -export type DrapedFeatureElevationCallback = ( - feature: Feature, - elevationProvider: ElevationProvider, -) => { geometry: SimpleGeometry; verticalOffset: number; mustReplace: boolean }; - /** * Either returns the same geometry if it already has a XYZ layout, or create an equivalent geometry in the XYZ layout. */ @@ -174,7 +161,13 @@ function getFeatureElevation(geometry: SimpleGeometry, provider: ElevationProvid return sample?.elevation ?? 0; } -type SupportedGeometry = Point | Polygon | LineString | MultiLineString | MultiPolygon; +type SupportedPerVertexGeometry = + | Polygon + | LineString + | MultiLineString + | MultiPoint + | MultiPolygon; +type SupportedGeometry = Point | SupportedPerVertexGeometry; function isGeometrySupported(g: Geometry): g is SupportedGeometry { switch (g.getType()) { @@ -190,9 +183,10 @@ function isGeometrySupported(g: Geometry): g is SupportedGeometry { } } -function applyPerVertexDraping< - G extends Polygon | LineString | MultiPoint | MultiLineString | MultiPolygon, ->(geometry: G, provider: ElevationProvider): G { +function applyPerVertexDraping( + geometry: G, + provider: ElevationProvider, +): G { const coordinates = geometry.getFlatCoordinates(); const stride = geometry.getStride(); @@ -224,89 +218,6 @@ function applyPerVertexDraping< return clone as G; } -export const defaultElevationCallback: DrapedFeatureElevationCallback = (feature, provider) => { - let result: ReturnType | null = null; - - const perFeature: (geometry: SimpleGeometry) => void = geometry => { - let center: Coordinate; - - if (geometry.getType() === 'Point') { - center = (geometry as Point).getCoordinates(); - } else if (geometry.getType() === 'Circle') { - center = (geometry as Circle).getCenter(); - } else { - center = getCenter(geometry.getExtent()); - } - - const [x, y] = center; - - const sample = provider.getElevationFast(x, y); - - result = { - verticalOffset: sample?.elevation ?? 0, - geometry, - mustReplace: false, - }; - }; - - const perVertex: ( - geometry: Polygon | LineString | MultiLineString | MultiPolygon, - ) => void = geometry => { - const coordinates = geometry.getFlatCoordinates(); - const stride = geometry.getStride(); - - // We have to possibly clone the geometry because OpenLayers does - // not allow changing the layout of an existing geometry, leading to issues. - const clone = cloneAsXYZIfRequired(geometry.clone()); - const coordinateCount = coordinates.length / stride; - const xyz = new Array(coordinateCount * 3); - - let k = 0; - - for (let i = 0; i < coordinates.length; i += stride) { - const x = coordinates[i + 0]; - const y = coordinates[i + 1]; - - const sample = provider.getElevationFast(x, y); - - const z = sample?.elevation ?? 0; - - xyz[k + 0] = x; - xyz[k + 1] = y; - xyz[k + 2] = z; - - k += 3; - } - - clone.setFlatCoordinates('XYZ', xyz); - - // TODO nous avons besoin d'une méthode pour savoir s'il faut remplacer le mesh ou non - result = { - verticalOffset: 0, - geometry: clone, - mustReplace: true, - }; - }; - - const geometry = nonNull(feature.getGeometry(), 'feature has no geometry') as SimpleGeometry; - - mapGeometry(geometry, { - processPoint: perFeature, - processCircle: perFeature, - processPolygon: perVertex, - processLineString: perVertex, - processMultiLineString: perVertex, - processMultiPolygon: perVertex, - }); - - if (result == null) { - // Just to please the typechecker - throw new Error(); - } - - return result; -}; - export type DrapedFeatureCollectionOptions = { /** * The data source. @@ -336,11 +247,6 @@ export type DrapedFeatureCollectionOptions = { * If a callback is given, it allows to extrude each feature individually. */ extrusionOffset?: FeatureExtrusionOffset | FeatureExtrusionOffsetCallback; - /** - * The elevation callback to compute elevations for a feature. - * @defaultValue {@link defaultElevationCallback} - */ - elevationCallback?: DrapedFeatureElevationCallback; /** * An optional material generator for shaded surfaces. */ @@ -389,7 +295,9 @@ type FeaturesEntry = { /** * Loads 3D features from a {@link FeatureSource} and displays them on top - * of a map, by taking terrain into account. + * of a map or map-like entity, by taking terrain into account. + * + * To drape features on custom entities, they must implement the {@link MapLike} interface. */ export default class DrapedFeatureCollection extends Entity3D { public override type = 'DrapedFeatureCollection' as const; @@ -398,7 +306,6 @@ export default class DrapedFeatureCollection extends Entity3D { private _map: MapLike | null = null; private readonly _drapingMode: DrapingMode | DrapingModeFunction; - private readonly _elevationCallback: DrapedFeatureElevationCallback; private readonly _geometryConverter: GeometryConverter; private readonly _activeTiles = new Map(); private readonly _objectOptions: ObjectOptions = { @@ -445,7 +352,6 @@ export default class DrapedFeatureCollection extends Entity3D { this._source = options.source; this._style = options.style; this._minLod = options.minLod ?? this._minLod; - this._elevationCallback = options.elevationCallback ?? defaultElevationCallback; this._eventHandlers = { onTileCreated: this.onTileCreated.bind(this), @@ -527,19 +433,12 @@ export default class DrapedFeatureCollection extends Entity3D { case 'PolygonMesh': case 'MultiPolygonMesh': { - // TODO - // const elevation = - // typeof this._elevation === 'function' - // ? this._elevation(feature) - // : this._elevation; - const extrusionOffset = this.getExtrusionOffset(feature); const options = { ...commonOptions, ...style, extrusionOffset, - // elevation, // TODO }; if (isPolygonMesh(obj)) { this._geometryConverter.updatePolygonMesh(obj, options); @@ -796,7 +695,6 @@ export default class DrapedFeatureCollection extends Entity3D { }; } - // TODO gérer l'élévation sur les lignes private getLineOptions(style?: FeatureStyle): LineOptions { return { ...style?.stroke, @@ -821,7 +719,9 @@ export default class DrapedFeatureCollection extends Entity3D { processLineString: p => converter.build(p, this.getLineOptions(style)), processMultiPolygon: p => converter.build(p, this.getPolygonOptions(feature, style)), processMultiLineString: p => converter.build(p, this.getLineOptions(style)), - // TODO faire le reste: + fallback: g => { + throw new Error(`unsupported geometry type: ${g.getType()}`); + }, }); if (result) { @@ -877,24 +777,20 @@ export default class DrapedFeatureCollection extends Entity3D { let shouldReplaceMesh = false; let verticalOffset = 0; + const map = nonNull(this._map); + if ( drapingMode === 'per-feature' || (drapingMode === 'per-vertex' && geometry.getType() === 'Point') ) { // Note that point is necessarily per feature, since there is only one vertex actualGeometry = geometry; - verticalOffset = getFeatureElevation(geometry, nonNull(this._map)); + verticalOffset = getFeatureElevation(geometry, map); } else if (drapingMode === 'per-vertex') { shouldReplaceMesh = true; - // TODO support multipoint ? - // @ts-expect-error cast - actualGeometry = applyPerVertexDraping(geometry, this._map); + actualGeometry = applyPerVertexDraping(geometry as SupportedPerVertexGeometry, map); } - // // TODO doit-on supporter plusieurs maps ? - // // TODO Callback de récupération de l'élévation - // const processed = this._elevationCallback(feature, this._maps[0]); - // We have to entirely recreate the mesh because // the vertices will have different elevations if (shouldReplaceMesh && existing.mesh) { -- GitLab From f278c48f23a776f6d88847b6bc6327b0c18be497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Wed, 1 Oct 2025 11:53:30 +0200 Subject: [PATCH 35/43] docs(StreamableFeatureSource): add documentation --- src/sources/StreamableFeatureSource.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/sources/StreamableFeatureSource.ts b/src/sources/StreamableFeatureSource.ts index 06798e8b35..131890080f 100644 --- a/src/sources/StreamableFeatureSource.ts +++ b/src/sources/StreamableFeatureSource.ts @@ -118,6 +118,9 @@ export const wfsBuilder: ( export type Getter = (url: string, type: Type) => Promise; +/** + * Getter for JSON, text, XML and ArrayBuffer data. + */ export const defaultGetter: Getter = (url, type) => { switch (type) { case 'arraybuffer': @@ -141,6 +144,10 @@ export interface StreamableFeatureSourceOptions { * @defaultValue {@link GeoJSON} */ format?: FeatureFormat; + /** + * The function to download and process the data. + * @defaultValue {@link defaultGetter} + */ getter?: Getter; featureGetter?: FeatureGetter; /** -- GitLab From 2de29c73963d9a34f9cccfbcbbad9994e1035c34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Wed, 1 Oct 2025 14:26:18 +0200 Subject: [PATCH 36/43] refactor(StreamableFeatureSource): use a strategy to split the input request into cacheable chunks --- src/sources/StreamableFeatureSource.ts | 272 +++++++++--------- src/sources/api.ts | 14 +- .../sources/StreamableFeatureSource.test.ts | 59 +++- 3 files changed, 210 insertions(+), 135 deletions(-) diff --git a/src/sources/StreamableFeatureSource.ts b/src/sources/StreamableFeatureSource.ts index 131890080f..a46d91e991 100644 --- a/src/sources/StreamableFeatureSource.ts +++ b/src/sources/StreamableFeatureSource.ts @@ -9,7 +9,6 @@ import type FeatureFormat from 'ol/format/Feature'; import type { Type } from 'ol/format/Feature'; import GeoJSON from 'ol/format/GeoJSON'; -import { MathUtils } from 'three'; import type { Cache } from '../core/Cache'; @@ -116,12 +115,12 @@ export const wfsBuilder: ( }; }; -export type Getter = (url: string, type: Type) => Promise; +export type StreamableFeatureSourceGetter = (url: string, type: Type) => Promise; /** * Getter for JSON, text, XML and ArrayBuffer data. */ -export const defaultGetter: Getter = (url, type) => { +export const defaultGetter: StreamableFeatureSourceGetter = (url, type) => { switch (type) { case 'arraybuffer': return Fetcher.arrayBuffer(url); @@ -148,141 +147,85 @@ export interface StreamableFeatureSourceOptions { * The function to download and process the data. * @defaultValue {@link defaultGetter} */ - getter?: Getter; - featureGetter?: FeatureGetter; + getter?: StreamableFeatureSourceGetter; + /** + * Enable caching of downloaded features. + * @defaultValue true + */ + enableCaching?: boolean; + /** + * The cache to use. + * @defaultValue {@link GlobalCache} + */ + cache?: Cache; + /** + * The loading strategy. + * @defaultValue {@link defaultLoadingStrategy} + */ + loadingStrategy?: StreamableFeatureSourceLoadingStrategy; /** * The source coordinate system. * @defaultValue EPSG:4326 */ sourceCoordinateSystem?: CoordinateSystem; /** - * Limits the extent in which features are queried. If a feature requests is + * Limits the extent in which features are queried. If a feature request is * outside this extent, no query happens. + * @defaultValue `null` */ - maxExtent?: Extent; -} - -/** - * Interface for StreamableFeatureSource feature getter. - */ -export interface FeatureGetter { - getFeatures( - extent: Extent, - options: StreamableFeatureSourceOptions, - targetCoordinateSystem: CoordinateSystem, - ): Promise; + extent?: Extent | null; } -export abstract class FeatureGetterBase implements FeatureGetter { - public abstract getFeatures( - extent: Extent, - options: StreamableFeatureSourceOptions, - targetCoordinateSystem: CoordinateSystem, - ): Promise; - - protected async fetchFeatures( - extent: Extent, - options: StreamableFeatureSourceOptions, - targetCoordinateSystem: CoordinateSystem, - ): Promise { - const sourceCoordinateSystem = nonNull(options.sourceCoordinateSystem); - const getter = nonNull(options.getter); - const format = nonNull(options.format); - - const url = options.queryBuilder({ - extent: extent, - sourceCoordinateSystem: sourceCoordinateSystem, - }); - - if (!url) { - return []; - } - - const data = await getter(url.toString(), format.getType()); - - const features = format.readFeatures(data) as Feature[]; - - const getFeatureId = (feature: Feature): number | string => { - return ( - feature.getId() ?? feature.get('id') ?? feature.get('fid') ?? feature.get('ogc_fid') - ); - }; - - return await processFeatures(features, sourceCoordinateSystem, targetCoordinateSystem, { - getFeatureId, - }); - } -} +export type StreamableFeatureSourceLoadingStrategy = (request: GetFeatureRequest) => { + requests: GetFeatureRequest[]; +}; /** - * The default StreamableFeatureSource feature getter. - * Directly queries the features from the datasource. + * A loading strategy that process the entire input request without any filtering or splitting. */ -export class DefaultFeatureGetter extends FeatureGetterBase { - public async getFeatures( - extent: Extent, - options: StreamableFeatureSourceOptions, - targetCoordinateSystem: CoordinateSystem, - ): Promise { - return await this.fetchFeatures(extent, options, targetCoordinateSystem); - } -} +export const defaultLoadingStrategy: StreamableFeatureSourceLoadingStrategy = request => ({ + requests: [request], +}); /** - * Cached/tiled StreamableFeatureSource feature getter. - * Queries the features from the datasource in tiles and caches the tiles. + * Splits the input request into a regular grid of requests to improves caching. */ -export class CachedTiledFeatureGetter extends FeatureGetterBase { - private readonly _tileSize: number; - private readonly _cacheKey = MathUtils.generateUUID(); - private readonly _cache: Cache; - - public constructor(params?: { tileSize?: number; cache?: Cache }) { - super(); - this._tileSize = params?.tileSize ?? 1000; - this._cache = params?.cache ?? GlobalCache; - } - - public async getFeatures( - extent: Extent, - options: StreamableFeatureSourceOptions, - targetCoordinateSystem: CoordinateSystem, - ): Promise { - const xmin = Math.floor(extent.west / this._tileSize); - const xmax = Math.ceil((extent.east + 1) / this._tileSize); - const ymin = Math.floor(extent.south / this._tileSize); - const ymax = Math.ceil((extent.north + 1) / this._tileSize); - - const features = []; +export const tiledLoadingStrategy: (params?: { + /** + * The size of the tiles in the grid. Expressed in CRS units (typically meters). + * @defaultValue 1000 + */ + tileSize?: number; +}) => StreamableFeatureSourceLoadingStrategy = params => { + const tileSize = params?.tileSize ?? 1000; + return request => { + const extent = request.extent; + const xmin = Math.floor(extent.west / tileSize); + const xmax = Math.ceil(extent.east / tileSize); + const ymin = Math.floor(extent.south / tileSize); + const ymax = Math.ceil(extent.north / tileSize); + + const tileRequests: GetFeatureRequest[] = []; for (let x = xmin; x < xmax; ++x) { for (let y = ymin; y < ymax; ++y) { - const key = `${this._cacheKey}-${x}/${y}`; - - let tileFeatures = this._cache.get(key) as Feature[]; - if (tileFeatures === undefined) { - const tileExtent = new Extent( - extent.crs, - x * this._tileSize, - (x + 1) * this._tileSize, - y * this._tileSize, - (y + 1) * this._tileSize, - ); - - tileFeatures = await super.fetchFeatures( - tileExtent, - options, - targetCoordinateSystem, - ); - - this._cache.set(key, features); - } - features.push(...tileFeatures); + const tileExtent = new Extent( + extent.crs, + x * tileSize, + (x + 1) * tileSize, + y * tileSize, + (y + 1) * tileSize, + ); + + tileRequests.push({ + extent: tileExtent, + signal: request.signal, + }); } } - return features; - } -} + return { requests: tileRequests }; + }; +}; /** * A feature source that supports streaming features from a @@ -292,7 +235,7 @@ export default class StreamableFeatureSource extends FeatureSourceBase { public readonly isStreamableFeatureSource = true as const; public readonly type = 'StreamableFeatureSource' as const; - private readonly _options: StreamableFeatureSourceOptions; + private readonly _options: Required; public constructor(params: StreamableFeatureSourceOptions) { super(); @@ -300,12 +243,64 @@ export default class StreamableFeatureSource extends FeatureSourceBase { queryBuilder: params.queryBuilder, format: params.format ?? new GeoJSON(), getter: params.getter ?? defaultGetter, - featureGetter: params.featureGetter ?? new DefaultFeatureGetter(), - maxExtent: params.maxExtent, + loadingStrategy: params.loadingStrategy ?? defaultLoadingStrategy, + extent: params.extent ?? null, + cache: params.cache ?? GlobalCache, + enableCaching: params.enableCaching ?? true, sourceCoordinateSystem: params.sourceCoordinateSystem ?? CoordinateSystem.epsg4326, }; } + private async processRequest(request: GetFeatureRequest): Promise { + const url = this._options.queryBuilder({ + extent: request.extent, + sourceCoordinateSystem: this._options.sourceCoordinateSystem, + }); + + if (!url) { + return { features: [] }; + } + + const urlString = url.toString(); + + if (this._options.enableCaching) { + const cached = this._options.cache.get(urlString); + if (cached != null) { + return cached as GetFeatureResult; + } + } + + const { getter, format, sourceCoordinateSystem } = this._options; + const targetCoordinateSystem = nonNull(this._targetCoordinateSystem); + + const data = await getter(urlString, format.getType()); + + const features = format.readFeatures(data) as Feature[]; + + const getFeatureId = (feature: Feature): number | string => { + return ( + feature.getId() ?? feature.get('id') ?? feature.get('fid') ?? feature.get('ogc_fid') + ); + }; + + const processedFeatures = await processFeatures( + features, + sourceCoordinateSystem, + targetCoordinateSystem, + { + getFeatureId, + }, + ); + + const result: GetFeatureResult = { features: processedFeatures }; + + if (this._options.enableCaching) { + this._options.cache.set(urlString, result); + } + + return result; + } + public async getFeatures(request: GetFeatureRequest): Promise { this.throwIfNotInitialized(); @@ -314,11 +309,11 @@ export default class StreamableFeatureSource extends FeatureSourceBase { let south = request.extent.south; let north = request.extent.north; - if (this._options.maxExtent) { - west = Math.max(west, this._options.maxExtent.west); - east = Math.min(east, this._options.maxExtent.east); - south = Math.max(south, this._options.maxExtent.south); - north = Math.min(north, this._options.maxExtent.north); + if (this._options.extent) { + west = Math.max(west, this._options.extent.west); + east = Math.min(east, this._options.extent.east); + south = Math.max(south, this._options.extent.south); + north = Math.min(north, this._options.extent.north); } if (west >= east || south >= north) { @@ -326,12 +321,27 @@ export default class StreamableFeatureSource extends FeatureSourceBase { return { features: [] }; } - const features = await nonNull(this._options.featureGetter).getFeatures( - new Extent(request.extent.crs, { east, north, south, west }), - this._options, - nonNull(this._targetCoordinateSystem), - ); + const adjustedExtent = new Extent(request.extent.crs, { east, north, south, west }); + + const strategy = nonNull(this._options.loadingStrategy); + + const { requests } = strategy({ + extent: adjustedExtent, + signal: request.signal, + }); + + if (requests.length === 0) { + return { features: [] }; + } + + const promises: Promise[] = []; + + for (const subRequest of requests) { + promises.push(this.processRequest(subRequest)); + } + + const results = await Promise.all(promises); - return { features: features }; + return { features: results.flatMap(item => item.features) }; } } diff --git a/src/sources/api.ts b/src/sources/api.ts index e4216f467b..35c68ee0cd 100644 --- a/src/sources/api.ts +++ b/src/sources/api.ts @@ -5,7 +5,6 @@ */ import AggregateFeatureSource, { AggregateFeatureSourceOptions } from './AggregateFeatureSource'; -// import CoordinateSystem from '../core/geographic/coordinate-system/CoordinateSystem'; import AggregateImageSource from './AggregateImageSource'; import AggregatePointCloudSource, { AggregatePointCloudSourceOptions, @@ -53,6 +52,11 @@ import StaticImageSource, { import StreamableFeatureSource, { StreamableFeatureSourceOptions, StreamableFeatureSourceQueryBuilder, + StreamableFeatureSourceGetter, + StreamableFeatureSourceLoadingStrategy, + defaultLoadingStrategy, + tiledLoadingStrategy, + wfsBuilder, ogcApiFeaturesBuilder, } from './StreamableFeatureSource'; import TiledImageSource, { type TiledImageSourceOptions } from './TiledImageSource'; @@ -70,7 +74,12 @@ export { AggregateFeatureSourceOptions, AggregatePointCloudSource, AggregatePointCloudSourceOptions, - // CoordinateSystem, + StreamableFeatureSourceGetter, + StreamableFeatureSourceLoadingStrategy, + ogcApiFeaturesBuilder, + defaultLoadingStrategy, + tiledLoadingStrategy, + wfsBuilder, COPCSource, COPCSourceOptions, ChannelMapping, @@ -123,5 +132,4 @@ export { WmtsSource, WmtsSourceOptions, las, - ogcApiFeaturesBuilder, }; diff --git a/test/unit/sources/StreamableFeatureSource.test.ts b/test/unit/sources/StreamableFeatureSource.test.ts index 24fc838228..36cbe42417 100644 --- a/test/unit/sources/StreamableFeatureSource.test.ts +++ b/test/unit/sources/StreamableFeatureSource.test.ts @@ -6,9 +6,15 @@ import { beforeEach, describe, expect, it, vitest } from 'vitest'; +import type { GetFeatureRequest } from '@giro3d/giro3d/sources/FeatureSource'; +import type { StreamableFeatureSourceLoadingStrategy } from '@giro3d/giro3d/sources/StreamableFeatureSource'; + import CoordinateSystem from '@giro3d/giro3d/core/geographic/coordinate-system/CoordinateSystem'; import Extent from '@giro3d/giro3d/core/geographic/Extent'; -import StreamableFeatureSource from '@giro3d/giro3d/sources/StreamableFeatureSource'; +import StreamableFeatureSource, { + defaultLoadingStrategy, + tiledLoadingStrategy, +} from '@giro3d/giro3d/sources/StreamableFeatureSource'; const queryBuilder = vitest.fn(); const getter = vitest.fn(); @@ -42,6 +48,29 @@ describe('getFeatures', () => { expect(getter).toHaveBeenCalledWith('http://example.com/', 'json'); }); + it('should use the provided loading strategy', async () => { + const strategy: StreamableFeatureSourceLoadingStrategy = vitest + .fn() + .mockReturnValue({ requests: [] }); + + source = new StreamableFeatureSource({ + sourceCoordinateSystem: CoordinateSystem.epsg4326, + queryBuilder, + getter, + loadingStrategy: strategy, + }); + + await source.initialize({ targetCoordinateSystem: CoordinateSystem.epsg4326 }); + + const extent = Extent.WGS84; + + await source.getFeatures({ extent }); + + expect(strategy).toHaveBeenCalledWith({ + extent, + }); + }); + it('should drop the query if the query builder returns undefined', async () => { const extent = Extent.WGS84; @@ -52,3 +81,31 @@ describe('getFeatures', () => { expect(getter).not.toHaveBeenCalled(); }); }); + +describe('tiledLoadingStrategy', () => { + it('should split the input request according to the tile size', () => { + const strategy = tiledLoadingStrategy({ tileSize: 1 }); + const request: GetFeatureRequest = { + extent: Extent.WGS84, + }; + const result = strategy(request); + + expect(result.requests).toHaveLength(360 * 180); + }); +}); + +describe('defaultLoadingStrategy', () => { + it('should return the same input', () => { + const request: GetFeatureRequest = { + extent: Extent.WGS84, + signal: {} as AbortSignal, + }; + const result = defaultLoadingStrategy(request); + + expect(result.requests).toHaveLength(1); + expect(result.requests[0]).toEqual({ + extent: request.extent, + signal: request.signal, + }); + }); +}); -- GitLab From 652f0d07e347410aaed79c3c2a161ec44bbefc14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Wed, 1 Oct 2025 15:20:43 +0200 Subject: [PATCH 37/43] fix(Map): getElevationFast() returns undefined if no elevation layer is present --- src/entities/Map.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/entities/Map.ts b/src/entities/Map.ts index a7001dc211..ad3adab27b 100644 --- a/src/entities/Map.ts +++ b/src/entities/Map.ts @@ -1965,7 +1965,12 @@ class Map } public getElevationFast(x: number, y: number): ElevationSample | undefined { - const elevationLayer = this.getElevationLayers()[0]; + if (!this._hasElevationLayer) { + return undefined; + } + + const elevationLayers = this.getElevationLayers(); + const elevationLayer = elevationLayers[0]; if (!elevationLayer.visible) { return undefined; -- GitLab From b4e559d0517094cc0a58398d509a1f0fd393a27b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Wed, 1 Oct 2025 15:21:30 +0200 Subject: [PATCH 38/43] fix(DrapedFeatureCollection): fix incorect event for elevation-loaded --- src/entities/DrapedFeatureCollection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index 88d8b04e90..6353eaeac6 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -322,7 +322,7 @@ export default class DrapedFeatureCollection extends Entity3D { private readonly _eventHandlers: { onTileCreated: EventHandler; onTileDeleted: EventHandler; - onElevationLoaded: EventHandler; + onElevationLoaded: EventHandler; onSourceUpdated: EventHandler; onTextureLoaded: () => void; }; -- GitLab From 50ec4e6753fbff226f43b20efe6869dce0316ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Mon, 6 Oct 2025 10:08:37 +0200 Subject: [PATCH 39/43] feat(DrapedFeatureCollection): don't register tiles if entity is frozen --- src/entities/DrapedFeatureCollection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index 6353eaeac6..dfdb1a0bc9 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -597,9 +597,10 @@ export default class DrapedFeatureCollection extends Entity3D { } private registerTile(tile: Tile, forceRecreateMeshes = false): void { - if (!this.visible) { + if (!this.visible || this.frozen) { return; } + if (!this._activeTiles.has(tile.id) || forceRecreateMeshes) { this._activeTiles.set(tile.id, tile); this._sortedTiles = null; -- GitLab From 6c15ac970f2dc5da587ea2bc6b3ebdca39883763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Mon, 6 Oct 2025 10:09:10 +0200 Subject: [PATCH 40/43] feat(DrapedFeatureCollection): add missing configuration for inspector --- src/entities/DrapedFeatureCollection.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index dfdb1a0bc9..f6a32ba813 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -912,7 +912,12 @@ export default class DrapedFeatureCollection extends Entity3D { class DrapedFeatureCollectionInspector extends EntityInspector { public constructor(gui: GUI, instance: Instance, entity: DrapedFeatureCollection) { - super(gui, instance, entity); + super(gui, instance, entity, { + visibility: true, + opacity: true, + boundingBoxColor: true, + boundingBoxes: true, + }); this.addController(entity, 'loadedFeatures'); } -- GitLab From 0c08f57e82ce590e5ea8ef731d3cdc002f55310f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Mon, 6 Oct 2025 10:09:43 +0200 Subject: [PATCH 41/43] feat(DrapedFeatureCollection): reload features when elevation layer is added/removed --- src/entities/DrapedFeatureCollection.ts | 44 +++++++++++++++++++++---- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index f6a32ba813..2e46e244e2 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -19,6 +19,7 @@ import type { FeatureExtrusionOffset, FeatureExtrusionOffsetCallback } from '../ import type Extent from '../core/geographic/Extent'; import type HasDefaultPointOfView from '../core/HasDefaultPointOfView'; import type Instance from '../core/Instance'; +import type Layer from '../core/layer/Layer'; import type PointOfView from '../core/PointOfView'; import type { BaseOptions, @@ -43,6 +44,7 @@ import { type PointMaterialGenerator, type SurfaceMaterialGenerator, } from '../core/FeatureTypes'; +import { isElevationLayer } from '../core/layer/ElevationLayer'; import EntityInspector from '../gui/EntityInspector'; import EntityPanel from '../gui/EntityPanel'; import GeometryConverter from '../renderer/geometries/GeometryConverter'; @@ -62,6 +64,8 @@ const tmpSphere = new Sphere(); interface MapLikeEventMap { 'elevation-loaded': { tile: Tile }; + 'layer-added': { layer: Layer }; + 'layer-removed': { layer: Layer }; 'tile-created': { tile: Tile }; 'tile-deleted': { tile: Tile }; } @@ -322,6 +326,8 @@ export default class DrapedFeatureCollection extends Entity3D { private readonly _eventHandlers: { onTileCreated: EventHandler; onTileDeleted: EventHandler; + onLayerAdded: EventHandler; + onLayerRemoved: EventHandler; onElevationLoaded: EventHandler; onSourceUpdated: EventHandler; onTextureLoaded: () => void; @@ -359,6 +365,8 @@ export default class DrapedFeatureCollection extends Entity3D { onElevationLoaded: this.onElevationLoaded.bind(this), onTextureLoaded: this.notifyChange.bind(this), onSourceUpdated: this.onSourceUpdated.bind(this), + onLayerAdded: this.onLayerAdded.bind(this), + onLayerRemoved: this.onLayerRemoved.bind(this), }; this._geometryConverter = new GeometryConverter({ @@ -577,10 +585,20 @@ export default class DrapedFeatureCollection extends Entity3D { public override updateVisibility(): void { super.updateVisibility(); - if (this.visible && this._map) { - this._map.traverseTiles(tile => { - this.registerTile(tile); - }); + if (this.visible) { + this.registerAllTiles(); + } + } + + private onLayerAdded({ layer }: MapLikeEventMap['layer-added']): void { + if (isElevationLayer(layer)) { + this.registerAllTiles(true); + } + } + + private onLayerRemoved({ layer }: MapLikeEventMap['layer-removed']): void { + if (isElevationLayer(layer)) { + this.registerAllTiles(true); } } @@ -596,6 +614,14 @@ export default class DrapedFeatureCollection extends Entity3D { this.registerTile(tile, true); } + private registerAllTiles(forceRecreateMeshes = false): void { + if (this._map) { + this._map.traverseTiles(tile => { + this.registerTile(tile, forceRecreateMeshes); + }); + } + } + private registerTile(tile: Tile, forceRecreateMeshes = false): void { if (!this.visible || this.frozen) { return; @@ -608,14 +634,18 @@ export default class DrapedFeatureCollection extends Entity3D { if (tile.lod >= this._minLod) { this.loadFeaturesOnExtent(tile.extent).then(features => { if (this._activeTiles.has(tile.id)) { - this.loadMeshes(features, tile.lod); + this.loadMeshes(features, tile.lod, forceRecreateMeshes); } }); } } } - private loadMeshes(features: Readonly, lod: number): void { + private loadMeshes( + features: Readonly, + lod: number, + forceRecreateMeshes = false, + ): void { for (const feature of features) { const geometry = feature.getGeometry(); @@ -637,7 +667,7 @@ export default class DrapedFeatureCollection extends Entity3D { } const existing = nonNull(this._features.get(id)); - if (!existing.mesh || existing.sampledLod < lod) { + if (forceRecreateMeshes || !existing.mesh || existing.sampledLod < lod) { this.loadFeatureMesh(id, existing); existing.sampledLod = lod; } -- GitLab From 48083e3766d972f0714434f0fd970d07a13f073e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Mon, 6 Oct 2025 10:26:28 +0200 Subject: [PATCH 42/43] feat(Map): add layer-visibility-changed event --- src/entities/Map.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/entities/Map.ts b/src/entities/Map.ts index ad3adab27b..55b6158663 100644 --- a/src/entities/Map.ts +++ b/src/entities/Map.ts @@ -387,6 +387,8 @@ export interface MapEventMap extends Entity3DEventMap { 'layer-added': { layer: Layer }; /** Fires when a layer is removed from the map. */ 'layer-removed': { layer: Layer }; + /** Fires when the visibility of a layer present on this map changes. */ + 'layer-visibility-changed': { layer: Layer }; /** Fires when elevation data has changed on a specific extent of the map. */ 'elevation-changed': { extent: Extent }; /** Fires when (final, non-interim) elevation data has been loaded for a specific tile */ @@ -1772,6 +1774,8 @@ class Map this.updateGlobalMinMax(); } + this.dispatchEvent({ type: 'layer-visibility-changed', layer: event.target }); + this.traverseTiles(tile => { tile.onLayerVisibilityChanged(event.target); }); -- GitLab From e8782f38f34fa661ce3e1e949a533da46a5c4941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Mon, 6 Oct 2025 10:26:57 +0200 Subject: [PATCH 43/43] feat(DrapedFeatureCollection): detect elevation layer visibility changes --- examples/draped_feature_collection.html | 10 ++++ examples/draped_feature_collection.js | 63 +++++++++++++++++++------ src/entities/DrapedFeatureCollection.ts | 15 ++++++ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/examples/draped_feature_collection.html b/examples/draped_feature_collection.html index 291fac8859..6100f24a10 100644 --- a/examples/draped_feature_collection.html +++ b/examples/draped_feature_collection.html @@ -12,6 +12,16 @@ tags: [features, draping, ign, map]
Parameters
+ +
+ + +
+
diff --git a/examples/draped_feature_collection.js b/examples/draped_feature_collection.js index 5a414bf142..a7cdc88078 100644 --- a/examples/draped_feature_collection.js +++ b/examples/draped_feature_collection.js @@ -125,6 +125,9 @@ function loadDrapedFeatures() { loadDrapedFeatures(); +/** @type {ElevationLayer | null} */ +let elevationLayer = null; + // Let's build the elevation layer from the WMTS capabilities WmtsSource.fromCapabilities(url, { layer: 'ELEVATION.ELEVATIONGRIDCOVERAGE.HIGHRES', @@ -132,21 +135,21 @@ WmtsSource.fromCapabilities(url, { noDataValue, }) .then(elevationWmts => { - map.addLayer( - new ElevationLayer({ - name: 'elevation', - extent: map.extent, - // We don't need the full resolution of terrain - // because we are not using any shading. This will save a lot of memory - // and make the terrain faster to load. - resolutionFactor: 1 / 2, - minmax: { min: 0, max: 5000 }, - noDataOptions: { - replaceNoData: false, - }, - source: elevationWmts, - }), - ); + elevationLayer = new ElevationLayer({ + name: 'elevation', + extent: map.extent, + // We don't need the full resolution of terrain + // because we are not using any shading. This will save a lot of memory + // and make the terrain faster to load. + resolutionFactor: 1 / 2, + minmax: { min: 0, max: 5000 }, + noDataOptions: { + replaceNoData: false, + }, + source: elevationWmts, + }); + + map.addLayer(elevationLayer); }) .catch(console.error); @@ -196,4 +199,34 @@ instance.view.setControls(controls); Inspector.attach('inspector', instance); +bindDropDown('elevationMode', newMode => { + if (elevationLayer == null) { + return; + } + + const currentLayers = map.getElevationLayers(); + + switch (newMode) { + case 'enabled': + if (!currentLayers.includes(elevationLayer)) { + map.addLayer(elevationLayer); + } + elevationLayer.visible = true; + break; + case 'hidden': + if (!currentLayers.includes(elevationLayer)) { + map.addLayer(elevationLayer); + } + elevationLayer.visible = false; + break; + case 'disabled': + if (currentLayers.includes(elevationLayer)) { + map.removeLayer(elevationLayer); + } + break; + } + + instance.notifyChange(map); +}); + StatusBar.bind(instance); diff --git a/src/entities/DrapedFeatureCollection.ts b/src/entities/DrapedFeatureCollection.ts index 2e46e244e2..abbadd60d3 100644 --- a/src/entities/DrapedFeatureCollection.ts +++ b/src/entities/DrapedFeatureCollection.ts @@ -66,6 +66,7 @@ interface MapLikeEventMap { 'elevation-loaded': { tile: Tile }; 'layer-added': { layer: Layer }; 'layer-removed': { layer: Layer }; + 'layer-visibility-changed': { layer: Layer }; 'tile-created': { tile: Tile }; 'tile-deleted': { tile: Tile }; } @@ -328,6 +329,7 @@ export default class DrapedFeatureCollection extends Entity3D { onTileDeleted: EventHandler; onLayerAdded: EventHandler; onLayerRemoved: EventHandler; + onLayerVisibilityChanged: EventHandler; onElevationLoaded: EventHandler; onSourceUpdated: EventHandler; onTextureLoaded: () => void; @@ -367,6 +369,7 @@ export default class DrapedFeatureCollection extends Entity3D { onSourceUpdated: this.onSourceUpdated.bind(this), onLayerAdded: this.onLayerAdded.bind(this), onLayerRemoved: this.onLayerRemoved.bind(this), + onLayerVisibilityChanged: this.onLayerVisibilityChanged.bind(this), }; this._geometryConverter = new GeometryConverter({ @@ -549,6 +552,12 @@ export default class DrapedFeatureCollection extends Entity3D { map.addEventListener('tile-created', this._eventHandlers.onTileCreated); map.addEventListener('tile-deleted', this._eventHandlers.onTileDeleted); map.addEventListener('elevation-loaded', this._eventHandlers.onElevationLoaded); + map.addEventListener('layer-added', this._eventHandlers.onLayerAdded); + map.addEventListener('layer-removed', this._eventHandlers.onLayerRemoved); + map.addEventListener( + 'layer-visibility-changed', + this._eventHandlers.onLayerVisibilityChanged, + ); map.traverseTiles(tile => { this.registerTile(tile); @@ -602,6 +611,12 @@ export default class DrapedFeatureCollection extends Entity3D { } } + private onLayerVisibilityChanged({ layer }: MapLikeEventMap['layer-visibility-changed']): void { + if (isElevationLayer(layer)) { + this.registerAllTiles(true); + } + } + private onTileCreated({ tile }: MapLikeEventMap['tile-created']): void { this.registerTile(tile); } -- GitLab