@itwin/core-frontend
Version:
iTwin.js frontend components
956 lines • 55.1 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 Tools
*/
import { BentleyStatus, CompressedId64Set, Id64, OrderedId64Array } from "@itwin/core-bentley";
import { ColorDef, QueryRowFormat } from "@itwin/core-common";
import { ClipPlane, ClipPlaneContainment, ClipPrimitive, ClipUtilities, ClipVector, ConvexClipPlaneSet, Point2d, Point3d, Range2d } from "@itwin/core-geometry";
import { AccuDrawHintBuilder } from "../AccuDraw";
import { LocateFilterStatus, LocateResponse } from "../ElementLocateManager";
import { IModelApp } from "../IModelApp";
import { NotifyMessageDetails, OutputMessagePriority } from "../NotificationManager";
import { Pixel } from "../render/Pixel";
import { ViewRect } from "../common/ViewRect";
import { PrimitiveTool } from "./PrimitiveTool";
import { SelectionMethod } from "./SelectTool";
import { BeButton, BeButtonEvent, BeModifierKeys, CoreTools, EventHandled } from "./Tool";
import { ToolAssistance, ToolAssistanceImage, ToolAssistanceInputMethod } from "./ToolAssistance";
import { ToolSettings } from "./ToolSettings";
/** Identifies the source of the elements in the agenda.
* @public
*/
export var ModifyElementSource;
(function (ModifyElementSource) {
/** The source for the element is unknown - not caused by a modification command. */
ModifyElementSource[ModifyElementSource["Unknown"] = 0] = "Unknown";
/** The element is selected by the user. */
ModifyElementSource[ModifyElementSource["Selected"] = 1] = "Selected";
/** The element is processed because it is in the selection set. */
ModifyElementSource[ModifyElementSource["SelectionSet"] = 2] = "SelectionSet";
/** The element is selected by the user using drag box or crossing line selection. */
ModifyElementSource[ModifyElementSource["DragSelect"] = 3] = "DragSelect";
})(ModifyElementSource || (ModifyElementSource = {}));
/** The ElementAgenda class is used by [[ElementSetTool]] to hold the collection of elements it will operate on
* and to manage their hilite state.
* @see [[ElementSetTool]]
* @public
*/
export class ElementAgenda {
iModel;
/** The IDs of the elements in this agenda.
* @note Prefer methods like [[ElementAgenda.add]] instead of modifying directly.
*/
elements = [];
/** The group source identifiers for the elements in this agenda.
* @note Prefer methods like [[ElementAgenda.add]] instead of modifying directly.
*/
groupMarks = [];
manageHiliteState = true; // Whether entries are hilited/unhilited as they are added/removed...
constructor(iModel) {
this.iModel = iModel;
}
/** Get the source for the last group added to this agenda, if applicable. The "source" is merely an indication of what the collection of elements represents. */
getSource() {
return this.groupMarks.length === 0 ? ModifyElementSource.Unknown : this.groupMarks[this.groupMarks.length - 1].source;
}
/** Set the source for the last group added to this agenda. */
setSource(val) {
if (this.groupMarks.length > 0)
this.groupMarks[this.groupMarks.length - 1].source = val;
}
get isEmpty() { return this.length === 0; }
get count() { return this.length; }
get length() { return this.elements.length; }
/** Create [[OrderedId64Array]] from agenda. */
orderIds() {
const ids = new OrderedId64Array();
this.elements.forEach((id) => ids.insert(id));
return ids;
}
/** Create [[CompressedId64Set]] from agenda. */
compressIds() {
const ids = this.orderIds();
return CompressedId64Set.compressIds(ids);
}
/** Empties the agenda and clears hilite state when manageHiliteState is true. */
clear() {
this.setEntriesHiliteState(false);
this.elements.length = 0;
this.groupMarks.length = 0;
}
setEntriesHiliteState(onOff, groupStart = 0, groupEnd = 0) {
if (!this.manageHiliteState)
return;
const ss = this.iModel.selectionSet.elements.size > 0 ? this.iModel.selectionSet.elements : undefined;
if (undefined === ss && 0 === groupEnd) {
this.iModel.hilited[onOff ? "add" : "remove"]({ elements: this.elements });
return;
}
const shouldChangeHilite = (id, index) => {
if (undefined !== ss && ss.has(id))
return false; // Don't turn hilite on/off for elements in current selection set...
return (0 === groupEnd || (index >= groupStart && index < groupEnd));
};
const group = this.elements.filter((id, index) => shouldChangeHilite(id, index));
this.iModel.hilited[onOff ? "add" : "remove"]({ elements: group });
}
/** Removes the last group of elements added to this agenda. */
popGroup() {
if (this.groupMarks.length <= 1) {
this.clear();
return;
}
const group = this.groupMarks.pop();
if (undefined === group)
return;
this.setEntriesHiliteState(false, group.start, this.length); // make sure removed entries aren't left hilited...
this.elements.splice(group.start);
}
/** Return true if elementId is already in this agenda. */
has(id) { return this.elements.some((entry) => id === entry); }
/** Return true if elementId is already in this agenda. */
find(id) { return this.has(id); }
/** Add elements to this agenda. */
add(arg) {
const groupStart = this.length;
for (const id of Id64.iterable(arg))
if (!this.has(id))
this.elements.push(id);
if (groupStart === this.length)
return false;
this.groupMarks.push({ start: groupStart, source: ModifyElementSource.Unknown });
this.setEntriesHiliteState(true, groupStart, this.length);
return true;
}
removeOne(id) {
let pos = -1;
const elements = this.elements;
const groupMarks = this.groupMarks;
elements.some((entry, index) => {
if (id !== entry)
return false;
pos = index;
return true;
});
if (pos === -1)
return false;
if (1 === elements.length || (1 === groupMarks.length && ModifyElementSource.DragSelect !== groupMarks[groupMarks.length - 1].source)) {
this.clear();
return true;
}
const groupIndex = pos;
let groupStart = 0, groupEnd = 0;
let markToErase = 0;
let removeSingleEntry = false;
for (let iMark = 0; iMark < groupMarks.length; ++iMark) {
if (0 === groupEnd) {
if (iMark + 1 === groupMarks.length) {
markToErase = iMark;
removeSingleEntry = (ModifyElementSource.DragSelect === groupMarks[iMark].source);
groupStart = groupMarks[iMark].start;
groupEnd = elements.length;
}
else if (groupMarks[iMark].start <= groupIndex && groupMarks[iMark + 1].start > groupIndex) {
markToErase = iMark;
removeSingleEntry = (ModifyElementSource.DragSelect === groupMarks[iMark].source);
groupStart = groupMarks[iMark].start;
groupEnd = groupMarks[iMark + 1].start;
}
continue;
}
if (removeSingleEntry)
groupMarks[iMark].start -= 1; // Only removing single entry, not entire group...
else
groupMarks[iMark].start -= (groupEnd - groupStart); // Adjust indices...
}
if (removeSingleEntry) { // Only remove single entry...
this.setEntriesHiliteState(false, groupIndex, groupIndex + 1); // make sure removed entry isn't left hilited...
elements.splice(groupIndex, 1);
if (groupEnd === groupStart + 1)
groupMarks.splice(markToErase, 1);
return true;
}
this.setEntriesHiliteState(false, groupStart, groupEnd); // make sure removed entries aren't left hilited...
elements.splice(groupStart, groupEnd - groupStart);
groupMarks.splice(markToErase, 1);
return true;
}
remove(arg) {
if (0 === this.length)
return false;
if (0 === Id64.sizeOf(arg))
return false;
let changed = false;
for (const elId of Id64.iterable(arg))
if (this.removeOne(elId))
changed = true; // NOTE: Removes group associated with this element, not just a single entry...
return changed;
}
/** Add elements not currently in the agenda and remove elements currently in the agenda. */
invert(arg) {
if (0 === this.length)
return this.add(arg);
if (0 === Id64.sizeOf(arg))
return false;
const adds = [];
const removes = [];
for (const id of Id64.iterable(arg)) {
if (this.has(id))
removes.push(id);
else
adds.push(id);
}
if (adds.length === 0 && removes.length === 0)
return false;
removes.forEach((id) => this.removeOne(id));
if (adds.length > 0) {
const groupStart = this.length;
adds.forEach((id) => this.elements.push(id));
this.groupMarks.push({ start: groupStart, source: ModifyElementSource.Unknown });
this.setEntriesHiliteState(true, groupStart, this.length); // make sure added entries are hilited (when not also removing)...
}
return true;
}
}
/** The ElementSetTool class is a specialization of [[PrimitiveTool]] designed to unify operations on sets of elements.
* Use to query or modify existing elements as well as to create new elements from existing elements.
* Basic tool sequence:
* - Populate [[ElementSetTool.agenda]] with the element ids to query or modify.
* - Gather any additional input and if requested, enable dynamics to preview result.
* - Call [[ElementSetTool.processAgenda]] to apply operation to [[ElementSetTool.agenda]].
* - Call [[ElementSetTool.onProcessComplete]] to restart or exit.
* Common element sources:
* - Pre-selected elements from an active [[SelectionSet]].
* - Clicking in a view to identify elements using [[ElementLocateManager]].
* - Drag box and crossing line selection.
* Default behavior:
* - Identify a single element with left-click.
* - Immediately apply operation.
* - Restart.
* Sub-classes are required to opt-in to additional element sources, dynamics, AccuSnap, additional input, etc.
* @public
*/
export class ElementSetTool extends PrimitiveTool {
_agenda;
_useSelectionSet = false;
_processDataButtonUp = false;
/** The accept point for a selection set, drag select, or final located element. */
anchorPoint;
/** The button down location that initiated box or crossing line selection. */
dragStartPoint;
/** Get the [[ElementAgenda]] the tool will operate on. */
get agenda() {
if (undefined === this._agenda)
this._agenda = new ElementAgenda(this.iModel);
return this._agenda;
}
/** Convenience method to get current count from [[ElementSetTool.agenda]]. */
get currentElementCount() { return undefined !== this._agenda ? this._agenda.count : 0; }
/** Minimum required number of elements for tool to be able to complete.
* @return number to compare with [[ElementSetTool.currentElementCount]] to determine if more elements remain to be identified.
* @note A tool to subtract elements is an example where returning 2 would be necessary.
*/
get requiredElementCount() { return 1; }
/** Whether to allow element identification by drag box or crossing line selection.
* @return true to allow drag select as an element source when the ctrl key is down.
* @note Use ctrl+left drag for box selection. Inside/overlap is based on left/right direction (shift key inverts).
* @note Use ctrl+right drag for crossing line selection.
*/
get allowDragSelect() { return false; }
/** Support operations on groups/assemblies independent of selection scope.
* @return true to add or remove all members of an assembly from [[ElementSetTool.agenda]] when any single member is identified.
* @note Applies to [[ElementSetTool.getLocateCandidates]] only.
*/
get allowGroups() { return false; }
/** Whether [[ElementSetTool.agenda]] should be populated from an active selection set.
* @return true to allow selection sets as an element source.
* @note A selection set must have at least [[ElementSetTool.requiredElementCount]] elements to be considered.
*/
get allowSelectionSet() { return false; }
/** Whether to clear the active selection set for tools that return false for [[ElementSetTool.allowSelectionSet]].
* @return true to clear unsupported selection sets (desired default behavior).
* @note It is expected that the selection set be cleared before using [[ElementLocateManager]] to identify elements.
* This allows the element hilite to be a visual representation of the [[ElementSetTool.agenda]] contents.
*/
get clearSelectionSet() { return !this.allowSelectionSet; }
/** Whether a selection set should be processed immediately upon installation or require a data button to accept.
* @return false only for tools without settings or a need for confirmation.
* @note A tool to delete elements is an example where returning false could be desirable.
*/
get requireAcceptForSelectionSetOperation() { return true; }
/** Whether to begin dynamics for a selection set immediately or wait for a data button.
* @return false for tools that can start showing dynamics without any additional input.
* @note A tool to rotate elements by an active angle setting is an example where returning false could be desirable.
*/
get requireAcceptForSelectionSetDynamics() { return true; }
/** Whether original source of elements being modified was the active selection set.
* @return true when [[ElementSetTool.allowSelectionSet]] and active selection set count >= [[ElementSetTool.requiredElementCount]].
*/
get isSelectionSetModify() { return this._useSelectionSet; }
/** Whether drag box or crossing line selection is currently active.
* @return true when [[ElementSetTool.allowDragSelect]] and corner points are currently being defined.
*/
get isSelectByPoints() { return undefined !== this.dragStartPoint; }
/** Whether to continue selection of additional elements by holding the ctrl key down.
* @return true to continue the element identification phase beyond [[ElementSetTool.requiredElementCount]] by holding down the ctrl key.
*/
get controlKeyContinuesSelection() { return false; }
/** Whether to invert selection of elements identified with the ctrl key held down.
* @return true to allow ctrl to deselect already selected elements.
*/
get controlKeyInvertsSelection() { return this.controlKeyContinuesSelection; }
/** Whether [[ElementSetTool.setupAndPromptForNextAction]] should call [[AccuSnap.enableSnap]] for current tool phase.
* @return true to enable snapping to elements.
* @note A tool that just needs to identify elements and doesn't care about location should not enable snapping.
*/
get wantAccuSnap() { return false; }
/** Whether to automatically start element dynamics after all required elements have been identified.
* @return true if tool will implement [[InteractiveTool.onDynamicFrame]] to show element dynamics.
*/
get wantDynamics() { return false; }
/** Whether tool is done identifying elements and is ready to move to the next phase.
* @return true when [[ElementSetTool.requiredElementCount]] is not yet satisfied or ctrl key is being used to extend selection.
*/
get wantAdditionalElements() {
if (this.isSelectionSetModify)
return false;
if (this.currentElementCount < this.requiredElementCount)
return true;
// A defined anchor indicates input collection phase has begun and ctrl should no longer extend selection...
return undefined === this.anchorPoint && this.controlKeyContinuesSelection && this.isControlDown;
}
/** Whether the tool has gathered enough input to call [[ElementSetTool.processAgenda]].
* Sub-classes should override to check for additional point input they collected in [[ElementSetTool.wantProcessAgenda]].
* @return true if tool does not yet have enough information to complete.
* @note When [[ElementSetTool.wantDynamics]] is true an additional point is automatically required to support the dynamic preview.
*/
get wantAdditionalInput() { return (!this.isDynamicsStarted && this.wantDynamics); }
/** Whether the tool is ready for [[ElementSetTool.processAgenda]] to be called to complete the tool operation.
* Sub-classes should override to collect additional point input before calling super or [[ElementSetTool.wantAdditionalInput]].
* @return true if tool has enough information and is ready to complete.
*/
wantProcessAgenda(_ev) { return !this.wantAdditionalInput; }
/** Whether tool should operate on an existing selection set or instead prompt user to identity elements.
* Unsupported selection sets will be cleared when [[ElementSetTool.clearSelectionSet]] is true.
*/
setPreferredElementSource() {
this._useSelectionSet = false;
if (!this.iModel.selectionSet.isActive)
return;
const isSelectionSetValid = () => {
if (0 === this.iModel.selectionSet.elements.size) {
IModelApp.notifications.outputMessage(new NotifyMessageDetails(OutputMessagePriority.Info, CoreTools.translate("ElementSet.Error.ActiveSSWithoutElems")));
return false;
}
return (this.iModel.selectionSet.elements.size >= this.requiredElementCount);
};
if (this.allowSelectionSet && isSelectionSetValid())
this._useSelectionSet = true;
else if (this.clearSelectionSet)
this.iModel.selectionSet.emptyAll();
}
/** Get element ids to process from the active selection set.
* Sub-classes may override to support selection scopes or apply tool specific filtering.
*/
async getSelectionSetCandidates(ss) {
const ids = new Set();
ss.elements.forEach((val) => {
if (this.isElementIdValid(val, ModifyElementSource.SelectionSet))
ids.add(val);
});
return ids;
}
/** Populate [[ElementSetTool.agenda]] from a [[SelectionSet]].
* @see [[ElementSetTool.getSelectionSetCandidates]] to filter or augment the set of elements.
*/
async buildSelectionSetAgenda(ss) {
const candidates = await this.getSelectionSetCandidates(ss);
if (Id64.sizeOf(candidates) < this.requiredElementCount || !this.agenda.add(candidates)) {
IModelApp.notifications.outputMessage(new NotifyMessageDetails(OutputMessagePriority.Info, CoreTools.translate("ElementSet.Error.NoSSElems")));
return false;
}
this.agenda.setSource(ModifyElementSource.SelectionSet);
await this.onAgendaModified();
return true;
}
/** If the supplied element is part of an assembly, return all member ids. */
async getGroupIds(id) {
const ids = new Set();
ids.add(id);
try {
// When assembly parent is selected, pick all geometric elements with it as the parent.
// When assembly member is selected, pick the parent as well as all the other members.
const ecsql = `SELECT ECInstanceId as id, Parent.Id as parentId FROM BisCore.GeometricElement WHERE Parent.Id IN (SELECT Parent.Id as parentId FROM BisCore.GeometricElement WHERE (parent.Id IS NOT NULL AND ECInstanceId IN (${id})) OR parent.Id IN (${id}))`;
for await (const row of this.iModel.createQueryReader(ecsql, undefined, { rowFormat: QueryRowFormat.UseJsPropertyNames })) {
ids.add(row.parentId);
ids.add(row.id);
}
}
catch { }
return ids;
}
/** Get element id(s) to process from a [[HitDetail]] already accepted by [[ElementSetTool.isElementValidForOperation]].
* Sub-classes may override to support selection scopes.
*/
async getLocateCandidates(hit) {
if (!this.allowGroups)
return hit.sourceId;
return this.getGroupIds(hit.sourceId);
}
/** Populate [[ElementSetTool.agenda]] from a [[HitDetail]].
* @see [[ElementSetTool.getLocateCandidates]] to add additional elements.
*/
async buildLocateAgenda(hit) {
if (this.agenda.find(hit.sourceId)) {
if (this.isControlDown && this.controlKeyInvertsSelection && this.agenda.remove(hit.sourceId)) {
await this.onAgendaModified();
return true;
}
return false;
}
const candidates = await this.getLocateCandidates(hit);
if (!this.agenda.add(candidates))
return false;
this.agenda.setSource(ModifyElementSource.Selected);
await this.onAgendaModified();
return true;
}
/** Get ids of spatial elements to process from a clip volume created by drag box selection. */
static async getVolumeSelectionCandidates(vp, origin, corner, allowOverlaps, filter) {
const contents = new Set();
if (!vp.view.isSpatialView())
return contents;
const boxRange = Range2d.createXYXY(origin.x, origin.y, corner.x, corner.y);
if (boxRange.isNull || boxRange.isAlmostZeroX || boxRange.isAlmostZeroY)
return contents;
const getClipPlane = (viewPt, viewDir, negate) => {
const point = vp.viewToWorld(Point3d.createFrom(viewPt));
const boresite = AccuDrawHintBuilder.getBoresite(point, vp);
const normal = viewDir.crossProduct(boresite.direction);
if (negate)
normal.negate(normal);
return ClipPlane.createNormalAndPoint(normal, point);
};
const planeSet = ConvexClipPlaneSet.createEmpty();
planeSet.addPlaneToConvexSet(getClipPlane(boxRange.low, vp.rotation.rowX(), true));
planeSet.addPlaneToConvexSet(getClipPlane(boxRange.low, vp.rotation.rowY(), true));
planeSet.addPlaneToConvexSet(getClipPlane(boxRange.high, vp.rotation.rowX(), false));
planeSet.addPlaneToConvexSet(getClipPlane(boxRange.high, vp.rotation.rowY(), false));
if (0 === planeSet.planes.length)
return contents;
const clip = ClipVector.createCapture([ClipPrimitive.createCapture(planeSet)]);
const viewRange = vp.computeViewRange();
const range = ClipUtilities.rangeOfClipperIntersectionWithRange(clip, viewRange);
if (range.isNull)
return contents;
// TODO: Possible to make UnionOfComplexClipPlaneSets from view clip and planes work and remove 2nd containment check?
const viewClip = (vp.viewFlags.clipVolume ? vp.view.getViewClip()?.clone() : undefined);
if (viewClip) {
const viewClipRange = ClipUtilities.rangeOfClipperIntersectionWithRange(viewClip, viewRange);
if (viewClipRange.isNull || !viewClipRange.intersectsRange(range))
return contents;
}
const candidates = [];
const categories = new Set();
try {
const viewedModels = [...vp.view.modelSelector.models].join(",");
const viewedCategories = [...vp.view.categorySelector.categories].join(",");
const ecsql = `SELECT e.ECInstanceId, Category.Id as category FROM bis.SpatialElement e JOIN bis.SpatialIndex i ON e.ECInstanceId=i.ECInstanceId WHERE Model.Id IN (${viewedModels}) AND Category.Id IN (${viewedCategories}) AND i.MinX <= ${range.xHigh} AND i.MinY <= ${range.yHigh} AND i.MinZ <= ${range.zHigh} AND i.MaxX >= ${range.xLow} AND i.MaxY >= ${range.yLow} AND i.MaxZ >= ${range.zLow}`;
const reader = vp.iModel.createQueryReader(ecsql, undefined, { rowFormat: QueryRowFormat.UseECSqlPropertyNames });
for await (const row of reader) {
candidates.push(row.ECInstanceId);
categories.add(row.category);
}
}
catch { }
if (0 === candidates.length)
return contents;
let offSubCategories;
if (0 !== categories.size) {
for (const categoryId of categories) {
const subcategories = vp.iModel.subcategories.getSubCategories(categoryId);
if (undefined === subcategories)
continue;
for (const subCategoryId of subcategories) {
const appearance = vp.iModel.subcategories.getSubCategoryAppearance(subCategoryId);
if (undefined === appearance || (!appearance.invisible && !appearance.dontLocate))
continue;
if (undefined === offSubCategories)
offSubCategories = new Array;
offSubCategories.push(subCategoryId);
}
}
}
const requestProps = {
candidates,
clip: clip.toJSON(),
allowOverlaps,
viewFlags: vp.viewFlags.toJSON(),
offSubCategories,
};
const result = await vp.iModel.getGeometryContainment(requestProps);
if (BentleyStatus.SUCCESS !== result.status || undefined === result.candidatesContainment)
return contents;
result.candidatesContainment.forEach((status, index) => {
if (ClipPlaneContainment.StronglyOutside !== status && (undefined === filter || filter(candidates[index])))
contents.add(candidates[index]);
});
if (0 !== contents.size && viewClip) {
requestProps.clip = viewClip.toJSON();
requestProps.candidates.length = 0;
for (const id of contents)
requestProps.candidates.push(id);
contents.clear();
const resultViewClip = await vp.iModel.getGeometryContainment(requestProps);
if (BentleyStatus.SUCCESS !== resultViewClip.status || undefined === resultViewClip.candidatesContainment)
return contents;
resultViewClip.candidatesContainment.forEach((status, index) => {
if (ClipPlaneContainment.StronglyOutside !== status)
contents.add(candidates[index]);
});
}
return contents;
}
/** Get ids of visible elements to process from drag box or crossing line selection. */
static getAreaSelectionCandidates(vp, origin, corner, method, allowOverlaps, filter) {
let contents = new Set();
const pts = [];
pts[0] = new Point2d(Math.floor(origin.x + 0.5), Math.floor(origin.y + 0.5));
pts[1] = new Point2d(Math.floor(corner.x + 0.5), Math.floor(corner.y + 0.5));
const range = Range2d.createArray(pts);
const rect = new ViewRect();
rect.initFromRange(range);
vp.readPixels(rect, Pixel.Selector.Feature, (pixels) => {
if (undefined === pixels)
return;
const sRange = Range2d.createNull();
sRange.extendPoint(Point2d.create(vp.cssPixelsToDevicePixels(range.low.x), vp.cssPixelsToDevicePixels(range.low.y)));
sRange.extendPoint(Point2d.create(vp.cssPixelsToDevicePixels(range.high.x), vp.cssPixelsToDevicePixels(range.high.y)));
pts[0].x = vp.cssPixelsToDevicePixels(pts[0].x);
pts[0].y = vp.cssPixelsToDevicePixels(pts[0].y);
pts[1].x = vp.cssPixelsToDevicePixels(pts[1].x);
pts[1].y = vp.cssPixelsToDevicePixels(pts[1].y);
const testPoint = Point2d.createZero();
const getPixelElementId = (pixel) => {
if (undefined === pixel.elementId || Id64.isInvalid(pixel.elementId))
return undefined; // no geometry at this location...
if (!vp.isPixelSelectable(pixel))
return undefined; // reality model, terrain, etc - not selectable
if (undefined !== filter && !filter(pixel.elementId))
return undefined;
return pixel.elementId;
};
if (SelectionMethod.Box === method) {
const outline = allowOverlaps ? undefined : new Set();
const offset = sRange.clone();
offset.expandInPlace(-2);
for (testPoint.x = sRange.low.x; testPoint.x <= sRange.high.x; ++testPoint.x) {
for (testPoint.y = sRange.low.y; testPoint.y <= sRange.high.y; ++testPoint.y) {
const pixel = pixels.getPixel(testPoint.x, testPoint.y);
const elementId = getPixelElementId(pixel);
if (undefined === elementId)
continue;
if (undefined !== outline && !offset.containsPoint(testPoint))
outline.add(elementId.toString());
else
contents.add(elementId.toString());
}
}
if (undefined !== outline && 0 !== outline.size) {
const inside = new Set();
contents.forEach((id) => {
if (!outline.has(id))
inside.add(id);
});
contents = inside;
}
}
else {
const closePoint = Point2d.createZero();
for (testPoint.x = sRange.low.x; testPoint.x <= sRange.high.x; ++testPoint.x) {
for (testPoint.y = sRange.low.y; testPoint.y <= sRange.high.y; ++testPoint.y) {
const pixel = pixels.getPixel(testPoint.x, testPoint.y);
const elementId = getPixelElementId(pixel);
if (undefined === elementId)
continue;
const fraction = testPoint.fractionOfProjectionToLine(pts[0], pts[1], 0.0);
pts[0].interpolate(fraction, pts[1], closePoint);
if (closePoint.distance(testPoint) < 1.5)
contents.add(elementId.toString());
}
}
}
}, true);
return contents;
}
/** Get ids of elements to process from drag box or crossing line selection using either the depth buffer or clip vector...
* @internal
*/
static async getAreaOrVolumeSelectionCandidates(vp, origin, corner, method, allowOverlaps, filter, includeDecorationsForVolume) {
let contents;
if (ToolSettings.enableVolumeSelection && SelectionMethod.Box === method && vp.view.isSpatialView()) {
contents = await ElementSetTool.getVolumeSelectionCandidates(vp, origin, corner, allowOverlaps, filter);
// Use area select to identify pickable transients...
if (includeDecorationsForVolume) {
const acceptTransientsFilter = (id) => { return Id64.isTransient(id) && (undefined === filter || filter(id)); };
const transients = ElementSetTool.getAreaSelectionCandidates(vp, origin, corner, method, allowOverlaps, acceptTransientsFilter);
for (const id of transients)
contents.add(id);
}
}
else {
contents = ElementSetTool.getAreaSelectionCandidates(vp, origin, corner, method, allowOverlaps, filter);
}
return contents;
}
/** Get element ids to process from drag box or crossing line selection.
* Sub-classes may override to support selection scopes or apply tool specific filtering.
*/
async getDragSelectCandidates(vp, origin, corner, method, overlap) {
const filter = (id) => { return this.isElementIdValid(id, ModifyElementSource.DragSelect); };
return ElementSetTool.getAreaOrVolumeSelectionCandidates(vp, origin, corner, method, overlap, filter, IModelApp.locateManager.options.allowDecorations);
}
/** Populate [[ElementSetTool.agenda]] by drag box or crossing line information.
* @see [[ElementSetTool.getDragSelectCandidates]] to filter or augment the set of elements.
*/
async buildDragSelectAgenda(vp, origin, corner, method, overlap) {
const candidates = await this.getDragSelectCandidates(vp, origin, corner, method, overlap);
if (!this.isControlDown || !this.controlKeyInvertsSelection) {
if (!this.agenda.add(candidates))
return false;
}
else {
if (!this.agenda.invert(candidates))
return false;
}
if (ModifyElementSource.Unknown === this.agenda.getSource())
this.agenda.setSource(ModifyElementSource.DragSelect); // Don't set source if invert only removed entries...
await this.onAgendaModified();
return true;
}
/** Quick id validity check. Sub-classes that wish to allow pickable decorations from selection sets can override. */
isElementIdValid(id, source) {
switch (source) {
case ModifyElementSource.Selected:
return true; // Locate options already checked prior to calling isElementValidForOperation...
case ModifyElementSource.SelectionSet:
return (!Id64.isInvalid(id) && !Id64.isTransient(id)); // Locate options are invalid, locate isn't enabled when processing a selection set...
case ModifyElementSource.DragSelect:
return (!Id64.isInvalid(id) && (IModelApp.locateManager.options.allowDecorations || !Id64.isTransient(id))); // Locate options are valid but have not yet been checked...
default:
return false;
}
}
/** Sub-classes should override to apply tool specific filtering and to provide an explanation for rejection. */
async isElementValidForOperation(hit, _out) {
return this.isElementIdValid(hit.sourceId, ModifyElementSource.Selected);
}
/** Called from [[ElementSetTool.doLocate]] as well as auto-locate to accept or reject elements under the cursor. */
async filterHit(hit, out) {
// Support deselect using control key and don't show "not" cursor over an already selected element...
if (undefined !== this._agenda && this._agenda.find(hit.sourceId)) {
const status = (this.isControlDown || !this.controlKeyInvertsSelection) ? LocateFilterStatus.Accept : LocateFilterStatus.Reject;
if (out && LocateFilterStatus.Reject === status)
out.explanation = CoreTools.translate(`ElementSet.Error.AlreadySelected`);
return status;
}
return await this.isElementValidForOperation(hit, out) ? LocateFilterStatus.Accept : LocateFilterStatus.Reject;
}
/** Identify an element and update the element agenda.
* @param newSearch true to locate new elements, false to cycle between elements within locate tolerance from a previous locate.
* @return true if [[ElementSetTool.agenda]] was changed.
*/
async doLocate(ev, newSearch) {
const hit = await IModelApp.locateManager.doLocate(new LocateResponse(), newSearch, ev.point, ev.viewport, ev.inputSource);
if (newSearch)
return (undefined !== hit && this.buildLocateAgenda(hit));
// If next element is already in agenda (part of a group, etc.) don't re-add group...
const addNext = (undefined !== hit && !this.agenda.has(hit.sourceId));
this.agenda.popGroup();
if (!addNext || !await this.buildLocateAgenda(hit))
await this.onAgendaModified(); // only change was popGroup...
return true;
}
/** Whether drag box selection only identifies elements that are wholly inside or also allows those that overlap
* the selection rectangle.
* @note Inside/overlap is based on left/right direction of corner points (shift key inverts check).
*/
useOverlapSelection(ev) {
if (undefined === ev.viewport || undefined === this.dragStartPoint)
return false;
const pt1 = ev.viewport.worldToView(this.dragStartPoint);
const pt2 = ev.viewport.worldToView(ev.point);
const overlapMode = (pt1.x > pt2.x);
return (ev.isShiftKey ? !overlapMode : overlapMode); // Shift inverts inside/overlap selection...
}
/** Initiate tool state for start of drag selection. */
async selectByPointsStart(ev) {
if (BeButton.Data !== ev.button && BeButton.Reset !== ev.button)
return false;
if (!ev.isControlKey || !this.allowDragSelect || !this.wantAdditionalElements)
return false;
this.dragStartPoint = ev.point.clone();
this.setupAndPromptForNextAction();
return true;
}
/** Finish drag selection and update [[ElementSetTool.agenda]] with any elements that may have been identified. */
async selectByPointsEnd(ev) {
if (undefined === this.dragStartPoint)
return false;
const vp = ev.viewport;
if (vp === undefined) {
this.dragStartPoint = undefined;
this.setupAndPromptForNextAction();
return false;
}
const origin = vp.worldToView(this.dragStartPoint);
const corner = vp.worldToView(ev.point);
if (BeButton.Reset === ev.button)
await this.buildDragSelectAgenda(vp, origin, corner, SelectionMethod.Line, true);
else
await this.buildDragSelectAgenda(vp, origin, corner, SelectionMethod.Box, this.useOverlapSelection(ev));
this.dragStartPoint = undefined;
this.setupAndPromptForNextAction();
vp.invalidateDecorations();
return true;
}
/** Display drag box and crossing line selection graphics. */
selectByPointsDecorate(context) {
if (undefined === this.dragStartPoint)
return;
const ev = new BeButtonEvent();
IModelApp.toolAdmin.fillEventFromCursorLocation(ev);
if (undefined === ev.viewport)
return;
const vp = context.viewport;
const bestContrastIsBlack = (ColorDef.black === vp.getContrastToBackgroundColor());
const crossingLine = (BeButton.Reset === ev.button);
const overlapSelection = (crossingLine || this.useOverlapSelection(ev));
const position = vp.worldToView(this.dragStartPoint);
position.x = Math.floor(position.x) + 0.5;
position.y = Math.floor(position.y) + 0.5;
const position2 = vp.worldToView(ev.point);
position2.x = Math.floor(position2.x) + 0.5;
position2.y = Math.floor(position2.y) + 0.5;
const offset = position2.minus(position);
const drawDecoration = (ctx) => {
ctx.strokeStyle = bestContrastIsBlack ? "black" : "white";
ctx.lineWidth = 1;
if (overlapSelection)
ctx.setLineDash([5, 5]);
if (crossingLine) {
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(offset.x, offset.y);
ctx.stroke();
}
else {
ctx.strokeRect(0, 0, offset.x, offset.y);
ctx.fillStyle = bestContrastIsBlack ? "rgba(0,0,0,.06)" : "rgba(255,255,255,.06)";
ctx.fillRect(0, 0, offset.x, offset.y);
}
};
context.addCanvasDecoration({ position, drawDecoration });
}
/** Show graphics for when drag selection is active. */
decorate(context) { this.selectByPointsDecorate(context); }
/** Make sure drag selection graphics are updated when mouse moves. */
async onMouseMotion(ev) {
if (undefined !== ev.viewport && this.isSelectByPoints)
ev.viewport.invalidateDecorations();
}
/** Support initiating drag selection on mouse start drag event when [[ElementSetTool.allowDragSelect]] is true. */
async onMouseStartDrag(ev) {
if (await this.selectByPointsStart(ev))
return EventHandled.Yes;
return super.onMouseStartDrag(ev);
}
/** Support completing active drag selection on mouse end drag event and update [[ElementSetTool.agenda]]. */
async onMouseEndDrag(ev) {
if (await this.selectByPointsEnd(ev))
return EventHandled.Yes;
return super.onMouseEndDrag(ev);
}
/** Update prompts, cursor, graphics, etc. as appropriate on ctrl and shift key transitions. */
async onModifierKeyTransition(_wentDown, modifier, _event) {
if (this.isSelectionSetModify)
return EventHandled.No;
if (this.isSelectByPoints)
return (BeModifierKeys.Shift === modifier ? EventHandled.Yes : EventHandled.No);
if (BeModifierKeys.Control !== modifier || undefined !== this.anchorPoint || !this.controlKeyContinuesSelection)
return EventHandled.No;
if (this.currentElementCount < this.requiredElementCount && !this.wantAccuSnap)
return EventHandled.No; // Can only early return if AccuSnap doesn't need to be disabled for ctrl selection...
this.setupAndPromptForNextAction(); // Enable/disable auto-locate, AccuSnap, update prompts...
return EventHandled.Yes;
}
/** Allow reset to cycle between elements identified for overlapping the locate circle.
* Advances to next pre-located hit from [[AccuSnap.aSnapHits]] or changes last accepted hit to next hit from [[ElementLocateManger.hitList]].
* @returns EventHandled.Yes if onReinitialize was called to restart or exit tool.
*/
async chooseNextHit(ev) {
if (this.isSelectionSetModify) {
await this.onReinitialize();
return EventHandled.Yes;
}
if (0 !== this.currentElementCount) {
let autoLocateChooseNext = false;
if (this.wantAdditionalElements) {
const lastHit = IModelApp.locateManager.currHit;
const autoHit = IModelApp.accuSnap.currHit;
// Choose next using auto-locate or normal locate?
if (undefined !== autoHit && (undefined === lastHit || !autoHit.isSameHit(lastHit)))
autoLocateChooseNext = true;
}
if (!autoLocateChooseNext) {
await this.doLocate(ev, false);
if (this.agenda.isEmpty) {
await this.onReinitialize();
return EventHandled.Yes;
}
this.setupAndPromptForNextAction();
return EventHandled.No;
}
}
await IModelApp.accuSnap.resetButton();
return EventHandled.No;
}
/** Orchestrates updating the internal state of the tool on a reset button event.
* @returns EventHandled.Yes if onReinitialize was called to restart or exit tool.
*/
async processResetButton(ev) {
if (ev.isDown)
return EventHandled.No;
return this.chooseNextHit(ev);
}
async onResetButtonUp(ev) {
return this.processResetButton(ev);
}
async onResetButtonDown(ev) {
return this.processResetButton(ev);
}
/** Collect element input until tool has a sufficient number to complete. */
async gatherElements(ev) {
if (this.isSelectionSetModify) {
if (this.agenda.isEmpty && !await this.buildSelectionSetAgenda(this.iModel.selectionSet)) {
await this.onReinitialize();
return EventHandled.Yes;
}
}
if (this.wantAdditionalElements) {
if (ev.isDown && ev.isControlKey && this.allowDragSelect) {
this._processDataButtonUp = true;
return EventHandled.No; // Defer locate to up event so that box select can be initiated while over an element...
}
if (!await this.doLocate(ev, true))
return EventHandled.No;
if (this.wantAdditionalElements) {
this.setupAndPromptForNextAction();
return EventHandled.No; // Continue identifying elements...
}
}
return undefined;
}
/** Collect point input until tool has a sufficient number to complete. */
async gatherInput(ev) {
if (undefined === this.anchorPoint) {
this.anchorPoint = ev.point.clone();
const hints = new AccuDrawHintBuilder();
hints.setOriginAlways = true;
hints.setOrigin(this.anchorPoint);
hints.sendHints(false); // Default activation on start of dynamics...
}
if (!this.wantProcessAgenda(ev)) {
if (this.wantDynamics)
await this.initAgendaDynamics();
this.setupAndPromptForNextAction();
return EventHandled.No;
}
return undefined;
}
/** Orchestrates advancing the internal state of the tool on a data button event.
* - Collect elements: Add to the element agenda until no additional elements are requested.
* - Gather input: Initiates element dynamics and accepts additional points as required.
* - Complete operation: Process agenda entries, restart or exit tool.
* @returns EventHandled.Yes if onReinitialize was called to restart or exit tool.
*/
async processDataButton(ev) {
if (!ev.isDown && !this._processDataButtonUp)
return EventHandled.No;
this._processDataButtonUp = false;
const elementStatus = await this.gatherElements(ev);
if (undefined !== elementStatus)
return elementStatus;
const inputStatus = await this.gatherInput(ev);
if (undefined !== inputStatus)
return inputStatus;
await this.processAgenda(ev);
await this.onProcessComplete();
return EventHandled.Yes;
}
async onDataButtonUp(ev) {
return this.processDataButton(ev);
}
async onDataButtonDown(ev) {
return this.processDataButton(ev);
}
async initAgendaDynamics() {
if (this.isDynamicsStarted)
return false;
this.beginDynamics();
return true;
}
/** Sub-classes can override to be notified of [[ElementSetTool.agenda]] changes by other methods.
* @note Tools should not modify [[ElementSetTool.agenda]] in this method, it should merely serve as a convenient place
* to update information, such as element graphics once dynamics has started, ex. [[ElementSetTool.chooseNextHit]].
*/
async onAgendaModified() { }
/** Sub-classes can override to continue with current [[ElementSetTool.agenda]] or restart after processing has completed. */
async onProcessComplete() { return this.onReinitialize(); }
/** Sub-classes that return false for [[ElementSetTool.requireAcceptForSelectionSetOperation]] should override to apply the tool operation to [[ElementSetTool.agenda]]. */
async processAgendaImmediate() { }
/** Sub-classes that require and use the accept point should override to apply the tool operation to [[ElementSetTool.agenda]].
* @note Not called for [[ElementSetTool.isSelectionSetModify]] when [[ElementSetTool.requireAcceptForSelectionSetOperation]] is false.
*/
async processAgenda(_ev) { return this.processAgendaImmediate(); }
/** Support either [[ElementSetTool.requireAcceptForSelectionSetOperation]] or [[ElementSetTool.requireAcceptForSelectionSetDynamics]] returning false. */
async doProcessSelectionSetImmediate() {
const buildImmediate = (!this.requireAcceptForSelectionSetOperation || (this.wantDynamics && !this.requireAcceptForSelectionSetDynamics));
if (!buildImmediate)
return;
if (!await this.buildSelectionSetAgenda(this.iModel.selectionSet))
return this.onReinitialize();
if (!this.requireAcceptForSelectionSetOperation) {
await this.processAgendaImmediate();
await this.onProcessComplete();
}
else {
await this.initAgendaDynamics();
}
}
/** Setup initial element state, prompts, check [[SelectionSet]], etc. */
async onPostInstall() {
await super.onPostInstall();
this.setPreferredElementSource();
this.setupAndPromptForNextAction();
if (this.isSelectionSetModify)
await this.doProcessSelectionSetImmediate();
}
/** Make sure elements from [[ElementSetTool.agenda]] that aren't also from [[SelectionSet]] aren't left hilited. */
async onCleanup() {
await super.onCleanup();
if (undefined !== this._agenda)
this._agenda.clear();
}
/** Exit and start default tool when [[ElementSetTool.isSelectionSetModify]] is true to allow [[SelectionSet]] to be modified,
* or call [[PrimitiveTool.onRestartTool]] to install a new tool instance.
*/
async onReinitialize() {
if (this.isSelectionSetModify)
return this.exitTool();
return this.onRestartTool();
}
/** Restore tool assistance after no longer being suspended by either a [[ViewTool]] or [[InputCollec