From bbdcd30a7330443b198426d8199024a22038e485 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Thu, 4 Apr 2024 16:31:23 +0800 Subject: [PATCH] refactor: update collision from ga to vector geometry (#7636) * new collision api * isPointOnShape * removed redundant code * new collision methods in app * curve shape takes starting point * clean up geometry * curve rotation * freedraw * inside curve * improve ellipse inside check * ellipse distance func * curve inside * include frame name bounds * replace previous private methods for getting elements at x,y * arrow bound text hit detection * keep iframes on top * remove dependence on old collision methods from app * remove old collision functions * move some hit functions outside of app * code refactor * type * text collision from inside * fix context menu test * highest z-index collision * fix 1px away binding test * strictly less * remove unused imports * lint * 'ignore' resize flipping test * more lint fix * skip 'flips while resizing' test * more test * fix merge errors * fix selection in resize test * added a bit more comment --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- .../excalidraw/actions/actionFinalize.tsx | 18 +- packages/excalidraw/actions/actionFlip.ts | 9 +- packages/excalidraw/components/App.tsx | 386 ++++-- .../components/hyperlink/Hyperlink.tsx | 14 +- .../components/hyperlink/helpers.ts | 11 +- packages/excalidraw/element/binding.ts | 696 ++++++++-- packages/excalidraw/element/bounds.ts | 7 - packages/excalidraw/element/collision.ts | 1191 +---------------- packages/excalidraw/element/index.ts | 4 - .../excalidraw/element/linearElementEditor.ts | 21 +- packages/excalidraw/element/textElement.ts | 51 +- .../excalidraw/renderer/interactiveScene.ts | 7 +- .../__snapshots__/contextmenu.test.tsx.snap | 184 +-- packages/excalidraw/tests/helpers/ui.ts | 11 +- .../tests/linearElementEditor.test.tsx | 32 +- packages/excalidraw/types.ts | 3 +- packages/utils/collision.ts | 66 + packages/utils/geometry/geometry.test.ts | 249 ++++ packages/utils/geometry/geometry.ts | 956 +++++++++++++ packages/utils/geometry/shape.ts | 278 ++++ 20 files changed, 2644 insertions(+), 1550 deletions(-) create mode 100644 packages/utils/collision.ts create mode 100644 packages/utils/geometry/geometry.test.ts create mode 100644 packages/utils/geometry/geometry.ts create mode 100644 packages/utils/geometry/shape.ts diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index a5f228f0f0..88ff366b64 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -8,7 +8,6 @@ import { register } from "./register"; import { mutateElement } from "../element/mutateElement"; import { isPathALoop } from "../math"; import { LinearElementEditor } from "../element/linearElementEditor"; -import Scene from "../scene/Scene"; import { maybeBindLinearElement, bindOrUnbindLinearElement, @@ -21,12 +20,9 @@ export const actionFinalize = register({ name: "finalize", label: "", trackEvent: false, - perform: ( - elements, - appState, - _, - { interactiveCanvas, focusContainer, scene }, - ) => { + perform: (elements, appState, _, app) => { + const { interactiveCanvas, focusContainer, scene } = app; + const elementsMap = scene.getNonDeletedElementsMap(); if (appState.editingLinearElement) { @@ -131,13 +127,7 @@ export const actionFinalize = register({ -1, arrayToMap(elements), ); - maybeBindLinearElement( - multiPointElement, - appState, - Scene.getScene(multiPointElement)!, - { x, y }, - elementsMap, - ); + maybeBindLinearElement(multiPointElement, appState, { x, y }, app); } } diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index be5e1a7aaf..d821b200d9 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -7,7 +7,7 @@ import { NonDeletedSceneElementsMap, } from "../element/types"; import { resizeMultipleElements } from "../element/resizeElements"; -import { AppState } from "../types"; +import { AppClassProperties, AppState } from "../types"; import { arrayToMap } from "../utils"; import { CODES, KEYS } from "../keys"; import { getCommonBoundingBox } from "../element/bounds"; @@ -32,6 +32,7 @@ export const actionFlipHorizontal = register({ app.scene.getNonDeletedElementsMap(), appState, "horizontal", + app, ), appState, app, @@ -56,6 +57,7 @@ export const actionFlipVertical = register({ app.scene.getNonDeletedElementsMap(), appState, "vertical", + app, ), appState, app, @@ -73,6 +75,7 @@ const flipSelectedElements = ( elementsMap: NonDeletedSceneElementsMap, appState: Readonly, flipDirection: "horizontal" | "vertical", + app: AppClassProperties, ) => { const selectedElements = getSelectedElements( getNonDeletedElements(elements), @@ -89,6 +92,7 @@ const flipSelectedElements = ( elementsMap, appState, flipDirection, + app, ); const updatedElementsMap = arrayToMap(updatedElements); @@ -104,6 +108,7 @@ const flipElements = ( elementsMap: NonDeletedSceneElementsMap, appState: AppState, flipDirection: "horizontal" | "vertical", + app: AppClassProperties, ): ExcalidrawElement[] => { const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements); @@ -118,7 +123,7 @@ const flipElements = ( ); isBindingEnabled(appState) - ? bindOrUnbindSelectedElements(selectedElements, elements, elementsMap) + ? bindOrUnbindSelectedElements(selectedElements, app) : unbindLinearElements(selectedElements, elementsMap); return selectedElements; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b33e04850e..cd99fb6141 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -107,8 +107,6 @@ import { getResizeOffsetXY, getLockedLinearCursorAlignSize, getTransformHandleTypeFromCoords, - hitTest, - isHittingElementBoundingBoxWithoutHittingElement, isInvisiblySmallElement, isNonDeletedElement, isTextElement, @@ -119,6 +117,7 @@ import { transformElements, updateTextElement, redrawTextBoundingBox, + getElementAbsoluteCoords, } from "../element"; import { bindOrUnbindLinearElement, @@ -162,6 +161,7 @@ import { isIframeElement, isIframeLikeElement, isMagicFrameElement, + isTextBindableContainer, } from "../element/typeChecks"; import { ExcalidrawBindableElement, @@ -212,7 +212,6 @@ import { } from "../math"; import { calculateScrollCenter, - getElementsAtPosition, getElementsWithinSelection, getNormalizedZoom, getSelectedElements, @@ -223,6 +222,15 @@ import Scene from "../scene/Scene"; import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types"; import { getStateForZoom } from "../scene/zoom"; import { findShapeByKey } from "../shapes"; +import { + GeometricShape, + getClosedCurveShape, + getCurveShape, + getEllipseShape, + getFreedrawShape, + getPolygonShape, +} from "../../utils/geometry/shape"; +import { isPointInShape } from "../../utils/collision"; import { AppClassProperties, AppProps, @@ -318,11 +326,9 @@ import { getContainerElement, getDefaultLineHeight, getLineHeightInPx, - getTextBindableContainerAtPosition, isMeasureTextSupported, isValidTextContainer, } from "../element/textElement"; -import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; import { showHyperlinkTooltip, hideHyperlinkToolip, @@ -407,6 +413,13 @@ import { AnimatedTrail } from "../animated-trail"; import { LaserTrails } from "../laser-trails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { getRenderOpacity } from "../renderer/renderElement"; +import { + hitElementBoundText, + hitElementBoundingBox, + hitElementBoundingBoxOnly, + hitElementItself, + shouldTestInside, +} from "../element/collision"; import { textWysiwyg } from "../element/textWysiwyg"; import { isOverScrollBars } from "../scene/scrollbars"; import { @@ -2757,7 +2770,6 @@ class App extends React.Component { maybeBindLinearElement( multiElement, this.state, - this.scene, tupleToCoors( LinearElementEditor.getPointAtIndexGlobalCoordinates( multiElement, @@ -2765,7 +2777,7 @@ class App extends React.Component { elementsMap, ), ), - elementsMap, + this, ); } this.history.record(this.state, elements); @@ -4048,11 +4060,7 @@ class App extends React.Component { const selectedElements = this.scene.getSelectedElements(this.state); const elementsMap = this.scene.getNonDeletedElementsMap(); isBindingEnabled(this.state) - ? bindOrUnbindSelectedElements( - selectedElements, - this.scene.getNonDeletedElements(), - elementsMap, - ) + ? bindOrUnbindSelectedElements(selectedElements, this) : unbindLinearElements(selectedElements, elementsMap); this.setState({ suggestedBindings: [] }); } @@ -4355,12 +4363,87 @@ class App extends React.Component { return null; } + /** + * get the pure geometric shape of an excalidraw element + * which is then used for hit detection + */ + public getElementShape(element: ExcalidrawElement): GeometricShape { + switch (element.type) { + case "rectangle": + case "diamond": + case "frame": + case "magicframe": + case "embeddable": + case "image": + case "iframe": + case "text": + case "selection": + return getPolygonShape(element); + case "arrow": + case "line": { + const roughShape = + ShapeCache.get(element)?.[0] ?? + ShapeCache.generateElementShape(element, null)[0]; + const [, , , , cx, cy] = getElementAbsoluteCoords( + element, + this.scene.getNonDeletedElementsMap(), + ); + + return shouldTestInside(element) + ? getClosedCurveShape( + roughShape, + [element.x, element.y], + element.angle, + [cx, cy], + ) + : getCurveShape(roughShape, [element.x, element.y], element.angle, [ + cx, + cy, + ]); + } + + case "ellipse": + return getEllipseShape(element); + + case "freedraw": { + const [, , , , cx, cy] = getElementAbsoluteCoords( + element, + this.scene.getNonDeletedElementsMap(), + ); + return getFreedrawShape(element, [cx, cy], shouldTestInside(element)); + } + } + } + + private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null { + const boundTextElement = getBoundTextElement( + element, + this.scene.getNonDeletedElementsMap(), + ); + + if (boundTextElement) { + if (element.type === "arrow") { + return this.getElementShape({ + ...boundTextElement, + // arrow's bound text accurate position is not stored in the element's property + // but rather calculated and returned from the following static method + ...LinearElementEditor.getBoundTextElementPosition( + element, + boundTextElement, + this.scene.getNonDeletedElementsMap(), + ), + }); + } + return this.getElementShape(boundTextElement); + } + + return null; + } + private getElementAtPosition( x: number, y: number, opts?: { - /** if true, returns the first selected element (with highest z-index) - of all hit elements */ preferSelected?: boolean; includeBoundTextElement?: boolean; includeLockedElements?: boolean; @@ -4372,6 +4455,7 @@ class App extends React.Component { opts?.includeBoundTextElement, opts?.includeLockedElements, ); + if (allHitElements.length > 1) { if (opts?.preferSelected) { for (let index = allHitElements.length - 1; index > -1; index--) { @@ -4382,22 +4466,20 @@ class App extends React.Component { } const elementWithHighestZIndex = allHitElements[allHitElements.length - 1]; + // If we're hitting element with highest z-index only on its bounding box // while also hitting other element figure, the latter should be considered. - return isHittingElementBoundingBoxWithoutHittingElement( - elementWithHighestZIndex, - this.state, - this.frameNameBoundsCache, - x, - y, - this.scene.getNonDeletedElementsMap(), + return isPointInShape( + [x, y], + this.getElementShape(elementWithHighestZIndex), ) - ? allHitElements[allHitElements.length - 2] - : elementWithHighestZIndex; + ? elementWithHighestZIndex + : allHitElements[allHitElements.length - 2]; } if (allHitElements.length === 1) { return allHitElements[0]; } + return null; } @@ -4407,7 +4489,11 @@ class App extends React.Component { includeBoundTextElement: boolean = false, includeLockedElements: boolean = false, ): NonDeleted[] { - const elements = + const iframeLikes: ExcalidrawIframeElement[] = []; + + const elementsMap = this.scene.getNonDeletedElementsMap(); + + const elements = ( includeBoundTextElement && includeLockedElements ? this.scene.getNonDeletedElements() : this.scene @@ -4417,29 +4503,120 @@ class App extends React.Component { (includeLockedElements || !element.locked) && (includeBoundTextElement || !(isTextElement(element) && element.containerId)), - ); + ) + ) + .filter((el) => this.hitElement(x, y, el)) + .filter((element) => { + // hitting a frame's element from outside the frame is not considered a hit + const containingFrame = getContainingFrame(element, elementsMap); + return containingFrame && + this.state.frameRendering.enabled && + this.state.frameRendering.clip + ? isCursorInFrame({ x, y }, containingFrame, elementsMap) + : true; + }) + .filter((el) => { + // The parameter elements comes ordered from lower z-index to higher. + // We want to preserve that order on the returned array. + // Exception being embeddables which should be on top of everything else in + // terms of hit testing. + if (isIframeElement(el)) { + iframeLikes.push(el); + return false; + } + return true; + }) + .concat(iframeLikes) as NonDeleted[]; - const elementsMap = this.scene.getNonDeletedElementsMap(); - return getElementsAtPosition(elements, (element) => - hitTest( - element, - this.state, - this.frameNameBoundsCache, + return elements; + } + + private getHitThreshold() { + return 10 / this.state.zoom.value; + } + + private hitElement( + x: number, + y: number, + element: ExcalidrawElement, + considerBoundingBox = true, + ) { + // if the element is selected, then hit test is done against its bounding box + if ( + considerBoundingBox && + this.state.selectedElementIds[element.id] && + shouldShowBoundingBox([element], this.state) + ) { + return hitElementBoundingBox( x, y, - elementsMap, - ), - ).filter((element) => { - // hitting a frame's element from outside the frame is not considered a hit - const containingFrame = getContainingFrame(element, elementsMap); - return containingFrame && - this.state.frameRendering.enabled && - this.state.frameRendering.clip - ? isCursorInFrame({ x, y }, containingFrame, elementsMap) - : true; + element, + this.scene.getNonDeletedElementsMap(), + this.getHitThreshold(), + ); + } + + // take bound text element into consideration for hit collision as well + const hitBoundTextOfElement = hitElementBoundText( + x, + y, + this.getBoundTextShape(element), + ); + if (hitBoundTextOfElement) { + return true; + } + + return hitElementItself({ + x, + y, + element, + shape: this.getElementShape(element), + threshold: this.getHitThreshold(), + frameNameBound: isFrameLikeElement(element) + ? this.frameNameBoundsCache.get(element) + : null, }); } + private getTextBindableContainerAtPosition(x: number, y: number) { + const elements = this.scene.getNonDeletedElements(); + const selectedElements = this.scene.getSelectedElements(this.state); + if (selectedElements.length === 1) { + return isTextBindableContainer(selectedElements[0], false) + ? selectedElements[0] + : null; + } + let hitElement = null; + // We need to do hit testing from front (end of the array) to back (beginning of the array) + for (let index = elements.length - 1; index >= 0; --index) { + if (elements[index].isDeleted) { + continue; + } + const [x1, y1, x2, y2] = getElementAbsoluteCoords( + elements[index], + this.scene.getNonDeletedElementsMap(), + ); + if ( + isArrowElement(elements[index]) && + hitElementItself({ + x, + y, + element: elements[index], + shape: this.getElementShape(elements[index]), + threshold: this.getHitThreshold(), + }) + ) { + hitElement = elements[index]; + break; + } else if (x1 < x && x < x2 && y1 < y && y < y2) { + hitElement = elements[index]; + break; + } + } + + return isTextBindableContainer(hitElement, false) ? hitElement : null; + } + private startTextEditing = ({ sceneX, sceneY, @@ -4667,25 +4844,19 @@ class App extends React.Component { return; } - const container = getTextBindableContainerAtPosition( - this.scene.getNonDeletedElements(), - this.state, - sceneX, - sceneY, - this.scene.getNonDeletedElementsMap(), - ); + const container = this.getTextBindableContainerAtPosition(sceneX, sceneY); if (container) { if ( hasBoundTextElement(container) || !isTransparent(container.backgroundColor) || - isHittingElementNotConsideringBoundingBox( - container, - this.state, - this.frameNameBoundsCache, - [sceneX, sceneY], - this.scene.getNonDeletedElementsMap(), - ) + hitElementItself({ + x: sceneX, + y: sceneY, + element: container, + shape: this.getElementShape(container), + threshold: this.getHitThreshold(), + }) ) { const midPoint = getContainerCenter( container, @@ -5281,7 +5452,7 @@ class App extends React.Component { scenePointer.x, scenePointer.y, ); - const threshold = 10 / this.state.zoom.value; + const threshold = this.getHitThreshold(); const point = { ...pointerDownState.lastCoords }; let samplingInterval = 0; while (samplingInterval <= distance) { @@ -5346,7 +5517,6 @@ class App extends React.Component { linearElementEditor.elementId, elementsMap, ); - const boundTextElement = getBoundTextElement(element, elementsMap); if (!element) { return; @@ -5355,13 +5525,12 @@ class App extends React.Component { let hoverPointIndex = -1; let segmentMidPointHoveredCoords = null; if ( - isHittingElementNotConsideringBoundingBox( + hitElementItself({ + x: scenePointerX, + y: scenePointerY, element, - this.state, - this.frameNameBoundsCache, - [scenePointerX, scenePointerY], - elementsMap, - ) + shape: this.getElementShape(element), + }) ) { hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, @@ -5383,29 +5552,7 @@ class App extends React.Component { } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } - } else if ( - shouldShowBoundingBox([element], this.state) && - isHittingElementBoundingBoxWithoutHittingElement( - element, - this.state, - this.frameNameBoundsCache, - scenePointerX, - scenePointerY, - elementsMap, - ) - ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); - } else if ( - boundTextElement && - hitTest( - boundTextElement, - this.state, - this.frameNameBoundsCache, - scenePointerX, - scenePointerY, - this.scene.getNonDeletedElementsMap(), - ) - ) { + } else if (this.hitElement(scenePointerX, scenePointerY, element)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } @@ -6159,8 +6306,7 @@ class App extends React.Component { this.history, pointerDownState.origin, linearElementEditor, - this.scene.getNonDeletedElements(), - elementsMap, + this, ); if (ret.hitElement) { pointerDownState.hit.element = ret.hitElement; @@ -6383,7 +6529,7 @@ class App extends React.Component { } // How many pixels off the shape boundary we still consider a hit - const threshold = 10 / this.state.zoom.value; + const threshold = this.getHitThreshold(); const [x1, y1, x2, y2] = getCommonBounds(selectedElements); return ( point.x > x1 - threshold && @@ -6411,13 +6557,7 @@ class App extends React.Component { }); // FIXME - let container = getTextBindableContainerAtPosition( - this.scene.getNonDeletedElements(), - this.state, - sceneX, - sceneY, - this.scene.getNonDeletedElementsMap(), - ); + let container = this.getTextBindableContainerAtPosition(sceneX, sceneY); if (hasBoundTextElement(element)) { container = element as ExcalidrawTextContainer; @@ -6497,8 +6637,7 @@ class App extends React.Component { const boundElement = getHoveredElementForBinding( pointerDownState.origin, - this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), + this, ); this.scene.addNewElement(element); this.setState({ @@ -6766,8 +6905,7 @@ class App extends React.Component { }); const boundElement = getHoveredElementForBinding( pointerDownState.origin, - this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), + this, ); this.scene.addNewElement(element); @@ -7551,7 +7689,6 @@ class App extends React.Component { ? this.state.editingElement : null, snapLines: updateStable(prevState.snapLines, []), - originSnapOffset: null, })); @@ -7578,8 +7715,7 @@ class App extends React.Component { childEvent, this.state.editingLinearElement, this.state, - this.scene.getNonDeletedElements(), - elementsMap, + this, ); if (editingLinearElement !== this.state.editingLinearElement) { this.setState({ @@ -7603,8 +7739,7 @@ class App extends React.Component { childEvent, this.state.selectedLinearElement, this.state, - this.scene.getNonDeletedElements(), - elementsMap, + this, ); const { startBindingElement, endBindingElement } = @@ -7753,9 +7888,8 @@ class App extends React.Component { maybeBindLinearElement( draggingElement, this.state, - this.scene, pointerCoords, - elementsMap, + this, ); } this.setState({ suggestedBindings: [], startBoundElement: null }); @@ -8207,16 +8341,24 @@ class App extends React.Component { } if ( + // not dragged !pointerDownState.drag.hasOccurred && + // not resized !this.state.isResizing && + // only hitting the bounding box of the previous hit element ((hitElement && - isHittingElementBoundingBoxWithoutHittingElement( - hitElement, - this.state, - this.frameNameBoundsCache, - pointerDownState.origin.x, - pointerDownState.origin.y, - this.scene.getNonDeletedElementsMap(), + hitElementBoundingBoxOnly( + { + x: pointerDownState.origin.x, + y: pointerDownState.origin.y, + element: hitElement, + shape: this.getElementShape(hitElement), + threshold: this.getHitThreshold(), + frameNameBound: isFrameLikeElement(hitElement) + ? this.frameNameBoundsCache.get(hitElement) + : null, + }, + elementsMap, )) || (!hitElement && pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements)) @@ -8232,6 +8374,8 @@ class App extends React.Component { activeEmbeddable: null, }); } + // reset cursor + setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); return; } @@ -8267,11 +8411,10 @@ class App extends React.Component { isBindingEnabled(this.state) ? bindOrUnbindSelectedElements( this.scene.getSelectedElements(this.state), - this.scene.getNonDeletedElements(), - elementsMap, + this, ) : unbindLinearElements( - this.scene.getSelectedElements(this.state), + this.scene.getNonDeletedElements(), elementsMap, ); } @@ -8758,8 +8901,7 @@ class App extends React.Component { }): void => { const hoveredBindableElement = getHoveredElementForBinding( pointerCoords, - this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), + this, ); this.setState({ suggestedBindings: @@ -8786,8 +8928,7 @@ class App extends React.Component { (acc: NonDeleted[], coords) => { const hoveredBindableElement = getHoveredElementForBinding( coords, - this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), + this, ); if ( hoveredBindableElement != null && @@ -8815,8 +8956,7 @@ class App extends React.Component { } const suggestedBindings = getEligibleElementsForBinding( selectedElements, - this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), + this, ); this.setState({ suggestedBindings }); } diff --git a/packages/excalidraw/components/hyperlink/Hyperlink.tsx b/packages/excalidraw/components/hyperlink/Hyperlink.tsx index 779305416f..932673ff15 100644 --- a/packages/excalidraw/components/hyperlink/Hyperlink.tsx +++ b/packages/excalidraw/components/hyperlink/Hyperlink.tsx @@ -26,9 +26,9 @@ import clsx from "clsx"; import { KEYS } from "../../keys"; import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants"; import { getElementAbsoluteCoords } from "../../element/bounds"; -import { getTooltipDiv, updateTooltipPosition } from "../Tooltip"; +import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip"; import { getSelectedElements } from "../../scene"; -import { isPointHittingElementBoundingBox } from "../../element/collision"; +import { hitElementBoundingBox } from "../../element/collision"; import { isLocalLink, normalizeLink } from "../../data/url"; import "./Hyperlink.scss"; @@ -425,15 +425,7 @@ const shouldHideLinkPopup = ( const threshold = 15 / appState.zoom.value; // hitbox to prevent hiding when hovered in element bounding box - if ( - isPointHittingElementBoundingBox( - element, - elementsMap, - [sceneX, sceneY], - threshold, - null, - ) - ) { + if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) { return false; } const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap); diff --git a/packages/excalidraw/components/hyperlink/helpers.ts b/packages/excalidraw/components/hyperlink/helpers.ts index 9b7da3d767..92b8e3cd4f 100644 --- a/packages/excalidraw/components/hyperlink/helpers.ts +++ b/packages/excalidraw/components/hyperlink/helpers.ts @@ -1,6 +1,6 @@ import { MIME_TYPES } from "../../constants"; import { Bounds, getElementAbsoluteCoords } from "../../element/bounds"; -import { isPointHittingElementBoundingBox } from "../../element/collision"; +import { hitElementBoundingBox } from "../../element/collision"; import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types"; import { rotate } from "../../math"; import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement"; @@ -75,17 +75,10 @@ export const isPointHittingLink = ( if (!element.link || appState.selectedElementIds[element.id]) { return false; } - const threshold = 4 / appState.zoom.value; if ( !isMobile && appState.viewModeEnabled && - isPointHittingElementBoundingBox( - element, - elementsMap, - [x, y], - threshold, - null, - ) + hitElementBoundingBox(x, y, element, elementsMap) ) { return true; } diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 8d3959bc7c..97f3ad25ab 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -1,28 +1,37 @@ +import * as GA from "../ga"; +import * as GAPoint from "../gapoints"; +import * as GADirection from "../gadirections"; +import * as GALine from "../galines"; +import * as GATransform from "../gatransforms"; + import { - ExcalidrawLinearElement, ExcalidrawBindableElement, + ExcalidrawElement, + ExcalidrawRectangleElement, + ExcalidrawDiamondElement, + ExcalidrawTextElement, + ExcalidrawEllipseElement, + ExcalidrawFreeDrawElement, + ExcalidrawImageElement, + ExcalidrawFrameLikeElement, + ExcalidrawIframeLikeElement, NonDeleted, - NonDeletedExcalidrawElement, + ExcalidrawLinearElement, PointBinding, - ExcalidrawElement, + NonDeletedExcalidrawElement, ElementsMap, NonDeletedSceneElementsMap, } from "./types"; + +import { getElementAbsoluteCoords } from "./bounds"; +import { AppClassProperties, AppState, Point } from "../types"; +import { isPointOnShape } from "../../utils/collision"; import { getElementAtPosition } from "../scene"; -import { AppState } from "../types"; import { isBindableElement, isBindingElement, isLinearElement, } from "./typeChecks"; -import { - bindingBorderTest, - distanceToBindableElement, - maxBindingGap, - determineFocusDistance, - intersectElementWithLine, - determineFocusPoint, -} from "./collision"; import { mutateElement } from "./mutateElement"; import Scene from "../scene/Scene"; import { LinearElementEditor } from "./linearElementEditor"; @@ -152,29 +161,22 @@ const bindOrUnbindLinearElementEdge = ( export const bindOrUnbindSelectedElements = ( selectedElements: NonDeleted[], - elements: readonly ExcalidrawElement[], - elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): void => { selectedElements.forEach((selectedElement) => { if (isBindingElement(selectedElement)) { bindOrUnbindLinearElement( selectedElement, - getElligibleElementForBindingElement( - selectedElement, - "start", - elements, - elementsMap, - ), - getElligibleElementForBindingElement( - selectedElement, - "end", - elements, - elementsMap, - ), - elementsMap, + getElligibleElementForBindingElement(selectedElement, "start", app), + getElligibleElementForBindingElement(selectedElement, "end", app), + app.scene.getNonDeletedElementsMap(), ); } else if (isBindableElement(selectedElement)) { - maybeBindBindableElement(selectedElement, elementsMap); + maybeBindBindableElement( + selectedElement, + app.scene.getNonDeletedElementsMap(), + app, + ); } }); }; @@ -182,40 +184,34 @@ export const bindOrUnbindSelectedElements = ( const maybeBindBindableElement = ( bindableElement: NonDeleted, elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): void => { - getElligibleElementsForBindableElementAndWhere( - bindableElement, - elementsMap, - ).forEach(([linearElement, where]) => - bindOrUnbindLinearElement( - linearElement, - where === "end" ? "keep" : bindableElement, - where === "start" ? "keep" : bindableElement, - elementsMap, - ), + getElligibleElementsForBindableElementAndWhere(bindableElement, app).forEach( + ([linearElement, where]) => + bindOrUnbindLinearElement( + linearElement, + where === "end" ? "keep" : bindableElement, + where === "start" ? "keep" : bindableElement, + elementsMap, + ), ); }; export const maybeBindLinearElement = ( linearElement: NonDeleted, appState: AppState, - scene: Scene, pointerCoords: { x: number; y: number }, - elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): void => { if (appState.startBoundElement != null) { bindLinearElement( linearElement, appState.startBoundElement, "start", - elementsMap, + app.scene.getNonDeletedElementsMap(), ); } - const hoveredElement = getHoveredElementForBinding( - pointerCoords, - scene.getNonDeletedElements(), - elementsMap, - ); + const hoveredElement = getHoveredElementForBinding(pointerCoords, app); if ( hoveredElement != null && !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( @@ -224,7 +220,12 @@ export const maybeBindLinearElement = ( "end", ) ) { - bindLinearElement(linearElement, hoveredElement, "end", elementsMap); + bindLinearElement( + linearElement, + hoveredElement, + "end", + app.scene.getNonDeletedElementsMap(), + ); } }; @@ -283,7 +284,7 @@ export const isLinearElementSimpleAndAlreadyBound = ( }; export const unbindLinearElements = ( - elements: NonDeleted[], + elements: readonly NonDeleted[], elementsMap: NonDeletedSceneElementsMap, ): void => { elements.forEach((element) => { @@ -311,14 +312,13 @@ export const getHoveredElementForBinding = ( x: number; y: number; }, - elements: readonly NonDeletedExcalidrawElement[], - elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): NonDeleted | null => { const hoveredElement = getElementAtPosition( - elements, + app.scene.getNonDeletedElements(), (element) => isBindableElement(element, false) && - bindingBorderTest(element, pointerCoords, elementsMap), + bindingBorderTest(element, pointerCoords, app), ); return hoveredElement as NonDeleted | null; }; @@ -547,23 +547,21 @@ const maybeCalculateNewGapWhenScaling = ( // TODO: this is a bottleneck, optimise export const getEligibleElementsForBinding = ( selectedElements: NonDeleted[], - elements: readonly ExcalidrawElement[], - elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): SuggestedBinding[] => { const includedElementIds = new Set(selectedElements.map(({ id }) => id)); return selectedElements.flatMap((selectedElement) => isBindingElement(selectedElement, false) ? (getElligibleElementsForBindingElement( selectedElement as NonDeleted, - elements, - elementsMap, + app, ).filter( (element) => !includedElementIds.has(element.id), ) as SuggestedBinding[]) : isBindableElement(selectedElement, false) ? getElligibleElementsForBindableElementAndWhere( selectedElement, - elementsMap, + app, ).filter((binding) => !includedElementIds.has(binding[0].id)) : [], ); @@ -571,22 +569,11 @@ export const getEligibleElementsForBinding = ( const getElligibleElementsForBindingElement = ( linearElement: NonDeleted, - elements: readonly ExcalidrawElement[], - elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): NonDeleted[] => { return [ - getElligibleElementForBindingElement( - linearElement, - "start", - elements, - elementsMap, - ), - getElligibleElementForBindingElement( - linearElement, - "end", - elements, - elementsMap, - ), + getElligibleElementForBindingElement(linearElement, "start", app), + getElligibleElementForBindingElement(linearElement, "end", app), ].filter( (element): element is NonDeleted => element != null, @@ -596,13 +583,15 @@ const getElligibleElementsForBindingElement = ( const getElligibleElementForBindingElement = ( linearElement: NonDeleted, startOrEnd: "start" | "end", - elements: readonly ExcalidrawElement[], - elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): NonDeleted | null => { return getHoveredElementForBinding( - getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), - elements, - elementsMap, + getLinearElementEdgeCoors( + linearElement, + startOrEnd, + app.scene.getNonDeletedElementsMap(), + ), + app, ); }; @@ -623,7 +612,7 @@ const getLinearElementEdgeCoors = ( const getElligibleElementsForBindableElementAndWhere = ( bindableElement: NonDeleted, - elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): SuggestedPointBinding[] => { const scene = Scene.getScene(bindableElement)!; return scene @@ -636,13 +625,15 @@ const getElligibleElementsForBindableElementAndWhere = ( element, "start", bindableElement, - elementsMap, + scene.getNonDeletedElementsMap(), + app, ); const canBindEnd = isLinearElementEligibleForNewBindingByBindable( element, "end", bindableElement, - elementsMap, + scene.getNonDeletedElementsMap(), + app, ); if (!canBindStart && !canBindEnd) { return null; @@ -661,6 +652,7 @@ const isLinearElementEligibleForNewBindingByBindable = ( startOrEnd: "start" | "end", bindableElement: NonDeleted, elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): boolean => { const existingBinding = linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"]; @@ -674,7 +666,7 @@ const isLinearElementEligibleForNewBindingByBindable = ( bindingBorderTest( bindableElement, getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), - elementsMap, + app, ) ); }; @@ -846,3 +838,547 @@ const newBoundElementsAfterDeletion = ( } return boundElements.filter((ele) => !deletedElementIds.has(ele.id)); }; + +export const bindingBorderTest = ( + element: NonDeleted, + { x, y }: { x: number; y: number }, + app: AppClassProperties, +): boolean => { + const threshold = maxBindingGap(element, element.width, element.height); + const shape = app.getElementShape(element); + return isPointOnShape([x, y], shape, threshold); +}; + +export const maxBindingGap = ( + element: ExcalidrawElement, + elementWidth: number, + elementHeight: number, +): number => { + // Aligns diamonds with rectangles + const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; + const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); + // We make the bindable boundary bigger for bigger elements + return Math.max(16, Math.min(0.25 * smallerDimension, 32)); +}; + +export const distanceToBindableElement = ( + element: ExcalidrawBindableElement, + point: Point, + elementsMap: ElementsMap, +): number => { + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + return distanceToRectangle(element, point, elementsMap); + case "diamond": + return distanceToDiamond(element, point, elementsMap); + case "ellipse": + return distanceToEllipse(element, point, elementsMap); + } +}; + +const distanceToRectangle = ( + element: + | ExcalidrawRectangleElement + | ExcalidrawTextElement + | ExcalidrawFreeDrawElement + | ExcalidrawImageElement + | ExcalidrawIframeLikeElement + | ExcalidrawFrameLikeElement, + point: Point, + elementsMap: ElementsMap, +): number => { + const [, pointRel, hwidth, hheight] = pointRelativeToElement( + element, + point, + elementsMap, + ); + return Math.max( + GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)), + GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)), + ); +}; + +const distanceToDiamond = ( + element: ExcalidrawDiamondElement, + point: Point, + elementsMap: ElementsMap, +): number => { + const [, pointRel, hwidth, hheight] = pointRelativeToElement( + element, + point, + elementsMap, + ); + const side = GALine.equation(hheight, hwidth, -hheight * hwidth); + return GAPoint.distanceToLine(pointRel, side); +}; + +export const distanceToEllipse = ( + element: ExcalidrawEllipseElement, + point: Point, + elementsMap: ElementsMap, +): number => { + const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap); + return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent); +}; + +const ellipseParamsForTest = ( + element: ExcalidrawEllipseElement, + point: Point, + elementsMap: ElementsMap, +): [GA.Point, GA.Line] => { + const [, pointRel, hwidth, hheight] = pointRelativeToElement( + element, + point, + elementsMap, + ); + const [px, py] = GAPoint.toTuple(pointRel); + + // We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)` + let tx = 0.707; + let ty = 0.707; + + const a = hwidth; + const b = hheight; + + // This is a numerical method to find the params tx, ty at which + // the ellipse has the closest point to the given point + [0, 1, 2, 3].forEach((_) => { + const xx = a * tx; + const yy = b * ty; + + const ex = ((a * a - b * b) * tx ** 3) / a; + const ey = ((b * b - a * a) * ty ** 3) / b; + + const rx = xx - ex; + const ry = yy - ey; + + const qx = px - ex; + const qy = py - ey; + + const r = Math.hypot(ry, rx); + const q = Math.hypot(qy, qx); + + tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); + ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); + const t = Math.hypot(ty, tx); + tx /= t; + ty /= t; + }); + + const closestPoint = GA.point(a * tx, b * ty); + + const tangent = GALine.orthogonalThrough(pointRel, closestPoint); + return [pointRel, tangent]; +}; + +// Returns: +// 1. the point relative to the elements (x, y) position +// 2. the point relative to the element's center with positive (x, y) +// 3. half element width +// 4. half element height +// +// Note that for linear elements the (x, y) position is not at the +// top right corner of their boundary. +// +// Rectangles, diamonds and ellipses are symmetrical over axes, +// and other elements have a rectangular boundary, +// so we only need to perform hit tests for the positive quadrant. +const pointRelativeToElement = ( + element: ExcalidrawElement, + pointTuple: Point, + elementsMap: ElementsMap, +): [GA.Point, GA.Point, number, number] => { + const point = GAPoint.from(pointTuple); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const center = coordsCenter(x1, y1, x2, y2); + // GA has angle orientation opposite to `rotate` + const rotate = GATransform.rotation(center, element.angle); + const pointRotated = GATransform.apply(rotate, point); + const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center)); + const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter); + const elementPos = GA.offset(element.x, element.y); + const pointRelToPos = GA.sub(pointRotated, elementPos); + const halfWidth = (x2 - x1) / 2; + const halfHeight = (y2 - y1) / 2; + return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight]; +}; + +const relativizationToElementCenter = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): GA.Transform => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const center = coordsCenter(x1, y1, x2, y2); + // GA has angle orientation opposite to `rotate` + const rotate = GATransform.rotation(center, element.angle); + const translate = GA.reverse( + GATransform.translation(GADirection.from(center)), + ); + return GATransform.compose(rotate, translate); +}; + +const coordsCenter = ( + x1: number, + y1: number, + x2: number, + y2: number, +): GA.Point => { + return GA.point((x1 + x2) / 2, (y1 + y2) / 2); +}; + +// The focus distance is the oriented ratio between the size of +// the `element` and the "focus image" of the element on which +// all focus points lie, so it's a number between -1 and 1. +// The line going through `a` and `b` is a tangent to the "focus image" +// of the element. +export const determineFocusDistance = ( + element: ExcalidrawBindableElement, + // Point on the line, in absolute coordinates + a: Point, + // Another point on the line, in absolute coordinates (closer to element) + b: Point, + elementsMap: ElementsMap, +): number => { + const relateToCenter = relativizationToElementCenter(element, elementsMap); + const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); + const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); + const line = GALine.through(aRel, bRel); + const q = element.height / element.width; + const hwidth = element.width / 2; + const hheight = element.height / 2; + const n = line[2]; + const m = line[3]; + const c = line[1]; + const mabs = Math.abs(m); + const nabs = Math.abs(n); + let ret; + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + ret = c / (hwidth * (nabs + q * mabs)); + break; + case "diamond": + ret = mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight); + break; + case "ellipse": + ret = c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2)); + break; + } + return ret || 0; +}; + +export const determineFocusPoint = ( + element: ExcalidrawBindableElement, + // The oriented, relative distance from the center of `element` of the + // returned focusPoint + focus: number, + adjecentPoint: Point, + elementsMap: ElementsMap, +): Point => { + if (focus === 0) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const center = coordsCenter(x1, y1, x2, y2); + return GAPoint.toTuple(center); + } + const relateToCenter = relativizationToElementCenter(element, elementsMap); + const adjecentPointRel = GATransform.apply( + relateToCenter, + GAPoint.from(adjecentPoint), + ); + const reverseRelateToCenter = GA.reverse(relateToCenter); + let point; + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "diamond": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + point = findFocusPointForRectangulars(element, focus, adjecentPointRel); + break; + case "ellipse": + point = findFocusPointForEllipse(element, focus, adjecentPointRel); + break; + } + return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)); +}; + +// Returns 2 or 0 intersection points between line going through `a` and `b` +// and the `element`, in ascending order of distance from `a`. +export const intersectElementWithLine = ( + element: ExcalidrawBindableElement, + // Point on the line, in absolute coordinates + a: Point, + // Another point on the line, in absolute coordinates + b: Point, + // If given, the element is inflated by this value + gap: number = 0, + elementsMap: ElementsMap, +): Point[] => { + const relateToCenter = relativizationToElementCenter(element, elementsMap); + const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); + const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); + const line = GALine.through(aRel, bRel); + const reverseRelateToCenter = GA.reverse(relateToCenter); + const intersections = getSortedElementLineIntersections( + element, + line, + aRel, + gap, + ); + return intersections.map((point) => + GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), + ); +}; + +const getSortedElementLineIntersections = ( + element: ExcalidrawBindableElement, + // Relative to element center + line: GA.Line, + // Relative to element center + nearPoint: GA.Point, + gap: number = 0, +): GA.Point[] => { + let intersections: GA.Point[]; + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "diamond": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + const corners = getCorners(element); + intersections = corners + .flatMap((point, i) => { + const edge: [GA.Point, GA.Point] = [point, corners[(i + 1) % 4]]; + return intersectSegment(line, offsetSegment(edge, gap)); + }) + .concat( + corners.flatMap((point) => getCircleIntersections(point, gap, line)), + ); + break; + case "ellipse": + intersections = getEllipseIntersections(element, gap, line); + break; + } + if (intersections.length < 2) { + // Ignore the "edge" case of only intersecting with a single corner + return []; + } + const sortedIntersections = intersections.sort( + (i1, i2) => + GAPoint.distance(i1, nearPoint) - GAPoint.distance(i2, nearPoint), + ); + return [ + sortedIntersections[0], + sortedIntersections[sortedIntersections.length - 1], + ]; +}; + +const getCorners = ( + element: + | ExcalidrawRectangleElement + | ExcalidrawImageElement + | ExcalidrawDiamondElement + | ExcalidrawTextElement + | ExcalidrawIframeLikeElement + | ExcalidrawFrameLikeElement, + scale: number = 1, +): GA.Point[] => { + const hx = (scale * element.width) / 2; + const hy = (scale * element.height) / 2; + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + return [ + GA.point(hx, hy), + GA.point(hx, -hy), + GA.point(-hx, -hy), + GA.point(-hx, hy), + ]; + case "diamond": + return [ + GA.point(0, hy), + GA.point(hx, 0), + GA.point(0, -hy), + GA.point(-hx, 0), + ]; + } +}; + +// Returns intersection of `line` with `segment`, with `segment` moved by +// `gap` in its polar direction. +// If intersection coincides with second segment point returns empty array. +const intersectSegment = ( + line: GA.Line, + segment: [GA.Point, GA.Point], +): GA.Point[] => { + const [a, b] = segment; + const aDist = GAPoint.distanceToLine(a, line); + const bDist = GAPoint.distanceToLine(b, line); + if (aDist * bDist >= 0) { + // The intersection is outside segment `(a, b)` + return []; + } + return [GAPoint.intersect(line, GALine.through(a, b))]; +}; + +const offsetSegment = ( + segment: [GA.Point, GA.Point], + distance: number, +): [GA.Point, GA.Point] => { + const [a, b] = segment; + const offset = GATransform.translationOrthogonal( + GADirection.fromTo(a, b), + distance, + ); + return [GATransform.apply(offset, a), GATransform.apply(offset, b)]; +}; + +const getEllipseIntersections = ( + element: ExcalidrawEllipseElement, + gap: number, + line: GA.Line, +): GA.Point[] => { + const a = element.width / 2 + gap; + const b = element.height / 2 + gap; + const m = line[2]; + const n = line[3]; + const c = line[1]; + const squares = a * a * m * m + b * b * n * n; + const discr = squares - c * c; + if (squares === 0 || discr <= 0) { + return []; + } + const discrRoot = Math.sqrt(discr); + const xn = -a * a * m * c; + const yn = -b * b * n * c; + return [ + GA.point( + (xn + a * b * n * discrRoot) / squares, + (yn - a * b * m * discrRoot) / squares, + ), + GA.point( + (xn - a * b * n * discrRoot) / squares, + (yn + a * b * m * discrRoot) / squares, + ), + ]; +}; + +export const getCircleIntersections = ( + center: GA.Point, + radius: number, + line: GA.Line, +): GA.Point[] => { + if (radius === 0) { + return GAPoint.distanceToLine(line, center) === 0 ? [center] : []; + } + const m = line[2]; + const n = line[3]; + const c = line[1]; + const [a, b] = GAPoint.toTuple(center); + const r = radius; + const squares = m * m + n * n; + const discr = r * r * squares - (m * a + n * b + c) ** 2; + if (squares === 0 || discr <= 0) { + return []; + } + const discrRoot = Math.sqrt(discr); + const xn = a * n * n - b * m * n - m * c; + const yn = b * m * m - a * m * n - n * c; + + return [ + GA.point((xn + n * discrRoot) / squares, (yn - m * discrRoot) / squares), + GA.point((xn - n * discrRoot) / squares, (yn + m * discrRoot) / squares), + ]; +}; + +// The focus point is the tangent point of the "focus image" of the +// `element`, where the tangent goes through `point`. +export const findFocusPointForEllipse = ( + ellipse: ExcalidrawEllipseElement, + // Between -1 and 1 (not 0) the relative size of the "focus image" of + // the element on which the focus point lies + relativeDistance: number, + // The point for which we're trying to find the focus point, relative + // to the ellipse center. + point: GA.Point, +): GA.Point => { + const relativeDistanceAbs = Math.abs(relativeDistance); + const a = (ellipse.width * relativeDistanceAbs) / 2; + const b = (ellipse.height * relativeDistanceAbs) / 2; + + const orientation = Math.sign(relativeDistance); + const [px, pyo] = GAPoint.toTuple(point); + + // The calculation below can't handle py = 0 + const py = pyo === 0 ? 0.0001 : pyo; + + const squares = px ** 2 * b ** 2 + py ** 2 * a ** 2; + // Tangent mx + ny + 1 = 0 + const m = + (-px * b ** 2 + + orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) / + squares; + + let n = (-m * px - 1) / py; + + if (n === 0) { + // if zero {-0, 0}, fall back to a same-sign value in the similar range + n = (Object.is(n, -0) ? -1 : 1) * 0.01; + } + + const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2); + return GA.point(x, (-m * x - 1) / n); +}; + +export const findFocusPointForRectangulars = ( + element: + | ExcalidrawRectangleElement + | ExcalidrawImageElement + | ExcalidrawDiamondElement + | ExcalidrawTextElement + | ExcalidrawIframeLikeElement + | ExcalidrawFrameLikeElement, + // Between -1 and 1 for how far away should the focus point be relative + // to the size of the element. Sign determines orientation. + relativeDistance: number, + // The point for which we're trying to find the focus point, relative + // to the element center. + point: GA.Point, +): GA.Point => { + const relativeDistanceAbs = Math.abs(relativeDistance); + const orientation = Math.sign(relativeDistance); + const corners = getCorners(element, relativeDistanceAbs); + + let maxDistance = 0; + let tangentPoint: null | GA.Point = null; + corners.forEach((corner) => { + const distance = orientation * GALine.through(point, corner)[1]; + if (distance > maxDistance) { + maxDistance = distance; + tangentPoint = corner; + } + }); + return tangentPoint!; +}; diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index e7c6f7fb3d..6d98087bae 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -299,13 +299,6 @@ export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => { ]; }; -export const pointRelativeTo = ( - element: ExcalidrawElement, - absoluteCoords: Point, -): Point => { - return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y]; -}; - export const getDiamondPoints = (element: ExcalidrawElement) => { // Here we add +1 to avoid these numbers to be 0 // otherwise rough.js will throw an error complaining about it diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index ff5a139de0..df3d9c9281 100644 --- a/packages/excalidraw/element/collision.ts +++ b/packages/excalidraw/element/collision.ts @@ -1,1170 +1,111 @@ -import * as GA from "../ga"; -import * as GAPoint from "../gapoints"; -import * as GADirection from "../gadirections"; -import * as GALine from "../galines"; -import * as GATransform from "../gatransforms"; +import { isPathALoop, isPointWithinBounds } from "../math"; import { - distance2d, - rotatePoint, - isPathALoop, - isPointInPolygon, - rotate, -} from "../math"; -import { pointsOnBezierCurves } from "points-on-curve"; - -import { - NonDeletedExcalidrawElement, - ExcalidrawBindableElement, + ElementsMap, ExcalidrawElement, ExcalidrawRectangleElement, - ExcalidrawDiamondElement, - ExcalidrawTextElement, - ExcalidrawEllipseElement, - NonDeleted, - ExcalidrawFreeDrawElement, - ExcalidrawImageElement, - ExcalidrawLinearElement, - StrokeRoundness, - ExcalidrawFrameLikeElement, - ExcalidrawIframeLikeElement, - ElementsMap, } from "./types"; +import { getElementBounds } from "./bounds"; +import { FrameNameBounds } from "../types"; import { - getElementAbsoluteCoords, - getCurvePathOps, - getRectangleBoxAbsoluteCoords, - RectangleBox, -} from "./bounds"; -import { FrameNameBoundsCache, Point } from "../types"; -import { Drawable } from "roughjs/bin/core"; -import { AppState } from "../types"; + Polygon, + GeometricShape, + getPolygonShape, +} from "../../utils/geometry/shape"; +import { isPointInShape, isPointOnShape } from "../../utils/collision"; +import { isTransparent } from "../utils"; import { hasBoundTextElement, - isFrameLikeElement, isIframeLikeElement, isImageElement, + isTextElement, } from "./typeChecks"; -import { isTextElement } from "."; -import { isTransparent } from "../utils"; -import { shouldShowBoundingBox } from "./transformHandles"; -import { getBoundTextElement } from "./textElement"; -import { Mutable } from "../utility-types"; -import { ShapeCache } from "../scene/ShapeCache"; -const isElementDraggableFromInside = ( - element: NonDeletedExcalidrawElement, -): boolean => { +export const shouldTestInside = (element: ExcalidrawElement) => { if (element.type === "arrow") { return false; } - if (element.type === "freedraw") { - return true; - } const isDraggableFromInside = !isTransparent(element.backgroundColor) || hasBoundTextElement(element) || - isIframeLikeElement(element); + isIframeLikeElement(element) || + isTextElement(element); + if (element.type === "line") { return isDraggableFromInside && isPathALoop(element.points); } - return isDraggableFromInside || isImageElement(element); -}; - -export const hitTest = ( - element: NonDeletedExcalidrawElement, - appState: AppState, - frameNameBoundsCache: FrameNameBoundsCache, - x: number, - y: number, - elementsMap: ElementsMap, -): boolean => { - // How many pixels off the shape boundary we still consider a hit - const threshold = 10 / appState.zoom.value; - const point: Point = [x, y]; - - if ( - isElementSelected(appState, element) && - shouldShowBoundingBox([element], appState) - ) { - return isPointHittingElementBoundingBox( - element, - elementsMap, - point, - threshold, - frameNameBoundsCache, - ); - } - - const boundTextElement = getBoundTextElement(element, elementsMap); - if (boundTextElement) { - const isHittingBoundTextElement = hitTest( - boundTextElement, - appState, - frameNameBoundsCache, - x, - y, - elementsMap, - ); - if (isHittingBoundTextElement) { - return true; - } - } - return isHittingElementNotConsideringBoundingBox( - element, - appState, - frameNameBoundsCache, - point, - elementsMap, - ); -}; -export const isHittingElementBoundingBoxWithoutHittingElement = ( - element: NonDeletedExcalidrawElement, - appState: AppState, - frameNameBoundsCache: FrameNameBoundsCache, - x: number, - y: number, - elementsMap: ElementsMap, -): boolean => { - const threshold = 10 / appState.zoom.value; - - // So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element - // eg for linear elements text can be outside the element bounding box - const boundTextElement = getBoundTextElement(element, elementsMap); - if ( - boundTextElement && - hitTest(boundTextElement, appState, frameNameBoundsCache, x, y, elementsMap) - ) { - return false; + if (element.type === "freedraw") { + return isDraggableFromInside && isPathALoop(element.points); } - return ( - !isHittingElementNotConsideringBoundingBox( - element, - appState, - frameNameBoundsCache, - [x, y], - elementsMap, - ) && - isPointHittingElementBoundingBox( - element, - elementsMap, - [x, y], - threshold, - frameNameBoundsCache, - ) - ); -}; - -export const isHittingElementNotConsideringBoundingBox = ( - element: NonDeletedExcalidrawElement, - appState: AppState, - frameNameBoundsCache: FrameNameBoundsCache | null, - point: Point, - elementsMap: ElementsMap, -): boolean => { - const threshold = 10 / appState.zoom.value; - const check = isTextElement(element) - ? isStrictlyInside - : isElementDraggableFromInside(element) - ? isInsideCheck - : isNearCheck; - return hitTestPointAgainstElement({ - element, - elementsMap, - point, - threshold, - check, - frameNameBoundsCache, - }); + return isDraggableFromInside || isImageElement(element); }; -const isElementSelected = ( - appState: AppState, - element: NonDeleted, -) => appState.selectedElementIds[element.id]; - -export const isPointHittingElementBoundingBox = ( - element: NonDeleted, - elementsMap: ElementsMap, - [x, y]: Point, - threshold: number, - frameNameBoundsCache: FrameNameBoundsCache | null, -) => { - // frames needs be checked differently so as to be able to drag it - // by its frame, whether it has been selected or not - // this logic here is not ideal - // TODO: refactor it later... - if (isFrameLikeElement(element)) { - return hitTestPointAgainstElement({ - element, - elementsMap, - point: [x, y], - threshold, - check: isInsideCheck, - frameNameBoundsCache, +export type HitTestArgs = { + x: number; + y: number; + element: ExcalidrawElement; + shape: GeometricShape; + threshold?: number; + frameNameBound?: FrameNameBounds | null; +}; + +export const hitElementItself = ({ + x, + y, + element, + shape, + threshold = 10, + frameNameBound = null, +}: HitTestArgs) => { + let hit = shouldTestInside(element) + ? isPointInShape([x, y], shape) + : isPointOnShape([x, y], shape, threshold); + + // hit test against a frame's name + if (!hit && frameNameBound) { + hit = isPointInShape([x, y], { + type: "polygon", + data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) + .data as Polygon, }); } - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const elementCenterX = (x1 + x2) / 2; - const elementCenterY = (y1 + y2) / 2; - // reverse rotate to take element's angle into account. - const [rotatedX, rotatedY] = rotate( - x, - y, - elementCenterX, - elementCenterY, - -element.angle, - ); - - return ( - rotatedX > x1 - threshold && - rotatedX < x2 + threshold && - rotatedY > y1 - threshold && - rotatedY < y2 + threshold - ); -}; - -export const bindingBorderTest = ( - element: NonDeleted, - { x, y }: { x: number; y: number }, - elementsMap: ElementsMap, -): boolean => { - const threshold = maxBindingGap(element, element.width, element.height); - const check = isOutsideCheck; - const point: Point = [x, y]; - return hitTestPointAgainstElement({ - element, - elementsMap, - point, - threshold, - check, - frameNameBoundsCache: null, - }); -}; - -export const maxBindingGap = ( - element: ExcalidrawElement, - elementWidth: number, - elementHeight: number, -): number => { - // Aligns diamonds with rectangles - const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; - const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); - // We make the bindable boundary bigger for bigger elements - return Math.max(16, Math.min(0.25 * smallerDimension, 32)); -}; - -type HitTestArgs = { - element: NonDeletedExcalidrawElement; - elementsMap: ElementsMap; - point: Point; - threshold: number; - check: (distance: number, threshold: number) => boolean; - frameNameBoundsCache: FrameNameBoundsCache | null; -}; - -const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { - switch (args.element.type) { - case "rectangle": - case "iframe": - case "embeddable": - case "image": - case "text": - case "diamond": - case "ellipse": - const distance = distanceToBindableElement( - args.element, - args.point, - args.elementsMap, - ); - return args.check(distance, args.threshold); - case "freedraw": { - if ( - !args.check( - distanceToRectangle(args.element, args.point, args.elementsMap), - args.threshold, - ) - ) { - return false; - } - - return hitTestFreeDrawElement( - args.element, - args.point, - args.threshold, - args.elementsMap, - ); - } - case "arrow": - case "line": - return hitTestLinear(args); - case "selection": - console.warn( - "This should not happen, we need to investigate why it does.", - ); - return false; - case "frame": - case "magicframe": { - // check distance to frame element first - if ( - args.check( - distanceToBindableElement(args.element, args.point, args.elementsMap), - args.threshold, - ) - ) { - return true; - } - - const frameNameBounds = args.frameNameBoundsCache?.get(args.element); - - if (frameNameBounds) { - return args.check( - distanceToRectangleBox(frameNameBounds, args.point), - args.threshold, - ); - } - return false; - } - } -}; - -export const distanceToBindableElement = ( - element: ExcalidrawBindableElement, - point: Point, - elementsMap: ElementsMap, -): number => { - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - return distanceToRectangle(element, point, elementsMap); - case "diamond": - return distanceToDiamond(element, point, elementsMap); - case "ellipse": - return distanceToEllipse(element, point, elementsMap); - } -}; - -const isStrictlyInside = (distance: number, threshold: number): boolean => { - return distance < 0; -}; - -const isInsideCheck = (distance: number, threshold: number): boolean => { - return distance < threshold; -}; - -const isNearCheck = (distance: number, threshold: number): boolean => { - return Math.abs(distance) < threshold; -}; - -const isOutsideCheck = (distance: number, threshold: number): boolean => { - return 0 <= distance && distance < threshold; -}; - -const distanceToRectangle = ( - element: - | ExcalidrawRectangleElement - | ExcalidrawTextElement - | ExcalidrawFreeDrawElement - | ExcalidrawImageElement - | ExcalidrawIframeLikeElement - | ExcalidrawFrameLikeElement, - point: Point, - elementsMap: ElementsMap, -): number => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement( - element, - point, - elementsMap, - ); - return Math.max( - GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)), - GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)), - ); -}; - -const distanceToRectangleBox = (box: RectangleBox, point: Point): number => { - const [, pointRel, hwidth, hheight] = pointRelativeToDivElement(point, box); - return Math.max( - GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)), - GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)), - ); -}; - -const distanceToDiamond = ( - element: ExcalidrawDiamondElement, - point: Point, - elementsMap: ElementsMap, -): number => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement( - element, - point, - elementsMap, - ); - const side = GALine.equation(hheight, hwidth, -hheight * hwidth); - return GAPoint.distanceToLine(pointRel, side); -}; - -const distanceToEllipse = ( - element: ExcalidrawEllipseElement, - point: Point, - elementsMap: ElementsMap, -): number => { - const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap); - return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent); -}; - -const ellipseParamsForTest = ( - element: ExcalidrawEllipseElement, - point: Point, - elementsMap: ElementsMap, -): [GA.Point, GA.Line] => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement( - element, - point, - elementsMap, - ); - const [px, py] = GAPoint.toTuple(pointRel); - - // We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)` - let tx = 0.707; - let ty = 0.707; - - const a = hwidth; - const b = hheight; - - // This is a numerical method to find the params tx, ty at which - // the ellipse has the closest point to the given point - [0, 1, 2, 3].forEach((_) => { - const xx = a * tx; - const yy = b * ty; - - const ex = ((a * a - b * b) * tx ** 3) / a; - const ey = ((b * b - a * a) * ty ** 3) / b; - - const rx = xx - ex; - const ry = yy - ey; - - const qx = px - ex; - const qy = py - ey; - - const r = Math.hypot(ry, rx); - const q = Math.hypot(qy, qx); - - tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); - ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); - const t = Math.hypot(ty, tx); - tx /= t; - ty /= t; - }); - - const closestPoint = GA.point(a * tx, b * ty); - - const tangent = GALine.orthogonalThrough(pointRel, closestPoint); - return [pointRel, tangent]; -}; - -const hitTestFreeDrawElement = ( - element: ExcalidrawFreeDrawElement, - point: Point, - threshold: number, - elementsMap: ElementsMap, -): boolean => { - // Check point-distance-to-line-segment for every segment in the - // element's points (its input points, not its outline points). - // This is... okay? It's plenty fast, but the GA library may - // have a faster option. - - let x: number; - let y: number; - - if (element.angle === 0) { - x = point[0] - element.x; - y = point[1] - element.y; - } else { - // Counter-rotate the point around center before testing - const [minX, minY, maxX, maxY] = getElementAbsoluteCoords( - element, - elementsMap, - ); - const rotatedPoint = rotatePoint( - point, - [minX + (maxX - minX) / 2, minY + (maxY - minY) / 2], - -element.angle, - ); - x = rotatedPoint[0] - element.x; - y = rotatedPoint[1] - element.y; - } - - let [A, B] = element.points; - let P: readonly [number, number]; - - // For freedraw dots - if ( - distance2d(A[0], A[1], x, y) < threshold || - distance2d(B[0], B[1], x, y) < threshold - ) { - return true; - } - - // For freedraw lines - for (let i = 0; i < element.points.length; i++) { - const delta = [B[0] - A[0], B[1] - A[1]]; - const length = Math.hypot(delta[1], delta[0]); - - const U = [delta[0] / length, delta[1] / length]; - const C = [x - A[0], y - A[1]]; - const d = (C[0] * U[0] + C[1] * U[1]) / Math.hypot(U[1], U[0]); - P = [A[0] + U[0] * d, A[1] + U[1] * d]; - - const da = distance2d(P[0], P[1], A[0], A[1]); - const db = distance2d(P[0], P[1], B[0], B[1]); - - P = db < da && da > length ? B : da < db && db > length ? A : P; - - if (Math.hypot(y - P[1], x - P[0]) < threshold) { - return true; - } - - A = B; - B = element.points[i + 1]; - } - - const shape = ShapeCache.get(element); - - // for filled freedraw shapes, support - // selecting from inside - if (shape && shape.sets.length) { - return element.fillStyle === "solid" - ? hitTestCurveInside(shape, x, y, "round") - : hitTestRoughShape(shape, x, y, threshold); - } - - return false; -}; - -const hitTestLinear = (args: HitTestArgs): boolean => { - const { element, threshold } = args; - if (!ShapeCache.get(element)) { - return false; - } - - const [point, pointAbs, hwidth, hheight] = pointRelativeToElement( - args.element, - args.point, - args.elementsMap, - ); - const side1 = GALine.equation(0, 1, -hheight); - const side2 = GALine.equation(1, 0, -hwidth); - if ( - !isInsideCheck(GAPoint.distanceToLine(pointAbs, side1), threshold) || - !isInsideCheck(GAPoint.distanceToLine(pointAbs, side2), threshold) - ) { - return false; - } - const [relX, relY] = GAPoint.toTuple(point); - - const shape = ShapeCache.get(element as ExcalidrawLinearElement); - - if (!shape) { - return false; - } - - if (args.check === isInsideCheck) { - const hit = shape.some((subshape) => - hitTestCurveInside( - subshape, - relX, - relY, - element.roundness ? "round" : "sharp", - ), - ); - if (hit) { - return true; - } - } - - // hit test all "subshapes" of the linear element - return shape.some((subshape) => - hitTestRoughShape(subshape, relX, relY, threshold), - ); -}; - -// Returns: -// 1. the point relative to the elements (x, y) position -// 2. the point relative to the element's center with positive (x, y) -// 3. half element width -// 4. half element height -// -// Note that for linear elements the (x, y) position is not at the -// top right corner of their boundary. -// -// Rectangles, diamonds and ellipses are symmetrical over axes, -// and other elements have a rectangular boundary, -// so we only need to perform hit tests for the positive quadrant. -const pointRelativeToElement = ( - element: ExcalidrawElement, - pointTuple: Point, - elementsMap: ElementsMap, -): [GA.Point, GA.Point, number, number] => { - const point = GAPoint.from(pointTuple); - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const center = coordsCenter(x1, y1, x2, y2); - // GA has angle orientation opposite to `rotate` - const rotate = GATransform.rotation(center, element.angle); - const pointRotated = GATransform.apply(rotate, point); - const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center)); - const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter); - const elementPos = GA.offset(element.x, element.y); - const pointRelToPos = GA.sub(pointRotated, elementPos); - const halfWidth = (x2 - x1) / 2; - const halfHeight = (y2 - y1) / 2; - return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight]; -}; - -const pointRelativeToDivElement = ( - pointTuple: Point, - rectangle: RectangleBox, -): [GA.Point, GA.Point, number, number] => { - const point = GAPoint.from(pointTuple); - const [x1, y1, x2, y2] = getRectangleBoxAbsoluteCoords(rectangle); - const center = coordsCenter(x1, y1, x2, y2); - const rotate = GATransform.rotation(center, rectangle.angle); - const pointRotated = GATransform.apply(rotate, point); - const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center)); - const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter); - const elementPos = GA.offset(rectangle.x, rectangle.y); - const pointRelToPos = GA.sub(pointRotated, elementPos); - const halfWidth = (x2 - x1) / 2; - const halfHeight = (y2 - y1) / 2; - return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight]; + return hit; }; -// Returns point in absolute coordinates -export const pointInAbsoluteCoords = ( - element: ExcalidrawElement, - elementsMap: ElementsMap, - // Point relative to the element position - point: Point, -): Point => { - const [x, y] = point; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const cx = (x2 - x1) / 2; - const cy = (y2 - y1) / 2; - const [rotatedX, rotatedY] = rotate(x, y, cx, cy, element.angle); - return [element.x + rotatedX, element.y + rotatedY]; -}; - -const relativizationToElementCenter = ( +export const hitElementBoundingBox = ( + x: number, + y: number, element: ExcalidrawElement, elementsMap: ElementsMap, -): GA.Transform => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const center = coordsCenter(x1, y1, x2, y2); - // GA has angle orientation opposite to `rotate` - const rotate = GATransform.rotation(center, element.angle); - const translate = GA.reverse( - GATransform.translation(GADirection.from(center)), - ); - return GATransform.compose(rotate, translate); -}; - -const coordsCenter = ( - x1: number, - y1: number, - x2: number, - y2: number, -): GA.Point => { - return GA.point((x1 + x2) / 2, (y1 + y2) / 2); -}; - -// The focus distance is the oriented ratio between the size of -// the `element` and the "focus image" of the element on which -// all focus points lie, so it's a number between -1 and 1. -// The line going through `a` and `b` is a tangent to the "focus image" -// of the element. -export const determineFocusDistance = ( - element: ExcalidrawBindableElement, - - // Point on the line, in absolute coordinates - a: Point, - // Another point on the line, in absolute coordinates (closer to element) - b: Point, - elementsMap: ElementsMap, -): number => { - const relateToCenter = relativizationToElementCenter(element, elementsMap); - const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); - const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); - const line = GALine.through(aRel, bRel); - const q = element.height / element.width; - const hwidth = element.width / 2; - const hheight = element.height / 2; - const n = line[2]; - const m = line[3]; - const c = line[1]; - const mabs = Math.abs(m); - const nabs = Math.abs(n); - let ret; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - ret = c / (hwidth * (nabs + q * mabs)); - break; - case "diamond": - ret = mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight); - break; - case "ellipse": - ret = c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2)); - break; - } - return ret || 0; -}; - -export const determineFocusPoint = ( - element: ExcalidrawBindableElement, - // The oriented, relative distance from the center of `element` of the - // returned focusPoint - focus: number, - adjecentPoint: Point, - elementsMap: ElementsMap, -): Point => { - if (focus === 0) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const center = coordsCenter(x1, y1, x2, y2); - return GAPoint.toTuple(center); - } - const relateToCenter = relativizationToElementCenter(element, elementsMap); - const adjecentPointRel = GATransform.apply( - relateToCenter, - GAPoint.from(adjecentPoint), - ); - const reverseRelateToCenter = GA.reverse(relateToCenter); - let point; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "diamond": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - point = findFocusPointForRectangulars(element, focus, adjecentPointRel); - break; - case "ellipse": - point = findFocusPointForEllipse(element, focus, adjecentPointRel); - break; - } - return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)); -}; - -// Returns 2 or 0 intersection points between line going through `a` and `b` -// and the `element`, in ascending order of distance from `a`. -export const intersectElementWithLine = ( - element: ExcalidrawBindableElement, - - // Point on the line, in absolute coordinates - a: Point, - // Another point on the line, in absolute coordinates - b: Point, - // If given, the element is inflated by this value - gap: number = 0, - elementsMap: ElementsMap, -): Point[] => { - const relateToCenter = relativizationToElementCenter(element, elementsMap); - const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); - const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); - const line = GALine.through(aRel, bRel); - const reverseRelateToCenter = GA.reverse(relateToCenter); - const intersections = getSortedElementLineIntersections( - element, - line, - aRel, - gap, - ); - return intersections.map((point) => - GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), - ); -}; - -const getSortedElementLineIntersections = ( - element: ExcalidrawBindableElement, - // Relative to element center - line: GA.Line, - // Relative to element center - nearPoint: GA.Point, - gap: number = 0, -): GA.Point[] => { - let intersections: GA.Point[]; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "diamond": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - const corners = getCorners(element); - intersections = corners - .flatMap((point, i) => { - const edge: [GA.Point, GA.Point] = [point, corners[(i + 1) % 4]]; - return intersectSegment(line, offsetSegment(edge, gap)); - }) - .concat( - corners.flatMap((point) => getCircleIntersections(point, gap, line)), - ); - break; - case "ellipse": - intersections = getEllipseIntersections(element, gap, line); - break; - } - if (intersections.length < 2) { - // Ignore the "edge" case of only intersecting with a single corner - return []; - } - const sortedIntersections = intersections.sort( - (i1, i2) => - GAPoint.distance(i1, nearPoint) - GAPoint.distance(i2, nearPoint), - ); - return [ - sortedIntersections[0], - sortedIntersections[sortedIntersections.length - 1], - ]; -}; - -const getCorners = ( - element: - | ExcalidrawRectangleElement - | ExcalidrawImageElement - | ExcalidrawDiamondElement - | ExcalidrawTextElement - | ExcalidrawIframeLikeElement - | ExcalidrawFrameLikeElement, - scale: number = 1, -): GA.Point[] => { - const hx = (scale * element.width) / 2; - const hy = (scale * element.height) / 2; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - return [ - GA.point(hx, hy), - GA.point(hx, -hy), - GA.point(-hx, -hy), - GA.point(-hx, hy), - ]; - case "diamond": - return [ - GA.point(0, hy), - GA.point(hx, 0), - GA.point(0, -hy), - GA.point(-hx, 0), - ]; - } -}; - -// Returns intersection of `line` with `segment`, with `segment` moved by -// `gap` in its polar direction. -// If intersection coincides with second segment point returns empty array. -const intersectSegment = ( - line: GA.Line, - segment: [GA.Point, GA.Point], -): GA.Point[] => { - const [a, b] = segment; - const aDist = GAPoint.distanceToLine(a, line); - const bDist = GAPoint.distanceToLine(b, line); - if (aDist * bDist >= 0) { - // The intersection is outside segment `(a, b)` - return []; - } - return [GAPoint.intersect(line, GALine.through(a, b))]; -}; - -const offsetSegment = ( - segment: [GA.Point, GA.Point], - distance: number, -): [GA.Point, GA.Point] => { - const [a, b] = segment; - const offset = GATransform.translationOrthogonal( - GADirection.fromTo(a, b), - distance, - ); - return [GATransform.apply(offset, a), GATransform.apply(offset, b)]; -}; - -const getEllipseIntersections = ( - element: ExcalidrawEllipseElement, - gap: number, - line: GA.Line, -): GA.Point[] => { - const a = element.width / 2 + gap; - const b = element.height / 2 + gap; - const m = line[2]; - const n = line[3]; - const c = line[1]; - const squares = a * a * m * m + b * b * n * n; - const discr = squares - c * c; - if (squares === 0 || discr <= 0) { - return []; - } - const discrRoot = Math.sqrt(discr); - const xn = -a * a * m * c; - const yn = -b * b * n * c; - return [ - GA.point( - (xn + a * b * n * discrRoot) / squares, - (yn - a * b * m * discrRoot) / squares, - ), - GA.point( - (xn - a * b * n * discrRoot) / squares, - (yn + a * b * m * discrRoot) / squares, - ), - ]; -}; - -export const getCircleIntersections = ( - center: GA.Point, - radius: number, - line: GA.Line, -): GA.Point[] => { - if (radius === 0) { - return GAPoint.distanceToLine(line, center) === 0 ? [center] : []; - } - const m = line[2]; - const n = line[3]; - const c = line[1]; - const [a, b] = GAPoint.toTuple(center); - const r = radius; - const squares = m * m + n * n; - const discr = r * r * squares - (m * a + n * b + c) ** 2; - if (squares === 0 || discr <= 0) { - return []; - } - const discrRoot = Math.sqrt(discr); - const xn = a * n * n - b * m * n - m * c; - const yn = b * m * m - a * m * n - n * c; - - return [ - GA.point((xn + n * discrRoot) / squares, (yn - m * discrRoot) / squares), - GA.point((xn - n * discrRoot) / squares, (yn + m * discrRoot) / squares), - ]; -}; - -// The focus point is the tangent point of the "focus image" of the -// `element`, where the tangent goes through `point`. -export const findFocusPointForEllipse = ( - ellipse: ExcalidrawEllipseElement, - // Between -1 and 1 (not 0) the relative size of the "focus image" of - // the element on which the focus point lies - relativeDistance: number, - // The point for which we're trying to find the focus point, relative - // to the ellipse center. - point: GA.Point, -): GA.Point => { - const relativeDistanceAbs = Math.abs(relativeDistance); - const a = (ellipse.width * relativeDistanceAbs) / 2; - const b = (ellipse.height * relativeDistanceAbs) / 2; - - const orientation = Math.sign(relativeDistance); - const [px, pyo] = GAPoint.toTuple(point); - - // The calculation below can't handle py = 0 - const py = pyo === 0 ? 0.0001 : pyo; - - const squares = px ** 2 * b ** 2 + py ** 2 * a ** 2; - // Tangent mx + ny + 1 = 0 - const m = - (-px * b ** 2 + - orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) / - squares; - - let n = (-m * px - 1) / py; - - if (n === 0) { - // if zero {-0, 0}, fall back to a same-sign value in the similar range - n = (Object.is(n, -0) ? -1 : 1) * 0.01; - } - - const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2); - return GA.point(x, (-m * x - 1) / n); -}; - -export const findFocusPointForRectangulars = ( - element: - | ExcalidrawRectangleElement - | ExcalidrawImageElement - | ExcalidrawDiamondElement - | ExcalidrawTextElement - | ExcalidrawIframeLikeElement - | ExcalidrawFrameLikeElement, - // Between -1 and 1 for how far away should the focus point be relative - // to the size of the element. Sign determines orientation. - relativeDistance: number, - // The point for which we're trying to find the focus point, relative - // to the element center. - point: GA.Point, -): GA.Point => { - const relativeDistanceAbs = Math.abs(relativeDistance); - const orientation = Math.sign(relativeDistance); - const corners = getCorners(element, relativeDistanceAbs); - - let maxDistance = 0; - let tangentPoint: null | GA.Point = null; - corners.forEach((corner) => { - const distance = orientation * GALine.through(point, corner)[1]; - if (distance > maxDistance) { - maxDistance = distance; - tangentPoint = corner; - } - }); - return tangentPoint!; -}; - -const pointInBezierEquation = ( - p0: Point, - p1: Point, - p2: Point, - p3: Point, - [mx, my]: Point, - lineThreshold: number, + tolerance = 0, ) => { - // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 - const equation = (t: number, idx: number) => - Math.pow(1 - t, 3) * p3[idx] + - 3 * t * Math.pow(1 - t, 2) * p2[idx] + - 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + - p0[idx] * Math.pow(t, 3); - - // go through t in increments of 0.01 - let t = 0; - while (t <= 1.0) { - const tx = equation(t, 0); - const ty = equation(t, 1); - - const diff = Math.sqrt(Math.pow(tx - mx, 2) + Math.pow(ty - my, 2)); - - if (diff < lineThreshold) { - return true; - } - - t += 0.01; - } - - return false; + let [x1, y1, x2, y2] = getElementBounds(element, elementsMap); + x1 -= tolerance; + y1 -= tolerance; + x2 += tolerance; + y2 += tolerance; + return isPointWithinBounds([x1, y1], [x, y], [x2, y2]); }; -const hitTestCurveInside = ( - drawable: Drawable, - x: number, - y: number, - roundness: StrokeRoundness, +export const hitElementBoundingBoxOnly = ( + hitArgs: HitTestArgs, + elementsMap: ElementsMap, ) => { - const ops = getCurvePathOps(drawable); - const points: Mutable[] = []; - let odd = false; // select one line out of double lines - for (const operation of ops) { - if (operation.op === "move") { - odd = !odd; - if (odd) { - points.push([operation.data[0], operation.data[1]]); - } - } else if (operation.op === "bcurveTo") { - if (odd) { - points.push([operation.data[0], operation.data[1]]); - points.push([operation.data[2], operation.data[3]]); - points.push([operation.data[4], operation.data[5]]); - } - } else if (operation.op === "lineTo") { - if (odd) { - points.push([operation.data[0], operation.data[1]]); - } - } - } - if (points.length >= 4) { - if (roundness === "sharp") { - return isPointInPolygon(points, x, y); - } - const polygonPoints = pointsOnBezierCurves(points, 10, 5); - return isPointInPolygon(polygonPoints, x, y); - } - return false; + return ( + !hitElementItself(hitArgs) && + hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap) + ); }; -const hitTestRoughShape = ( - drawable: Drawable, +export const hitElementBoundText = ( x: number, y: number, - lineThreshold: number, + textShape: GeometricShape | null, ) => { - // read operations from first opSet - const ops = getCurvePathOps(drawable); - - // set start position as (0,0) just in case - // move operation does not exist (unlikely but it is worth safekeeping it) - let currentP: Point = [0, 0]; - - return ops.some(({ op, data }, idx) => { - // There are only four operation types: - // move, bcurveTo, lineTo, and curveTo - if (op === "move") { - // change starting point - currentP = data as unknown as Point; - // move operation does not draw anything; so, it always - // returns false - } else if (op === "bcurveTo") { - // create points from bezier curve - // bezier curve stores data as a flattened array of three positions - // [x1, y1, x2, y2, x3, y3] - const p1 = [data[0], data[1]] as Point; - const p2 = [data[2], data[3]] as Point; - const p3 = [data[4], data[5]] as Point; - - const p0 = currentP; - currentP = p3; - - // check if points are on the curve - // cubic bezier curves require four parameters - // the first parameter is the last stored position (p0) - const retVal = pointInBezierEquation( - p0, - p1, - p2, - p3, - [x, y], - lineThreshold, - ); - - // set end point of bezier curve as the new starting point for - // upcoming operations as each operation is based on the last drawn - // position of the previous operation - return retVal; - } else if (op === "lineTo") { - return hitTestCurveInside(drawable, x, y, "sharp"); - } else if (op === "qcurveTo") { - // TODO: Implement this - console.warn("qcurveTo is not implemented yet"); - } - - return false; - }); + return textShape && isPointInShape([x, y], textShape); }; diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts index 7e9769d832..e7d699dae3 100644 --- a/packages/excalidraw/element/index.ts +++ b/packages/excalidraw/element/index.ts @@ -29,10 +29,6 @@ export { getTransformHandlesFromCoords, getTransformHandles, } from "./transformHandles"; -export { - hitTest, - isHittingElementBoundingBoxWithoutHittingElement, -} from "./collision"; export { resizeTest, getCursorForResizingElement, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index d493f1fbdf..29fa65c35f 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -6,7 +6,6 @@ import { ExcalidrawBindableElement, ExcalidrawTextElementWithContainer, ElementsMap, - NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, } from "./types"; import { @@ -34,6 +33,7 @@ import { AppState, PointerCoords, InteractiveCanvasAppState, + AppClassProperties, } from "../types"; import { mutateElement } from "./mutateElement"; import History from "../history"; @@ -334,9 +334,10 @@ export class LinearElementEditor { event: PointerEvent, editingLinearElement: LinearElementEditor, appState: AppState, - elements: readonly NonDeletedExcalidrawElement[], - elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): LinearElementEditor { + const elementsMap = app.scene.getNonDeletedElementsMap(); + const { elementId, selectedPointsIndices, isDragging, pointerDownState } = editingLinearElement; const element = LinearElementEditor.getElement(elementId, elementsMap); @@ -380,8 +381,7 @@ export class LinearElementEditor { elementsMap, ), ), - elements, - elementsMap, + app, ) : null; @@ -645,13 +645,14 @@ export class LinearElementEditor { history: History, scenePointer: { x: number; y: number }, linearElementEditor: LinearElementEditor, - elements: readonly NonDeletedExcalidrawElement[], - elementsMap: NonDeletedSceneElementsMap, + app: AppClassProperties, ): { didAddPoint: boolean; hitElement: NonDeleted | null; linearElementEditor: LinearElementEditor | null; } { + const elementsMap = app.scene.getNonDeletedElementsMap(); + const ret: ReturnType = { didAddPoint: false, hitElement: null, @@ -714,11 +715,7 @@ export class LinearElementEditor { }, selectedPointsIndices: [element.points.length - 1], lastUncommittedPoint: null, - endBindingElement: getHoveredElementForBinding( - scenePointer, - elements, - elementsMap, - ), + endBindingElement: getHoveredElementForBinding(scenePointer, app), }; ret.didAddPoint = true; diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index b54cc1c8ed..6f45561f81 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -26,16 +26,11 @@ import { isTextElement } from "."; import { isBoundToContainer, isArrowElement } from "./typeChecks"; import { LinearElementEditor } from "./linearElementEditor"; import { AppState } from "../types"; -import { isTextBindableContainer } from "./typeChecks"; -import { getElementAbsoluteCoords } from "."; -import { getSelectedElements } from "../scene"; -import { isHittingElementNotConsideringBoundingBox } from "./collision"; - -import { ExtractSetType, MakeBrand } from "../utility-types"; import { resetOriginalContainerCache, updateOriginalContainerCache, } from "./containerCache"; +import { ExtractSetType, MakeBrand } from "../utility-types"; export const normalizeText = (text: string) => { return ( @@ -771,50 +766,6 @@ export const suppportsHorizontalAlign = ( }); }; -export const getTextBindableContainerAtPosition = ( - elements: readonly ExcalidrawElement[], - appState: AppState, - x: number, - y: number, - elementsMap: ElementsMap, -): ExcalidrawTextContainer | null => { - const selectedElements = getSelectedElements(elements, appState); - if (selectedElements.length === 1) { - return isTextBindableContainer(selectedElements[0], false) - ? selectedElements[0] - : null; - } - let hitElement = null; - // We need to to hit testing from front (end of the array) to back (beginning of the array) - for (let index = elements.length - 1; index >= 0; --index) { - if (elements[index].isDeleted) { - continue; - } - const [x1, y1, x2, y2] = getElementAbsoluteCoords( - elements[index], - elementsMap, - ); - if ( - isArrowElement(elements[index]) && - isHittingElementNotConsideringBoundingBox( - elements[index], - appState, - null, - [x, y], - elementsMap, - ) - ) { - hitElement = elements[index]; - break; - } else if (x1 < x && x < x2 && y1 < y && y < y2) { - hitElement = elements[index]; - break; - } - } - - return isTextBindableContainer(hitElement, false) ? hitElement : null; -}; - const VALID_CONTAINER_TYPES = new Set([ "rectangle", "ellipse", diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 0fd814e890..b1855cfe8b 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -34,8 +34,11 @@ import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants"; import { renderSnaps } from "../renderer/renderSnaps"; -import { maxBindingGap } from "../element/collision"; -import { SuggestedBinding, SuggestedPointBinding } from "../element/binding"; +import { + maxBindingGap, + SuggestedBinding, + SuggestedPointBinding, +} from "../element/binding"; import { LinearElementEditor } from "../element/linearElementEditor"; import { bootstrapCanvas, diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index f493dbe6ba..42b796d2d2 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -2294,14 +2294,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1116226695, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -2354,14 +2354,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -2396,14 +2396,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1116226695, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -2540,14 +2540,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -2573,14 +2573,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": 0, "y": 10, @@ -2633,14 +2633,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -2677,14 +2677,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -2707,14 +2707,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": 0, "y": 10, @@ -2858,14 +2858,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1505387817, + "versionNonce": 400692809, "width": 20, "x": -10, "y": 0, @@ -2893,14 +2893,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 23633383, + "versionNonce": 1604849351, "width": 20, "x": 20, "y": 30, @@ -2953,14 +2953,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -2997,14 +2997,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -3027,14 +3027,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 238820263, + "versionNonce": 1116226695, "width": 20, "x": 20, "y": 30, @@ -3076,14 +3076,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1505387817, + "versionNonce": 400692809, "width": 20, "x": -10, "y": 0, @@ -3108,14 +3108,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 23633383, + "versionNonce": 1604849351, "width": 20, "x": 20, "y": 30, @@ -3254,14 +3254,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 640725609, + "versionNonce": 1315507081, "width": 20, "x": -10, "y": 0, @@ -3287,14 +3287,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 760410951, + "seed": 747212839, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 8, - "versionNonce": 1315507081, + "versionNonce": 1006504105, "width": 20, "x": 20, "y": 30, @@ -3347,14 +3347,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -3391,14 +3391,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -3421,14 +3421,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 238820263, + "versionNonce": 1116226695, "width": 20, "x": 20, "y": 30, @@ -3465,14 +3465,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -3495,14 +3495,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#e03131", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1604849351, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -3539,14 +3539,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -3569,14 +3569,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#e03131", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 23633383, + "versionNonce": 1604849351, "width": 20, "x": 20, "y": 30, @@ -3613,14 +3613,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -3643,14 +3643,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#e03131", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 5, - "versionNonce": 915032327, + "versionNonce": 23633383, "width": 20, "x": 20, "y": 30, @@ -3687,14 +3687,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -3717,14 +3717,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 6, - "versionNonce": 747212839, + "versionNonce": 915032327, "width": 20, "x": 20, "y": 30, @@ -3761,14 +3761,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -3791,14 +3791,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 760410951, + "seed": 747212839, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 7, - "versionNonce": 1006504105, + "versionNonce": 1723083209, "width": 20, "x": 20, "y": 30, @@ -3835,14 +3835,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -3865,14 +3865,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 760410951, + "seed": 747212839, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 8, - "versionNonce": 1315507081, + "versionNonce": 1006504105, "width": 20, "x": 20, "y": 30, @@ -3909,14 +3909,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 640725609, + "versionNonce": 1315507081, "width": 20, "x": -10, "y": 0, @@ -3939,14 +3939,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 760410951, + "seed": 747212839, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 8, - "versionNonce": 1315507081, + "versionNonce": 1006504105, "width": 20, "x": 20, "y": 30, @@ -4468,14 +4468,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1604849351, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -4501,14 +4501,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -4561,14 +4561,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -4605,14 +4605,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -4635,14 +4635,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 238820263, + "versionNonce": 1116226695, "width": 20, "x": 20, "y": 30, @@ -4679,14 +4679,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 2019559783, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1604849351, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -4709,14 +4709,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 2019559783, + "versionNonce": 453191, "width": 20, "x": -10, "y": 0, @@ -6115,14 +6115,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1116226695, + "versionNonce": 2019559783, "width": 10, "x": -10, "y": 0, @@ -6148,14 +6148,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "roundness": { "type": 3, }, - "seed": 238820263, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1604849351, + "versionNonce": 238820263, "width": 10, "x": 10, "y": 0, @@ -6208,14 +6208,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1116226695, + "versionNonce": 2019559783, "width": 10, "x": -10, "y": 0, @@ -6252,14 +6252,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1116226695, + "versionNonce": 2019559783, "width": 10, "x": -10, "y": 0, @@ -6282,14 +6282,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "roundness": { "type": 3, }, - "seed": 238820263, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1604849351, + "versionNonce": 238820263, "width": 10, "x": 10, "y": 0, diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index c03b889df4..ec0a743536 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -287,9 +287,16 @@ const transform = ( keyboardModifiers: KeyboardModifiers = {}, ) => { const elements = Array.isArray(element) ? element : [element]; - mouse.select(elements); + h.setState({ + selectedElementIds: elements.reduce( + (acc, e) => ({ + ...acc, + [e.id]: true, + }), + {}, + ), + }); let handleCoords: TransformHandle | undefined; - if (elements.length === 1) { handleCoords = getTransformHandles( elements[0], diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 551b794792..51bc70a4a5 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -321,9 +321,9 @@ describe("Test Linear Elements", () => { fireEvent.click(screen.getByTitle("Round")); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `10`, + `9`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints( h.elements[0] as ExcalidrawLinearElement, @@ -379,9 +379,9 @@ describe("Test Linear Elements", () => { drag(startPoint, endPoint); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `13`, + `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect([line.x, line.y]).toEqual([ points[0][0] + deltaX, @@ -441,9 +441,9 @@ describe("Test Linear Elements", () => { ]); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `17`, + `16`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(line.points.length).toEqual(5); @@ -492,9 +492,9 @@ describe("Test Linear Elements", () => { drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `13`, + `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -533,9 +533,9 @@ describe("Test Linear Elements", () => { drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `13`, + `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -581,9 +581,9 @@ describe("Test Linear Elements", () => { deletePoint(points[2]); expect(line.points.length).toEqual(3); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `19`, + `18`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, @@ -631,9 +631,9 @@ describe("Test Linear Elements", () => { lastSegmentMidpoint[1] + delta, ]); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `17`, + `16`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(line.points.length).toEqual(5); expect((h.elements[0] as ExcalidrawLinearElement).points) @@ -729,9 +729,9 @@ describe("Test Linear Elements", () => { drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `13`, + `12`, ); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 2c66eec4cf..01e582a8d9 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -586,6 +586,7 @@ export type AppClassProperties = { setOpenDialog: App["setOpenDialog"]; insertEmbeddableElement: App["insertEmbeddableElement"]; onMagicframeToolSelect: App["onMagicframeToolSelect"]; + getElementShape: App["getElementShape"]; getName: App["getName"]; }; @@ -722,7 +723,7 @@ export type Device = Readonly<{ isTouchScreen: boolean; }>; -type FrameNameBounds = { +export type FrameNameBounds = { x: number; y: number; width: number; diff --git a/packages/utils/collision.ts b/packages/utils/collision.ts new file mode 100644 index 0000000000..777ba962ad --- /dev/null +++ b/packages/utils/collision.ts @@ -0,0 +1,66 @@ +import { Point, Polygon, GeometricShape } from "./geometry/shape"; +import { + pointInEllipse, + pointInPolygon, + pointOnCurve, + pointOnEllipse, + pointOnLine, + pointOnPolycurve, + pointOnPolygon, + pointOnPolyline, + close, +} from "./geometry/geometry"; + +// check if the given point is considered on the given shape's border +export const isPointOnShape = ( + point: Point, + shape: GeometricShape, + tolerance = 0, +) => { + // get the distance from the given point to the given element + // check if the distance is within the given epsilon range + switch (shape.type) { + case "polygon": + return pointOnPolygon(point, shape.data, tolerance); + case "ellipse": + return pointOnEllipse(point, shape.data, tolerance); + case "line": + return pointOnLine(point, shape.data, tolerance); + case "polyline": + return pointOnPolyline(point, shape.data, tolerance); + case "curve": + return pointOnCurve(point, shape.data, tolerance); + case "polycurve": + return pointOnPolycurve(point, shape.data, tolerance); + default: + throw Error(`shape ${shape} is not implemented`); + } +}; + +// check if the given point is considered inside the element's border +export const isPointInShape = (point: Point, shape: GeometricShape) => { + switch (shape.type) { + case "polygon": + return pointInPolygon(point, shape.data); + case "line": + return false; + case "curve": + return false; + case "ellipse": + return pointInEllipse(point, shape.data); + case "polyline": { + const polygon = close(shape.data.flat()) as Polygon; + return pointInPolygon(point, polygon); + } + case "polycurve": { + return false; + } + default: + throw Error(`shape ${shape} is not implemented`); + } +}; + +// check if the given element is in the given bounds +export const isPointInBounds = (point: Point, bounds: Polygon) => { + return pointInPolygon(point, bounds); +}; diff --git a/packages/utils/geometry/geometry.test.ts b/packages/utils/geometry/geometry.test.ts new file mode 100644 index 0000000000..a0103deee1 --- /dev/null +++ b/packages/utils/geometry/geometry.test.ts @@ -0,0 +1,249 @@ +import { + lineIntersectsLine, + lineRotate, + pointInEllipse, + pointInPolygon, + pointLeftofLine, + pointOnCurve, + pointOnEllipse, + pointOnLine, + pointOnPolygon, + pointOnPolyline, + pointRightofLine, + pointRotate, +} from "./geometry"; +import { Curve, Ellipse, Line, Point, Polygon, Polyline } from "./shape"; + +describe("point and line", () => { + const line: Line = [ + [1, 0], + [1, 2], + ]; + + it("point on left or right of line", () => { + expect(pointLeftofLine([0, 1], line)).toBe(true); + expect(pointLeftofLine([1, 1], line)).toBe(false); + expect(pointLeftofLine([2, 1], line)).toBe(false); + + expect(pointRightofLine([0, 1], line)).toBe(false); + expect(pointRightofLine([1, 1], line)).toBe(false); + expect(pointRightofLine([2, 1], line)).toBe(true); + }); + + it("point on the line", () => { + expect(pointOnLine([0, 1], line)).toBe(false); + expect(pointOnLine([1, 1], line, 0)).toBe(true); + expect(pointOnLine([2, 1], line)).toBe(false); + }); +}); + +describe("point and polylines", () => { + const polyline: Polyline = [ + [ + [1, 0], + [1, 2], + ], + [ + [1, 2], + [2, 2], + ], + [ + [2, 2], + [2, 1], + ], + [ + [2, 1], + [3, 1], + ], + ]; + + it("point on the line", () => { + expect(pointOnPolyline([1, 0], polyline)).toBe(true); + expect(pointOnPolyline([1, 2], polyline)).toBe(true); + expect(pointOnPolyline([2, 2], polyline)).toBe(true); + expect(pointOnPolyline([2, 1], polyline)).toBe(true); + expect(pointOnPolyline([3, 1], polyline)).toBe(true); + + expect(pointOnPolyline([1, 1], polyline)).toBe(true); + expect(pointOnPolyline([2, 1.5], polyline)).toBe(true); + expect(pointOnPolyline([2.5, 1], polyline)).toBe(true); + + expect(pointOnPolyline([0, 1], polyline)).toBe(false); + expect(pointOnPolyline([2.1, 1.5], polyline)).toBe(false); + }); + + it("point on the line with rotation", () => { + const truePoints = [ + [1, 0], + [1, 2], + [2, 2], + [2, 1], + [3, 1], + ] as Point[]; + + truePoints.forEach((point) => { + const rotation = Math.random() * 360; + const rotatedPoint = pointRotate(point, rotation); + const rotatedPolyline: Polyline = polyline.map((line) => + lineRotate(line, rotation, [0, 0]), + ); + expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true); + }); + + const falsePoints = [ + [0, 1], + [2.1, 1.5], + ] as Point[]; + + falsePoints.forEach((point) => { + const rotation = Math.random() * 360; + const rotatedPoint = pointRotate(point, rotation); + const rotatedPolyline: Polyline = polyline.map((line) => + lineRotate(line, rotation, [0, 0]), + ); + expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false); + }); + }); +}); + +describe("point and polygon", () => { + const polygon: Polygon = [ + [10, 10], + [50, 10], + [50, 50], + [10, 50], + ]; + + it("point on polygon", () => { + expect(pointOnPolygon([30, 10], polygon)).toBe(true); + expect(pointOnPolygon([50, 30], polygon)).toBe(true); + expect(pointOnPolygon([30, 50], polygon)).toBe(true); + expect(pointOnPolygon([10, 30], polygon)).toBe(true); + expect(pointOnPolygon([30, 30], polygon)).toBe(false); + expect(pointOnPolygon([30, 70], polygon)).toBe(false); + }); + + it("point in polygon", () => { + const polygon: Polygon = [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ]; + expect(pointInPolygon([1, 1], polygon)).toBe(true); + expect(pointInPolygon([3, 3], polygon)).toBe(false); + }); +}); + +describe("point and curve", () => { + const curve: Curve = [ + [1.4, 1.65], + [1.9, 7.9], + [5.9, 1.65], + [6.44, 4.84], + ]; + + it("point on curve", () => { + expect(pointOnCurve(curve[0], curve)).toBe(true); + expect(pointOnCurve(curve[3], curve)).toBe(true); + + expect(pointOnCurve([2, 4], curve, 0.1)).toBe(true); + expect(pointOnCurve([4, 4.4], curve, 0.1)).toBe(true); + expect(pointOnCurve([5.6, 3.85], curve, 0.1)).toBe(true); + + expect(pointOnCurve([5.6, 4], curve, 0.1)).toBe(false); + expect(pointOnCurve(curve[1], curve, 0.1)).toBe(false); + expect(pointOnCurve(curve[2], curve, 0.1)).toBe(false); + }); +}); + +describe("point and ellipse", () => { + const ellipse: Ellipse = { + center: [0, 0], + angle: 0, + halfWidth: 2, + halfHeight: 1, + }; + + it("point on ellipse", () => { + [ + [0, 1], + [0, -1], + [2, 0], + [-2, 0], + ].forEach((point) => { + expect(pointOnEllipse(point as Point, ellipse)).toBe(true); + }); + expect(pointOnEllipse([-1.4, 0.7], ellipse, 0.1)).toBe(true); + expect(pointOnEllipse([-1.4, 0.71], ellipse, 0.01)).toBe(true); + + expect(pointOnEllipse([1.4, 0.7], ellipse, 0.1)).toBe(true); + expect(pointOnEllipse([1.4, 0.71], ellipse, 0.01)).toBe(true); + + expect(pointOnEllipse([1, -0.86], ellipse, 0.1)).toBe(true); + expect(pointOnEllipse([1, -0.86], ellipse, 0.01)).toBe(true); + + expect(pointOnEllipse([-1, -0.86], ellipse, 0.1)).toBe(true); + expect(pointOnEllipse([-1, -0.86], ellipse, 0.01)).toBe(true); + + expect(pointOnEllipse([-1, 0.8], ellipse)).toBe(false); + expect(pointOnEllipse([1, -0.8], ellipse)).toBe(false); + }); + + it("point in ellipse", () => { + [ + [0, 1], + [0, -1], + [2, 0], + [-2, 0], + ].forEach((point) => { + expect(pointInEllipse(point as Point, ellipse)).toBe(true); + }); + + expect(pointInEllipse([-1, 0.8], ellipse)).toBe(true); + expect(pointInEllipse([1, -0.8], ellipse)).toBe(true); + + expect(pointInEllipse([-1, 1], ellipse)).toBe(false); + expect(pointInEllipse([-1.4, 0.8], ellipse)).toBe(false); + }); +}); + +describe("line and line", () => { + const lineA: Line = [ + [1, 4], + [3, 4], + ]; + const lineB: Line = [ + [2, 1], + [2, 7], + ]; + const lineC: Line = [ + [1, 8], + [3, 8], + ]; + const lineD: Line = [ + [1, 8], + [3, 8], + ]; + const lineE: Line = [ + [1, 9], + [3, 9], + ]; + const lineF: Line = [ + [1, 2], + [3, 4], + ]; + const lineG: Line = [ + [0, 1], + [2, 3], + ]; + + it("intersection", () => { + expect(lineIntersectsLine(lineA, lineB)).toBe(true); + expect(lineIntersectsLine(lineA, lineC)).toBe(false); + expect(lineIntersectsLine(lineB, lineC)).toBe(false); + expect(lineIntersectsLine(lineC, lineD)).toBe(true); + expect(lineIntersectsLine(lineE, lineD)).toBe(false); + expect(lineIntersectsLine(lineF, lineG)).toBe(true); + }); +}); diff --git a/packages/utils/geometry/geometry.ts b/packages/utils/geometry/geometry.ts new file mode 100644 index 0000000000..000f33a239 --- /dev/null +++ b/packages/utils/geometry/geometry.ts @@ -0,0 +1,956 @@ +import { distance2d } from "../../excalidraw/math"; +import { + Point, + Line, + Polygon, + Curve, + Ellipse, + Polycurve, + Polyline, +} from "./shape"; + +const DEFAULT_THRESHOLD = 10e-5; + +/** + * utils + */ + +// the two vectors are ao and bo +export const cross = (a: Point, b: Point, o: Point) => { + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); +}; + +export const isClosed = (polygon: Polygon) => { + const first = polygon[0]; + const last = polygon[polygon.length - 1]; + return first[0] === last[0] && first[1] === last[1]; +}; + +export const close = (polygon: Polygon) => { + return isClosed(polygon) ? polygon : [...polygon, polygon[0]]; +}; + +/** + * angles + */ + +// convert radians to degress +export const angleToDegrees = (angle: number) => { + return (angle * 180) / Math.PI; +}; + +// convert degrees to radians +export const angleToRadians = (angle: number) => { + return (angle / 180) * Math.PI; +}; + +// return the angle of reflection given an angle of incidence and a surface angle in degrees +export const angleReflect = (incidenceAngle: number, surfaceAngle: number) => { + const a = surfaceAngle * 2 - incidenceAngle; + return a >= 360 ? a - 360 : a < 0 ? a + 360 : a; +}; + +/** + * points + */ + +const rotate = (point: Point, angle: number): Point => { + return [ + point[0] * Math.cos(angle) - point[1] * Math.sin(angle), + point[0] * Math.sin(angle) + point[1] * Math.cos(angle), + ]; +}; + +const isOrigin = (point: Point) => { + return point[0] === 0 && point[1] === 0; +}; + +// rotate a given point about a given origin at the given angle +export const pointRotate = ( + point: Point, + angle: number, + origin?: Point, +): Point => { + const r = angleToRadians(angle); + + if (!origin || isOrigin(origin)) { + return rotate(point, r); + } + return rotate(point.map((c, i) => c - origin[i]) as Point, r).map( + (c, i) => c + origin[i], + ) as Point; +}; + +// translate a point by an angle (in degrees) and distance +export const pointTranslate = (point: Point, angle = 0, distance = 0) => { + const r = angleToRadians(angle); + return [ + point[0] + distance * Math.cos(r), + point[1] + distance * Math.sin(r), + ] as Point; +}; + +export const pointInverse = (point: Point) => { + return [-point[0], -point[1]] as Point; +}; + +export const pointAdd = (pointA: Point, pointB: Point): Point => { + return [pointA[0] + pointB[0], pointA[1] + pointB[1]]; +}; + +export const distanceToPoint = (p1: Point, p2: Point) => { + return distance2d(...p1, ...p2); +}; + +/** + * lines + */ + +// return the angle of a line, in degrees +export const lineAngle = (line: Line) => { + return angleToDegrees( + Math.atan2(line[1][1] - line[0][1], line[1][0] - line[0][0]), + ); +}; + +// get the distance between the endpoints of a line segment +export const lineLength = (line: Line) => { + return Math.sqrt( + Math.pow(line[1][0] - line[0][0], 2) + Math.pow(line[1][1] - line[0][1], 2), + ); +}; + +// get the midpoint of a line segment +export const lineMidpoint = (line: Line) => { + return [ + (line[0][0] + line[1][0]) / 2, + (line[0][1] + line[1][1]) / 2, + ] as Point; +}; + +// return the coordinates resulting from rotating the given line about an origin by an angle in degrees +// note that when the origin is not given, the midpoint of the given line is used as the origin +export const lineRotate = (line: Line, angle: number, origin?: Point): Line => { + return line.map((point) => + pointRotate(point, angle, origin || lineMidpoint(line)), + ) as Line; +}; + +// returns the coordinates resulting from translating a line by an angle in degrees and a distance. +export const lineTranslate = (line: Line, angle: number, distance: number) => { + return line.map((point) => pointTranslate(point, angle, distance)); +}; + +export const lineInterpolate = (line: Line, clamp = false) => { + const [[x1, y1], [x2, y2]] = line; + return (t: number) => { + const t0 = clamp ? (t < 0 ? 0 : t > 1 ? 1 : t) : t; + return [(x2 - x1) * t0 + x1, (y2 - y1) * t0 + y1] as Point; + }; +}; + +/** + * curves + */ +function clone(p: Point): Point { + return [...p] as Point; +} + +export const curveToBezier = ( + pointsIn: readonly Point[], + curveTightness = 0, +): Point[] => { + const len = pointsIn.length; + if (len < 3) { + throw new Error("A curve must have at least three points."); + } + const out: Point[] = []; + if (len === 3) { + out.push( + clone(pointsIn[0]), + clone(pointsIn[1]), + clone(pointsIn[2]), + clone(pointsIn[2]), + ); + } else { + const points: Point[] = []; + points.push(pointsIn[0], pointsIn[0]); + for (let i = 1; i < pointsIn.length; i++) { + points.push(pointsIn[i]); + if (i === pointsIn.length - 1) { + points.push(pointsIn[i]); + } + } + const b: Point[] = []; + const s = 1 - curveTightness; + out.push(clone(points[0])); + for (let i = 1; i + 2 < points.length; i++) { + const cachedVertArray = points[i]; + b[0] = [cachedVertArray[0], cachedVertArray[1]]; + b[1] = [ + cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6, + cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6, + ]; + b[2] = [ + points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6, + points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6, + ]; + b[3] = [points[i + 1][0], points[i + 1][1]]; + out.push(b[1], b[2], b[3]); + } + } + return out; +}; + +export const curveRotate = (curve: Curve, angle: number, origin: Point) => { + return curve.map((p) => pointRotate(p, angle, origin)); +}; + +export const cubicBezierPoint = (t: number, controlPoints: Curve): Point => { + const [p0, p1, p2, p3] = controlPoints; + + const x = + Math.pow(1 - t, 3) * p0[0] + + 3 * Math.pow(1 - t, 2) * t * p1[0] + + 3 * (1 - t) * Math.pow(t, 2) * p2[0] + + Math.pow(t, 3) * p3[0]; + + const y = + Math.pow(1 - t, 3) * p0[1] + + 3 * Math.pow(1 - t, 2) * t * p1[1] + + 3 * (1 - t) * Math.pow(t, 2) * p2[1] + + Math.pow(t, 3) * p3[1]; + + return [x, y]; +}; + +const solveCubicEquation = (a: number, b: number, c: number, d: number) => { + // This function solves the cubic equation ax^3 + bx^2 + cx + d = 0 + const roots: number[] = []; + + const discriminant = + 18 * a * b * c * d - + 4 * Math.pow(b, 3) * d + + Math.pow(b, 2) * Math.pow(c, 2) - + 4 * a * Math.pow(c, 3) - + 27 * Math.pow(a, 2) * Math.pow(d, 2); + + if (discriminant >= 0) { + const C = Math.cbrt((discriminant + Math.sqrt(discriminant)) / 2); + const D = Math.cbrt((discriminant - Math.sqrt(discriminant)) / 2); + + const root1 = (-b - C - D) / (3 * a); + const root2 = (-b + (C + D) / 2) / (3 * a); + const root3 = (-b + (C + D) / 2) / (3 * a); + + roots.push(root1, root2, root3); + } else { + const realPart = -b / (3 * a); + + const root1 = + 2 * Math.sqrt(-b / (3 * a)) * Math.cos(Math.acos(realPart) / 3); + const root2 = + 2 * + Math.sqrt(-b / (3 * a)) * + Math.cos((Math.acos(realPart) + 2 * Math.PI) / 3); + const root3 = + 2 * + Math.sqrt(-b / (3 * a)) * + Math.cos((Math.acos(realPart) + 4 * Math.PI) / 3); + + roots.push(root1, root2, root3); + } + + return roots; +}; + +const findClosestParameter = (point: Point, controlPoints: Curve) => { + // This function finds the parameter t that minimizes the distance between the point + // and any point on the cubic Bezier curve. + + const [p0, p1, p2, p3] = controlPoints; + + // Use the direct formula to find the parameter t + const a = p3[0] - 3 * p2[0] + 3 * p1[0] - p0[0]; + const b = 3 * p2[0] - 6 * p1[0] + 3 * p0[0]; + const c = 3 * p1[0] - 3 * p0[0]; + const d = p0[0] - point[0]; + + const rootsX = solveCubicEquation(a, b, c, d); + + // Do the same for the y-coordinate + const e = p3[1] - 3 * p2[1] + 3 * p1[1] - p0[1]; + const f = 3 * p2[1] - 6 * p1[1] + 3 * p0[1]; + const g = 3 * p1[1] - 3 * p0[1]; + const h = p0[1] - point[1]; + + const rootsY = solveCubicEquation(e, f, g, h); + + // Select the real root that is between 0 and 1 (inclusive) + const validRootsX = rootsX.filter((root) => root >= 0 && root <= 1); + const validRootsY = rootsY.filter((root) => root >= 0 && root <= 1); + + if (validRootsX.length === 0 || validRootsY.length === 0) { + // No valid roots found, use the midpoint as a fallback + return 0.5; + } + + // Choose the parameter t that minimizes the distance + let minDistance = Infinity; + let closestT = 0; + + for (const rootX of validRootsX) { + for (const rootY of validRootsY) { + const distance = Math.sqrt( + (rootX - point[0]) ** 2 + (rootY - point[1]) ** 2, + ); + if (distance < minDistance) { + minDistance = distance; + closestT = (rootX + rootY) / 2; // Use the average for a smoother result + } + } + } + + return closestT; +}; + +export const cubicBezierDistance = (point: Point, controlPoints: Curve) => { + // Calculate the closest point on the Bezier curve to the given point + const t = findClosestParameter(point, controlPoints); + + // Calculate the coordinates of the closest point on the curve + const [closestX, closestY] = cubicBezierPoint(t, controlPoints); + + // Calculate the distance between the given point and the closest point on the curve + const distance = Math.sqrt( + (point[0] - closestX) ** 2 + (point[1] - closestY) ** 2, + ); + + return distance; +}; + +/** + * polygons + */ + +export const polygonRotate = ( + polygon: Polygon, + angle: number, + origin: Point, +) => { + return polygon.map((p) => pointRotate(p, angle, origin)); +}; + +export const polygonBounds = (polygon: Polygon) => { + let xMin = Infinity; + let xMax = -Infinity; + let yMin = Infinity; + let yMax = -Infinity; + + for (let i = 0, l = polygon.length; i < l; i++) { + const p = polygon[i]; + const x = p[0]; + const y = p[1]; + + if (x != null && isFinite(x) && y != null && isFinite(y)) { + if (x < xMin) { + xMin = x; + } + if (x > xMax) { + xMax = x; + } + if (y < yMin) { + yMin = y; + } + if (y > yMax) { + yMax = y; + } + } + } + + return [ + [xMin, yMin], + [xMax, yMax], + ] as [Point, Point]; +}; + +export const polygonCentroid = (vertices: Point[]) => { + let a = 0; + let x = 0; + let y = 0; + const l = vertices.length; + + for (let i = 0; i < l; i++) { + const s = i === l - 1 ? 0 : i + 1; + const v0 = vertices[i]; + const v1 = vertices[s]; + const f = v0[0] * v1[1] - v1[0] * v0[1]; + + a += f; + x += (v0[0] + v1[0]) * f; + y += (v0[1] + v1[1]) * f; + } + + const d = a * 3; + + return [x / d, y / d] as Point; +}; + +export const polygonScale = ( + polygon: Polygon, + scale: number, + origin?: Point, +) => { + if (!origin) { + origin = polygonCentroid(polygon); + } + + const p: Polygon = []; + + for (let i = 0, l = polygon.length; i < l; i++) { + const v = polygon[i]; + const d = lineLength([origin, v]); + const a = lineAngle([origin, v]); + + p[i] = pointTranslate(origin, a, d * scale); + } + + return p; +}; + +export const polygonScaleX = ( + polygon: Polygon, + scale: number, + origin?: Point, +) => { + if (!origin) { + origin = polygonCentroid(polygon); + } + + const p: Polygon = []; + + for (let i = 0, l = polygon.length; i < l; i++) { + const v = polygon[i]; + const d = lineLength([origin, v]); + const a = lineAngle([origin, v]); + const t = pointTranslate(origin, a, d * scale); + + p[i] = [t[0], v[1]]; + } + + return p; +}; + +export const polygonScaleY = ( + polygon: Polygon, + scale: number, + origin?: Point, +) => { + if (!origin) { + origin = polygonCentroid(polygon); + } + + const p: Polygon = []; + + for (let i = 0, l = polygon.length; i < l; i++) { + const v = polygon[i]; + const d = lineLength([origin, v]); + const a = lineAngle([origin, v]); + const t = pointTranslate(origin, a, d * scale); + + p[i] = [v[0], t[1]]; + } + + return p; +}; + +export const polygonReflectX = (polygon: Polygon, reflectFactor = 1) => { + const [[min], [max]] = polygonBounds(polygon); + const p: Point[] = []; + + for (let i = 0, l = polygon.length; i < l; i++) { + const [x, y] = polygon[i]; + const r: Point = [min + max - x, y]; + + if (reflectFactor === 0) { + p[i] = [x, y]; + } else if (reflectFactor === 1) { + p[i] = r; + } else { + const t = lineInterpolate([[x, y], r]); + p[i] = t(Math.max(Math.min(reflectFactor, 1), 0)); + } + } + + return p; +}; + +export const polygonReflectY = (polygon: Polygon, reflectFactor = 1) => { + const [[, min], [, max]] = polygonBounds(polygon); + const p: Point[] = []; + + for (let i = 0, l = polygon.length; i < l; i++) { + const [x, y] = polygon[i]; + const r: Point = [x, min + max - y]; + + if (reflectFactor === 0) { + p[i] = [x, y]; + } else if (reflectFactor === 1) { + p[i] = r; + } else { + const t = lineInterpolate([[x, y], r]); + p[i] = t(Math.max(Math.min(reflectFactor, 1), 0)); + } + } + + return p; +}; + +export const polygonTranslate = ( + polygon: Polygon, + angle: number, + distance: number, +) => { + return polygon.map((p) => pointTranslate(p, angle, distance)); +}; + +/** + * ellipses + */ + +export const ellipseAxes = (ellipse: Ellipse) => { + const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight; + + const majorAxis = widthGreaterThanHeight + ? ellipse.halfWidth * 2 + : ellipse.halfHeight * 2; + const minorAxis = widthGreaterThanHeight + ? ellipse.halfHeight * 2 + : ellipse.halfWidth * 2; + + return { + majorAxis, + minorAxis, + }; +}; + +export const ellipseFocusToCenter = (ellipse: Ellipse) => { + const { majorAxis, minorAxis } = ellipseAxes(ellipse); + + return Math.sqrt(majorAxis ** 2 - minorAxis ** 2); +}; + +export const ellipseExtremes = (ellipse: Ellipse) => { + const { center, angle } = ellipse; + const { majorAxis, minorAxis } = ellipseAxes(ellipse); + + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + const sqSum = majorAxis ** 2 + minorAxis ** 2; + const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle); + + const yMax = Math.sqrt((sqSum - sqDiff) / 2); + const xAtYMax = + (yMax * sqSum * sin * cos) / + (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2); + + const xMax = Math.sqrt((sqSum + sqDiff) / 2); + const yAtXMax = + (xMax * sqSum * sin * cos) / + (majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2); + + return [ + pointAdd([xAtYMax, yMax], center), + pointAdd(pointInverse([xAtYMax, yMax]), center), + pointAdd([xMax, yAtXMax], center), + pointAdd([xMax, yAtXMax], center), + ]; +}; + +export const pointRelativeToCenter = ( + point: Point, + center: Point, + angle: number, +): Point => { + const translated = pointAdd(point, pointInverse(center)); + const rotated = pointRotate(translated, -angleToDegrees(angle)); + + return rotated; +}; + +/** + * relationships + */ + +const topPointFirst = (line: Line) => { + return line[1][1] > line[0][1] ? line : [line[1], line[0]]; +}; + +export const pointLeftofLine = (point: Point, line: Line) => { + const t = topPointFirst(line); + return cross(point, t[1], t[0]) < 0; +}; + +export const pointRightofLine = (point: Point, line: Line) => { + const t = topPointFirst(line); + return cross(point, t[1], t[0]) > 0; +}; + +export const distanceToSegment = (point: Point, line: Line) => { + const [x, y] = point; + const [[x1, y1], [x2, y2]] = line; + + const A = x - x1; + const B = y - y1; + const C = x2 - x1; + const D = y2 - y1; + + const dot = A * C + B * D; + const len_sq = C * C + D * D; + let param = -1; + if (len_sq !== 0) { + param = dot / len_sq; + } + + let xx; + let yy; + + if (param < 0) { + xx = x1; + yy = y1; + } else if (param > 1) { + xx = x2; + yy = y2; + } else { + xx = x1 + param * C; + yy = y1 + param * D; + } + + const dx = x - xx; + const dy = y - yy; + return Math.sqrt(dx * dx + dy * dy); +}; + +export const pointOnLine = ( + point: Point, + line: Line, + threshold = DEFAULT_THRESHOLD, +) => { + const distance = distanceToSegment(point, line); + + if (distance === 0) { + return true; + } + + return distance < threshold; +}; + +export const pointOnPolyline = ( + point: Point, + polyline: Polyline, + threshold = DEFAULT_THRESHOLD, +) => { + return polyline.some((line) => pointOnLine(point, line, threshold)); +}; + +export const lineIntersectsLine = (lineA: Line, lineB: Line) => { + const [[a0x, a0y], [a1x, a1y]] = lineA; + const [[b0x, b0y], [b1x, b1y]] = lineB; + + // shared points + if (a0x === b0x && a0y === b0y) { + return true; + } + if (a1x === b1x && a1y === b1y) { + return true; + } + + // point on line + if (pointOnLine(lineA[0], lineB) || pointOnLine(lineA[1], lineB)) { + return true; + } + if (pointOnLine(lineB[0], lineA) || pointOnLine(lineB[1], lineA)) { + return true; + } + + const denom = (b1y - b0y) * (a1x - a0x) - (b1x - b0x) * (a1y - a0y); + + if (denom === 0) { + return false; + } + + const deltaY = a0y - b0y; + const deltaX = a0x - b0x; + const numer0 = (b1x - b0x) * deltaY - (b1y - b0y) * deltaX; + const numer1 = (a1x - a0x) * deltaY - (a1y - a0y) * deltaX; + const quotA = numer0 / denom; + const quotB = numer1 / denom; + + return quotA > 0 && quotA < 1 && quotB > 0 && quotB < 1; +}; + +export const lineIntersectsPolygon = (line: Line, polygon: Polygon) => { + let intersects = false; + const closed = close(polygon); + + for (let i = 0, l = closed.length - 1; i < l; i++) { + const v0 = closed[i]; + const v1 = closed[i + 1]; + + if ( + lineIntersectsLine(line, [v0, v1]) || + (pointOnLine(v0, line) && pointOnLine(v1, line)) + ) { + intersects = true; + break; + } + } + + return intersects; +}; + +export const pointInBezierEquation = ( + p0: Point, + p1: Point, + p2: Point, + p3: Point, + [mx, my]: Point, + lineThreshold: number, +) => { + // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 + const equation = (t: number, idx: number) => + Math.pow(1 - t, 3) * p3[idx] + + 3 * t * Math.pow(1 - t, 2) * p2[idx] + + 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + + p0[idx] * Math.pow(t, 3); + + const lineSegmentPoints: Point[] = []; + let t = 0; + while (t <= 1.0) { + const tx = equation(t, 0); + const ty = equation(t, 1); + + const diff = Math.sqrt(Math.pow(tx - mx, 2) + Math.pow(ty - my, 2)); + + if (diff < lineThreshold) { + return true; + } + + lineSegmentPoints.push([tx, ty]); + + t += 0.1; + } + + // check the distance from line segments to the given point + + return false; +}; + +export const cubicBezierEquation = (curve: Curve) => { + const [p0, p1, p2, p3] = curve; + // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 + return (t: number, idx: number) => + Math.pow(1 - t, 3) * p3[idx] + + 3 * t * Math.pow(1 - t, 2) * p2[idx] + + 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + + p0[idx] * Math.pow(t, 3); +}; + +export const polyLineFromCurve = (curve: Curve, segments = 10): Polyline => { + const equation = cubicBezierEquation(curve); + let startingPoint = [equation(0, 0), equation(0, 1)] as Point; + const lineSegments: Polyline = []; + let t = 0; + const increment = 1 / segments; + + for (let i = 0; i < segments; i++) { + t += increment; + if (t <= 1) { + const nextPoint: Point = [equation(t, 0), equation(t, 1)]; + lineSegments.push([startingPoint, nextPoint]); + startingPoint = nextPoint; + } + } + + return lineSegments; +}; + +export const pointOnCurve = ( + point: Point, + curve: Curve, + threshold = DEFAULT_THRESHOLD, +) => { + return pointOnPolyline(point, polyLineFromCurve(curve), threshold); +}; + +export const pointOnPolycurve = ( + point: Point, + polycurve: Polycurve, + threshold = DEFAULT_THRESHOLD, +) => { + return polycurve.some((curve) => pointOnCurve(point, curve, threshold)); +}; + +export const pointInPolygon = (point: Point, polygon: Polygon) => { + const x = point[0]; + const y = point[1]; + let inside = false; + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i][0]; + const yi = polygon[i][1]; + const xj = polygon[j][0]; + const yj = polygon[j][1]; + + if ( + ((yi > y && yj <= y) || (yi <= y && yj > y)) && + x < ((xj - xi) * (y - yi)) / (yj - yi) + xi + ) { + inside = !inside; + } + } + + return inside; +}; + +export const pointOnPolygon = ( + point: Point, + polygon: Polygon, + threshold = DEFAULT_THRESHOLD, +) => { + let on = false; + const closed = close(polygon); + + for (let i = 0, l = closed.length - 1; i < l; i++) { + if (pointOnLine(point, [closed[i], closed[i + 1]], threshold)) { + on = true; + break; + } + } + + return on; +}; + +export const polygonInPolygon = (polygonA: Polygon, polygonB: Polygon) => { + let inside = true; + const closed = close(polygonA); + + for (let i = 0, l = closed.length - 1; i < l; i++) { + const v0 = closed[i]; + + // Points test + if (!pointInPolygon(v0, polygonB)) { + inside = false; + break; + } + + // Lines test + if (lineIntersectsPolygon([v0, closed[i + 1]], polygonB)) { + inside = false; + break; + } + } + + return inside; +}; + +export const polygonIntersectPolygon = ( + polygonA: Polygon, + polygonB: Polygon, +) => { + let intersects = false; + let onCount = 0; + const closed = close(polygonA); + + for (let i = 0, l = closed.length - 1; i < l; i++) { + const v0 = closed[i]; + const v1 = closed[i + 1]; + + if (lineIntersectsPolygon([v0, v1], polygonB)) { + intersects = true; + break; + } + + if (pointOnPolygon(v0, polygonB)) { + ++onCount; + } + + if (onCount === 2) { + intersects = true; + break; + } + } + + return intersects; +}; + +const distanceToEllipse = (point: Point, ellipse: Ellipse) => { + const { angle, halfWidth, halfHeight, center } = ellipse; + const a = halfWidth; + const b = halfHeight; + const [rotatedPointX, rotatedPointY] = pointRelativeToCenter( + point, + center, + angle, + ); + + const px = Math.abs(rotatedPointX); + const py = Math.abs(rotatedPointY); + + let tx = 0.707; + let ty = 0.707; + + for (let i = 0; i < 3; i++) { + const x = a * tx; + const y = b * ty; + + const ex = ((a * a - b * b) * tx ** 3) / a; + const ey = ((b * b - a * a) * ty ** 3) / b; + + const rx = x - ex; + const ry = y - ey; + + const qx = px - ex; + const qy = py - ey; + + const r = Math.hypot(ry, rx); + const q = Math.hypot(qy, qx); + + tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); + ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); + const t = Math.hypot(ty, tx); + tx /= t; + ty /= t; + } + + const [minX, minY] = [ + a * tx * Math.sign(rotatedPointX), + b * ty * Math.sign(rotatedPointY), + ]; + + return distanceToPoint([rotatedPointX, rotatedPointY], [minX, minY]); +}; + +export const pointOnEllipse = ( + point: Point, + ellipse: Ellipse, + threshold = DEFAULT_THRESHOLD, +) => { + return distanceToEllipse(point, ellipse) <= threshold; +}; + +export const pointInEllipse = (point: Point, ellipse: Ellipse) => { + const { center, angle, halfWidth, halfHeight } = ellipse; + const [rotatedPointX, rotatedPointY] = pointRelativeToCenter( + point, + center, + angle, + ); + + return ( + (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) + + (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <= + 1 + ); +}; diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts new file mode 100644 index 0000000000..1fbcd7935c --- /dev/null +++ b/packages/utils/geometry/shape.ts @@ -0,0 +1,278 @@ +/** + * this file defines pure geometric shapes + * + * for instance, a cubic bezier curve is specified by its four control points and + * an ellipse is defined by its center, angle, semi major axis and semi minor axis + * (but in semi-width and semi-height so it's more relevant to Excalidraw) + * + * the idea with pure shapes is so that we can provide collision and other geoemtric methods not depending on + * the specifics of roughjs or elements in Excalidraw; instead, we can focus on the pure shapes themselves + * + * also included in this file are methods for converting an Excalidraw element or a Drawable from roughjs + * to pure shapes + */ + +import { + ExcalidrawDiamondElement, + ExcalidrawEllipseElement, + ExcalidrawEmbeddableElement, + ExcalidrawFrameLikeElement, + ExcalidrawFreeDrawElement, + ExcalidrawIframeElement, + ExcalidrawImageElement, + ExcalidrawRectangleElement, + ExcalidrawSelectionElement, + ExcalidrawTextElement, +} from "../../excalidraw/element/types"; +import { angleToDegrees, close, pointAdd, pointRotate } from "./geometry"; +import { pointsOnBezierCurves } from "points-on-curve"; +import type { Drawable, Op } from "roughjs/bin/core"; + +// a point is specified by its coordinate (x, y) +export type Point = [number, number]; +export type Vector = Point; + +// a line (segment) is defined by two endpoints +export type Line = [Point, Point]; + +// a polyline (made up term here) is a line consisting of other line segments +// this corresponds to a straight line element in the editor but it could also +// be used to model other elements +export type Polyline = Line[]; + +// cubic bezier curve with four control points +export type Curve = [Point, Point, Point, Point]; + +// a polycurve is a curve consisting of ther curves, this corresponds to a complex +// curve on the canvas +export type Polycurve = Curve[]; + +// a polygon is a closed shape by connecting the given points +// rectangles and diamonds are modelled by polygons +export type Polygon = Point[]; + +// an ellipse is specified by its center, angle, and its major and minor axes +// but for the sake of simplicity, we've used halfWidth and halfHeight instead +// in replace of semi major and semi minor axes +export type Ellipse = { + center: Point; + angle: number; + halfWidth: number; + halfHeight: number; +}; + +export type GeometricShape = + | { + type: "line"; + data: Line; + } + | { + type: "polygon"; + data: Polygon; + } + | { + type: "curve"; + data: Curve; + } + | { + type: "ellipse"; + data: Ellipse; + } + | { + type: "polyline"; + data: Polyline; + } + | { + type: "polycurve"; + data: Polycurve; + }; + +type RectangularElement = + | ExcalidrawRectangleElement + | ExcalidrawDiamondElement + | ExcalidrawFrameLikeElement + | ExcalidrawEmbeddableElement + | ExcalidrawImageElement + | ExcalidrawIframeElement + | ExcalidrawTextElement + | ExcalidrawSelectionElement; + +// polygon +export const getPolygonShape = ( + element: RectangularElement, +): GeometricShape => { + const { angle, width, height, x, y } = element; + const angleInDegrees = angleToDegrees(angle); + const cx = x + width / 2; + const cy = y + height / 2; + + const center: Point = [cx, cy]; + + let data: Polygon = []; + + if (element.type === "diamond") { + data = [ + pointRotate([cx, y], angleInDegrees, center), + pointRotate([x + width, cy], angleInDegrees, center), + pointRotate([cx, y + height], angleInDegrees, center), + pointRotate([x, cy], angleInDegrees, center), + ] as Polygon; + } else { + data = [ + pointRotate([x, y], angleInDegrees, center), + pointRotate([x + width, y], angleInDegrees, center), + pointRotate([x + width, y + height], angleInDegrees, center), + pointRotate([x, y + height], angleInDegrees, center), + ] as Polygon; + } + + return { + type: "polygon", + data, + }; +}; + +// ellipse +export const getEllipseShape = ( + element: ExcalidrawEllipseElement, +): GeometricShape => { + const { width, height, angle, x, y } = element; + + return { + type: "ellipse", + data: { + center: [x + width / 2, y + height / 2], + angle, + halfWidth: width / 2, + halfHeight: height / 2, + }, + }; +}; + +export const getCurvePathOps = (shape: Drawable): Op[] => { + for (const set of shape.sets) { + if (set.type === "path") { + return set.ops; + } + } + return shape.sets[0].ops; +}; + +// linear +export const getCurveShape = ( + roughShape: Drawable, + startingPoint: Point = [0, 0], + angleInRadian: number, + center: Point, +): GeometricShape => { + const transform = (p: Point) => + pointRotate( + [p[0] + startingPoint[0], p[1] + startingPoint[1]], + angleToDegrees(angleInRadian), + center, + ); + + const ops = getCurvePathOps(roughShape); + const polycurve: Polycurve = []; + let p0: Point = [0, 0]; + + for (const op of ops) { + if (op.op === "move") { + p0 = transform(op.data as Point); + } + if (op.op === "bcurveTo") { + const p1: Point = transform([op.data[0], op.data[1]]); + const p2: Point = transform([op.data[2], op.data[3]]); + const p3: Point = transform([op.data[4], op.data[5]]); + polycurve.push([p0, p1, p2, p3]); + p0 = p3; + } + } + + return { + type: "polycurve", + data: polycurve, + }; +}; + +const polylineFromPoints = (points: Point[]) => { + let previousPoint = points[0]; + const polyline: Polyline = []; + + for (let i = 1; i < points.length; i++) { + const nextPoint = points[i]; + polyline.push([previousPoint, nextPoint]); + previousPoint = nextPoint; + } + + return polyline; +}; + +export const getFreedrawShape = ( + element: ExcalidrawFreeDrawElement, + center: Point, + isClosed: boolean = false, +): GeometricShape => { + const angle = angleToDegrees(element.angle); + const transform = (p: Point) => + pointRotate(pointAdd(p, [element.x, element.y] as Point), angle, center); + + const polyline = polylineFromPoints( + element.points.map((p) => transform(p as Point)), + ); + + return isClosed + ? { + type: "polygon", + data: close(polyline.flat()) as Polygon, + } + : { + type: "polyline", + data: polyline, + }; +}; + +export const getClosedCurveShape = ( + roughShape: Drawable, + startingPoint: Point = [0, 0], + angleInRadian: number, + center: Point, +): GeometricShape => { + const ops = getCurvePathOps(roughShape); + const transform = (p: Point) => + pointRotate( + [p[0] + startingPoint[0], p[1] + startingPoint[1]], + angleToDegrees(angleInRadian), + center, + ); + + const points: Point[] = []; + let odd = false; + for (const operation of ops) { + if (operation.op === "move") { + odd = !odd; + if (odd) { + points.push([operation.data[0], operation.data[1]]); + } + } else if (operation.op === "bcurveTo") { + if (odd) { + points.push([operation.data[0], operation.data[1]]); + points.push([operation.data[2], operation.data[3]]); + points.push([operation.data[4], operation.data[5]]); + } + } else if (operation.op === "lineTo") { + if (odd) { + points.push([operation.data[0], operation.data[1]]); + } + } + } + + const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) => + transform(p), + ); + + return { + type: "polygon", + data: polygonPoints, + }; +};