mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
perf: make eraser great again (#9352)
* perf: make eraser great again * lint * refactor and improve perf * lint
This commit is contained in:
parent
6fe7de8020
commit
58f7d33d80
5 changed files with 259 additions and 120 deletions
|
@ -454,7 +454,6 @@ import {
|
||||||
import { Emitter } from "../emitter";
|
import { Emitter } from "../emitter";
|
||||||
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
|
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
|
||||||
import { Store, CaptureUpdateAction } from "../store";
|
import { Store, CaptureUpdateAction } from "../store";
|
||||||
import { AnimatedTrail } from "../animated-trail";
|
|
||||||
import { LaserTrails } from "../laser-trails";
|
import { LaserTrails } from "../laser-trails";
|
||||||
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
||||||
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
||||||
|
@ -464,6 +463,8 @@ import { isMaybeMermaidDefinition } from "../mermaid";
|
||||||
|
|
||||||
import { LassoTrail } from "../lasso";
|
import { LassoTrail } from "../lasso";
|
||||||
|
|
||||||
|
import { EraserTrail } from "../eraser";
|
||||||
|
|
||||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||||
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
|
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
|
||||||
|
@ -675,26 +676,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
animationFrameHandler = new AnimationFrameHandler();
|
animationFrameHandler = new AnimationFrameHandler();
|
||||||
|
|
||||||
laserTrails = new LaserTrails(this.animationFrameHandler, this);
|
laserTrails = new LaserTrails(this.animationFrameHandler, this);
|
||||||
eraserTrail = new AnimatedTrail(this.animationFrameHandler, this, {
|
eraserTrail = new EraserTrail(this.animationFrameHandler, this);
|
||||||
streamline: 0.2,
|
|
||||||
size: 5,
|
|
||||||
keepHead: true,
|
|
||||||
sizeMapping: (c) => {
|
|
||||||
const DECAY_TIME = 200;
|
|
||||||
const DECAY_LENGTH = 10;
|
|
||||||
const t = Math.max(0, 1 - (performance.now() - c.pressure) / DECAY_TIME);
|
|
||||||
const l =
|
|
||||||
(DECAY_LENGTH -
|
|
||||||
Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
|
|
||||||
DECAY_LENGTH;
|
|
||||||
|
|
||||||
return Math.min(easeOut(l), easeOut(t));
|
|
||||||
},
|
|
||||||
fill: () =>
|
|
||||||
this.state.theme === THEME.LIGHT
|
|
||||||
? "rgba(0, 0, 0, 0.2)"
|
|
||||||
: "rgba(255, 255, 255, 0.2)",
|
|
||||||
});
|
|
||||||
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
|
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
|
||||||
|
|
||||||
onChangeEmitter = new Emitter<
|
onChangeEmitter = new Emitter<
|
||||||
|
@ -1676,8 +1658,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
<SVGLayer
|
<SVGLayer
|
||||||
trails={[
|
trails={[
|
||||||
this.laserTrails,
|
this.laserTrails,
|
||||||
this.eraserTrail,
|
|
||||||
this.lassoTrail,
|
this.lassoTrail,
|
||||||
|
this.eraserTrail,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{selectedElements.length === 1 &&
|
{selectedElements.length === 1 &&
|
||||||
|
@ -5163,7 +5145,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getElementHitThreshold() {
|
getElementHitThreshold() {
|
||||||
return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value;
|
return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6219,101 +6201,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
|
|
||||||
private handleEraser = (
|
private handleEraser = (
|
||||||
event: PointerEvent,
|
event: PointerEvent,
|
||||||
pointerDownState: PointerDownState,
|
|
||||||
scenePointer: { x: number; y: number },
|
scenePointer: { x: number; y: number },
|
||||||
) => {
|
) => {
|
||||||
this.eraserTrail.addPointToPath(scenePointer.x, scenePointer.y);
|
const elementsToErase = this.eraserTrail.addPointToPath(
|
||||||
|
scenePointer.x,
|
||||||
let didChange = false;
|
scenePointer.y,
|
||||||
|
event.altKey,
|
||||||
const processedGroups = new Set<ExcalidrawElement["id"]>();
|
|
||||||
const nonDeletedElements = this.scene.getNonDeletedElements();
|
|
||||||
|
|
||||||
const processElements = (elements: ExcalidrawElement[]) => {
|
|
||||||
for (const element of elements) {
|
|
||||||
if (element.locked) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.altKey) {
|
|
||||||
if (this.elementsPendingErasure.delete(element.id)) {
|
|
||||||
didChange = true;
|
|
||||||
}
|
|
||||||
} else if (!this.elementsPendingErasure.has(element.id)) {
|
|
||||||
didChange = true;
|
|
||||||
this.elementsPendingErasure.add(element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (un)erase groups atomically
|
|
||||||
if (didChange && element.groupIds?.length) {
|
|
||||||
const shallowestGroupId = element.groupIds.at(-1)!;
|
|
||||||
if (!processedGroups.has(shallowestGroupId)) {
|
|
||||||
processedGroups.add(shallowestGroupId);
|
|
||||||
const elems = getElementsInGroup(
|
|
||||||
nonDeletedElements,
|
|
||||||
shallowestGroupId,
|
|
||||||
);
|
);
|
||||||
for (const elem of elems) {
|
|
||||||
if (event.altKey) {
|
|
||||||
this.elementsPendingErasure.delete(elem.id);
|
|
||||||
} else {
|
|
||||||
this.elementsPendingErasure.add(elem.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const distance = pointDistance(
|
this.elementsPendingErasure = new Set(elementsToErase);
|
||||||
pointFrom(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
|
|
||||||
pointFrom(scenePointer.x, scenePointer.y),
|
|
||||||
);
|
|
||||||
const threshold = this.getElementHitThreshold();
|
|
||||||
const p = { ...pointerDownState.lastCoords };
|
|
||||||
let samplingInterval = 0;
|
|
||||||
while (samplingInterval <= distance) {
|
|
||||||
const hitElements = this.getElementsAtPosition(p.x, p.y);
|
|
||||||
processElements(hitElements);
|
|
||||||
|
|
||||||
// Exit since we reached current point
|
|
||||||
if (samplingInterval === distance) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate next point in the line at a distance of sampling interval
|
|
||||||
samplingInterval = Math.min(samplingInterval + threshold, distance);
|
|
||||||
|
|
||||||
const distanceRatio = samplingInterval / distance;
|
|
||||||
const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x;
|
|
||||||
const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y;
|
|
||||||
p.x = nextX;
|
|
||||||
p.y = nextY;
|
|
||||||
}
|
|
||||||
|
|
||||||
pointerDownState.lastCoords.x = scenePointer.x;
|
|
||||||
pointerDownState.lastCoords.y = scenePointer.y;
|
|
||||||
|
|
||||||
if (didChange) {
|
|
||||||
for (const element of this.scene.getNonDeletedElements()) {
|
|
||||||
if (
|
|
||||||
isBoundToContainer(element) &&
|
|
||||||
(this.elementsPendingErasure.has(element.id) ||
|
|
||||||
this.elementsPendingErasure.has(element.containerId))
|
|
||||||
) {
|
|
||||||
if (event.altKey) {
|
|
||||||
this.elementsPendingErasure.delete(element.id);
|
|
||||||
this.elementsPendingErasure.delete(element.containerId);
|
|
||||||
} else {
|
|
||||||
this.elementsPendingErasure.add(element.id);
|
|
||||||
this.elementsPendingErasure.add(element.containerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.elementsPendingErasure = new Set(this.elementsPendingErasure);
|
|
||||||
this.triggerRender();
|
this.triggerRender();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// set touch moving for mobile context menu
|
// set touch moving for mobile context menu
|
||||||
|
@ -8159,7 +8056,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEraserActive(this.state)) {
|
if (isEraserActive(this.state)) {
|
||||||
this.handleEraser(event, pointerDownState, pointerCoords);
|
this.handleEraser(event, pointerCoords);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
239
packages/excalidraw/eraser/index.ts
Normal file
239
packages/excalidraw/eraser/index.ts
Normal file
|
@ -0,0 +1,239 @@
|
||||||
|
import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
|
||||||
|
import { getElementLineSegments } from "@excalidraw/element/bounds";
|
||||||
|
import {
|
||||||
|
lineSegment,
|
||||||
|
lineSegmentIntersectionPoints,
|
||||||
|
pointFrom,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
|
import { getElementsInGroup } from "@excalidraw/element/groups";
|
||||||
|
|
||||||
|
import { getElementShape } from "@excalidraw/element/shapes";
|
||||||
|
import { shouldTestInside } from "@excalidraw/element/collision";
|
||||||
|
import { isPointInShape } from "@excalidraw/utils/collision";
|
||||||
|
import {
|
||||||
|
hasBoundTextElement,
|
||||||
|
isBoundToContainer,
|
||||||
|
} from "@excalidraw/element/typeChecks";
|
||||||
|
import { getBoundTextElementId } from "@excalidraw/element/textElement";
|
||||||
|
|
||||||
|
import type { GeometricShape } from "@excalidraw/utils/shape";
|
||||||
|
import type {
|
||||||
|
ElementsSegmentsMap,
|
||||||
|
GlobalPoint,
|
||||||
|
LineSegment,
|
||||||
|
} from "@excalidraw/math/types";
|
||||||
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
|
import { AnimatedTrail } from "../animated-trail";
|
||||||
|
|
||||||
|
import type { AnimationFrameHandler } from "../animation-frame-handler";
|
||||||
|
|
||||||
|
import type App from "../components/App";
|
||||||
|
|
||||||
|
// just enough to form a segment; this is sufficient for eraser
|
||||||
|
const POINTS_ON_TRAIL = 2;
|
||||||
|
|
||||||
|
export class EraserTrail extends AnimatedTrail {
|
||||||
|
private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
|
private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
|
||||||
|
private segmentsCache: Map<string, LineSegment<GlobalPoint>[]> = new Map();
|
||||||
|
private geometricShapesCache: Map<string, GeometricShape<GlobalPoint>> =
|
||||||
|
new Map();
|
||||||
|
|
||||||
|
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
|
||||||
|
super(animationFrameHandler, app, {
|
||||||
|
streamline: 0.2,
|
||||||
|
size: 5,
|
||||||
|
keepHead: true,
|
||||||
|
sizeMapping: (c) => {
|
||||||
|
const DECAY_TIME = 200;
|
||||||
|
const DECAY_LENGTH = 10;
|
||||||
|
const t = Math.max(
|
||||||
|
0,
|
||||||
|
1 - (performance.now() - c.pressure) / DECAY_TIME,
|
||||||
|
);
|
||||||
|
const l =
|
||||||
|
(DECAY_LENGTH -
|
||||||
|
Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
|
||||||
|
DECAY_LENGTH;
|
||||||
|
|
||||||
|
return Math.min(easeOut(l), easeOut(t));
|
||||||
|
},
|
||||||
|
fill: () =>
|
||||||
|
app.state.theme === THEME.LIGHT
|
||||||
|
? "rgba(0, 0, 0, 0.2)"
|
||||||
|
: "rgba(255, 255, 255, 0.2)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startPath(x: number, y: number): void {
|
||||||
|
this.endPath();
|
||||||
|
super.startPath(x, y);
|
||||||
|
this.elementsToErase.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
addPointToPath(x: number, y: number, restore = false) {
|
||||||
|
super.addPointToPath(x, y);
|
||||||
|
|
||||||
|
const elementsToEraser = this.updateElementsToBeErased(restore);
|
||||||
|
|
||||||
|
return elementsToEraser;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateElementsToBeErased(restoreToErase?: boolean) {
|
||||||
|
let eraserPath: GlobalPoint[] =
|
||||||
|
super
|
||||||
|
.getCurrentTrail()
|
||||||
|
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || [];
|
||||||
|
|
||||||
|
// for efficiency and avoid unnecessary calculations,
|
||||||
|
// take only POINTS_ON_TRAIL points to form some number of segments
|
||||||
|
eraserPath = eraserPath?.slice(eraserPath.length - POINTS_ON_TRAIL);
|
||||||
|
|
||||||
|
const visibleElementsMap = arrayToMap(this.app.visibleElements);
|
||||||
|
|
||||||
|
const pathSegments = eraserPath.reduce((acc, point, index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc.push(lineSegment(eraserPath[index - 1], point));
|
||||||
|
return acc;
|
||||||
|
}, [] as LineSegment<GlobalPoint>[]);
|
||||||
|
|
||||||
|
if (pathSegments.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of this.app.visibleElements) {
|
||||||
|
// restore only if already added to the to-be-erased set
|
||||||
|
if (restoreToErase && this.elementsToErase.has(element.id)) {
|
||||||
|
const intersects = eraserTest(
|
||||||
|
pathSegments,
|
||||||
|
element,
|
||||||
|
this.segmentsCache,
|
||||||
|
this.geometricShapesCache,
|
||||||
|
visibleElementsMap,
|
||||||
|
this.app,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (intersects) {
|
||||||
|
const shallowestGroupId = element.groupIds.at(-1)!;
|
||||||
|
|
||||||
|
if (this.groupsToErase.has(shallowestGroupId)) {
|
||||||
|
const elementsInGroup = getElementsInGroup(
|
||||||
|
this.app.scene.getNonDeletedElementsMap(),
|
||||||
|
shallowestGroupId,
|
||||||
|
);
|
||||||
|
for (const elementInGroup of elementsInGroup) {
|
||||||
|
this.elementsToErase.delete(elementInGroup.id);
|
||||||
|
}
|
||||||
|
this.groupsToErase.delete(shallowestGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBoundToContainer(element)) {
|
||||||
|
this.elementsToErase.delete(element.containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBoundTextElement(element)) {
|
||||||
|
const boundText = getBoundTextElementId(element);
|
||||||
|
|
||||||
|
if (boundText) {
|
||||||
|
this.elementsToErase.delete(boundText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.elementsToErase.delete(element.id);
|
||||||
|
}
|
||||||
|
} else if (!restoreToErase && !this.elementsToErase.has(element.id)) {
|
||||||
|
const intersects = eraserTest(
|
||||||
|
pathSegments,
|
||||||
|
element,
|
||||||
|
this.segmentsCache,
|
||||||
|
this.geometricShapesCache,
|
||||||
|
visibleElementsMap,
|
||||||
|
this.app,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (intersects) {
|
||||||
|
const shallowestGroupId = element.groupIds.at(-1)!;
|
||||||
|
|
||||||
|
if (!this.groupsToErase.has(shallowestGroupId)) {
|
||||||
|
const elementsInGroup = getElementsInGroup(
|
||||||
|
this.app.scene.getNonDeletedElementsMap(),
|
||||||
|
shallowestGroupId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const elementInGroup of elementsInGroup) {
|
||||||
|
this.elementsToErase.add(elementInGroup.id);
|
||||||
|
}
|
||||||
|
this.groupsToErase.add(shallowestGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBoundTextElement(element)) {
|
||||||
|
const boundText = getBoundTextElementId(element);
|
||||||
|
|
||||||
|
if (boundText) {
|
||||||
|
this.elementsToErase.add(boundText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBoundToContainer(element)) {
|
||||||
|
this.elementsToErase.add(element.containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.elementsToErase.add(element.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(this.elementsToErase);
|
||||||
|
}
|
||||||
|
|
||||||
|
endPath(): void {
|
||||||
|
super.endPath();
|
||||||
|
super.clearTrails();
|
||||||
|
this.elementsToErase.clear();
|
||||||
|
this.groupsToErase.clear();
|
||||||
|
this.segmentsCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eraserTest = (
|
||||||
|
pathSegments: LineSegment<GlobalPoint>[],
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsSegments: ElementsSegmentsMap,
|
||||||
|
shapesCache = new Map<string, GeometricShape<GlobalPoint>>(),
|
||||||
|
visibleElementsMap = new Map<string, ExcalidrawElement>(),
|
||||||
|
app: App,
|
||||||
|
): boolean => {
|
||||||
|
let shape = shapesCache.get(element.id);
|
||||||
|
|
||||||
|
if (!shape) {
|
||||||
|
shape = getElementShape<GlobalPoint>(element, visibleElementsMap);
|
||||||
|
shapesCache.set(element.id, shape);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastPoint = pathSegments[pathSegments.length - 1][1];
|
||||||
|
if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let elementSegments = elementsSegments.get(element.id);
|
||||||
|
|
||||||
|
if (!elementSegments) {
|
||||||
|
elementSegments = getElementLineSegments(element, visibleElementsMap);
|
||||||
|
elementsSegments.set(element.id, elementSegments);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathSegments.some((pathSegment) =>
|
||||||
|
elementSegments?.some(
|
||||||
|
(elementSegment) =>
|
||||||
|
lineSegmentIntersectionPoints(
|
||||||
|
pathSegment,
|
||||||
|
elementSegment,
|
||||||
|
app.getElementHitThreshold(),
|
||||||
|
) !== null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
|
@ -7,11 +7,13 @@ import {
|
||||||
polygonIncludesPointNonZero,
|
polygonIncludesPointNonZero,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
|
import type {
|
||||||
|
ElementsSegmentsMap,
|
||||||
|
GlobalPoint,
|
||||||
|
LineSegment,
|
||||||
|
} from "@excalidraw/math/types";
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
export type ElementsSegmentsMap = Map<string, LineSegment<GlobalPoint>[]>;
|
|
||||||
|
|
||||||
export const getLassoSelectedElementIds = (input: {
|
export const getLassoSelectedElementIds = (input: {
|
||||||
lassoPath: GlobalPoint[];
|
lassoPath: GlobalPoint[];
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
type Radians,
|
type Radians,
|
||||||
|
type ElementsSegmentsMap,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { getElementLineSegments } from "@excalidraw/element/bounds";
|
import { getElementLineSegments } from "@excalidraw/element/bounds";
|
||||||
|
@ -33,8 +34,6 @@ import { getLassoSelectedElementIds } from "../lasso/utils";
|
||||||
|
|
||||||
import { act, render } from "./test-utils";
|
import { act, render } from "./test-utils";
|
||||||
|
|
||||||
import type { ElementsSegmentsMap } from "../lasso/utils";
|
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -138,3 +138,5 @@ export type Ellipse<Point extends GlobalPoint | LocalPoint> = {
|
||||||
} & {
|
} & {
|
||||||
_brand: "excalimath_ellipse";
|
_brand: "excalimath_ellipse";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ElementsSegmentsMap = Map<string, LineSegment<GlobalPoint>[]>;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue