UNPKG

@itwin/core-frontend

Version:
956 lines • 55.1 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 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