@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
1,203 lines (1,034 loc) • 42.1 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import {
AdditiveBlending,
BackSide,
EventDispatcher,
MeshBasicMaterial,
Vector2,
Vector3,
} from 'three';
import type Disposable from '../core/Disposable';
import type Instance from '../core/Instance';
import type PickResult from '../core/picking/PickResult';
import type { EntityUserData } from '../entities/Entity';
import type { ShapePickResult, VerticalLineLabelFormatter } from '../entities/Shape';
import Shape, {
angleFormatter,
isShape,
isShapePickResult,
slopeSegmentFormatter,
type ShapeOptions,
} from '../entities/Shape';
import ConstantSizeSphere from '../renderer/ConstantSizeSphere';
import { isVector2 } from '../utils/predicates';
import { AbortError } from '../utils/PromiseUtils';
const DEFAULT_MARKER_RADIUS = 5;
const MIN_MARKER_RADIUS = 4;
const MARKER_BORDER_WIDTH = 2;
const OPACITY_OVER_VERTEX = 0.4;
const OPACITY_OVER_EDGE = 0.4;
const SQUARE_DISTANCE_LIMIT_FOR_CLICK_DETECTION = 25; // 5 pixels squared
const tmpVec2 = new Vector2();
/**
* Various constraints that can be applied to shapes created by this tool.
*/
interface Permissions {
insertPoint: boolean;
movePoint?: boolean;
removePoint?: boolean;
}
interface ShapeUserData extends EntityUserData {
permissions?: Permissions;
}
/**
* A callback that can be used to test for a mouse button or key combination.
* If the function returns `true`, the associated action is executed.
*/
export type MouseCallback = (e: MouseEvent) => boolean;
/**
* A pick function that is used by the drawtool to interact with the scene.
*/
export type PickCallback<T extends PickResult = PickResult> = (
eventOrCanvasCoordinate: MouseEvent | Vector2,
) => T[];
export interface CommonCreationOptions {
/**
* The optional signal to listen to cancel the creation of a shape.
*/
signal?: AbortSignal;
/**
* The optional custom picking function.
*/
pick?: PickCallback;
/**
* An optional callback to be called when a point has been moved.
* @param shape - The shape being created.
* @param position - The position of the point.
*/
onTemporaryPointMoved?: (shape: Shape, position: Vector3) => void;
/**
* The input required to finish drawing the shape.
* Does not apply to shapes that require a fixed number of points (i.e point, segment, etc).
* @defaultValue right click
*/
endCondition?: MouseCallback;
}
export interface CreationOptions extends Partial<ShapeOptions>, CommonCreationOptions {}
/**
* Verify that the given operation is possible on the shape.
*
* Note: if the shape was created outside of this tool,
* the operations list is absent. In that case we allow every operation.
*/
function isOperationAllowed<K extends keyof Permissions>(
shape: Shape<ShapeUserData>,
constraint: K,
): boolean {
if (shape.userData.permissions == null) {
return true;
}
return shape.userData.permissions[constraint] ?? true;
}
const isFirstVertexPicked = (shape: Shape, e: MouseEvent | Vector2): boolean => {
const canvasCoordinates = isVector2(e) ? e : tmpVec2.set(e.offsetX, e.offsetY);
const pickSelf = shape.pick(canvasCoordinates);
return pickSelf.length > 0 && pickSelf[0].pickedVertexIndex === 0;
};
/**
* Options for the {@link DrawTool.createShape} method.
*/
export interface CreateShapeOptions extends Partial<ShapeOptions>, CommonCreationOptions {
/**
* The minimum number of points to create before the shape can be completed.
*/
minPoints?: number;
/**
* The maximum number of points to create before the shape is automatically completed.
*/
maxPoints?: number;
/**
* If `true`, the shape's line will be closed just before being returned to the caller.
*/
closeRing?: boolean;
/**
* An optional callback to be called when a point has been added to the shape.
* @param shape - The shape being created.
* @param index - The index of the point.
* @param position - The position of the point.
*/
onPointCreated?: (shape: Shape, index: number, position: Vector3) => void;
/**
* An optional list of permitted operations.
*/
constraints?: Permissions;
}
function inhibit(e: Event): void {
e.preventDefault();
e.stopImmediatePropagation();
e.stopPropagation();
}
const verticalLengthFormatter: VerticalLineLabelFormatter = (params: {
shape: Shape;
defaultFormatter: VerticalLineLabelFormatter;
vertexIndex: number;
length: number;
}) => {
if (params.vertexIndex === 0) {
// We don't want to display the first label because it will have a length of zero.
return null;
}
return params.defaultFormatter(params);
};
export interface DrawToolEventMap {
'start-drag': Record<string, unknown>;
'end-drag': Record<string, unknown>;
}
/**
* A hook that prevents the operation from occuring.
*/
export const inhibitHook = (): boolean => false;
/**
* A hook that prevents the removal of a point if the new number of points is below a limit (e.g
* removing a point of a 2-point LineString).
*/
export const limitRemovePointHook =
(limit: number) =>
(options: { shape: Shape }): boolean => {
return options.shape.points.length > limit;
};
/**
* A hook that ensures the ring remains closed after the first or last point of the ring is removed.
*/
export const afterRemovePointOfRing = (options: { shape: Shape; index: number }): void => {
const { shape, index } = options;
if (index === 0) {
// Also remove last point
shape.removePoint(shape.points.length - 1);
} else if (index === shape.points.length - 1) {
// Also remove first point
shape.removePoint(0);
}
shape.makeClosed();
};
/**
* A hook that ensures the ring remains closed after the first or last point of the ring is moved.
*/
export const afterUpdatePointOfRing = (options: {
shape: Shape;
index: number;
newPosition: Vector3;
}): void => {
const { index, shape, newPosition } = options;
if (index === 0) {
// Also update last point
shape.updatePoint(shape.points.length - 1, newPosition);
} else if (index === shape.points.length - 1) {
// Also update first point
shape.updatePoint(0, newPosition);
}
};
const LEFT_BUTTON = 0;
const MIDDLE_BUTTON = 1;
const RIGHT_BUTTON = 2;
function middleButtonOrLeftButtonAndAlt(e: Event): boolean {
if (e.type === 'mousedown') {
const mouseEvent = e as MouseEvent;
if (mouseEvent.button === MIDDLE_BUTTON) {
return true;
}
// OpenLayers style
if (mouseEvent.button === LEFT_BUTTON && mouseEvent.altKey) {
return true;
}
}
return false;
}
function leftButton(e: Event): boolean {
if (e.type === 'mousedown') {
if ((e as MouseEvent).button === LEFT_BUTTON) {
return true;
}
}
return false;
}
let lastMousePosition: Vector2 | null = null;
let mouseCumulativeDistance: Vector2 | null = null;
const rightClick: MouseCallback = e => {
if (e.type === 'mousedown' && e.button === RIGHT_BUTTON) {
mouseCumulativeDistance = new Vector2(0, 0);
lastMousePosition = new Vector2(e.screenX, e.screenY);
} else if (e.type === 'mousemove') {
if (lastMousePosition != null && mouseCumulativeDistance != null) {
const deltaX = Math.abs(e.screenX - lastMousePosition.x);
const deltaY = Math.abs(e.screenY - lastMousePosition.y);
mouseCumulativeDistance.x += deltaX;
mouseCumulativeDistance.y += deltaY;
}
lastMousePosition?.set(e.screenX, e.screenY);
} else if (e.type === 'mouseup' && e.button === RIGHT_BUTTON) {
const sqDistance = mouseCumulativeDistance?.lengthSq() ?? 0;
// We don't want the prevent the user from using the right button for other purposes,
// e.g rotating the camera, so let's ensure that any dragging motion of the mouse is
// not intepreted as a click. Note that the "contextmenu" event is not 100% equivalent
// to a right click, so we're not using it.
if (sqDistance < SQUARE_DISTANCE_LIMIT_FOR_CLICK_DETECTION) {
e.stopPropagation();
mouseCumulativeDistance = null;
lastMousePosition = null;
return true;
}
}
return false;
};
const doubleClick: MouseCallback = e => {
if (e.type === 'dblclick') {
if ((e as MouseEvent).button === LEFT_BUTTON) {
e.stopPropagation();
return true;
}
}
return false;
};
export const conditions = {
rightClick,
doubleClick,
};
/**
* A callback that is called after a shape has been modified.
*/
export type ShapeModifiedCallback<T> = (
arg: {
/**
* The modified shape.
*/
shape: Shape;
} & T,
) => void;
/**
* Called when a point has been inserted in a shape during edition.
*/
export type PointInsertedCallback = ShapeModifiedCallback<{
/**
* The index of the inserted point.
*/
pointIndex: number;
/**
* The position of the inserted point.
*/
position: Vector3;
}>;
/**
* Called when a point has been removed in a shape during edition.
*/
export type PointRemovedCallback = ShapeModifiedCallback<{
/**
* The index of the inserted point.
*/
pointIndex: number;
}>;
/**
* Called when a point has been moved during edition.
*/
export type PointUpdatedCallback = ShapeModifiedCallback<{
/**
* The index of the updated point.
*/
pointIndex: number;
/**
* The new position of the updated point.
*/
newPosition: Vector3;
}>;
function computeMarkerRadius(shape: Shape, type: 'vertex' | 'segment'): number {
let baseRadius: number;
// If we display the vertex marker on a vertex, we need it to be slightly
// bigger than the vertex. Otherwise, make it slightly bigger than the line.
switch (type) {
case 'vertex':
baseRadius =
shape.showVertices === true
? shape.vertexRadius + shape.borderWidth
: DEFAULT_MARKER_RADIUS;
break;
case 'segment':
baseRadius = shape.lineWidth / 2 + shape.borderWidth;
break;
}
return Math.max(MIN_MARKER_RADIUS, baseRadius + MARKER_BORDER_WIDTH);
}
/**
* A tool that allows interactive creation and edition of {@link Shape}s.
*
* ## Creation
*
* To create shapes, you can either use one of the preset methods ({@link createSegment},
* {@link createPolygon}...), or start creating a free shape with {@link createShape}.
*
* This method allows fine control over the constraints to apply to the shape (how many vertices,
* styling options, what component to display...).
*
* ## Edition
*
* The {@link enterEditMode} method allows the user to edit any shape that the mouse interacts with.
* Depending on the constraints put on the shape during the creation (assuming of course that the
* shape was created with this tool), some operations might not be permitted.
*
* To exit edition mode, call {@link exitEditMode}.
*
* ### Examples of constraints
*
* - If a shape was created with the {@link createSegment} method, it is not possible to insert
* or remove points, because the constraint forces the shape to have exactly 2 points.
*
* - If a shape was created with the {@link createPolygon} method, then any time the user moves the first or
* last vertex, the other one is automatically moved at the same position, to ensure the shape
* remains closed.
*/
export class DrawTool extends EventDispatcher<DrawToolEventMap> implements Disposable {
private readonly _domElement: HTMLElement;
private readonly _instance: Instance;
private readonly _markerMaterial: MeshBasicMaterial;
private _selectedVertexMarker?: ConstantSizeSphere;
private _editionModeController?: AbortController;
private _inhibitEdition = false;
private _mouseEventHandler: (e: MouseEvent) => void;
private _lastMouseCoordinate: Vector2 | null = null;
public constructor(options: {
/**
* The Giro3D instance.
*/
instance: Instance;
/**
* The DOM element to listen to. If unspecified, this will use {@link Instance.domElement}.
*/
domElement?: HTMLElement;
}) {
super();
this._instance = options.instance;
this._domElement = options.domElement ?? this._instance.domElement;
this._markerMaterial = new MeshBasicMaterial({
color: 'white',
depthTest: false,
side: BackSide,
transparent: true,
blending: AdditiveBlending,
});
// We listen to the global mousemove event to track the mouse location without
// relying on a mousemove event on the DOM element (which might not be focused yet).
// This will be used to preview the shape being created, even when the mouse has not been
// moved after the creation started. This can happen if the creation is triggered by a
// key press rather than a click for example.
this._mouseEventHandler = this.onMouseEvent.bind(this);
window.addEventListener('mousemove', this._mouseEventHandler);
}
private onMouseEvent(e: MouseEvent): void {
const rect = this._domElement.getBoundingClientRect();
const x = e.clientX - rect.x;
const y = e.clientY - rect.y;
this._lastMouseCoordinate = new Vector2(x, y);
}
private defaultPickShapes(e: MouseEvent | Vector2, shapes?: Shape[]): ShapePickResult[] {
return this._instance.pickObjectsAt(e, {
where: shapes,
sortByDistance: true,
}) as ShapePickResult[];
}
private defaultPick(e: MouseEvent | Vector2): PickResult[] {
return this._instance.pickObjectsAt(e, { sortByDistance: true });
}
private hideVertexMarker(): void {
if (this._selectedVertexMarker != null) {
this._selectedVertexMarker.visible = false;
}
this._instance.notifyChange();
}
private displayVertexMarker(
shape: Shape,
position: Vector3,
radius: number,
opacity: number,
): void {
if (this._selectedVertexMarker == null) {
this._selectedVertexMarker = new ConstantSizeSphere({
radius: radius,
material: this._markerMaterial,
});
this._selectedVertexMarker.enableRaycast = false;
this._selectedVertexMarker.visible = false;
this._instance.add(this._selectedVertexMarker);
}
this._selectedVertexMarker.renderOrder = shape.renderOrder + 1000;
this._selectedVertexMarker.visible = true;
this._selectedVertexMarker.radius = radius;
this._markerMaterial.opacity = opacity;
this._selectedVertexMarker.position.copy(position);
this._selectedVertexMarker.updateMatrixWorld(true);
this._instance.notifyChange();
}
/**
* Enter edition mode. In this mode, existing {@link Shape}s can be modified (add/remove points, move points).
* @param options - The options.
*/
public enterEditMode(options?: {
/**
* The custom picking function. If unspecified, the default one will be used.
*/
pick?: PickCallback;
/**
* A picking function to pick **shapes only**. If unspecified, the default one will be used.
*/
pickShapes?: PickCallback<ShapePickResult>;
/**
* The optional callback called just before a point is clicked, to determine if it can be deleted.
* By default, points are removed with a **click on the middle mouse button** or **Alt + Left click**.
*/
onBeforePointRemoved?: MouseCallback;
/**
* The optional callback called just before a point is clicked, to determine if it can be moved.
* By default, points are moved with a **left click**.
*/
onBeforePointMoved?: MouseCallback;
/**
* The optional callback to test for mouse or key combination when a segment is clicked.
* By default, points are inserted with a **left click**.
*/
onSegmentClicked?: MouseCallback;
/**
* An optional callback called when a point has been inserted.
*/
onPointInserted?: PointInsertedCallback;
/**
* An optional callback called when a point has been removed.
*/
onPointRemoved?: PointRemovedCallback;
/**
* An optional callback called when a point has been updated (i.e moved).
*/
onPointUpdated?: PointUpdatedCallback;
/**
* The shapes to edit. If `undefined` or empty, all shapes become editable.
*/
shapesToEdit?: Shape[];
}): void {
this._editionModeController?.abort();
this._editionModeController = new AbortController();
// Optionally limit the shapes to edit to the specified entity ids.
let ids: Set<string> | null = null;
if (options?.shapesToEdit != null && options.shapesToEdit.length > 0) {
ids = new Set(options.shapesToEdit.map(shape => shape.id));
}
const onBeforePointRemoved =
options?.onBeforePointRemoved ?? middleButtonOrLeftButtonAndAlt;
const onBeforePointMoved = options?.onBeforePointMoved ?? leftButton;
const onBeforePointInserted = options?.onSegmentClicked ?? leftButton;
const noOp = (): void => {};
const onPointInserted = options?.onPointInserted ?? noOp;
const onPointRemoved = options?.onPointRemoved ?? noOp;
const onPointUpdated = options?.onPointUpdated ?? noOp;
const pick: PickCallback = options?.pick ?? this.defaultPick.bind(this);
const pickShapes: PickCallback<ShapePickResult> =
options?.pickShapes ??
((e): ShapePickResult[] => this.defaultPickShapes(e, options?.shapesToEdit));
const pickFirstShape = (e: MouseEvent): ShapePickResult | null => {
const picked = pickShapes(e);
for (const item of picked) {
const entity = item.entity;
if (ids == null || ids.has(entity.id)) {
return item as ShapePickResult;
}
}
return null;
};
const pickNonShapes = (e: MouseEvent): PickResult | null => {
const picked = pick(e);
for (const item of picked) {
if (!isShape(item.entity)) {
return item;
}
}
return null;
};
let pickedVertexIndex: number | null = null;
let isDragging = false;
let pickedShape: Shape | null = null;
// Clicking will either start dragging the picked vertex,
// or insert/remove a vertex depending on the mouse button.
const onMouseDown = (e: MouseEvent): void => {
if (this._inhibitEdition) {
return;
}
const picked = pickFirstShape(e);
if (picked) {
if (isShape(picked.entity)) {
// TODO configure buttons
let index = picked.pickedVertexIndex;
const segment = picked.pickedSegment;
const shape = picked.entity;
// We didn't pick a vertex, we are then inserting a vertex on a segment
if (
index == null &&
segment != null &&
isOperationAllowed(shape, 'insertPoint')
) {
if (onBeforePointInserted(e)) {
index = segment + 1;
shape.insertPoint(index, picked.point);
onPointInserted({ shape, pointIndex: index, position: picked.point });
const radius = computeMarkerRadius(shape, 'vertex');
this.displayVertexMarker(
shape,
picked.point,
radius,
OPACITY_OVER_VERTEX,
);
}
}
if (index != null) {
// Start dragging the picked vertex
if (isOperationAllowed(shape, 'movePoint') && onBeforePointMoved(e)) {
pickedVertexIndex = index;
isDragging = true;
pickedShape = shape;
const radius = computeMarkerRadius(shape, 'vertex');
this.displayVertexMarker(
shape,
picked.point,
radius,
OPACITY_OVER_VERTEX,
);
this.dispatchEvent({ type: 'start-drag' });
}
if (isOperationAllowed(shape, 'removePoint') && onBeforePointRemoved(e)) {
shape.removePoint(index);
onPointRemoved({ shape, pointIndex: index });
}
}
}
}
};
const onMouseUp = (): void => {
if (this._inhibitEdition) {
return;
}
this._instance.notifyChange();
this.dispatchEvent({ type: 'end-drag' });
isDragging = false;
pickedVertexIndex = null;
pickedShape = null;
};
const onMouseMove = (e: MouseEvent): void => {
if (this._inhibitEdition) {
return;
}
if (isDragging) {
if (pickedShape != null && pickedVertexIndex != null) {
const position = pickNonShapes(e)?.point;
if (position) {
pickedShape.updatePoint(pickedVertexIndex, position);
onPointUpdated({
shape: pickedShape,
pointIndex: pickedVertexIndex,
newPosition: position,
});
if (this._selectedVertexMarker != null) {
this.displayVertexMarker(
pickedShape,
position,
computeMarkerRadius(pickedShape, 'vertex'),
OPACITY_OVER_VERTEX,
);
}
}
}
} else {
const picked = pickFirstShape(e);
if (picked) {
const isVertex = picked.pickedVertexIndex != null;
const isSegment = picked.pickedSegment != null;
const shape = picked.entity;
const opacity = isVertex ? OPACITY_OVER_VERTEX : OPACITY_OVER_EDGE;
if (isVertex || (isSegment && isOperationAllowed(shape, 'insertPoint'))) {
const radius = computeMarkerRadius(shape, isVertex ? 'vertex' : 'segment');
this.displayVertexMarker(shape, picked.point, radius, opacity);
} else {
this.hideVertexMarker();
}
} else {
this.hideVertexMarker();
}
}
};
this._editionModeController.signal.addEventListener('abort', () => {
this._domElement.removeEventListener('mousemove', onMouseMove);
this._domElement.removeEventListener('mousedown', onMouseDown);
this._domElement.removeEventListener('mouseup', onMouseUp);
this._domElement.removeEventListener('contextmenu', inhibit);
});
this._domElement.addEventListener('mousemove', onMouseMove);
this._domElement.addEventListener('mousedown', onMouseDown);
this._domElement.addEventListener('mouseup', onMouseUp);
this._domElement.addEventListener('contextmenu', inhibit);
}
/**
* Exits edition mode.
*/
public exitEditMode(): void {
this._editionModeController?.abort();
this.hideVertexMarker();
}
private exitCreateMode(): void {
this._inhibitEdition = false;
}
/**
* Starts creating a {@link Shape} with the given parameters.
* @param options - The shape creation options.
* @returns A promise that eventually resolves with the created shape, or `null` if the creation
* was cancelled.
*/
public createShape(options: CreateShapeOptions): Promise<Shape | null> {
const shape = new Shape<ShapeUserData>({ ...options });
shape.visible = false;
shape.userData.permissions = options.constraints;
const pickableLabels = shape.pickableLabels;
// We don't want labels to prevent us from drawing points.
shape.pickableLabels = false;
this._inhibitEdition = true;
const endCondition = options.endCondition ?? rightClick;
const domElement = this._domElement;
const { minPoints, maxPoints } = options;
const pick: PickCallback = options?.pick ?? this.defaultPick.bind(this);
this._instance.add(shape);
const firstPoint = new Vector3();
const points = [firstPoint];
const lastPointerLocation = new Vector2();
const currentPointerLocation = new Vector2();
function updatePoints(): void {
shape.setPoints([...points]);
}
const promise = new Promise<Shape | null>((resolve, reject) => {
let clickCount = 0;
let removeListeners: (() => void) | undefined = undefined;
const finalize = (shapeToFinalize: Shape | null): void => {
if (shapeToFinalize != null) {
shapeToFinalize.pickableLabels = pickableLabels;
}
if (removeListeners) {
removeListeners();
}
this.exitCreateMode();
resolve(shapeToFinalize);
};
const onAbort = (): void => {
this._instance.remove(shape);
if (removeListeners) {
removeListeners();
}
this.exitCreateMode();
reject(new AbortError());
};
const updateTemporaryPoint = (e: MouseEvent | Vector2): void => {
// When moving the temporary point around, we ecounter two possible scenarios:
// - we picked the first point of the shape
// - we picked something else
const picked = pick(e);
if (picked.length > 0) {
let point: Vector3 | null = null;
const shapePickResults = picked.filter(p => isShapePickResult(p));
// First scenario: we clicked on the first point of the shape and the shape
// is marked as a closed ring. We have to complete the drawing by closing the shape.
if (
options.closeRing === true &&
shapePickResults.length > 0 &&
shapePickResults[0].pickedVertexIndex === 0
) {
// Snap to first vertex to close the ring
points[points.length - 1].copy(shape.points[0]);
point = shape.points[0];
} else {
// Second scenario: we didn't pick the first point of the shape
// in ring mode. Let's see if we did actually pick the environment.
// If not, then we didn't really pick anything and shouldn't
// update the shape. Note that we don't want to pick the shape here,
// although we might want to consider picking the shape to provide a
// "snap" feature in the future. But for now, let's keep things simple.
const nonShapeResults = picked.filter(p => !isShapePickResult(p));
if (nonShapeResults.length > 0) {
point = nonShapeResults[0].point;
points[points.length - 1].copy(point);
}
}
updatePoints();
if (point != null && options?.onTemporaryPointMoved) {
options.onTemporaryPointMoved(shape, point);
}
shape.visible = true;
} else {
shape.visible = clickCount > 0;
}
};
const onMouseMove = (e: MouseEvent): void => {
updateTemporaryPoint(e);
};
const finishDrawing = (): void => {
if (minPoints != null && clickCount >= minPoints) {
shape.setPoints(points);
if (options?.closeRing === true) {
shape.makeClosed();
}
finalize(shape);
} else {
this._instance.remove(shape);
finalize(null);
}
};
const onMouseDown = (e: MouseEvent): void => {
lastPointerLocation.set(e.screenX, e.screenY);
};
const onClick = (e: MouseEvent): void => {
// Not a simple click
if (e.detail !== 1) {
return;
}
e.stopPropagation();
currentPointerLocation.set(e.screenX, e.screenY);
// Check that the mouse is not dragging (might be a camera movement)
const distance = currentPointerLocation.distanceToSquared(lastPointerLocation);
if (distance <= SQUARE_DISTANCE_LIMIT_FOR_CLICK_DETECTION) {
lastPointerLocation.copy(currentPointerLocation);
if (e.button === LEFT_BUTTON) {
const point = pick(e)[0]?.point;
if (point != null) {
clickCount++;
if (
clickCount > 2 &&
options.closeRing === true &&
isFirstVertexPicked(shape, e)
) {
// Special case: in the case of rings, if the user clicks on the first
// point, we close the ring and finish the drawing.
points.pop();
finishDrawing();
} else {
// Let's create a new point
if (maxPoints != null && points.length < maxPoints) {
if (options?.onPointCreated) {
const pointIndex = clickCount - 1;
options.onPointCreated(shape, pointIndex, point);
}
points.push(point);
}
updatePoints();
if (clickCount === maxPoints) {
finalize(shape);
}
}
}
}
}
};
const signal = options.signal;
const handleEvent = (event: MouseEvent): void => {
if (endCondition(event)) {
finishDrawing();
} else {
switch (event.type) {
case 'click':
onClick(event);
break;
case 'mousedown':
onMouseDown(event);
break;
case 'mousemove':
onMouseMove(event);
break;
}
}
};
removeListeners = (): void => {
domElement.removeEventListener('mousedown', handleEvent);
domElement.removeEventListener('mousemove', handleEvent);
domElement.removeEventListener('mouseup', handleEvent);
domElement.removeEventListener('dblclick', handleEvent);
domElement.removeEventListener('click', handleEvent);
signal?.removeEventListener('abort', onAbort);
};
domElement.addEventListener('mousedown', handleEvent, { signal });
domElement.addEventListener('mousemove', handleEvent, { signal });
domElement.addEventListener('mouseup', handleEvent, { signal });
domElement.addEventListener('dblclick', handleEvent, { signal });
domElement.addEventListener('click', handleEvent, { signal });
signal?.addEventListener('abort', onAbort);
// Show the temporary point at the last mouse coordinate.
// Useful if the user started the creation by something else than a
// mouse action (e.g a keyboars shortcut), which would otherwise not
// display the point until the first mouse move event.
if (this._lastMouseCoordinate != null) {
updateTemporaryPoint(this._lastMouseCoordinate);
}
});
return promise;
}
/**
* Create a segment (straight line between two points).
* @param options - The options.
* @returns A promise that eventually returns the {@link Shape} or `null` if creation was cancelled.
*/
public createSegment(options?: CreationOptions): Promise<Shape | null> {
return this.createShape({
...options,
minPoints: 2,
maxPoints: 2,
constraints: {
insertPoint: false,
movePoint: true,
removePoint: false,
},
beforeRemovePoint: inhibitHook,
beforeInsertPoint: inhibitHook,
});
}
/**
* Creates a LineString {@link Shape}.
* @param creationOptions - The options.
* @returns A promise that eventually returns the {@link Shape} or `null` if creation was cancelled.
*/
public createLineString(creationOptions?: CreationOptions): Promise<Shape | null> {
return this.createShape({
...creationOptions,
beforeRemovePoint: limitRemovePointHook(2),
minPoints: 2,
maxPoints: +Infinity,
});
}
/**
* Creates a vertical measure {@link Shape} that displays the vertical distance between
* the start and end point, as well as the angle between the segment formed by those points
* and the horizontal plane. The shape looks like a right triangle.
* @param options - The options.
* @returns A promise that eventually returns the {@link Shape} or `null` if creation was cancelled.
*/
public createVerticalMeasure(options?: CreationOptions): Promise<Shape | null> {
let canUpdateFloor = true;
const updateDashSize = (shape: Shape): void => {
if (shape.points.length > 1) {
const p0 = shape.points[0];
const p1 = shape.points[1];
const height = Math.max(p0.z, p1.z) - Math.min(p0.z, p1.z);
shape.dashSize = height / 20;
}
};
const onPointCreated = (shape: Shape, index: number, position: Vector3): void => {
if (index === 0) {
canUpdateFloor = false;
const height = position.z;
shape.floorElevation = height;
shape.showFloorLine = true;
shape.showVerticalLines = true;
shape.showFloorVertices = true;
shape.showVerticalLineLabels = true;
}
updateDashSize(shape);
};
// Whenever the first point is updated, we need to set the floor height to
// this point's height, so that we always display a nice right triangle.
const updateFloor = (shape: Shape, position: Vector3): void => {
const height = position.z;
shape.floorElevation = height;
};
const onTemporaryPointMoved = (shape: Shape, position: Vector3): void => {
if (canUpdateFloor) {
updateFloor(shape, position);
}
updateDashSize(shape);
};
const afterUpdatePoint = (updateOptions: {
shape: Shape;
index: number;
newPosition: Vector3;
}): void => {
const { index, shape, newPosition } = updateOptions;
if (index === 0) {
updateFloor(shape, newPosition);
}
updateDashSize(shape);
};
return this.createShape({
showFloorLine: false,
showVerticalLines: false,
showFloorVertices: false,
showVerticalLineLabels: false,
showSegmentLabels: true,
constraints: {
insertPoint: false,
removePoint: false,
movePoint: true,
},
verticalLineLabelFormatter: verticalLengthFormatter,
segmentLabelFormatter: slopeSegmentFormatter,
beforeRemovePoint: inhibitHook,
beforeInsertPoint: inhibitHook,
onPointCreated,
onTemporaryPointMoved,
afterUpdatePoint,
...options,
minPoints: 2,
maxPoints: 2,
});
}
/**
* Creates a single point {@link Shape}.
* @param options - The options.
* @returns A promise that eventually returns the {@link Shape} or `null` if creation was cancelled.
*/
public createPoint(options?: CreationOptions): Promise<Shape | null> {
return this.createShape({
...options,
minPoints: 1,
maxPoints: 1,
beforeRemovePoint: inhibitHook,
});
}
/**
* Creates multiple point {@link Shape}s.
* @param options - The options.
* @returns A promise that eventually returns the {@link Shape} or `null` if creation was cancelled.
*/
public createMultiPoint(options?: CreationOptions): Promise<Shape | null> {
return this.createShape({
showLine: false,
...options,
beforeRemovePoint: limitRemovePointHook(1),
minPoints: 1,
maxPoints: +Infinity,
});
}
/**
* Creates a polygon {@link Shape}.
* @param options - The options.
* @returns A promise that eventually returns the {@link Shape} or `null` if creation was cancelled.
*/
public createPolygon(options?: CreationOptions): Promise<Shape | null> {
return this.createShape({
showSurface: true,
closeRing: true,
...options,
minPoints: 3,
maxPoints: +Infinity,
beforeRemovePoint: limitRemovePointHook(4), // We take into account the doubled first/last point
afterRemovePoint: afterRemovePointOfRing,
afterUpdatePoint: afterUpdatePointOfRing,
});
}
/**
* Create a closed ring {@link Shape}.
* @param options - The options.
* @returns A promise that eventually returns the {@link Shape} or `null` if creation was cancelled.
*/
public createRing(options?: CreationOptions): Promise<Shape | null> {
return this.createShape({
closeRing: true,
...options,
minPoints: 3,
maxPoints: +Infinity,
beforeRemovePoint: limitRemovePointHook(3),
afterRemovePoint: afterRemovePointOfRing,
afterUpdatePoint: afterUpdatePointOfRing,
});
}
/**
* Create a sector {@link Shape}.
* @param options - The options.
* @returns A promise that eventually returns the {@link Shape} or `null` if creation was cancelled.
*/
public createSector(options?: CreationOptions): Promise<Shape | null> {
return this.createShape({
vertexLabelFormatter: angleFormatter,
showVertexLabels: true,
showSurface: true,
...options,
constraints: {
insertPoint: false,
removePoint: false,
movePoint: true,
},
minPoints: 3,
maxPoints: 3,
});
}
/**
* Disposes unmanaged resources created by this instance.
*/
public dispose(): void {
this._markerMaterial.dispose();
if (this._selectedVertexMarker != null) {
this._instance.remove(this._selectedVertexMarker);
this._selectedVertexMarker = undefined;
}
window.removeEventListener('mousemove', this._mouseEventHandler);
}
}
export default DrawTool;