UNPKG

@itwin/core-frontend

Version:
302 lines • 14.5 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ /** @packageDocumentation * @module IModelConnection */ // cspell:ignore GCRS import { assert, BeEvent, Dictionary, Logger, SortedArray, } from "@itwin/core-bentley"; import { GeoCoordStatus, IModelReadRpcInterface, } from "@itwin/core-common"; import { FrontendLoggerCategory } from "./common/FrontendLoggerCategory"; function compareXYAndZ(lhs, rhs) { return lhs.x - rhs.x || lhs.y - rhs.y || lhs.z - rhs.z; } function cloneXYAndZ(xyz) { return { x: xyz.x, y: xyz.y, z: xyz.z }; } /** Performs conversion of coordinates from one coordinate system to another. * A [[GeoConverter]] has a pair of these for converting between iModel coordinates and geographic coordinates. * Uses a cache to avoid repeatedly requesting the same points, and a batching strategy to avoid making frequent small requests. * The cache stores every point that was ever converted by [[convert]]. It is currently permitted to grow to unbounded size. * The batching works as follows: * When a conversion is requested via [[convert]], if all the requested points are in the cache, they are returned immediately. * Otherwise, any points not in the cache and not in the current in-flight request (if any) are placed onto the queue of pending requests. * A pending request is scheduled if one hasn't already been scheduled, via requestAnimationFrame. * In the animation frame callback, the pending requests are split into batches of no more than options.maxPointsPerRequest and dispatched to the backend. * Once the response is received, the results are loaded into and returned from the cache. * If more calls to convert occurred while the request was in flight, another request is dispatched. * @internal exported strictly for tests. */ export class CoordinateConverter { _cache; _state = "idle"; // The accumulated set of points to be converted by the next request. _pending; // The set of points that were included in the current in-flight request, if any. _inflight; // An event fired when the next request completes. _onCompleted = new BeEvent(); // Used for creating cache keys (XYAndZ) from XYZProps without having to allocate temporary objects. _scratchXYZ = { x: 0, y: 0, z: 0 }; _maxPointsPerRequest; _isIModelClosed; _requestPoints; // If true, [[dispatch]] will schedule another dispatch after it receives a response. // This is needed when all the points requested after the most recent dispatch were included in the currently-in-flight request - // _pending will be empty but new callers will be awaiting the results of the in-flight request. _redispatchOnCompletion = false; get isIdle() { return "idle" === this._state; } toXYAndZ(input, output) { if (Array.isArray(input)) { output.x = input[0] ?? 0; output.y = input[1] ?? 0; output.z = input[2] ?? 0; } else { output.x = input.x ?? 0; output.y = input.y ?? 0; output.z = input.z ?? 0; } return output; } constructor(opts) { this._maxPointsPerRequest = Math.max(1, opts.maxPointsPerRequest ?? 300); this._isIModelClosed = opts.isIModelClosed; this._requestPoints = opts.requestPoints; this._cache = new Dictionary(compareXYAndZ, cloneXYAndZ); this._pending = new SortedArray(compareXYAndZ, false, cloneXYAndZ); this._inflight = new SortedArray(compareXYAndZ, false, cloneXYAndZ); } async dispatch() { assert(this._state === "scheduled"); if (this._isIModelClosed() || this._pending.isEmpty) { this._state = "idle"; this._onCompleted.raiseEvent(); return; } this._state = "in-flight"; // Ensure subsequently-enqueued requests listen for the *next* response to be received. const onCompleted = this._onCompleted; this._onCompleted = new BeEvent(); // Pending requests are now in flight. Start a new list of pending requests. It's cheaper to swap than to allocate new objects. const inflight = this._pending; this._pending = this._inflight; assert(this._pending.isEmpty); this._inflight = inflight; // Split requests if necessary to avoid requesting more than the maximum allowed number of points. const promises = []; for (let i = 0; i < inflight.length; i += this._maxPointsPerRequest) { const requests = inflight.slice(i, i + this._maxPointsPerRequest).extractArray(); const promise = this._requestPoints(requests).then((results) => { if (this._isIModelClosed()) return; if (results.length !== requests.length) Logger.logError(`${FrontendLoggerCategory.Package}.geoservices`, `requested conversion of ${requests.length} points, but received ${results.length} points`); for (let j = 0; j < results.length; j++) { if (j < requests.length) this._cache.set(requests[j], results[j]); } }).catch((err) => { Logger.logException(`${FrontendLoggerCategory.Package}.geoservices`, err); }); promises.push(promise); } await Promise.all(promises); assert(this._state === "in-flight"); this._state = "idle"; this._inflight.clear(); // If any more pending conversions arrived while awaiting this request, schedule another request. if (!this._pending.isEmpty || this._redispatchOnCompletion) { this._redispatchOnCompletion = false; this.scheduleDispatch(); // eslint-disable-line @typescript-eslint/no-floating-promises } // Resolve promises of all callers who were awaiting this request. onCompleted.raiseEvent(); } // Add any points not present in cache to pending request list. // Return the number of points present in cache. enqueue(points) { let numInCache = 0; for (const point of points) { const xyz = this.toXYAndZ(point, this._scratchXYZ); if (this._cache.get(xyz)) ++numInCache; else if (this._inflight.contains(xyz)) this._redispatchOnCompletion = true; else this._pending.insert(xyz); } return numInCache; } // Obtain converted points from the cache. The assumption is that every point in `inputs` is already present in the cache. // Any point not present will be returned unconverted with an error status. getFromCache(inputs) { const outputs = []; for (const input of inputs) { const xyz = this.toXYAndZ(input, this._scratchXYZ); let output = this._cache.get(xyz); if (!output) output = { p: { ...xyz }, s: GeoCoordStatus.CSMapError }; outputs.push(output); } return outputs; } async scheduleDispatch() { if ("idle" === this._state) { this._state = "scheduled"; requestAnimationFrame(() => { this.dispatch(); // eslint-disable-line @typescript-eslint/no-floating-promises }); } return new Promise((resolve) => { this._onCompleted.addOnce(() => resolve()); }); } async convert(inputs) { const fromCache = this.enqueue(inputs); assert(fromCache >= 0); assert(fromCache <= inputs.length); if (fromCache === inputs.length) return { points: this.getFromCache(inputs), fromCache }; await this.scheduleDispatch(); return { points: this.getFromCache(inputs), fromCache }; } findCached(inputs) { const result = []; let missing; for (const input of inputs) { const key = this.toXYAndZ(input, this._scratchXYZ); const output = this._cache.get(key); result.push(output); if (!output) { if (!missing) missing = []; missing.push(input); } } return { result, missing }; } } /** An object capable of communicating with the backend to convert between coordinates in a geographic coordinate system and coordinates in an [[IModelConnection]]'s own coordinate system. * @see [[GeoServices.getConverter]] to obtain a converter. * @see [GeographicCRS]($common) for more information about geographic coordinate reference systems. * @public */ export class GeoConverter { _geoToIModel; _iModelToGeo; /** Used for removing this converter from GeoServices' cache after all requests are completed. * @internal */ onAllRequestsCompleted = new BeEvent(); /** @internal */ constructor(opts) { const isIModelClosed = opts.isIModelClosed; this._geoToIModel = new CoordinateConverter({ isIModelClosed, requestPoints: async (geoCoords) => opts.toIModelCoords({ source: opts.datum, geoCoords }), }); this._iModelToGeo = new CoordinateConverter({ isIModelClosed, requestPoints: async (iModelCoords) => opts.fromIModelCoords({ target: opts.datum, iModelCoords }), }); } /** Convert the specified geographic coordinates into iModel coordinates. */ async convertToIModelCoords(geoPoints) { const result = await this.getIModelCoordinatesFromGeoCoordinates(geoPoints); return result.iModelCoords; } /** Convert the specified iModel coordinates into geographic coordinates. */ async convertFromIModelCoords(iModelCoords) { const result = await this.getGeoCoordinatesFromIModelCoordinates(iModelCoords); return result.geoCoords; } /** @internal */ async getIModelCoordinatesFromGeoCoordinates(geoPoints) { const result = await this._geoToIModel.convert(geoPoints); this.checkCompletion(); return { iModelCoords: result.points, fromCache: result.fromCache, }; } /** @internal */ async getGeoCoordinatesFromIModelCoordinates(iModelPoints) { const result = await this._iModelToGeo.convert(iModelPoints); this.checkCompletion(); return { geoCoords: result.points, fromCache: result.fromCache, }; } checkCompletion() { if (this._geoToIModel.isIdle && this._iModelToGeo.isIdle) this.onAllRequestsCompleted.raiseEvent(); } /** @internal */ getCachedIModelCoordinatesFromGeoCoordinates(geoPoints) { return this._geoToIModel.findCached(geoPoints); } } /** The Geographic Services available for an [[IModelConnection]]. * @see [[IModelConnection.geoServices]] to obtain the GeoServices for a specific iModel. * @public */ export class GeoServices { _options; /** Each GeoConverter has its own independent request queue and cache of previously-converted points. * Some callers like RealityTileTree obtain a single GeoConverter and reuse it throughout their own lifetime. Therefore they benefit from both batching and caching, and * the cache gets deleted once the RealityTileTree becomes disused. * * Other callers like IModelConnection.spatialToCartographic obtain a new GeoConverter every time they need one, use it to convert a single point(!), and then discard the converter. * This entirely prevents batching - e.g., calling spatialToCartographic 20 times in one frame results in 20 http requests. * To address that, we cache each GeoConverter returned by getConverter until it has converted at least one point and has no further outstanding conversion requests. * In this way, the converter lives for as long as (and no longer than) any caller is awaiting conversion to/from its datum - it and its cache are deleted once it becomes disused. * This makes the coordinate caching generally less useful, but at least bounded - and maximizes batching of requests. */ _cache = new Map(); /** @internal */ constructor(options) { this._options = options; } /** @internal */ static createForIModel(iModel) { return new GeoServices({ isIModelClosed: () => iModel.isClosed, toIModelCoords: async (request) => { const rpc = IModelReadRpcInterface.getClientForRouting(iModel.routingContext.token); const response = await rpc.getIModelCoordinatesFromGeoCoordinates(iModel.getRpcProps(), request); return response.iModelCoords; }, fromIModelCoords: async (request) => { const rpc = IModelReadRpcInterface.getClientForRouting(iModel.routingContext.token); const response = await rpc.getGeoCoordinatesFromIModelCoordinates(iModel.getRpcProps(), request); return response.geoCoords; }, }); } /** Obtain a converter that can convert between a geographic coordinate system and the iModel's own coordinate system. * @param datumOrGCRS The name or JSON representation of the geographic coordinate system datum - for example, "WGS84". * @returns a converter, or `undefined` if the iModel is not open. * @note A [[BlankConnection]] has no connection to a backend, so it is never "open"; therefore it always returns `undefined`. */ getConverter(datumOrGCRS) { if (this._options.isIModelClosed()) return undefined; const datum = (typeof datumOrGCRS === "object" ? JSON.stringify(datumOrGCRS) : datumOrGCRS) ?? ""; let converter = this._cache.get(datum); if (!converter) { converter = new GeoConverter({ ...this._options, datum }); this._cache.set(datum, converter); converter.onAllRequestsCompleted.addOnce(() => { if (converter === this._cache.get(datum)) this._cache.delete(datum); }); } return converter; } } //# sourceMappingURL=GeoServices.js.map