@itwin/core-frontend
Version:
iTwin.js frontend components
379 lines • 17.4 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 LocatingElements
*/
import { Id64 } from "@itwin/core-bentley";
import { Point2d, Point3d } from "@itwin/core-geometry";
import { HitDetail, HitList, HitSource } from "./HitDetail";
import { IModelApp } from "./IModelApp";
import { Pixel } from "./render/Pixel";
import { InputSource, InteractiveTool } from "./tools/Tool";
import { ViewRect } from "./common/ViewRect";
/** The possible actions for which a locate filter can be called.
* @public
* @extensions
*/
export var LocateAction;
(function (LocateAction) {
LocateAction[LocateAction["Identify"] = 0] = "Identify";
LocateAction[LocateAction["AutoLocate"] = 1] = "AutoLocate";
})(LocateAction || (LocateAction = {}));
/** Values to return from a locate filter.
* Return `Reject` to indicate the element is unacceptable.
* @public
* @extensions
*/
export var LocateFilterStatus;
(function (LocateFilterStatus) {
LocateFilterStatus[LocateFilterStatus["Accept"] = 0] = "Accept";
LocateFilterStatus[LocateFilterStatus["Reject"] = 1] = "Reject";
})(LocateFilterStatus || (LocateFilterStatus = {}));
/**
* @public
* @extensions
*/
export var SnapStatus;
(function (SnapStatus) {
SnapStatus[SnapStatus["Success"] = 0] = "Success";
SnapStatus[SnapStatus["Aborted"] = 1] = "Aborted";
SnapStatus[SnapStatus["NoElements"] = 2] = "NoElements";
SnapStatus[SnapStatus["Disabled"] = 100] = "Disabled";
SnapStatus[SnapStatus["NoSnapPossible"] = 200] = "NoSnapPossible";
SnapStatus[SnapStatus["NotSnappable"] = 300] = "NotSnappable";
SnapStatus[SnapStatus["FilteredByApp"] = 600] = "FilteredByApp";
SnapStatus[SnapStatus["FilteredByAppQuietly"] = 700] = "FilteredByAppQuietly";
})(SnapStatus || (SnapStatus = {}));
/** Options that customize the way element location (i.e. *picking*) works.
* @public
* @extensions
*/
export class LocateOptions {
/** If true, also test graphics from view decorations. */
allowDecorations = false;
/** If true, also test graphics with non-locatable flag set. */
allowNonLocatable = false;
/** Maximum number of hits to return. */
maxHits = 20;
/** The [[HitSource]] identifying the caller. */
hitSource = HitSource.DataPoint;
/** If true, also test graphics from an IModelConnection other than the one associated with the Viewport. This can occur if, e.g., a
* [[TiledGraphicsProvider]] is used to display graphics from a different iModel into the [[Viewport]].
* @note If you override this, you must be prepared to properly handle [[HitDetail]]s originating from other IModelConnections.
* @see [[HitDetail.iModel]] and [[HitDetail.isExternalIModelHit]]
*/
allowExternalIModels = false;
/** If true, then the world point of a hit on a model will preserve any transforms applied to the model at display time,
* such as those supplied by a [[ModelDisplayTransformProvider]] or [PlanProjectionSettings.elevation]($common).
* Otherwise, the world point will be multiplied by the inverse of any such transforms to correlate it with the model's true coordinate space.
*/
preserveModelDisplayTransforms = false;
/** Make a copy of this LocateOptions. */
clone() {
const other = new LocateOptions();
other.allowDecorations = this.allowDecorations;
other.allowNonLocatable = this.allowNonLocatable;
other.maxHits = this.maxHits;
other.hitSource = this.hitSource;
other.allowExternalIModels = this.allowExternalIModels;
return other;
}
setFrom(other) {
this.allowDecorations = other.allowDecorations;
this.allowNonLocatable = other.allowNonLocatable;
this.maxHits = other.maxHits;
this.hitSource = other.hitSource;
this.allowExternalIModels = other.allowExternalIModels;
}
init() {
this.allowDecorations = this.allowNonLocatable = this.allowExternalIModels = false;
this.maxHits = 20;
this.hitSource = HitSource.DataPoint;
}
}
/**
* @public
* @extensions
*/
export class LocateResponse {
snapStatus = SnapStatus.Success;
reason;
explanation = "";
/** @internal */
clone() {
const other = new LocateResponse();
other.snapStatus = this.snapStatus;
other.reason = this.reason;
other.explanation = this.explanation;
return other;
}
/** @internal */
setFrom(other) {
this.snapStatus = other.snapStatus;
this.reason = other.reason;
this.explanation = other.explanation;
}
}
/**
* @public
* @extensions
*/
export class ElementPicker {
viewport;
pickPointWorld = new Point3d();
hitList;
empty() {
this.pickPointWorld.setZero();
this.viewport = undefined;
if (this.hitList)
this.hitList.empty();
else
this.hitList = new HitList();
}
/** return the HitList for the last Pick performed. Optionally allows the caller to take ownership of the list. */
getHitList(takeOwnership) {
const list = this.hitList;
if (takeOwnership)
this.hitList = undefined;
return list;
}
getNextHit() { return this.hitList ? this.hitList.getNextHit() : undefined; }
/** Return a hit from the list of hits created the last time pickElements was called. */
getHit(i) { return this.hitList ? this.hitList.getHit(i) : undefined; }
resetCurrentHit() {
if (this.hitList)
this.hitList.resetCurrentHit();
}
comparePixel(pixel1, pixel2, distXY1, distXY2) {
let priority1 = pixel1.computeHitPriority();
let priority2 = pixel2.computeHitPriority();
// If two hits have the same priority, prefer a contour over a non-contour.
priority1 -= pixel1.contour ? 0.5 : 0;
priority2 -= pixel2.contour ? 0.5 : 0;
if (priority1 < priority2)
return -1;
if (priority1 > priority2)
return 1;
if (distXY1 < distXY2)
return -1;
if (distXY1 > distXY2)
return 1;
if (pixel1.distanceFraction > pixel2.distanceFraction)
return -1;
if (pixel1.distanceFraction < pixel2.distanceFraction)
return 1;
return 0;
}
/** Generate a list of elements that are close to a given point.
* @param vp Viewport to use for pick
* @param pickPointWorld Pick location in world coordinates
* @param pickRadiusView Pick radius in pixels
* @param options Pick options to use
* @param excludedElements Optional ids to not draw during pick. Allows hits for geometry obscured by these ids to be returned.
* @returns The number of hits in the hitList of this object.
*/
doPick(vp, pickPointWorld, pickRadiusView, options, excludedElements) {
if (this.hitList && this.hitList.length > 0 && vp === this.viewport && pickPointWorld.isAlmostEqual(this.pickPointWorld)) {
this.hitList.resetCurrentHit();
return this.hitList.length;
}
this.empty(); // empty the hit list
this.viewport = vp;
this.pickPointWorld.setFrom(pickPointWorld);
const pickPointView = vp.worldToView(pickPointWorld);
const testPointView = new Point2d(Math.floor(pickPointView.x + 0.5), Math.floor(pickPointView.y + 0.5));
let pixelRadius = Math.floor(pickRadiusView + 0.5);
const rect = new ViewRect(testPointView.x - pixelRadius, testPointView.y - pixelRadius, testPointView.x + pixelRadius, testPointView.y + pixelRadius);
if (rect.isNull)
return 0;
const receiver = (pixels) => {
if (undefined === pixels)
return;
testPointView.x = vp.cssPixelsToDevicePixels(testPointView.x);
testPointView.y = vp.cssPixelsToDevicePixels(testPointView.y);
pixelRadius = vp.cssPixelsToDevicePixels(pixelRadius);
const elmHits = new Map();
const testPoint = Point2d.createZero();
for (testPoint.x = testPointView.x - pixelRadius; testPoint.x <= testPointView.x + pixelRadius; ++testPoint.x) {
for (testPoint.y = testPointView.y - pixelRadius; testPoint.y <= testPointView.y + pixelRadius; ++testPoint.y) {
const pixel = pixels.getPixel(testPoint.x, testPoint.y);
if (undefined === pixel || undefined === pixel.elementId || Id64.isInvalid(pixel.elementId))
continue; // no geometry at this location...
const distXY = testPointView.distance(testPoint);
if (distXY > pixelRadius)
continue; // ignore corners. it's a locate circle not square...
const oldPoint = elmHits.get(pixel.elementId);
if (undefined !== oldPoint) {
if (this.comparePixel(pixel, pixels.getPixel(oldPoint.x, oldPoint.y), distXY, testPointView.distance(oldPoint)) < 0)
oldPoint.setFrom(testPoint); // new hit is better, update location...
}
else {
elmHits.set(pixel.elementId, testPoint.clone());
}
}
}
if (0 === elmHits.size)
return;
for (const elmPoint of elmHits.values()) {
const pixel = pixels.getPixel(elmPoint.x, elmPoint.y);
if (undefined === pixel || undefined === pixel.elementId)
continue;
const hitPointWorld = vp.getPixelDataWorldPoint({
pixels,
x: elmPoint.x,
y: elmPoint.y,
preserveModelDisplayTransforms: options.preserveModelDisplayTransforms,
});
if (!hitPointWorld)
continue;
const hit = new HitDetail({
...pixel.toHitProps(vp),
testPoint: pickPointWorld,
viewport: vp,
hitSource: options.hitSource,
hitPoint: hitPointWorld,
distXY: testPointView.distance(elmPoint),
});
this.hitList.addHit(hit);
if (this.hitList.hits.length > options.maxHits)
this.hitList.hits.length = options.maxHits; // truncate array...
}
result = this.hitList.length;
};
const args = {
receiver,
rect,
selector: Pixel.Selector.All,
excludeNonLocatable: !options.allowNonLocatable,
excludedElements,
};
let result = 0;
vp.readPixels(args);
return result;
}
testHit(hit, vp, pickPointWorld, pickRadiusView, options) {
if (0 === this.doPick(vp, pickPointWorld, pickRadiusView, options))
return false;
return this.hitList.hits.some((thisHit) => hit.isSameHit(thisHit));
}
}
/**
* @public
* @extensions
*/
export class ElementLocateManager {
hitList;
currHit;
options = new LocateOptions();
picker = new ElementPicker();
/** get the full message key for a locate failure */
static getFailureMessageKey(key) { return `LocateFailure.${key}`; }
onInitialized() { }
get apertureInches() { return 0.11; }
get touchApertureInches() { return 0.22; }
clear() { this.setCurrHit(undefined); }
setHitList(list) { this.hitList = list; }
setCurrHit(hit) { this.currHit = hit; }
getNextHit() { return this.hitList ? this.hitList.getNextHit() : undefined; }
/** return the current path from either the snapping logic or the pre-locating systems. */
getPreLocatedHit() {
// NOTE: Check AccuSnap first as Tentative is used to build intersect snap. For normal snaps when a Tentative is active there should be no AccuSnap.
const fromAccuSnap = IModelApp.accuSnap.getHitAndList(this);
const preLocated = fromAccuSnap ?? IModelApp.tentativePoint.getHitAndList(this);
if (preLocated) {
const excludedElements = (preLocated.isElementHit ? new Set([preLocated.sourceId]) : undefined);
if (excludedElements || !fromAccuSnap) {
// NOTE: For tentative snap, get new hit list at snap point; want reset to cycle hits using adjusted point location...
const point = (fromAccuSnap ? preLocated.hitPoint : preLocated.getPoint());
const vp = preLocated.viewport;
this.picker.empty();
this.picker.doPick(vp, point, (vp.pixelsFromInches(this.apertureInches) / 2.0) + 1.5, this.options, excludedElements);
this.setHitList(this.picker.getHitList(true));
if (excludedElements) {
if (undefined === this.hitList)
this.hitList = new HitList();
this.hitList.insertHit(0, preLocated);
}
}
}
if (this.hitList)
this.hitList.resetCurrentHit();
return preLocated;
}
async filterHit(hit, _action, out) {
// Tools must opt-in to locate of transient geometry as it requires special treatment.
if (!this.options.allowDecorations && !hit.isElementHit) {
if (hit.isModelHit)
out.reason = ElementLocateManager.getFailureMessageKey("RealityModel");
else if (hit.isMapHit)
out.reason = ElementLocateManager.getFailureMessageKey("Map");
else
out.reason = ElementLocateManager.getFailureMessageKey("Transient");
return LocateFilterStatus.Reject;
}
// Tools must opt-in to locate geometry from external iModels.
if (!this.options.allowExternalIModels && hit.isExternalIModelHit) {
out.reason = ElementLocateManager.getFailureMessageKey("ExternalIModel");
return LocateFilterStatus.Reject;
}
if (undefined !== hit.subCategoryId && !hit.isExternalIModelHit) {
const appearance = hit.viewport.getSubCategoryAppearance(hit.subCategoryId);
if (appearance.dontLocate) {
out.reason = ElementLocateManager.getFailureMessageKey("NotLocatableSubCategory");
return LocateFilterStatus.Reject;
}
}
const tool = IModelApp.toolAdmin.activeTool;
if (!(tool && tool instanceof InteractiveTool))
return LocateFilterStatus.Accept;
const status = await tool.filterHit(hit, out);
if (LocateFilterStatus.Reject === status)
out.reason = ElementLocateManager.getFailureMessageKey("ByApp");
return status;
}
initLocateOptions() { this.options.init(); }
initToolLocate() {
this.initLocateOptions();
this.clear();
this.picker.empty();
IModelApp.tentativePoint.clear(true);
}
async _doLocate(response, newSearch, testPoint, vp, source, filterHits) {
if (!vp)
return;
// the "newSearch" flag indicates whether the caller wants us to conduct a new search at the testPoint, or just continue returning paths from the previous search.
if (newSearch) {
const hit = this.getPreLocatedHit();
// if we're snapped to something, that path has the highest priority and becomes the active hit.
if (hit) {
if (!filterHits || LocateFilterStatus.Accept === await this.filterHit(hit, LocateAction.Identify, response))
return hit;
response = new LocateResponse(); // we have the reason and explanation we want.
}
this.picker.empty();
this.picker.doPick(vp, testPoint, (vp.pixelsFromInches(InputSource.Touch === source ? this.touchApertureInches : this.apertureInches) / 2.0) + 1.5, this.options);
const hitList = this.picker.getHitList(true);
this.setHitList(hitList);
}
let newHit;
while (undefined !== (newHit = this.getNextHit())) {
if (!filterHits || LocateFilterStatus.Accept === await this.filterHit(newHit, LocateAction.Identify, response))
return newHit;
response = new LocateResponse(); // we have the reason and explanation we want.
}
return undefined;
}
async doLocate(response, newSearch, testPoint, view, source, filterHits = true) {
response.reason = ElementLocateManager.getFailureMessageKey("NoElements");
response.explanation = "";
const hit = await this._doLocate(response, newSearch, testPoint, view, source, filterHits);
this.setCurrHit(hit);
// if we found a hit, remove it from the list of remaining hits near the current search point.
if (hit && this.hitList)
this.hitList.removeHitsFrom(hit.sourceId);
return hit;
}
}
//# sourceMappingURL=ElementLocateManager.js.map