@itwin/measure-tools-react
Version:
Frontend framework and tools for measurements
454 lines • 21.2 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { Geometry, Range1d } from "@itwin/core-geometry";
import { IModelJson, LineSegment3d, Point3d, PointString3d, Ray3d, Vector3d, } from "@itwin/core-geometry";
import { GraphicType, IModelApp, QuantityType } from "@itwin/core-frontend";
import { FormatterUtils } from "../api/FormatterUtils.js";
import { StyleSet, TextOffsetType, WellKnownGraphicStyleType, WellKnownTextStyleType, } from "../api/GraphicStyle.js";
import { Measurement, MeasurementPickContext, MeasurementSerializer, } from "../api/Measurement.js";
import { MeasurementManager } from "../api/MeasurementManager.js";
import { MeasurementPreferences, MeasurementPreferencesProperty, } from "../api/MeasurementPreferences.js";
import { MeasurementPropertyHelper } from "../api/MeasurementPropertyHelper.js";
import { MeasurementSelectionSet } from "../api/MeasurementSelectionSet.js";
import { TextMarker } from "../api/TextMarker.js";
import { MeasureTools } from "../MeasureTools.js";
/** Serializer for a [[DistanceMeasurement]]. */
export class DistanceMeasurementSerializer extends MeasurementSerializer {
get measurementName() {
return DistanceMeasurementSerializer.distanceMeasurementName;
}
isValidType(measurement) {
return measurement instanceof DistanceMeasurement;
}
isValidJSON(json) {
if (!super.isValidJSON(json) ||
!json.hasOwnProperty("startPoint") ||
!json.hasOwnProperty("endPoint"))
return false;
return true;
}
parseSingle(data) {
if (!this.isValidJSON(data))
return undefined;
const props = data;
return DistanceMeasurement.fromJSON(props);
}
}
DistanceMeasurementSerializer.distanceMeasurementName = "distanceMeasurement";
/**
* Distance measurement. Composed of a line and a formatted distance in a "text pill" in the middle of the line.
*/
export class DistanceMeasurement extends Measurement {
get startPointRef() {
return this._startPoint;
}
get endPointRef() {
return this._endPoint;
}
get isDynamic() {
return this._isDynamic;
}
set isDynamic(v) {
this._isDynamic = v;
this._runRiseAxes.forEach((axis) => (axis.isDynamic = v));
if (this._textMarker)
this._textMarker.pickable = !v;
}
get showAxes() {
return this._showAxes;
}
set showAxes(v) {
this._showAxes = v;
}
// eslint-disable-next-line @typescript-eslint/naming-convention
get isAxis() {
return (undefined !== this._textStyleOverride ||
undefined !== this._graphicStyleOverride);
}
constructor(props) {
super(props);
this._startPoint = Point3d.createZero();
this._endPoint = Point3d.createZero();
this._isDynamic = false;
this._showAxes = MeasurementPreferences.current.displayMeasurementAxes;
this._runRiseAxes = [];
if (props)
this.readFromJSON(props);
this.createTextMarker().catch(); // eslint-disable-line @typescript-eslint/no-floating-promises
}
setStartPoint(point) {
this._startPoint.setFrom(point);
this.createTextMarker().catch(); // eslint-disable-line @typescript-eslint/no-floating-promises
this.buildRunRiseAxes();
}
setEndPoint(point) {
this._endPoint.setFrom(point);
this.createTextMarker().catch(); // eslint-disable-line @typescript-eslint/no-floating-promises
this.buildRunRiseAxes();
}
setStartEndPoints(start, end) {
this._startPoint.setFrom(start);
this._endPoint.setFrom(end);
this.createTextMarker().catch(); // eslint-disable-line @typescript-eslint/no-floating-promises
this.buildRunRiseAxes();
}
setupAxis(ovrGraphicStyle, ovrTextStyle, start, end) {
this._graphicStyleOverride = ovrGraphicStyle;
this._textStyleOverride = ovrTextStyle;
this._startPoint.setFrom(start);
this._endPoint.setFrom(end);
}
testDecorationHit(pickContext) {
if (this.transientId && this.transientId === pickContext.geomId)
return true;
if (pickContext.buttonEvent &&
this._textMarker &&
this.displayLabels &&
this._textMarker.pick(pickContext.buttonEvent.viewPoint))
return true;
if (this._showAxes) {
for (const axis of this._runRiseAxes) {
if (axis.testDecorationHit(pickContext))
return true;
}
}
return false;
}
getDecorationGeometry(pickContext) {
const geometry = [
IModelJson.Writer.toIModelJson(PointString3d.create(this._startPoint, this._endPoint)),
];
if (this._showAxes) {
this._runRiseAxes.forEach((axis) => {
const geom = axis.getDecorationGeometry(pickContext);
if (geom)
geometry.push(geom);
});
}
return geometry;
}
async getDecorationToolTip(_pickContext) {
return MeasureTools.localization.getLocalizedString("MeasureTools:Measurements.distanceMeasurement");
}
getSnapId() {
if (!this.transientId)
this.transientId = MeasurementSelectionSet.nextTransientId;
if (this.isDynamic)
return undefined;
return this.transientId;
}
onTransientIdChanged(_prevId) {
this._runRiseAxes.forEach((axis) => (axis.transientId = this.transientId));
if (this._textMarker)
this._textMarker.transientHiliteId = this.transientId;
}
decorate(context) {
super.decorate(context);
const styleTheme = StyleSet.getOrDefault(this.activeStyle);
const points = [this._startPoint, this._endPoint];
const style = styleTheme.getGraphicStyle(this._graphicStyleOverride ||
WellKnownGraphicStyleType.DistanceMeasurement);
const xBuilder = context.createGraphicBuilder(GraphicType.WorldOverlay, undefined, this.getSnapId());
style.addStyledLineString(xBuilder, points, true);
style.addStyledPointString(xBuilder, points, true);
context.addDecorationFromBuilder(xBuilder);
if (!this._textMarker || this._startPoint.isAlmostEqual(this._endPoint))
return;
const textLocation = this.calculateWorldTextLocation(context);
if (undefined === textLocation)
return;
this._textMarker.worldLocation = textLocation;
// Determine which side to place the text marker relative to its anchor point
const vPoints = [this._startPoint.clone(), this._endPoint.clone()];
context.viewport.worldToViewArray(vPoints);
const v0 = Vector3d.createStartEnd(vPoints[1], vPoints[0]);
v0.z = 0.0;
v0.rotate90CCWXY(v0);
v0.normalizeInPlace();
const xMax = 0.65;
const yMax = 0.9;
const x = v0.x < 0 ? Math.max(v0.x, -xMax) : Math.min(v0.x, xMax);
const y = v0.y < 0 ? Math.max(v0.y, -yMax) : Math.min(v0.y, yMax);
this._textMarker.offset = { type: TextOffsetType.Percentage, x, y };
if (this.displayLabels)
this._textMarker.addDecoration(context);
if (this._showAxes)
this._runRiseAxes.forEach((axis) => axis.decorate(context));
}
/** Clamps the segment to the current view frustum and return its midpoint.
* @remarks Returns undefined if the segment is entirely 'behind' the camera eye
*/
calculateWorldTextLocation(context) {
const clipFront = context.viewport.view.is3d() && context.viewport.view.isCameraOn;
const clipPlanes = context.viewport.getWorldFrustum().getRangePlanes(clipFront, false, 0.0);
const startIn = clipPlanes.isPointOnOrInside(this._startPoint, Geometry.smallMetricDistance);
const endIn = clipPlanes.isPointOnOrInside(this._endPoint, Geometry.smallMetricDistance);
if (startIn && endIn)
return Point3d.createAdd2Scaled(this._startPoint, 0.5, this._endPoint, 0.5);
const range = Range1d.createNull();
let ray = Ray3d.createStartEnd(this._startPoint.clone(), this._endPoint);
// Either start/end or BOTH are outside the clip planes. If nothing intersects, don't bother displaying anything.
if (!clipPlanes.hasIntersectionWithRay(ray, range))
return undefined;
let clampedStartPoint = this._startPoint;
let clampedEndPoint = this._endPoint;
if (!endIn) {
if (range.high < 0)
return undefined;
clampedEndPoint = ray.fractionToPoint(range.high);
}
if (!startIn) {
ray = Ray3d.createStartEnd(this._endPoint.clone(), this._startPoint);
if (!clipPlanes.hasIntersectionWithRay(ray, range) || range.high < 0)
return undefined;
clampedStartPoint = ray.fractionToPoint(range.high);
}
return Point3d.createAdd2Scaled(clampedStartPoint, 0.5, clampedEndPoint, 0.5);
}
buildRunRiseAxes() {
this._runRiseAxes = [];
if (this.isAxis)
return;
// Run point
const runPoint = this._endPoint.clone();
runPoint.z = this._startPoint.z;
// It"s irrelevant to draw the axes because we"re in 2D already.
if (runPoint.isAlmostEqual(this._endPoint))
return;
const lines = [
LineSegment3d.create(this._endPoint, runPoint),
LineSegment3d.create(runPoint, this._startPoint),
];
const graphicStyles = [
WellKnownGraphicStyleType.Rise,
WellKnownGraphicStyleType.Run,
];
const textStyles = [
WellKnownTextStyleType.Rise,
WellKnownTextStyleType.Run,
];
for (let i = 0; i < 2; ++i) {
const line = lines[i];
const dm = new DistanceMeasurement();
dm.setupAxis(graphicStyles[i], textStyles[i], line.point0Ref, line.point1Ref);
dm.viewTarget.copyFrom(this.viewTarget);
dm.isDynamic = this.isDynamic;
dm.transientId = this.transientId;
dm.displayLabels = this.displayLabels;
this._runRiseAxes.push(dm);
}
// When all text markers are ready for display, trigger a refresh
const promises = this._runRiseAxes.map(async (value) => value.createTextMarker()); // eslint-disable-line @typescript-eslint/no-floating-promises
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Promise.all(promises)
.then(() => {
for (const axis of this._runRiseAxes) {
if (axis._textMarker) {
axis._textMarker.pickable = !this._isDynamic;
axis._textMarker.transientHiliteId = this.transientId;
axis._textMarker.setMouseButtonHandler(this.handleTextMarkerButtonEvent.bind(this));
}
}
if (this._showAxes)
IModelApp.viewManager.invalidateDecorationsAllViews();
})
.catch();
}
onStyleChanged(_isLock, _prevStyle) {
this.updateMarkerStyle();
}
onLockToggled() {
this.updateMarkerStyle();
}
onDisplayLabelsToggled() {
for (const dm of this._runRiseAxes)
dm.displayLabels = this.displayLabels;
}
onDisplayUnitsChanged() {
this.createTextMarker().catch(); // eslint-disable-line @typescript-eslint/no-floating-promises
this._runRiseAxes.forEach((axis) => axis.onDisplayUnitsChanged());
}
updateMarkerStyle() {
if (!this._textMarker)
return;
const styleTheme = StyleSet.getOrDefault(this.activeStyle);
const tStyle = styleTheme.getTextStyle(this._textStyleOverride || WellKnownTextStyleType.DistanceMeasurement);
this._textMarker.applyStyle(tStyle);
}
async createTextMarker() {
const lengthSpec = await IModelApp.quantityFormatter.getFormatterSpecByQuantityType(QuantityType.LengthEngineering);
const distance = this._startPoint.distance(this._endPoint);
const fDistance = IModelApp.quantityFormatter.formatQuantity(distance * this.worldScale, lengthSpec);
const midPoint = Point3d.createAdd2Scaled(this._startPoint, 0.5, this._endPoint, 0.5);
const styleTheme = StyleSet.getOrDefault(this.activeStyle);
const tStyle = styleTheme.getTextStyle(this._textStyleOverride || WellKnownTextStyleType.DistanceMeasurement);
this._textMarker = TextMarker.createStyled([fDistance], midPoint, tStyle);
this._textMarker.transientHiliteId = this.transientId;
this._textMarker.pickable = !this.isDynamic;
this._textMarker.setMouseButtonHandler(this.handleTextMarkerButtonEvent.bind(this));
}
handleTextMarkerButtonEvent(ev) {
if (!this._isDynamic) {
void this.onDecorationButtonEvent(MeasurementPickContext.createFromSourceId("Invalid", ev));
}
return true;
}
async getDataForMeasurementWidgetInternal() {
const lengthSpec = await IModelApp.quantityFormatter.getFormatterSpecByQuantityType(QuantityType.LengthEngineering);
const distance = this.worldScale * this._startPoint.distance(this._endPoint);
const run = this.drawingMetadata?.worldScale !== undefined ? this.worldScale * Math.abs(this._endPoint.x - this._startPoint.x) : this._startPoint.distanceXY(this._endPoint);
const rise = this.drawingMetadata?.worldScale !== undefined ? this.worldScale * (this._endPoint.y - this._startPoint.y) : this._endPoint.z - this._startPoint.z;
const slope = 0.0 < run ? (100 * rise) / run : 0.0;
const dx = Math.abs(this._endPoint.x - this._startPoint.x);
const dy = Math.abs(this._endPoint.y - this._startPoint.y);
const adjustedStart = this.adjustPointForGlobalOrigin(this._startPoint);
const adjustedEnd = this.adjustPointForGlobalOrigin(this._endPoint);
const fDistance = IModelApp.quantityFormatter.formatQuantity(distance, lengthSpec);
const fStartCoords = FormatterUtils.formatCoordinatesImmediate(adjustedStart);
const fEndCoords = FormatterUtils.formatCoordinatesImmediate(adjustedEnd);
const fSlope = FormatterUtils.formatSlope(slope, true);
const fRun = IModelApp.quantityFormatter.formatQuantity(run, lengthSpec);
const fDeltaX = IModelApp.quantityFormatter.formatQuantity(dx, lengthSpec);
const fDeltaY = IModelApp.quantityFormatter.formatQuantity(dy, lengthSpec);
const fRise = IModelApp.quantityFormatter.formatQuantity(rise, lengthSpec);
let title = MeasureTools.localization.getLocalizedString("MeasureTools:Measurements.distanceMeasurement");
title += ` [${fDistance}]`;
const data = { title, properties: [] };
MeasurementPropertyHelper.tryAddNameProperty(this, data.properties);
data.properties.push({
label: MeasureTools.localization.getLocalizedString("MeasureTools:tools.MeasureDistance.distance"),
name: "DistanceMeasurement_Distance",
value: fDistance,
aggregatableValue: lengthSpec !== undefined
? { value: distance, formatSpec: lengthSpec }
: undefined,
}, {
label: MeasureTools.localization.getLocalizedString("MeasureTools:tools.MeasureDistance.run"),
name: "DistanceMeasurement_Run",
value: fRun,
aggregatableValue: lengthSpec !== undefined
? { value: run, formatSpec: lengthSpec }
: undefined,
}, {
label: MeasureTools.localization.getLocalizedString("MeasureTools:tools.MeasureDistance.rise"),
name: "DistanceMeasurement_Rise",
value: fRise,
}, {
label: MeasureTools.localization.getLocalizedString("MeasureTools:tools.MeasureDistance.slope"),
name: "DistanceMeasurement_Slope",
value: fSlope,
});
if (this.drawingMetadata?.worldScale === undefined) {
data.properties.push({
label: MeasureTools.localization.getLocalizedString("MeasureTools:tools.MeasureDistance.delta_x"),
name: "DistanceMeasurement_Dx",
value: fDeltaX,
}, {
label: MeasureTools.localization.getLocalizedString("MeasureTools:tools.MeasureDistance.delta_y"),
name: "DistanceMeasurement_Dy",
value: fDeltaY,
}, {
label: MeasureTools.localization.getLocalizedString("MeasureTools:tools.MeasureDistance.startCoordinates"),
name: "DistanceMeasurement_StartPoint",
value: fStartCoords,
}, {
label: MeasureTools.localization.getLocalizedString("MeasureTools:tools.MeasureDistance.endCoordinates"),
name: "DistanceMeasurement_EndPoint",
value: fEndCoords,
});
}
return data;
}
/**
* Tests equality with another measurement.
* @param other Measurement to test equality for.
* @param opts Options for equality testing.
* @returns true if the other measurement is equal, false if some property is not the same or if the measurement is not of the same type.
*/
equals(other, opts) {
if (!super.equals(other, opts))
return false;
// Compare data (ignore isDynamic)
const tol = opts ? opts.tolerance : undefined;
const otherDist = other;
if (otherDist === undefined ||
!this._startPoint.isAlmostEqual(otherDist._startPoint, tol) ||
!this._endPoint.isAlmostEqual(otherDist._endPoint, tol) ||
this._showAxes !== otherDist._showAxes)
return false;
return true;
}
/**
* Copies data from the other measurement into this instance.
* @param other Measurement to copy property values from.
*/
copyFrom(other) {
super.copyFrom(other);
if (other instanceof DistanceMeasurement) {
this._isDynamic = other._isDynamic;
this._showAxes = other._showAxes;
this._startPoint.setFrom(other._startPoint);
this._endPoint.setFrom(other._endPoint);
this.buildRunRiseAxes();
this.createTextMarker().catch(); // eslint-disable-line @typescript-eslint/no-floating-promises
}
}
/**
* Deserializes properties (if they exist) from the JSON object.
* @param json JSON object to read data from.
*/
readFromJSON(json) {
super.readFromJSON(json);
const jsonDist = json;
if (jsonDist.startPoint !== undefined)
this._startPoint.setFromJSON(jsonDist.startPoint);
if (jsonDist.endPoint !== undefined)
this._endPoint.setFromJSON(jsonDist.endPoint);
this._showAxes =
jsonDist.showAxes !== undefined
? jsonDist.showAxes
: MeasurementPreferences.current.displayMeasurementAxes;
this.buildRunRiseAxes();
this.createTextMarker().catch(); // eslint-disable-line @typescript-eslint/no-floating-promises
}
/**
* Serializes properties to a JSON object.
* @param json JSON object to append data to.
*/
writeToJSON(json) {
super.writeToJSON(json);
const jsonDist = json;
jsonDist.startPoint = this._startPoint.toJSON();
jsonDist.endPoint = this._endPoint.toJSON();
jsonDist.showAxes = this._showAxes;
}
static create(start, end, viewType) {
// Don't ned to serialize the points, will just work as is
const measurement = new DistanceMeasurement({
startPoint: start,
endPoint: end,
});
if (viewType)
measurement.viewTarget.include(viewType);
return measurement;
}
static fromJSON(data) {
return new DistanceMeasurement(data);
}
}
DistanceMeasurement.serializer = Measurement.registerSerializer(new DistanceMeasurementSerializer());
// Ensure all distance measurements respond to when show axes is turned on/off in preferences
function onDisplayMeasurementAxesHandler(propChanged) {
if (propChanged !== MeasurementPreferencesProperty.displayMeasurementAxes)
return;
const showAxes = MeasurementPreferences.current.displayMeasurementAxes;
MeasurementManager.instance.forAllMeasurements((measurement) => {
if (measurement instanceof DistanceMeasurement)
measurement.showAxes = showAxes;
return true;
});
}
MeasurementPreferences.current.onPreferenceChanged.addListener(onDisplayMeasurementAxesHandler);
//# sourceMappingURL=DistanceMeasurement.js.map