@itwin/core-frontend
Version:
iTwin.js frontend components
311 lines • 14.6 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* 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
*/
import { assert, BeEvent, CompressedId64Set, Guid, IModelStatus, OpenMode } from "@itwin/core-bentley";
import { getPullChangesIpcChannel, IModelError, } from "@itwin/core-common";
import { BriefcaseTxns } from "./BriefcaseTxns";
import { GraphicalEditingScope } from "./GraphicalEditingScope";
import { IModelApp } from "./IModelApp";
import { IModelConnection } from "./IModelConnection";
import { IpcApp } from "./IpcApp";
import { disposeTileTreesForGeometricModels } from "./tile/internal";
import { Viewport } from "./Viewport";
/** Keeps track of changes to models, buffering them until synchronization points.
* While a GraphicalEditingScope is open, the changes are buffered until the scope exits, at which point they are processed.
* Otherwise, the buffered changes are processed after undo/redo, commit, or pull+merge changes.
*/
class ModelChangeMonitor {
_editingScope;
_briefcase;
_deletedModels = new Set();
_modelIdToGuid = new Map();
_removals = [];
constructor(briefcase) {
this._briefcase = briefcase;
// Buffer updated geometry guids.
this._removals.push(briefcase.txns.onModelGeometryChanged.addListener((changes) => {
for (const change of changes) {
this._deletedModels.delete(change.id);
this._modelIdToGuid.set(change.id, change.guid);
}
}));
// Buffer deletions of models.
this._removals.push(briefcase.txns.onModelsChanged.addListener((changes) => {
if (changes.deleted) {
for (const id of CompressedId64Set.iterable(changes.deleted)) {
this._modelIdToGuid.delete(id);
this._deletedModels.add(id);
}
}
}));
// Outside of an editing scope, we want to update viewport contents after commit, undo/redo, or merging changes.
const maybeProcess = async () => {
if (this.editingScope)
return;
const modelIds = Array.from(this._modelIdToGuid.keys());
if (modelIds.length > 0)
await IModelApp.tileAdmin.purgeTileTrees(this._briefcase, modelIds);
this.processBuffered();
};
this._removals.push(briefcase.txns.onCommitted.addListener(maybeProcess));
this._removals.push(briefcase.txns.onReplayedExternalTxns.addListener(maybeProcess));
this._removals.push(briefcase.txns.onAfterUndoRedo.addListener(maybeProcess));
this._removals.push(briefcase.txns.onChangesPulled.addListener(maybeProcess));
}
async close() {
for (const removal of this._removals)
removal();
this._removals.length = 0;
if (this._editingScope) {
await this._editingScope.exit();
this._editingScope = undefined;
}
}
get editingScope() {
return this._editingScope;
}
async enterEditingScope() {
if (this._editingScope)
throw new Error("Cannot create an editing scope for an iModel that already has one");
this._editingScope = await GraphicalEditingScope.enter(this._briefcase);
const removeGeomListener = this._editingScope.onGeometryChanges.addListener((changes) => {
const modelIds = [];
for (const change of changes)
modelIds.push(change.id);
this.invalidateScenes(modelIds);
});
this._editingScope.onExited.addOnce((scope) => {
assert(scope === this._editingScope);
this._editingScope = undefined;
removeGeomListener();
this.processBuffered();
});
return this._editingScope;
}
processBuffered() {
const models = this._briefcase.models;
for (const [id, guid] of this._modelIdToGuid) {
const model = models.getLoaded(id)?.asGeometricModel;
if (model)
model.geometryGuid = guid;
}
const modelIds = new Set(this._modelIdToGuid.keys());
for (const deleted of this._deletedModels) {
modelIds.add(deleted);
models.unload(deleted);
}
this.invalidateScenes(modelIds);
disposeTileTreesForGeometricModels(modelIds, this._briefcase);
this._briefcase.onBufferedModelChanges.raiseEvent(modelIds);
this._modelIdToGuid.clear();
this._deletedModels.clear();
}
invalidateScenes(changedModels) {
for (const user of IModelApp.tileAdmin.tileUsers) {
if (user instanceof Viewport && user.iModel === this._briefcase) {
for (const modelId of changedModels) {
if (user.view.viewsModel(modelId)) {
user.invalidateScene();
user.setFeatureOverrideProviderChanged();
break;
}
}
}
}
}
}
/** Settings that can be used to control the behavior of [[Tool]]s that modify a [[BriefcaseConnection]].
* For example, tools that want to create new elements can consult the briefcase's editor tool settings to
* determine into which model and category to insert the elements.
* Specialized tools are free to ignore these settings.
* @see [[BriefcaseConnection.editorToolSettings]] to query or modify the current settings for a briefcase.
* @see [CreateElementTool]($editor-frontend) for an example of a tool that uses these settings.
* @beta
*/
export class BriefcaseEditorToolSettings {
_category;
_model;
/** An event raised just after the default [[category]] is changed. */
onCategoryChanged = new BeEvent();
/** An event raised just after the default [[model]] is changed. */
onModelChanged = new BeEvent();
/** The [Category]($backend) into which new elements should be inserted by default.
* Specialized tools are free to ignore this setting and instead use their own logic to select an appropriate category.
* @see [[onCategoryChanged]] to be notified when this property is modified.
* @see [CreateElementTool.targetCategory]($editor-frontend) for an example of a tool that uses this setting.
*/
get category() {
return this._category;
}
set category(category) {
const previousCategory = this.category;
if (category !== this.category) {
this._category = category;
this.onCategoryChanged.raiseEvent(previousCategory);
}
}
/** The [Model]($backend) into which new elements should be inserted by default.
* Specialized tools are free to ignore this setting and instead use their own logic to select an appropriate model.
* @see [[onModelChanged]] to be notified when this property is modified.
* @see [CreateElementTool.targetModelId]($editor-frontend) for an example of a tool that uses this setting.
*/
get model() {
return this._model;
}
set model(model) {
const previousModel = this.model;
if (model !== this.model) {
this._model = model;
this.onModelChanged.raiseEvent(previousModel);
}
}
}
/** A connection to an editable briefcase on the backend. This class uses [Ipc]($docs/learning/IpcInterface.md) to communicate
* to the backend and may only be used by [[IpcApp]]s.
* @public
*/
export class BriefcaseConnection extends IModelConnection {
_isClosed;
_modelsMonitor;
/** Default settings that can be used to control the behavior of [[Tool]]s that modify this briefcase.
* @beta
*/
editorToolSettings = new BriefcaseEditorToolSettings();
/** Manages local changes to the briefcase via [Txns]($docs/learning/InteractiveEditing.md). */
txns;
isBriefcaseConnection() { return true; }
/** The Guid that identifies the iTwin that owns this iModel. */
get iTwinId() { return super.iTwinId; } // GuidString | undefined for IModelConnection, but required for BriefcaseConnection
/** The Guid that identifies this iModel. */
get iModelId() { return super.iModelId; } // GuidString | undefined for IModelConnection, but required for BriefcaseConnection
constructor(props, openMode) {
super(props);
this._openMode = openMode;
this.txns = new BriefcaseTxns(this);
this._modelsMonitor = new ModelChangeMonitor(this);
if (OpenMode.ReadWrite === this._openMode)
this.txns.onAfterUndoRedo.addListener(async () => { await IModelApp.toolAdmin.restartPrimitiveTool(); });
}
/** Open a BriefcaseConnection to a [BriefcaseDb]($backend). */
static async openFile(briefcaseProps) {
const iModelProps = await IpcApp.appFunctionIpc.openBriefcase(briefcaseProps);
const connection = new this({ ...briefcaseProps, ...iModelProps }, briefcaseProps.readonly ? OpenMode.Readonly : OpenMode.ReadWrite);
IModelConnection.onOpen.raiseEvent(connection);
return connection;
}
/** Open a BriefcaseConnection to a [StandaloneDb]($backend)
* @note StandaloneDbs, by definition, may not push or pull changes. Attempting to do so will throw exceptions.
*/
static async openStandalone(filePath, openMode = OpenMode.ReadWrite, opts) {
const openResponse = await IpcApp.appFunctionIpc.openStandalone(filePath, openMode, opts);
const connection = new this(openResponse, openMode);
IModelConnection.onOpen.raiseEvent(connection);
return connection;
}
/** Returns `true` if [[close]] has already been called. */
get isClosed() { return this._isClosed === true; }
/**
* Close this BriefcaseConnection.
* @note make sure to call [[saveChanges]] before calling this method. Unsaved local changes are abandoned.
*/
async close() {
if (this.isClosed)
return;
await this._modelsMonitor.close();
this.beforeClose();
this.txns[Symbol.dispose]();
this._isClosed = true;
await IpcApp.appFunctionIpc.closeIModel(this._fileKey);
}
requireTimeline() {
if (this.iTwinId === Guid.empty)
throw new IModelError(IModelStatus.WrongIModel, "iModel has no timeline");
}
/** Query if there are any pending Txns in this briefcase that are waiting to be pushed. */
async hasPendingTxns() {
return this.txns.hasPendingTxns();
}
/** Commit pending changes to this briefcase.
* @param description Optional description of the changes.
*/
async saveChanges(description) {
await IpcApp.appFunctionIpc.saveChanges(this.key, description);
}
/** Abandon pending changes to this briefcase. */
async abandonChanges() {
await IpcApp.appFunctionIpc.abandonChanges(this.key);
}
/** Pull (and potentially merge if there are local changes) up to a specified changeset from iModelHub into this briefcase
* @param toIndex The changeset index to pull changes to. If `undefined`, pull all changes.
* @param options Options for pulling changes.
* @see [[BriefcaseTxns.onChangesPulled]] for the event dispatched after changes are pulled.
*/
async pullChanges(toIndex, options) {
const removeListeners = [];
const shouldReportProgress = !!options?.downloadProgressCallback;
if (shouldReportProgress) {
const handleProgress = (_evt, data) => {
options?.downloadProgressCallback?.(data);
};
const removeProgressListener = IpcApp.addListener(getPullChangesIpcChannel(this.iModelId), handleProgress);
removeListeners.push(removeProgressListener);
}
if (options?.abortSignal) {
const abort = () => void IpcApp.appFunctionIpc.cancelPullChangesRequest(this.key);
options?.abortSignal.addEventListener("abort", abort);
removeListeners.push(() => options?.abortSignal?.removeEventListener("abort", abort));
}
this.requireTimeline();
const ipcAppOptions = {
reportProgress: shouldReportProgress,
progressInterval: options?.progressInterval,
enableCancellation: !!options?.abortSignal,
};
try {
this.changeset = await IpcApp.appFunctionIpc.pullChanges(this.key, toIndex, ipcAppOptions);
}
finally {
removeListeners.forEach((remove) => remove());
}
}
/** Create a changeset from local Txns and push to iModelHub. On success, clear Txn table.
* @param description The description for the changeset
* @returns the changesetId of the pushed changes
* @see [[BriefcaseTxns.onChangesPushed]] for the event dispatched after changes are pushed.
*/
async pushChanges(description) {
this.requireTimeline();
return IpcApp.appFunctionIpc.pushChanges(this.key, description);
}
/** The current graphical editing scope, if one is in progress.
* @see [[enterEditingScope]] to begin graphical editing.
*/
get editingScope() {
return this._modelsMonitor.editingScope;
}
/** Return whether graphical editing is supported for this briefcase. It is not supported if the briefcase is read-only, or the briefcase contains a version of
* the BisCore ECSchema older than v0.1.11.
* @see [[enterEditingScope]] to enable graphical editing.
*/
async supportsGraphicalEditing() {
return IpcApp.appFunctionIpc.isGraphicalEditingSupported(this.key);
}
/** Begin a new graphical editing scope.
* @throws Error if an editing scope already exists or one could not be created.
* @see [[GraphicalEditingScope.exit]] to exit the scope.
* @see [[supportsGraphicalEditing]] to determine whether this method should be expected to succeed.
* @see [[editingScope]] to obtain the current editing scope, if one is in progress.
*/
async enterEditingScope() {
return this._modelsMonitor.enterEditingScope();
}
/** Strictly for tests - dispatched from ModelChangeMonitor.processBuffered.
* @internal
*/
onBufferedModelChanges = new BeEvent();
}
//# sourceMappingURL=BriefcaseConnection.js.map