diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index 00376c48f..b610ab7b5 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/excalidraw-app/components/DebugCanvas.tsx @@ -1,7 +1,6 @@ import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; import { type AppState } from "../../packages/excalidraw/types"; import { throttleRAF } from "../../packages/excalidraw/utils"; -import type { LineSegment } from "../../packages/utils"; import { bootstrapCanvas, getNormalizedCanvasDimensions, @@ -13,12 +12,16 @@ import { TrashIcon, } from "../../packages/excalidraw/components/icons"; import { STORAGE_KEYS } from "../app_constants"; -import { isLineSegment } from "../../packages/excalidraw/element/typeChecks"; +import { + isLineSegment, + type GlobalPoint, + type LineSegment, +} from "../../packages/math"; const renderLine = ( context: CanvasRenderingContext2D, zoom: number, - segment: LineSegment, + segment: LineSegment, color: string, ) => { context.save(); @@ -47,10 +50,15 @@ const render = ( context: CanvasRenderingContext2D, appState: AppState, ) => { - frame.forEach((el) => { + frame.forEach((el: DebugElement) => { switch (true) { case isLineSegment(el.data): - renderLine(context, appState.zoom.value, el.data, el.color); + renderLine( + context, + appState.zoom.value, + el.data as LineSegment, + el.color, + ); break; } }); diff --git a/package.json b/package.json index f4a23235a..45a07b42c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "excalidraw-app", "packages/excalidraw", "packages/utils", + "packages/math", "examples/excalidraw", "examples/excalidraw/*" ], diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index c7ff38e3f..35fabcaf9 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -38,7 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import type { SceneBounds } from "../element/bounds"; import { setCursor } from "../cursor"; import { StoreAction } from "../store"; -import { clamp } from "../math"; +import { clamp } from "../../math"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index eb81e715d..d19bfa59d 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -42,20 +42,21 @@ export const actionDuplicateSelection = register({ perform: (elements, appState, formData, app) => { // duplicate selected point(s) if editing a line if (appState.editingLinearElement) { - const ret = LinearElementEditor.duplicateSelectedPoints( - appState, - app.scene.getNonDeletedElementsMap(), - ); + // TODO: Invariants should be checked here instead of duplicateSelectedPoints() + try { + const newAppState = LinearElementEditor.duplicateSelectedPoints( + appState, + app.scene.getNonDeletedElementsMap(), + ); - if (!ret) { + return { + elements, + appState: newAppState, + storeAction: StoreAction.CAPTURE, + }; + } catch { return false; } - - return { - elements, - appState: ret.appState, - storeAction: StoreAction.CAPTURE, - }; } return { diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 95cc19c96..f19ab981f 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -6,7 +6,6 @@ import { done } from "../components/icons"; import { t } from "../i18n"; import { register } from "./register"; import { mutateElement } from "../element/mutateElement"; -import { isPathALoop } from "../math"; import { LinearElementEditor } from "../element/linearElementEditor"; import { maybeBindLinearElement, @@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks"; import type { AppState } from "../types"; import { resetCursor } from "../cursor"; import { StoreAction } from "../store"; +import { point } from "../../math"; +import { isPathALoop } from "../shapes"; export const actionFinalize = register({ name: "finalize", @@ -112,10 +113,10 @@ export const actionFinalize = register({ const linePoints = multiPointElement.points; const firstPoint = linePoints[0]; mutateElement(multiPointElement, { - points: linePoints.map((point, index) => + points: linePoints.map((p, index) => index === linePoints.length - 1 - ? ([firstPoint[0], firstPoint[1]] as const) - : point, + ? point(firstPoint[0], firstPoint[1]) + : p, ), }); } diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 6bcf24972..0fa705f23 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { AppClassProperties, AppState, Point, Primitive } from "../types"; +import type { AppClassProperties, AppState, Primitive } from "../types"; import type { StoreActionType } from "../store"; import { DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, @@ -115,6 +115,8 @@ import { } from "../element/binding"; import { mutateElbowArrow } from "../element/routing"; import { LinearElementEditor } from "../element/linearElementEditor"; +import type { LocalPoint } from "../../math"; +import { point, vector } from "../../math"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; @@ -1648,10 +1650,10 @@ export const actionChangeArrowType = register({ newElement, elementsMap, [finalStartPoint, finalEndPoint].map( - (point) => - [point[0] - newElement.x, point[1] - newElement.y] as Point, + (p): LocalPoint => + point(p[0] - newElement.x, p[1] - newElement.y), ), - [0, 0], + vector(0, 0), { ...(startElement && newElement.startBinding ? { diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts index 2a80a4ba7..87d2b34fe 100644 --- a/packages/excalidraw/charts.ts +++ b/packages/excalidraw/charts.ts @@ -1,3 +1,5 @@ +import type { Radians } from "../math"; +import { point } from "../math"; import { COLOR_PALETTE, DEFAULT_CHART_COLOR_INDEX, @@ -203,7 +205,7 @@ const chartXLabels = ( x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2, y: y + BAR_GAP / 2, width: BAR_WIDTH, - angle: 5.87, + angle: 5.87 as Radians, fontSize: 16, textAlign: "center", verticalAlign: "top", @@ -258,10 +260,7 @@ const chartLines = ( x, y, width: chartWidth, - points: [ - [0, 0], - [chartWidth, 0], - ], + points: [point(0, 0), point(chartWidth, 0)], }); const yLine = newLinearElement({ @@ -272,10 +271,7 @@ const chartLines = ( x, y, height: chartHeight, - points: [ - [0, 0], - [0, -chartHeight], - ], + points: [point(0, 0), point(0, -chartHeight)], }); const maxLine = newLinearElement({ @@ -288,10 +284,7 @@ const chartLines = ( strokeStyle: "dotted", width: chartWidth, opacity: GRID_OPACITY, - points: [ - [0, 0], - [chartWidth, 0], - ], + points: [point(0, 0), point(chartWidth, 0)], }); return [xLine, yLine, maxLine]; @@ -448,10 +441,7 @@ const chartTypeLine = ( height: cy, strokeStyle: "dotted", opacity: GRID_OPACITY, - points: [ - [0, 0], - [0, cy], - ], + points: [point(0, 0), point(0, cy)], }); }); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 6357336c1..00c0a882a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -210,12 +210,6 @@ import { isElementCompletelyInViewport, isElementInViewport, } from "../element/sizeHelpers"; -import { - distance2d, - getCornerRadius, - getGridPoint, - isPathALoop, -} from "../math"; import { calculateScrollCenter, getElementsWithinSelection, @@ -230,7 +224,13 @@ import type { ScrollBars, } from "../scene/types"; import { getStateForZoom } from "../scene/zoom"; -import { findShapeByKey, getBoundTextShape, getElementShape } from "../shapes"; +import { + findShapeByKey, + getBoundTextShape, + getCornerRadius, + getElementShape, + isPathALoop, +} from "../shapes"; import { getSelectionBoxShape } from "../../utils/geometry/shape"; import { isPointInShape } from "../../utils/collision"; import type { @@ -386,6 +386,7 @@ import { getReferenceSnapPoints, SnapCache, isGridModeEnabled, + getGridPoint, } from "../snapping"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; import BraveMeasureTextError from "./BraveMeasureTextError"; @@ -439,6 +440,8 @@ import { FlowChartNavigator, getLinkDirectionFromKey, } from "../element/flowchart"; +import type { LocalPoint, Radians } from "../../math"; +import { point, pointDistance, vector } from "../../math"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -4844,7 +4847,7 @@ class App extends React.Component { this.getElementHitThreshold(), ); - return isPointInShape([x, y], selectionShape); + return isPointInShape(point(x, y), selectionShape); } // take bound text element into consideration for hit collision as well @@ -5035,7 +5038,7 @@ class App extends React.Component { containerId: shouldBindToContainer ? container?.id : undefined, groupIds: container?.groupIds ?? [], lineHeight, - angle: container?.angle ?? 0, + angle: container?.angle ?? (0 as Radians), frameId: topLayerFrame ? topLayerFrame.id : null, }); @@ -5203,7 +5206,7 @@ class App extends React.Component { element, this.scene.getNonDeletedElementsMap(), this.state, - [scenePointer.x, scenePointer.y], + point(scenePointer.x, scenePointer.y), this.device.editor.isMobile, ) ); @@ -5214,11 +5217,12 @@ class App extends React.Component { event: React.PointerEvent, isTouchScreen: boolean, ) => { - const draggedDistance = distance2d( - this.lastPointerDownEvent!.clientX, - this.lastPointerDownEvent!.clientY, - this.lastPointerUpEvent!.clientX, - this.lastPointerUpEvent!.clientY, + const draggedDistance = pointDistance( + point( + this.lastPointerDownEvent!.clientX, + this.lastPointerDownEvent!.clientY, + ), + point(this.lastPointerUpEvent!.clientX, this.lastPointerUpEvent!.clientY), ); if ( !this.hitLinkElement || @@ -5237,7 +5241,7 @@ class App extends React.Component { this.hitLinkElement, elementsMap, this.state, - [lastPointerDownCoords.x, lastPointerDownCoords.y], + point(lastPointerDownCoords.x, lastPointerDownCoords.y), this.device.editor.isMobile, ); const lastPointerUpCoords = viewportCoordsToSceneCoords( @@ -5248,7 +5252,7 @@ class App extends React.Component { this.hitLinkElement, elementsMap, this.state, - [lastPointerUpCoords.x, lastPointerUpCoords.y], + point(lastPointerUpCoords.x, lastPointerUpCoords.y), this.device.editor.isMobile, ); if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) { @@ -5497,17 +5501,18 @@ class App extends React.Component { // if we haven't yet created a temp point and we're beyond commit-zone // threshold, add a point if ( - distance2d( - scenePointerX - rx, - scenePointerY - ry, - lastPoint[0], - lastPoint[1], + pointDistance( + point(scenePointerX - rx, scenePointerY - ry), + lastPoint, ) >= LINE_CONFIRM_THRESHOLD ) { mutateElement( multiElement, { - points: [...points, [scenePointerX - rx, scenePointerY - ry]], + points: [ + ...points, + point(scenePointerX - rx, scenePointerY - ry), + ], }, false, ); @@ -5519,11 +5524,9 @@ class App extends React.Component { } else if ( points.length > 2 && lastCommittedPoint && - distance2d( - scenePointerX - rx, - scenePointerY - ry, - lastCommittedPoint[0], - lastCommittedPoint[1], + pointDistance( + point(scenePointerX - rx, scenePointerY - ry), + lastCommittedPoint, ) < LINE_CONFIRM_THRESHOLD ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); @@ -5570,10 +5573,10 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), [ ...points.slice(0, -1), - [ + point( lastCommittedX + dxFromLastCommitted, lastCommittedY + dyFromLastCommitted, - ], + ), ], undefined, undefined, @@ -5589,10 +5592,10 @@ class App extends React.Component { { points: [ ...points.slice(0, -1), - [ + point( lastCommittedX + dxFromLastCommitted, lastCommittedY + dyFromLastCommitted, - ], + ), ], }, false, @@ -5817,17 +5820,15 @@ class App extends React.Component { } }; - const distance = distance2d( - pointerDownState.lastCoords.x, - pointerDownState.lastCoords.y, - scenePointer.x, - scenePointer.y, + const distance = pointDistance( + point(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y), + point(scenePointer.x, scenePointer.y), ); const threshold = this.getElementHitThreshold(); - const point = { ...pointerDownState.lastCoords }; + const p = { ...pointerDownState.lastCoords }; let samplingInterval = 0; while (samplingInterval <= distance) { - const hitElements = this.getElementsAtPosition(point.x, point.y); + const hitElements = this.getElementsAtPosition(p.x, p.y); processElements(hitElements); // Exit since we reached current point @@ -5839,12 +5840,10 @@ class App extends React.Component { samplingInterval = Math.min(samplingInterval + threshold, distance); const distanceRatio = samplingInterval / distance; - const nextX = - (1 - distanceRatio) * point.x + distanceRatio * scenePointer.x; - const nextY = - (1 - distanceRatio) * point.y + distanceRatio * scenePointer.y; - point.x = nextX; - point.y = nextY; + const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x; + const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y; + p.x = nextX; + p.y = nextY; } pointerDownState.lastCoords.x = scenePointer.x; @@ -6325,7 +6324,7 @@ class App extends React.Component { this.hitLinkElement, this.scene.getNonDeletedElementsMap(), this.state, - [scenePointer.x, scenePointer.y], + point(scenePointer.x, scenePointer.y), ) ) { this.handleEmbeddableCenterClick(this.hitLinkElement); @@ -7008,7 +7007,7 @@ class App extends React.Component { simulatePressure, locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, - points: [[0, 0]], + points: [point(0, 0)], pressures: simulatePressure ? [] : [event.pressure], }); @@ -7216,11 +7215,9 @@ class App extends React.Component { if ( multiElement.points.length > 1 && lastCommittedPoint && - distance2d( - pointerDownState.origin.x - rx, - pointerDownState.origin.y - ry, - lastCommittedPoint[0], - lastCommittedPoint[1], + pointDistance( + point(pointerDownState.origin.x - rx, pointerDownState.origin.y - ry), + lastCommittedPoint, ) < LINE_CONFIRM_THRESHOLD ) { this.actionManager.executeAction(actionFinalize); @@ -7321,7 +7318,7 @@ class App extends React.Component { }; }); mutateElement(element, { - points: [...element.points, [0, 0]], + points: [...element.points, point(0, 0)], }); const boundElement = getHoveredElementForBinding( pointerDownState.origin, @@ -7573,11 +7570,9 @@ class App extends React.Component { this.state.activeTool.type === "line") ) { if ( - distance2d( - pointerCoords.x, - pointerCoords.y, - pointerDownState.origin.x, - pointerDownState.origin.y, + pointDistance( + point(pointerCoords.x, pointerCoords.y), + point(pointerDownState.origin.x, pointerDownState.origin.y), ) < DRAGGING_THRESHOLD ) { return; @@ -7926,7 +7921,7 @@ class App extends React.Component { mutateElement( newElement, { - points: [...points, [dx, dy]], + points: [...points, point(dx, dy)], pressures, }, false, @@ -7955,7 +7950,7 @@ class App extends React.Component { mutateElement( newElement, { - points: [...points, [dx, dy]], + points: [...points, point(dx, dy)], }, false, ); @@ -7963,8 +7958,8 @@ class App extends React.Component { mutateElbowArrow( newElement, elementsMap, - [...points.slice(0, -1), [dx, dy]], - [0, 0], + [...points.slice(0, -1), point(dx, dy)], + vector(0, 0), undefined, { isDragging: true, @@ -7975,7 +7970,7 @@ class App extends React.Component { mutateElement( newElement, { - points: [...points.slice(0, -1), [dx, dy]], + points: [...points.slice(0, -1), point(dx, dy)], }, false, ); @@ -8284,9 +8279,9 @@ class App extends React.Component { : [...newElement.pressures, childEvent.pressure]; mutateElement(newElement, { - points: [...points, [dx, dy]], + points: [...points, point(dx, dy)], pressures, - lastCommittedPoint: [dx, dy], + lastCommittedPoint: point(dx, dy), }); this.actionManager.executeAction(actionFinalize); @@ -8333,7 +8328,10 @@ class App extends React.Component { mutateElement(newElement, { points: [ ...newElement.points, - [pointerCoords.x - newElement.x, pointerCoords.y - newElement.y], + point( + pointerCoords.x - newElement.x, + pointerCoords.y - newElement.y, + ), ], }); this.setState({ @@ -8643,11 +8641,9 @@ class App extends React.Component { if (isEraserActive(this.state) && pointerStart && pointerEnd) { this.eraserTrail.endPath(); - const draggedDistance = distance2d( - pointerStart.clientX, - pointerStart.clientY, - pointerEnd.clientX, - pointerEnd.clientY, + const draggedDistance = pointDistance( + point(pointerStart.clientX, pointerStart.clientY), + point(pointerEnd.clientX, pointerEnd.clientY), ); if (draggedDistance === 0) { diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index e83c2f02d..b7a98a437 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -2,13 +2,14 @@ import { mutateElement } from "../../element/mutateElement"; import { getBoundTextElement } from "../../element/textElement"; import { isArrowElement, isElbowArrow } from "../../element/typeChecks"; import type { ExcalidrawElement } from "../../element/types"; -import { degreeToRadian, radianToDegree } from "../../math"; import { angleIcon } from "../icons"; import DragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils"; import type Scene from "../../scene/Scene"; import type { AppState } from "../../types"; +import type { Degrees } from "../../../math"; +import { degreesToRadians, radiansToDegrees } from "../../../math"; interface AngleProps { element: ExcalidrawElement; @@ -36,7 +37,7 @@ const handleDegreeChange: DragInputCallbackType = ({ } if (nextValue !== undefined) { - const nextAngle = degreeToRadian(nextValue); + const nextAngle = degreesToRadians(nextValue as Degrees); mutateElement(latestElement, { angle: nextAngle, }); @@ -51,7 +52,7 @@ const handleDegreeChange: DragInputCallbackType = ({ } const originalAngleInDegrees = - Math.round(radianToDegree(origElement.angle) * 100) / 100; + Math.round(radiansToDegrees(origElement.angle) * 100) / 100; const changeInDegrees = Math.round(accumulatedChange); let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360; if (shouldChangeByStepSize) { @@ -61,7 +62,7 @@ const handleDegreeChange: DragInputCallbackType = ({ nextAngleInDegrees = nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees; - const nextAngle = degreeToRadian(nextAngleInDegrees); + const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees); mutateElement(latestElement, { angle: nextAngle, @@ -80,7 +81,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => { !isInGroup(el) && isPropertyEditable(el, "angle"), ); const angles = editableLatestIndividualElements.map( - (el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100, + (el) => Math.round((radiansToDegrees(el.angle) % 360) * 100) / 100, ); const value = new Set(angles).size === 1 ? angles[0] : "Mixed"; diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index 516b3aaf6..f36200585 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -13,13 +13,14 @@ import type { NonDeletedSceneElementsMap, } from "../../element/types"; import type Scene from "../../scene/Scene"; -import type { AppState, Point } from "../../types"; +import type { AppState } from "../../types"; import DragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { getElementsInAtomicUnit, resizeElement } from "./utils"; import type { AtomicUnit } from "./utils"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; +import { point, type GlobalPoint } from "../../../math"; interface MultiDimensionProps { property: "width" | "height"; @@ -104,7 +105,7 @@ const resizeGroup = ( nextHeight: number, initialHeight: number, aspectRatio: number, - anchor: Point, + anchor: GlobalPoint, property: MultiDimensionProps["property"], latestElements: ExcalidrawElement[], originalElements: ExcalidrawElement[], @@ -181,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType< nextHeight, initialHeight, aspectRatio, - [x1, y1], + point(x1, y1), property, latestElements, originalElements, @@ -286,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType< nextHeight, initialHeight, aspectRatio, - [x1, y1], + point(x1, y1), property, latestElements, originalElements, diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index 652a6b82f..d0f001663 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -4,7 +4,6 @@ import type { NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, } from "../../element/types"; -import { rotate } from "../../math"; import type Scene from "../../scene/Scene"; import StatsDragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; @@ -14,6 +13,7 @@ import { useMemo } from "react"; import { getElementsInAtomicUnit, moveElement } from "./utils"; import type { AtomicUnit } from "./utils"; import type { AppState } from "../../types"; +import { point, pointRotateRads } from "../../../math"; interface MultiPositionProps { property: "x" | "y"; @@ -43,11 +43,9 @@ const moveElements = ( origElement.x + origElement.width / 2, origElement.y + origElement.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - origElement.x, - origElement.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(origElement.x, origElement.y), + point(cx, cy), origElement.angle, ); @@ -98,11 +96,9 @@ const moveGroupTo = ( latestElement.y + latestElement.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - latestElement.x, - latestElement.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(latestElement.x, latestElement.y), + point(cx, cy), latestElement.angle, ); @@ -174,11 +170,9 @@ const handlePositionChange: DragInputCallbackType< origElement.x + origElement.width / 2, origElement.y + origElement.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - origElement.x, - origElement.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(origElement.x, origElement.y), + point(cx, cy), origElement.angle, ); @@ -246,7 +240,11 @@ const MultiPosition = ({ const [el] = elementsInUnit; const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2]; - const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle); + const [topLeftX, topLeftY] = pointRotateRads( + point(el.x, el.y), + point(cx, cy), + el.angle, + ); return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100; }), diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx index 511aa9c24..8e7671685 100644 --- a/packages/excalidraw/components/Stats/Position.tsx +++ b/packages/excalidraw/components/Stats/Position.tsx @@ -1,10 +1,10 @@ import type { ElementsMap, ExcalidrawElement } from "../../element/types"; -import { rotate } from "../../math"; import StatsDragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { getStepSizedValue, moveElement } from "./utils"; import type Scene from "../../scene/Scene"; import type { AppState } from "../../types"; +import { point, pointRotateRads } from "../../../math"; interface PositionProps { property: "x" | "y"; @@ -32,11 +32,9 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ origElement.x + origElement.width / 2, origElement.y + origElement.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - origElement.x, - origElement.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(origElement.x, origElement.y), + point(cx, cy), origElement.angle, ); @@ -94,11 +92,9 @@ const Position = ({ scene, appState, }: PositionProps) => { - const [topLeftX, topLeftY] = rotate( - element.x, - element.y, - element.x + element.width / 2, - element.y + element.height / 2, + const [topLeftX, topLeftY] = pointRotateRads( + point(element.x, element.y), + point(element.x + element.width / 2, element.y + element.height / 2), element.angle, ); const value = diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index e2abb2fd8..f281931c8 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -19,12 +19,13 @@ import type { ExcalidrawLinearElement, ExcalidrawTextElement, } from "../../element/types"; -import { degreeToRadian, rotate } from "../../math"; import { getTextEditor, updateTextEditor } from "../../tests/queries/dom"; import { getCommonBounds, isTextElement } from "../../element"; import { API } from "../../tests/helpers/api"; import { actionGroup } from "../../actions"; import { isInGroup } from "../../groups"; +import type { Degrees } from "../../../math"; +import { degreesToRadians, point, pointRotateRads } from "../../../math"; const { h } = window; const mouse = new Pointer("mouse"); @@ -46,7 +47,9 @@ const testInputProperty = ( expect(input.value).toBe(initialValue.toString()); UI.updateInput(input, String(nextValue)); if (property === "angle") { - expect(element[property]).toBe(degreeToRadian(Number(nextValue))); + expect(element[property]).toBe( + degreesToRadians(Number(nextValue) as Degrees), + ); } else if (property === "fontSize" && isTextElement(element)) { expect(element[property]).toBe(Number(nextValue)); } else if (property !== "fontSize") { @@ -260,11 +263,9 @@ describe("stats for a generic element", () => { rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - rectangle.x, - rectangle.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(rectangle.x, rectangle.y), + point(cx, cy), rectangle.angle, ); @@ -281,11 +282,9 @@ describe("stats for a generic element", () => { testInputProperty(rectangle, "angle", "A", 0, 45); - let [newTopLeftX, newTopLeftY] = rotate( - rectangle.x, - rectangle.y, - cx, - cy, + let [newTopLeftX, newTopLeftY] = pointRotateRads( + point(rectangle.x, rectangle.y), + point(cx, cy), rectangle.angle, ); @@ -294,11 +293,9 @@ describe("stats for a generic element", () => { testInputProperty(rectangle, "angle", "A", 45, 66); - [newTopLeftX, newTopLeftY] = rotate( - rectangle.x, - rectangle.y, - cx, - cy, + [newTopLeftX, newTopLeftY] = pointRotateRads( + point(rectangle.x, rectangle.y), + point(cx, cy), rectangle.angle, ); expect(newTopLeftX.toString()).not.toEqual(xInput.value); @@ -313,11 +310,9 @@ describe("stats for a generic element", () => { rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - rectangle.x, - rectangle.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(rectangle.x, rectangle.y), + point(cx, cy), rectangle.angle, ); testInputProperty(rectangle, "width", "W", rectangle.width, 400); @@ -325,11 +320,9 @@ describe("stats for a generic element", () => { rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ]; - let [currentTopLeftX, currentTopLeftY] = rotate( - rectangle.x, - rectangle.y, - cx, - cy, + let [currentTopLeftX, currentTopLeftY] = pointRotateRads( + point(rectangle.x, rectangle.y), + point(cx, cy), rectangle.angle, ); expect(currentTopLeftX).toBeCloseTo(topLeftX, 4); @@ -340,11 +333,9 @@ describe("stats for a generic element", () => { rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ]; - [currentTopLeftX, currentTopLeftY] = rotate( - rectangle.x, - rectangle.y, - cx, - cy, + [currentTopLeftX, currentTopLeftY] = pointRotateRads( + point(rectangle.x, rectangle.y), + point(cx, cy), rectangle.angle, ); @@ -642,7 +633,7 @@ describe("stats for multiple elements", () => { UI.updateInput(angle, "40"); - const angleInRadian = degreeToRadian(40); + const angleInRadian = degreesToRadians(40 as Degrees); expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4); expect(text?.angle).toBeCloseTo(angleInRadian, 4); expect(frame.angle).toBe(0); diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index f2e9765dc..f6cf16708 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -1,3 +1,5 @@ +import type { Radians } from "../../../math"; +import { point, pointRotateRads } from "../../../math"; import { bindOrUnbindLinearElements, updateBoundElements, @@ -30,7 +32,6 @@ import { getElementsInGroup, isInGroup, } from "../../groups"; -import { rotate } from "../../math"; import type Scene from "../../scene/Scene"; import type { AppState } from "../../types"; import { getFontString } from "../../utils"; @@ -229,23 +230,19 @@ export const moveElement = ( originalElement.x + originalElement.width / 2, originalElement.y + originalElement.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - originalElement.x, - originalElement.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(originalElement.x, originalElement.y), + point(cx, cy), originalElement.angle, ); const changeInX = newTopLeftX - topLeftX; const changeInY = newTopLeftY - topLeftY; - const [x, y] = rotate( - newTopLeftX, - newTopLeftY, - cx + changeInX, - cy + changeInY, - -originalElement.angle, + const [x, y] = pointRotateRads( + point(newTopLeftX, newTopLeftY), + point(cx + changeInX, cy + changeInY), + -originalElement.angle as Radians, ); mutateElement( diff --git a/packages/excalidraw/components/TTDDialog/TTDDialog.tsx b/packages/excalidraw/components/TTDDialog/TTDDialog.tsx index d6192b295..f0c63770a 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialog.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialog.tsx @@ -25,11 +25,11 @@ import type { BinaryFiles } from "../../types"; import { ArrowRightIcon } from "../icons"; import "./TTDDialog.scss"; -import { isFiniteNumber } from "../../utils"; import { atom, useAtom } from "jotai"; import { trackEvent } from "../../analytics"; import { InlineIcon } from "../InlineIcon"; import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut"; +import { isFiniteNumber } from "../../../math"; const MIN_PROMPT_LENGTH = 3; const MAX_PROMPT_LENGTH = 1000; diff --git a/packages/excalidraw/components/hyperlink/Hyperlink.tsx b/packages/excalidraw/components/hyperlink/Hyperlink.tsx index cbd6b4112..cb11c46ca 100644 --- a/packages/excalidraw/components/hyperlink/Hyperlink.tsx +++ b/packages/excalidraw/components/hyperlink/Hyperlink.tsx @@ -1,4 +1,4 @@ -import type { AppState, ExcalidrawProps, Point, UIAppState } from "../../types"; +import type { AppState, ExcalidrawProps, UIAppState } from "../../types"; import { sceneCoordsToViewportCoords, viewportCoordsToSceneCoords, @@ -36,6 +36,7 @@ import { trackEvent } from "../../analytics"; import { useAppProps, useExcalidrawAppState } from "../App"; import { isEmbeddableElement } from "../../element/typeChecks"; import { getLinkHandleFromCoords } from "./helpers"; +import { point, type GlobalPoint } from "../../../math"; const CONTAINER_WIDTH = 320; const SPACE_BOTTOM = 85; @@ -176,10 +177,12 @@ export const Hyperlink = ({ if (timeoutId) { clearTimeout(timeoutId); } - const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [ - event.clientX, - event.clientY, - ]) as boolean; + const shouldHide = shouldHideLinkPopup( + element, + elementsMap, + appState, + point(event.clientX, event.clientY), + ) as boolean; if (shouldHide) { timeoutId = window.setTimeout(() => { setAppState({ showHyperlinkPopup: false }); @@ -416,7 +419,7 @@ const shouldHideLinkPopup = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, appState: AppState, - [clientX, clientY]: Point, + [clientX, clientY]: GlobalPoint, ): Boolean => { const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( { clientX, clientY }, diff --git a/packages/excalidraw/components/hyperlink/helpers.ts b/packages/excalidraw/components/hyperlink/helpers.ts index 88dc916ef..3f451a230 100644 --- a/packages/excalidraw/components/hyperlink/helpers.ts +++ b/packages/excalidraw/components/hyperlink/helpers.ts @@ -1,3 +1,5 @@ +import type { GlobalPoint, Radians } from "../../../math"; +import { point, pointRotateRads } from "../../../math"; import { MIME_TYPES } from "../../constants"; import type { Bounds } from "../../element/bounds"; import { getElementAbsoluteCoords } from "../../element/bounds"; @@ -6,9 +8,8 @@ import type { ElementsMap, NonDeletedExcalidrawElement, } from "../../element/types"; -import { rotate } from "../../math"; import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement"; -import type { AppState, Point, UIAppState } from "../../types"; +import type { AppState, UIAppState } from "../../types"; export const EXTERNAL_LINK_IMG = document.createElement("img"); EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent( @@ -17,7 +18,7 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent( export const getLinkHandleFromCoords = ( [x1, y1, x2, y2]: Bounds, - angle: number, + angle: Radians, appState: Pick, ): Bounds => { const size = DEFAULT_LINK_SIZE; @@ -33,11 +34,9 @@ export const getLinkHandleFromCoords = ( const x = x2 + dashedLineMargin - centeringOffset; const y = y1 - dashedLineMargin - linkMarginY + centeringOffset; - const [rotatedX, rotatedY] = rotate( - x + linkWidth / 2, - y + linkHeight / 2, - centerX, - centerY, + const [rotatedX, rotatedY] = pointRotateRads( + point(x + linkWidth / 2, y + linkHeight / 2), + point(centerX, centerY), angle, ); return [ @@ -52,7 +51,7 @@ export const isPointHittingLinkIcon = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, appState: AppState, - [x, y]: Point, + [x, y]: GlobalPoint, ) => { const threshold = 4 / appState.zoom.value; const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); @@ -73,7 +72,7 @@ export const isPointHittingLink = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, appState: AppState, - [x, y]: Point, + [x, y]: GlobalPoint, isMobile: boolean, ) => { if (!element.link || appState.selectedElementIds[element.id]) { @@ -86,5 +85,5 @@ export const isPointHittingLink = ( ) { return true; } - return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]); + return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y)); }; diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 8fbe1e236..62652066f 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -40,11 +40,7 @@ import { import { getDefaultAppState } from "../appState"; import { LinearElementEditor } from "../element/linearElementEditor"; import { bumpVersion } from "../element/mutateElement"; -import { - getUpdatedTimestamp, - isFiniteNumber, - updateActiveTool, -} from "../utils"; +import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { arrayToMap } from "../utils"; import type { MarkOptional, Mutable } from "../utility-types"; import { detectLineHeight, getContainerElement } from "../element/textElement"; @@ -58,6 +54,8 @@ import { getNormalizedGridStep, getNormalizedZoom, } from "../scene"; +import type { LocalPoint, Radians } from "../../math"; +import { isFiniteNumber, point } from "../../math"; type RestoredAppState = Omit< AppState, @@ -152,7 +150,7 @@ const restoreElementWithProperties = < roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness, opacity: element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity, - angle: element.angle || 0, + angle: element.angle || (0 as Radians), x: extra.x ?? element.x ?? 0, y: extra.y ?? element.y ?? 0, strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor, @@ -266,10 +264,7 @@ const restoreElement = ( let y = element.y; let points = // migrate old arrow model to new one !Array.isArray(element.points) || element.points.length < 2 - ? [ - [0, 0], - [element.width, element.height], - ] + ? [point(0, 0), point(element.width, element.height)] : element.points; if (points[0][0] !== 0 || points[0][1] !== 0) { @@ -293,14 +288,11 @@ const restoreElement = ( }); case "arrow": { const { startArrowhead = null, endArrowhead = "arrow" } = element; - let x = element.x; - let y = element.y; - let points = // migrate old arrow model to new one + let x: number | undefined = element.x; + let y: number | undefined = element.y; + let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one !Array.isArray(element.points) || element.points.length < 2 - ? [ - [0, 0], - [element.width, element.height], - ] + ? [point(0, 0), point(element.width, element.height)] : element.points; if (points[0][0] !== 0 || points[0][1] !== 0) { diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index bdb37bc96..d930cb923 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/packages/excalidraw/data/transform.test.ts @@ -2,6 +2,7 @@ import { vi } from "vitest"; import type { ExcalidrawElementSkeleton } from "./transform"; import { convertToExcalidrawElements } from "./transform"; import type { ExcalidrawArrowElement } from "../element/types"; +import { point } from "../../math"; const opts = { regenerateIds: false }; @@ -911,10 +912,7 @@ describe("Test Transform", () => { x: 111.262, y: 57, strokeWidth: 2, - points: [ - [0, 0], - [272.985, 0], - ], + points: [point(0, 0), point(272.985, 0)], label: { text: "How are you?", fontSize: 20, @@ -937,7 +935,7 @@ describe("Test Transform", () => { x: 77.017, y: 79, strokeWidth: 2, - points: [[0, 0]], + points: [point(0, 0)], label: { text: "Friendship", fontSize: 20, diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index cbddafb70..6573abd0d 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -53,6 +53,7 @@ import { randomId } from "../random"; import { syncInvalidIndices } from "../fractionalIndex"; import { getLineHeight } from "../fonts"; import { isArrowElement } from "../element/typeChecks"; +import { point, type LocalPoint } from "../../math"; export type ValidLinearElement = { type: "arrow" | "line"; @@ -417,7 +418,7 @@ const bindLinearElementToElement = ( const endPointIndex = linearElement.points.length - 1; const delta = 0.5; - const newPoints = cloneJSON(linearElement.points) as [number, number][]; + const newPoints = cloneJSON(linearElement.points); // left to right so shift the arrow towards right if ( @@ -535,10 +536,7 @@ export const convertToExcalidrawElements = ( excalidrawElement = newLinearElement({ width, height, - points: [ - [0, 0], - [width, height], - ], + points: [point(0, 0), point(width, height)], ...element, }); @@ -551,10 +549,7 @@ export const convertToExcalidrawElements = ( width, height, endArrowhead: "arrow", - points: [ - [0, 0], - [width, height], - ], + points: [point(0, 0), point(width, height)], ...element, type: "arrow", }); diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 2dae8d854..fe820723f 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -1,8 +1,8 @@ -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 * as GA from "../../math/ga/ga"; +import * as GAPoint from "../../math/ga/gapoints"; +import * as GADirection from "../../math/ga/gadirections"; +import * as GALine from "../../math/ga/galines"; +import * as GATransform from "../../math/ga/gatransforms"; import type { ExcalidrawBindableElement, @@ -10,7 +10,6 @@ import type { ExcalidrawRectangleElement, ExcalidrawDiamondElement, ExcalidrawEllipseElement, - ExcalidrawFreeDrawElement, ExcalidrawImageElement, ExcalidrawFrameLikeElement, ExcalidrawIframeLikeElement, @@ -26,11 +25,12 @@ import type { ExcalidrawElbowArrowElement, FixedPoint, SceneElementsMap, + ExcalidrawRectanguloidElement, } from "./types"; import type { Bounds } from "./bounds"; -import { getElementAbsoluteCoords } from "./bounds"; -import type { AppState, Point } from "../types"; +import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds"; +import type { AppState } from "../types"; import { isPointOnShape } from "../../utils/collision"; import { getElementAtPosition } from "../scene"; import { @@ -51,17 +51,7 @@ import { LinearElementEditor } from "./linearElementEditor"; import { arrayToMap, tupleToCoors } from "../utils"; import { KEYS } from "../keys"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; -import { getElementShape } from "../shapes"; -import { - aabbForElement, - clamp, - distanceSq2d, - getCenterForBounds, - getCenterForElement, - pointInsideBounds, - pointToVector, - rotatePoint, -} from "../math"; +import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes"; import { compareHeading, HEADING_DOWN, @@ -72,7 +62,18 @@ import { vectorToHeading, type Heading, } from "./heading"; -import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry"; +import type { LocalPoint, Radians } from "../../math"; +import { + lineSegment, + point, + pointRotateRads, + type GlobalPoint, + vectorFromPoint, + pointFromPair, + pointDistanceSq, + clamp, +} from "../../math"; +import { segmentIntersectRectangleElement } from "../../utils/geometry/shape"; export type SuggestedBinding = | NonDeleted @@ -649,7 +650,7 @@ export const updateBoundElements = ( update, ): update is NonNullable<{ index: number; - point: Point; + point: LocalPoint; isDragging?: boolean; }> => update !== null, ); @@ -695,14 +696,14 @@ const getSimultaneouslyUpdatedElementIds = ( }; export const getHeadingForElbowArrowSnap = ( - point: Readonly, - otherPoint: Readonly, + p: Readonly, + otherPoint: Readonly, bindableElement: ExcalidrawBindableElement | undefined | null, aabb: Bounds | undefined | null, elementsMap: ElementsMap, - origPoint: Point, + origPoint: GlobalPoint, ): Heading => { - const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point)); + const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p)); if (!bindableElement || !aabb) { return otherPointHeading; @@ -716,17 +717,23 @@ export const getHeadingForElbowArrowSnap = ( if (!distance) { return vectorToHeading( - pointToVector(point, getCenterForElement(bindableElement)), + vectorFromPoint( + p, + point( + bindableElement.x + bindableElement.width / 2, + bindableElement.y + bindableElement.height / 2, + ), + ), ); } - const pointHeading = headingForPointFromElement(bindableElement, aabb, point); + const pointHeading = headingForPointFromElement(bindableElement, aabb, p); return pointHeading; }; const getDistanceForBinding = ( - point: Readonly, + point: Readonly, bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, ) => { @@ -745,89 +752,87 @@ const getDistanceForBinding = ( }; export const bindPointToSnapToElementOutline = ( - point: Readonly, - otherPoint: Readonly, + p: Readonly, + otherPoint: Readonly, bindableElement: ExcalidrawBindableElement | undefined, elementsMap: ElementsMap, -): Point => { +): GlobalPoint => { const aabb = bindableElement && aabbForElement(bindableElement); if (bindableElement && aabb) { // TODO: Dirty hacks until tangents are properly calculated - const heading = headingForPointFromElement(bindableElement, aabb, point); + const heading = headingForPointFromElement(bindableElement, aabb, p); const intersections = [ - ...intersectElementWithLine( + ...(intersectElementWithLine( bindableElement, - [point[0], point[1] - 2 * bindableElement.height], - [point[0], point[1] + 2 * bindableElement.height], + point(p[0], p[1] - 2 * bindableElement.height), + point(p[0], p[1] + 2 * bindableElement.height), FIXED_BINDING_DISTANCE, elementsMap, - ), - ...intersectElementWithLine( + ) ?? []), + ...(intersectElementWithLine( bindableElement, - [point[0] - 2 * bindableElement.width, point[1]], - [point[0] + 2 * bindableElement.width, point[1]], + point(p[0] - 2 * bindableElement.width, p[1]), + point(p[0] + 2 * bindableElement.width, p[1]), FIXED_BINDING_DISTANCE, elementsMap, - ), + ) ?? []), ]; const isVertical = compareHeading(heading, HEADING_LEFT) || compareHeading(heading, HEADING_RIGHT); const dist = Math.abs( - distanceToBindableElement(bindableElement, point, elementsMap), + distanceToBindableElement(bindableElement, p, elementsMap), ); const isInner = isVertical ? dist < bindableElement.width * -0.1 : dist < bindableElement.height * -0.1; - intersections.sort( - (a, b) => distanceSq2d(a, point) - distanceSq2d(b, point), - ); + intersections.sort((a, b) => pointDistanceSq(a, p) - pointDistanceSq(b, p)); return isInner ? headingToMidBindPoint(otherPoint, bindableElement, aabb) : intersections.filter((i) => isVertical - ? Math.abs(point[1] - i[1]) < 0.1 - : Math.abs(point[0] - i[0]) < 0.1, + ? Math.abs(p[1] - i[1]) < 0.1 + : Math.abs(p[0] - i[0]) < 0.1, )[0] ?? point; } - return point; + return p; }; const headingToMidBindPoint = ( - point: Point, + p: GlobalPoint, bindableElement: ExcalidrawBindableElement, aabb: Bounds, -): Point => { +): GlobalPoint => { const center = getCenterForBounds(aabb); - const heading = vectorToHeading(pointToVector(point, center)); + const heading = vectorToHeading(vectorFromPoint(p, center)); switch (true) { case compareHeading(heading, HEADING_UP): - return rotatePoint( - [(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]], + return pointRotateRads( + point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]), center, bindableElement.angle, ); case compareHeading(heading, HEADING_RIGHT): - return rotatePoint( - [aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1], + return pointRotateRads( + point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1), center, bindableElement.angle, ); case compareHeading(heading, HEADING_DOWN): - return rotatePoint( - [(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]], + return pointRotateRads( + point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]), center, bindableElement.angle, ); default: - return rotatePoint( - [aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1], + return pointRotateRads( + point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1), center, bindableElement.angle, ); @@ -836,22 +841,25 @@ const headingToMidBindPoint = ( export const avoidRectangularCorner = ( element: ExcalidrawBindableElement, - p: Point, -): Point => { - const center = getCenterForElement(element); - const nonRotatedPoint = rotatePoint(p, center, -element.angle); + p: GlobalPoint, +): GlobalPoint => { + const center = point( + element.x + element.width / 2, + element.y + element.height / 2, + ); + const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { // Top left if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) { - return rotatePoint( - [element.x - FIXED_BINDING_DISTANCE, element.y], + return pointRotateRads( + point(element.x - FIXED_BINDING_DISTANCE, element.y), center, element.angle, ); } - return rotatePoint( - [element.x, element.y - FIXED_BINDING_DISTANCE], + return pointRotateRads( + point(element.x, element.y - FIXED_BINDING_DISTANCE), center, element.angle, ); @@ -861,14 +869,14 @@ export const avoidRectangularCorner = ( ) { // Bottom left if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) { - return rotatePoint( - [element.x, element.y + element.height + FIXED_BINDING_DISTANCE], + return pointRotateRads( + point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE), center, element.angle, ); } - return rotatePoint( - [element.x - FIXED_BINDING_DISTANCE, element.y + element.height], + return pointRotateRads( + point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height), center, element.angle, ); @@ -881,20 +889,20 @@ export const avoidRectangularCorner = ( nonRotatedPoint[0] - element.x < element.width + FIXED_BINDING_DISTANCE ) { - return rotatePoint( - [ + return pointRotateRads( + point( element.x + element.width, element.y + element.height + FIXED_BINDING_DISTANCE, - ], + ), center, element.angle, ); } - return rotatePoint( - [ + return pointRotateRads( + point( element.x + element.width + FIXED_BINDING_DISTANCE, element.y + element.height, - ], + ), center, element.angle, ); @@ -907,14 +915,14 @@ export const avoidRectangularCorner = ( nonRotatedPoint[0] - element.x < element.width + FIXED_BINDING_DISTANCE ) { - return rotatePoint( - [element.x + element.width, element.y - FIXED_BINDING_DISTANCE], + return pointRotateRads( + point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE), center, element.angle, ); } - return rotatePoint( - [element.x + element.width + FIXED_BINDING_DISTANCE, element.y], + return pointRotateRads( + point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y), center, element.angle, ); @@ -925,12 +933,12 @@ export const avoidRectangularCorner = ( export const snapToMid = ( element: ExcalidrawBindableElement, - p: Point, + p: GlobalPoint, tolerance: number = 0.05, -): Point => { +): GlobalPoint => { const { x, y, width, height, angle } = element; - const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point; - const nonRotated = rotatePoint(p, center, -angle); + const center = point(x + width / 2 - 0.1, y + height / 2 - 0.1); + const nonRotated = pointRotateRads(p, center, -angle as Radians); // snap-to-center point is adaptive to element size, but we don't want to go // above and below certain px distance @@ -943,22 +951,30 @@ export const snapToMid = ( nonRotated[1] < center[1] + verticalThrehsold ) { // LEFT - return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle); + return pointRotateRads( + point(x - FIXED_BINDING_DISTANCE, center[1]), + center, + angle, + ); } else if ( nonRotated[1] <= y + height / 2 && nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] < center[0] + horizontalThrehsold ) { // TOP - return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle); + return pointRotateRads( + point(center[0], y - FIXED_BINDING_DISTANCE), + center, + angle, + ); } else if ( nonRotated[0] >= x + width / 2 && nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] < center[1] + verticalThrehsold ) { // RIGHT - return rotatePoint( - [x + width + FIXED_BINDING_DISTANCE, center[1]], + return pointRotateRads( + point(x + width + FIXED_BINDING_DISTANCE, center[1]), center, angle, ); @@ -968,8 +984,8 @@ export const snapToMid = ( nonRotated[0] < center[0] + horizontalThrehsold ) { // DOWN - return rotatePoint( - [center[0], y + height + FIXED_BINDING_DISTANCE], + return pointRotateRads( + point(center[0], y + height + FIXED_BINDING_DISTANCE), center, angle, ); @@ -984,7 +1000,7 @@ const updateBoundPoint = ( binding: PointBinding | null | undefined, bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, -): Point | null => { +): LocalPoint | null => { if ( binding == null || // We only need to update the other end if this is a 2 point line element @@ -1006,15 +1022,15 @@ const updateBoundPoint = ( startOrEnd === "startBinding" ? "start" : "end", elementsMap, ).fixedPoint; - const globalMidPoint = [ + const globalMidPoint = point( bindableElement.x + bindableElement.width / 2, bindableElement.y + bindableElement.height / 2, - ] as Point; - const global = [ + ); + const global = point( bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.y + fixedPoint[1] * bindableElement.height, - ] as Point; - const rotatedGlobal = rotatePoint( + ); + const rotatedGlobal = pointRotateRads( global, globalMidPoint, bindableElement.angle, @@ -1040,7 +1056,7 @@ const updateBoundPoint = ( elementsMap, ); - let newEdgePoint: Point; + let newEdgePoint: GlobalPoint; // The linear element was not originally pointing inside the bound shape, // we can point directly at the focus point @@ -1054,7 +1070,7 @@ const updateBoundPoint = ( binding.gap, elementsMap, ); - if (intersections.length === 0) { + if (!intersections || intersections.length === 0) { // This should never happen, since focusPoint should always be // inside the element, but just in case, bail out newEdgePoint = focusPointAbsolute; @@ -1101,15 +1117,15 @@ export const calculateFixedPointForElbowArrowBinding = ( hoveredElement, elementsMap, ); - const globalMidPoint = [ + const globalMidPoint = point( bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2, - ] as Point; - const nonRotatedSnappedGlobalPoint = rotatePoint( + ); + const nonRotatedSnappedGlobalPoint = pointRotateRads( snappedPoint, globalMidPoint, - -hoveredElement.angle, - ) as Point; + -hoveredElement.angle as Radians, + ); return { fixedPoint: normalizeFixedPoint([ @@ -1320,8 +1336,9 @@ export const bindingBorderTest = ( const threshold = maxBindingGap(element, element.width, element.height); const shape = getElementShape(element, elementsMap); return ( - isPointOnShape([x, y], shape, threshold) || - (fullShape === true && pointInsideBounds([x, y], aabbForElement(element))) + isPointOnShape(point(x, y), shape, threshold) || + (fullShape === true && + pointInsideBounds(point(x, y), aabbForElement(element))) ); }; @@ -1339,7 +1356,7 @@ export const maxBindingGap = ( export const distanceToBindableElement = ( element: ExcalidrawBindableElement, - point: Point, + point: GlobalPoint, elementsMap: ElementsMap, ): number => { switch (element.type) { @@ -1359,19 +1376,13 @@ export const distanceToBindableElement = ( }; const distanceToRectangle = ( - element: - | ExcalidrawRectangleElement - | ExcalidrawTextElement - | ExcalidrawFreeDrawElement - | ExcalidrawImageElement - | ExcalidrawIframeLikeElement - | ExcalidrawFrameLikeElement, - point: Point, + element: ExcalidrawRectanguloidElement, + p: GlobalPoint, elementsMap: ElementsMap, ): number => { const [, pointRel, hwidth, hheight] = pointRelativeToElement( element, - point, + p, elementsMap, ); return Math.max( @@ -1382,7 +1393,7 @@ const distanceToRectangle = ( const distanceToDiamond = ( element: ExcalidrawDiamondElement, - point: Point, + point: GlobalPoint, elementsMap: ElementsMap, ): number => { const [, pointRel, hwidth, hheight] = pointRelativeToElement( @@ -1396,7 +1407,7 @@ const distanceToDiamond = ( const distanceToEllipse = ( element: ExcalidrawEllipseElement, - point: Point, + point: GlobalPoint, elementsMap: ElementsMap, ): number => { const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap); @@ -1405,7 +1416,7 @@ const distanceToEllipse = ( const ellipseParamsForTest = ( element: ExcalidrawEllipseElement, - point: Point, + point: GlobalPoint, elementsMap: ElementsMap, ): [GA.Point, GA.Line] => { const [, pointRel, hwidth, hheight] = pointRelativeToElement( @@ -1467,7 +1478,7 @@ const ellipseParamsForTest = ( // so we only need to perform hit tests for the positive quadrant. const pointRelativeToElement = ( element: ExcalidrawElement, - pointTuple: Point, + pointTuple: GlobalPoint, elementsMap: ElementsMap, ): [GA.Point, GA.Point, number, number] => { const point = GAPoint.from(pointTuple); @@ -1516,9 +1527,9 @@ const coordsCenter = ( const determineFocusDistance = ( element: ExcalidrawBindableElement, // Point on the line, in absolute coordinates - a: Point, + a: GlobalPoint, // Another point on the line, in absolute coordinates (closer to element) - b: Point, + b: GlobalPoint, elementsMap: ElementsMap, ): number => { const relateToCenter = relativizationToElementCenter(element, elementsMap); @@ -1559,13 +1570,13 @@ const determineFocusPoint = ( // The oriented, relative distance from the center of `element` of the // returned focusPoint focus: number, - adjecentPoint: Point, + adjecentPoint: GlobalPoint, elementsMap: ElementsMap, -): Point => { +): GlobalPoint => { if (focus === 0) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const center = coordsCenter(x1, y1, x2, y2); - return GAPoint.toTuple(center); + return pointFromPair(GAPoint.toTuple(center)); } const relateToCenter = relativizationToElementCenter(element, elementsMap); const adjecentPointRel = GATransform.apply( @@ -1589,7 +1600,9 @@ const determineFocusPoint = ( point = findFocusPointForEllipse(element, focus, adjecentPointRel); break; } - return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)); + return pointFromPair( + GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), + ); }; // Returns 2 or 0 intersection points between line going through `a` and `b` @@ -1597,15 +1610,15 @@ const determineFocusPoint = ( const intersectElementWithLine = ( element: ExcalidrawBindableElement, // Point on the line, in absolute coordinates - a: Point, + a: GlobalPoint, // Another point on the line, in absolute coordinates - b: Point, + b: GlobalPoint, // If given, the element is inflated by this value gap: number = 0, elementsMap: ElementsMap, -): Point[] => { +): GlobalPoint[] | undefined => { if (isRectangularElement(element)) { - return segmentIntersectRectangleElement(element, [a, b], gap); + return segmentIntersectRectangleElement(element, lineSegment(a, b), gap); } const relateToCenter = relativizationToElementCenter(element, elementsMap); @@ -1619,8 +1632,14 @@ const intersectElementWithLine = ( aRel, gap, ); - return intersections.map((point) => - GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), + return intersections.map( + (point) => + pointFromPair( + GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), + ), + // pointFromArray( + // , + // ), ); }; @@ -2173,12 +2192,18 @@ export class BindableElement { export const getGlobalFixedPointForBindableElement = ( fixedPointRatio: [number, number], element: ExcalidrawBindableElement, -) => { +): GlobalPoint => { const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); - return rotatePoint( - [element.x + element.width * fixedX, element.y + element.height * fixedY], - getCenterForElement(element), + return pointRotateRads( + point( + element.x + element.width * fixedX, + element.y + element.height * fixedY, + ), + point( + element.x + element.width / 2, + element.y + element.height / 2, + ), element.angle, ); }; @@ -2186,7 +2211,7 @@ export const getGlobalFixedPointForBindableElement = ( const getGlobalFixedPoints = ( arrow: ExcalidrawElbowArrowElement, elementsMap: ElementsMap, -) => { +): [GlobalPoint, GlobalPoint] => { const startElement = arrow.startBinding && (elementsMap.get(arrow.startBinding.elementId) as @@ -2197,23 +2222,26 @@ const getGlobalFixedPoints = ( (elementsMap.get(arrow.endBinding.elementId) as | ExcalidrawBindableElement | undefined); - const startPoint: Point = + const startPoint = startElement && arrow.startBinding ? getGlobalFixedPointForBindableElement( arrow.startBinding.fixedPoint, startElement as ExcalidrawBindableElement, ) - : [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]]; - const endPoint: Point = + : point( + arrow.x + arrow.points[0][0], + arrow.y + arrow.points[0][1], + ); + const endPoint = endElement && arrow.endBinding ? getGlobalFixedPointForBindableElement( arrow.endBinding.fixedPoint, endElement as ExcalidrawBindableElement, ) - : [ + : point( arrow.x + arrow.points[arrow.points.length - 1][0], arrow.y + arrow.points[arrow.points.length - 1][1], - ]; + ); return [startPoint, endPoint]; }; diff --git a/packages/excalidraw/element/bounds.test.ts b/packages/excalidraw/element/bounds.test.ts index 3d9a4840d..f5ca0e901 100644 --- a/packages/excalidraw/element/bounds.test.ts +++ b/packages/excalidraw/element/bounds.test.ts @@ -1,3 +1,5 @@ +import type { LocalPoint } from "../../math"; +import { point } from "../../math"; import { ROUNDNESS } from "../constants"; import { arrayToMap } from "../utils"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; @@ -123,9 +125,9 @@ describe("getElementBounds", () => { a: 0.6447741904932416, }), points: [ - [0, 0] as [number, number], - [67.33984375, 92.48828125] as [number, number], - [-102.7890625, 52.15625] as [number, number], + point(0, 0), + point(67.33984375, 92.48828125), + point(-102.7890625, 52.15625), ], } as ExcalidrawLinearElement; diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index 588e24eb0..16f431855 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -7,10 +7,10 @@ import type { ExcalidrawTextElementWithContainer, ElementsMap, } from "./types"; -import { distance2d, rotate, rotatePoint } from "../math"; import rough from "roughjs/bin/rough"; +import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Drawable, Op } from "roughjs/bin/core"; -import type { AppState, Point } from "../types"; +import type { AppState } from "../types"; import { generateRoughOptions } from "../scene/Shape"; import { isArrowElement, @@ -22,9 +22,24 @@ import { import { rescalePoints } from "../points"; import { getBoundTextElement, getContainerElement } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; -import type { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; -import { arrayToMap } from "../utils"; +import { arrayToMap, invariant } from "../utils"; +import type { + Degrees, + GlobalPoint, + LineSegment, + LocalPoint, + Radians, +} from "../../math"; +import { + degreesToRadians, + lineSegment, + point, + pointDistance, + pointFromArray, + pointRotateRads, +} from "../../math"; +import type { Mutable } from "../utility-types"; export type RectangleBox = { x: number; @@ -97,7 +112,11 @@ export class ElementBounds { if (isFreeDrawElement(element)) { const [minX, minY, maxX, maxY] = getBoundsFromPoints( element.points.map(([x, y]) => - rotate(x, y, cx - element.x, cy - element.y, element.angle), + pointRotateRads( + point(x, y), + point(cx - element.x, cy - element.y), + element.angle, + ), ), ); @@ -110,10 +129,26 @@ export class ElementBounds { } else if (isLinearElement(element)) { bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap); } else if (element.type === "diamond") { - const [x11, y11] = rotate(cx, y1, cx, cy, element.angle); - const [x12, y12] = rotate(cx, y2, cx, cy, element.angle); - const [x22, y22] = rotate(x1, cy, cx, cy, element.angle); - const [x21, y21] = rotate(x2, cy, cx, cy, element.angle); + const [x11, y11] = pointRotateRads( + point(cx, y1), + point(cx, cy), + element.angle, + ); + const [x12, y12] = pointRotateRads( + point(cx, y2), + point(cx, cy), + element.angle, + ); + const [x22, y22] = pointRotateRads( + point(x1, cy), + point(cx, cy), + element.angle, + ); + const [x21, y21] = pointRotateRads( + point(x2, cy), + point(cx, cy), + element.angle, + ); const minX = Math.min(x11, x12, x22, x21); const minY = Math.min(y11, y12, y22, y21); const maxX = Math.max(x11, x12, x22, x21); @@ -128,10 +163,26 @@ export class ElementBounds { const hh = Math.hypot(h * cos, w * sin); bounds = [cx - ww, cy - hh, cx + ww, cy + hh]; } else { - const [x11, y11] = rotate(x1, y1, cx, cy, element.angle); - const [x12, y12] = rotate(x1, y2, cx, cy, element.angle); - const [x22, y22] = rotate(x2, y2, cx, cy, element.angle); - const [x21, y21] = rotate(x2, y1, cx, cy, element.angle); + const [x11, y11] = pointRotateRads( + point(x1, y1), + point(cx, cy), + element.angle, + ); + const [x12, y12] = pointRotateRads( + point(x1, y2), + point(cx, cy), + element.angle, + ); + const [x22, y22] = pointRotateRads( + point(x2, y2), + point(cx, cy), + element.angle, + ); + const [x21, y21] = pointRotateRads( + point(x2, y1), + point(cx, cy), + element.angle, + ); const minX = Math.min(x11, x12, x22, x21); const minY = Math.min(y11, y12, y22, y21); const maxX = Math.max(x11, x12, x22, x21); @@ -165,18 +216,18 @@ export const getElementAbsoluteCoords = ( ? getContainerElement(element, elementsMap) : null; if (isArrowElement(container)) { - const coords = LinearElementEditor.getBoundTextElementPosition( + const { x, y } = LinearElementEditor.getBoundTextElementPosition( container, element as ExcalidrawTextElementWithContainer, elementsMap, ); return [ - coords.x, - coords.y, - coords.x + element.width, - coords.y + element.height, - coords.x + element.width / 2, - coords.y + element.height / 2, + x, + y, + x + element.width, + y + element.height, + x + element.width / 2, + y + element.height / 2, ]; } } @@ -198,38 +249,40 @@ export const getElementAbsoluteCoords = ( export const getElementLineSegments = ( element: ExcalidrawElement, elementsMap: ElementsMap, -): [Point, Point][] => { +): LineSegment[] => { const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( element, elementsMap, ); - const center: Point = [cx, cy]; + const center: GlobalPoint = point(cx, cy); if (isLinearElement(element) || isFreeDrawElement(element)) { - const segments: [Point, Point][] = []; + const segments: LineSegment[] = []; let i = 0; while (i < element.points.length - 1) { - segments.push([ - rotatePoint( - [ - element.points[i][0] + element.x, - element.points[i][1] + element.y, - ] as Point, - center, - element.angle, + segments.push( + lineSegment( + pointRotateRads( + point( + element.points[i][0] + element.x, + element.points[i][1] + element.y, + ), + center, + element.angle, + ), + pointRotateRads( + point( + element.points[i + 1][0] + element.x, + element.points[i + 1][1] + element.y, + ), + center, + element.angle, + ), ), - rotatePoint( - [ - element.points[i + 1][0] + element.x, - element.points[i + 1][1] + element.y, - ] as Point, - center, - element.angle, - ), - ]); + ); i++; } @@ -246,40 +299,40 @@ export const getElementLineSegments = ( [cx, y2], [x1, cy], [x2, cy], - ] as Point[] - ).map((point) => rotatePoint(point, center, element.angle)); + ] as GlobalPoint[] + ).map((point) => pointRotateRads(point, center, element.angle)); if (element.type === "diamond") { return [ - [n, w], - [n, e], - [s, w], - [s, e], + lineSegment(n, w), + lineSegment(n, e), + lineSegment(s, w), + lineSegment(s, e), ]; } if (element.type === "ellipse") { return [ - [n, w], - [n, e], - [s, w], - [s, e], - [n, w], - [n, e], - [s, w], - [s, e], + lineSegment(n, w), + lineSegment(n, e), + lineSegment(s, w), + lineSegment(s, e), + lineSegment(n, w), + lineSegment(n, e), + lineSegment(s, w), + lineSegment(s, e), ]; } return [ - [nw, ne], - [sw, se], - [nw, sw], - [ne, se], - [nw, e], - [sw, e], - [ne, w], - [se, w], + lineSegment(nw, ne), + lineSegment(sw, se), + lineSegment(nw, sw), + lineSegment(ne, se), + lineSegment(nw, e), + lineSegment(sw, e), + lineSegment(ne, w), + lineSegment(se, w), ]; }; @@ -386,10 +439,10 @@ const solveQuadratic = ( }; const getCubicBezierCurveBound = ( - p0: Point, - p1: Point, - p2: Point, - p3: Point, + p0: GlobalPoint, + p1: GlobalPoint, + p2: GlobalPoint, + p3: GlobalPoint, ): Bounds => { const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]); const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]); @@ -415,9 +468,9 @@ const getCubicBezierCurveBound = ( export const getMinMaxXYFromCurvePathOps = ( ops: Op[], - transformXY?: (x: number, y: number) => [number, number], + transformXY?: (p: GlobalPoint) => GlobalPoint, ): Bounds => { - let currentP: Point = [0, 0]; + let currentP: GlobalPoint = point(0, 0); const { minX, minY, maxX, maxY } = ops.reduce( (limits, { op, data }) => { @@ -425,19 +478,21 @@ export const getMinMaxXYFromCurvePathOps = ( // move, bcurveTo, lineTo, and curveTo if (op === "move") { // change starting point - currentP = data as unknown as Point; + const p: GlobalPoint | undefined = pointFromArray(data); + invariant(p != null, "Op data is not a point"); + currentP = p; // move operation does not draw anything; so, it always // returns false } else if (op === "bcurveTo") { - 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 _p1 = point(data[0], data[1]); + const _p2 = point(data[2], data[3]); + const _p3 = point(data[4], data[5]); - const p1 = transformXY ? transformXY(..._p1) : _p1; - const p2 = transformXY ? transformXY(..._p2) : _p2; - const p3 = transformXY ? transformXY(..._p3) : _p3; + const p1 = transformXY ? transformXY(_p1) : _p1; + const p2 = transformXY ? transformXY(_p2) : _p2; + const p3 = transformXY ? transformXY(_p3) : _p3; - const p0 = transformXY ? transformXY(...currentP) : currentP; + const p0 = transformXY ? transformXY(currentP) : currentP; currentP = _p3; const [minX, minY, maxX, maxY] = getCubicBezierCurveBound( @@ -507,14 +562,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => { }; /** @returns number in degrees */ -export const getArrowheadAngle = (arrowhead: Arrowhead): number => { +export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => { switch (arrowhead) { case "bar": - return 90; + return 90 as Degrees; case "arrow": - return 20; + return 20 as Degrees; default: - return 25; + return 25 as Degrees; } }; @@ -533,19 +588,24 @@ export const getArrowheadPoints = ( const index = position === "start" ? 1 : ops.length - 1; const data = ops[index].data; - const p3 = [data[4], data[5]] as Point; - const p2 = [data[2], data[3]] as Point; - const p1 = [data[0], data[1]] as Point; + + invariant(data.length === 6, "Op data length is not 6"); + + const p3 = point(data[4], data[5]); + const p2 = point(data[2], data[3]); + const p1 = point(data[0], data[1]); // We need to find p0 of the bezier curve. // It is typically the last point of the previous // curve; it can also be the position of moveTo operation. const prevOp = ops[index - 1]; - let p0: Point = [0, 0]; + let p0 = point(0, 0); if (prevOp.op === "move") { - p0 = prevOp.data as unknown as Point; + const p = pointFromArray(prevOp.data); + invariant(p != null, "Op data is not a point"); + p0 = p; } else if (prevOp.op === "bcurveTo") { - p0 = [prevOp.data[4], prevOp.data[5]]; + p0 = point(prevOp.data[4], prevOp.data[5]); } // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 @@ -610,8 +670,16 @@ export const getArrowheadPoints = ( const angle = getArrowheadAngle(arrowhead); // Return points - const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180); - const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180); + const [x3, y3] = pointRotateRads( + point(xs, ys), + point(x2, y2), + ((-angle * Math.PI) / 180) as Radians, + ); + const [x4, y4] = pointRotateRads( + point(xs, ys), + point(x2, y2), + degreesToRadians(angle), + ); if (arrowhead === "diamond" || arrowhead === "diamond_outline") { // point opposite to the arrowhead point @@ -621,12 +689,10 @@ export const getArrowheadPoints = ( if (position === "start") { const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; - [ox, oy] = rotate( - x2 + minSize * 2, - y2, - x2, - y2, - Math.atan2(py - y2, px - x2), + [ox, oy] = pointRotateRads( + point(x2 + minSize * 2, y2), + point(x2, y2), + Math.atan2(py - y2, px - x2) as Radians, ); } else { const [px, py] = @@ -634,12 +700,10 @@ export const getArrowheadPoints = ( ? element.points[element.points.length - 2] : [0, 0]; - [ox, oy] = rotate( - x2 - minSize * 2, - y2, - x2, - y2, - Math.atan2(y2 - py, x2 - px), + [ox, oy] = pointRotateRads( + point(x2 - minSize * 2, y2), + point(x2, y2), + Math.atan2(y2 - py, x2 - px) as Radians, ); } @@ -665,7 +729,10 @@ const generateLinearElementShape = ( return "linearPath"; })(); - return generator[method](element.points as Mutable[], options); + return generator[method]( + element.points as Mutable[] as RoughPoint[], + options, + ); }; const getLinearElementRotatedBounds = ( @@ -678,11 +745,9 @@ const getLinearElementRotatedBounds = ( if (element.points.length < 2) { const [pointX, pointY] = element.points[0]; - const [x, y] = rotate( - element.x + pointX, - element.y + pointY, - cx, - cy, + const [x, y] = pointRotateRads( + point(element.x + pointX, element.y + pointY), + point(cx, cy), element.angle, ); @@ -708,8 +773,12 @@ const getLinearElementRotatedBounds = ( const cachedShape = ShapeCache.get(element)?.[0]; const shape = cachedShape ?? generateLinearElementShape(element); const ops = getCurvePathOps(shape); - const transformXY = (x: number, y: number) => - rotate(element.x + x, element.y + y, cx, cy, element.angle); + const transformXY = ([x, y]: GlobalPoint) => + pointRotateRads( + point(element.x + x, element.y + y), + point(cx, cy), + element.angle, + ); const res = getMinMaxXYFromCurvePathOps(ops, transformXY); let coords: Bounds = [res[0], res[1], res[2], res[3]]; if (boundTextElement) { @@ -861,7 +930,10 @@ export const getClosestElementBounds = ( const elementsMap = arrayToMap(elements); elements.forEach((element) => { const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); - const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y); + const distance = pointDistance( + point((x1 + x2) / 2, (y1 + y2) / 2), + point(from.x, from.y), + ); if (distance < minDistance) { minDistance = distance; @@ -916,3 +988,9 @@ export const getVisibleSceneBounds = ({ -scrollY + height / zoom.value, ]; }; + +export const getCenterForBounds = (bounds: Bounds): GlobalPoint => + point( + bounds[0] + (bounds[2] - bounds[0]) / 2, + bounds[1] + (bounds[3] - bounds[1]) / 2, + ); diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index 954326ca0..7eafa7dfa 100644 --- a/packages/excalidraw/element/collision.ts +++ b/packages/excalidraw/element/collision.ts @@ -1,14 +1,11 @@ -import { isPathALoop, isPointWithinBounds } from "../math"; - import type { ElementsMap, ExcalidrawElement, ExcalidrawRectangleElement, } from "./types"; - import { getElementBounds } from "./bounds"; import type { FrameNameBounds } from "../types"; -import type { Polygon, GeometricShape } from "../../utils/geometry/shape"; +import type { GeometricShape } from "../../utils/geometry/shape"; import { getPolygonShape } from "../../utils/geometry/shape"; import { isPointInShape, isPointOnShape } from "../../utils/collision"; import { isTransparent } from "../utils"; @@ -18,7 +15,9 @@ import { isImageElement, isTextElement, } from "./typeChecks"; -import { getBoundTextShape } from "../shapes"; +import { getBoundTextShape, isPathALoop } from "../shapes"; +import type { GlobalPoint, LocalPoint, Polygon } from "../../math"; +import { isPointWithinBounds, point } from "../../math"; export const shouldTestInside = (element: ExcalidrawElement) => { if (element.type === "arrow") { @@ -42,35 +41,36 @@ export const shouldTestInside = (element: ExcalidrawElement) => { return isDraggableFromInside || isImageElement(element); }; -export type HitTestArgs = { +export type HitTestArgs = { x: number; y: number; element: ExcalidrawElement; - shape: GeometricShape; + shape: GeometricShape; threshold?: number; frameNameBound?: FrameNameBounds | null; }; -export const hitElementItself = ({ +export const hitElementItself = ({ x, y, element, shape, threshold = 10, frameNameBound = null, -}: HitTestArgs) => { +}: HitTestArgs) => { let hit = shouldTestInside(element) ? // Since `inShape` tests STRICTLY againt the insides of a shape // we would need `onShape` as well to include the "borders" - isPointInShape([x, y], shape) || isPointOnShape([x, y], shape, threshold) - : isPointOnShape([x, y], shape, threshold); + isPointInShape(point(x, y), shape) || + isPointOnShape(point(x, y), shape, threshold) + : isPointOnShape(point(x, y), shape, threshold); // hit test against a frame's name if (!hit && frameNameBound) { - hit = isPointInShape([x, y], { + hit = isPointInShape(point(x, y), { type: "polygon", data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) - .data as Polygon, + .data as Polygon, }); } @@ -89,11 +89,13 @@ export const hitElementBoundingBox = ( y1 -= tolerance; x2 += tolerance; y2 += tolerance; - return isPointWithinBounds([x1, y1], [x, y], [x2, y2]); + return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2)); }; -export const hitElementBoundingBoxOnly = ( - hitArgs: HitTestArgs, +export const hitElementBoundingBoxOnly = < + Point extends GlobalPoint | LocalPoint, +>( + hitArgs: HitTestArgs, elementsMap: ElementsMap, ) => { return ( @@ -108,10 +110,10 @@ export const hitElementBoundingBoxOnly = ( ); }; -export const hitElementBoundText = ( +export const hitElementBoundText = ( x: number, y: number, - textShape: GeometricShape | null, + textShape: GeometricShape | null, ): boolean => { - return !!textShape && isPointInShape([x, y], textShape); + return !!textShape && isPointInShape(point(x, y), textShape); }; diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index 02288a883..18d78fdbe 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -11,7 +11,6 @@ import type { PointerDownState, } from "../types"; import { getBoundTextElement, getMinTextElementWidth } from "./textElement"; -import { getGridPoint } from "../math"; import type Scene from "../scene/Scene"; import { isArrowElement, @@ -21,6 +20,7 @@ import { } from "./typeChecks"; import { getFontString } from "../utils"; import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; +import { getGridPoint } from "../snapping"; export const dragSelectedElements = ( pointerDownState: PointerDownState, diff --git a/packages/excalidraw/element/flowchart.ts b/packages/excalidraw/element/flowchart.ts index 83850be82..cc174bfa9 100644 --- a/packages/excalidraw/element/flowchart.ts +++ b/packages/excalidraw/element/flowchart.ts @@ -10,7 +10,6 @@ import { import { bindLinearElement } from "./binding"; import { LinearElementEditor } from "./linearElementEditor"; import { newArrowElement, newElement } from "./newElement"; -import { aabbForElement } from "../math"; import type { ElementsMap, ExcalidrawBindableElement, @@ -20,7 +19,7 @@ import type { OrderedExcalidrawElement, } from "./types"; import { KEYS } from "../keys"; -import type { AppState, PendingExcalidrawElements, Point } from "../types"; +import type { AppState, PendingExcalidrawElements } from "../types"; import { mutateElement } from "./mutateElement"; import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame"; import { @@ -30,6 +29,8 @@ import { isFlowchartNodeElement, } from "./typeChecks"; import { invariant } from "../utils"; +import { point, type LocalPoint } from "../../math"; +import { aabbForElement } from "../shapes"; type LinkDirection = "up" | "right" | "down" | "left"; @@ -81,13 +82,14 @@ const getNodeRelatives = ( "not an ExcalidrawBindableElement", ); - const edgePoint: Point = - type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]; + const edgePoint = ( + type === "predecessors" ? el.points[el.points.length - 1] : [0, 0] + ) as Readonly; const heading = headingForPointFromElement(node, aabbForElement(node), [ edgePoint[0] + el.x, edgePoint[1] + el.y, - ]); + ] as Readonly); acc.push({ relative, @@ -419,10 +421,7 @@ const createBindingArrow = ( strokeColor: appState.currentItemStrokeColor, strokeStyle: appState.currentItemStrokeStyle, strokeWidth: appState.currentItemStrokeWidth, - points: [ - [0, 0], - [endX, endY], - ], + points: [point(0, 0), point(endX, endY)], elbowed: true, }); diff --git a/packages/excalidraw/element/heading.ts b/packages/excalidraw/element/heading.ts index a8b3a3fa0..b22316c6a 100644 --- a/packages/excalidraw/element/heading.ts +++ b/packages/excalidraw/element/heading.ts @@ -1,12 +1,18 @@ -import { lineAngle } from "../../utils/geometry/geometry"; -import type { Point, Vector } from "../../utils/geometry/shape"; +import type { + LocalPoint, + GlobalPoint, + Triangle, + Vector, + Radians, +} from "../../math"; import { - getCenterForBounds, - PointInTriangle, - rotatePoint, - scalePointFromOrigin, -} from "../math"; -import type { Bounds } from "./bounds"; + point, + pointRotateRads, + pointScaleFromOrigin, + radiansToDegrees, + triangleIncludesPoint, +} from "../../math"; +import { getCenterForBounds, type Bounds } from "./bounds"; import type { ExcalidrawBindableElement } from "./types"; export const HEADING_RIGHT = [1, 0] as Heading; @@ -15,8 +21,13 @@ export const HEADING_LEFT = [-1, 0] as Heading; export const HEADING_UP = [0, -1] as Heading; export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1]; -export const headingForDiamond = (a: Point, b: Point) => { - const angle = lineAngle([a, b]); +export const headingForDiamond = ( + a: Point, + b: Point, +) => { + const angle = radiansToDegrees( + Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians, + ); if (angle >= 315 || angle < 45) { return HEADING_UP; } else if (angle >= 45 && angle < 135) { @@ -47,56 +58,58 @@ export const compareHeading = (a: Heading, b: Heading) => // Gets the heading for the point by creating a bounding box around the rotated // close fitting bounding box, then creating 4 search cones around the center of // the external bbox. -export const headingForPointFromElement = ( +export const headingForPointFromElement = < + Point extends GlobalPoint | LocalPoint, +>( element: Readonly, aabb: Readonly, - point: Readonly, + p: Readonly, ): Heading => { const SEARCH_CONE_MULTIPLIER = 2; const midPoint = getCenterForBounds(aabb); if (element.type === "diamond") { - if (point[0] < element.x) { + if (p[0] < element.x) { return HEADING_LEFT; - } else if (point[1] < element.y) { + } else if (p[1] < element.y) { return HEADING_UP; - } else if (point[0] > element.x + element.width) { + } else if (p[0] > element.x + element.width) { return HEADING_RIGHT; - } else if (point[1] > element.y + element.height) { + } else if (p[1] > element.y + element.height) { return HEADING_DOWN; } - const top = rotatePoint( - scalePointFromOrigin( - [element.x + element.width / 2, element.y], + const top = pointRotateRads( + pointScaleFromOrigin( + point(element.x + element.width / 2, element.y), midPoint, SEARCH_CONE_MULTIPLIER, ), midPoint, element.angle, ); - const right = rotatePoint( - scalePointFromOrigin( - [element.x + element.width, element.y + element.height / 2], + const right = pointRotateRads( + pointScaleFromOrigin( + point(element.x + element.width, element.y + element.height / 2), midPoint, SEARCH_CONE_MULTIPLIER, ), midPoint, element.angle, ); - const bottom = rotatePoint( - scalePointFromOrigin( - [element.x + element.width / 2, element.y + element.height], + const bottom = pointRotateRads( + pointScaleFromOrigin( + point(element.x + element.width / 2, element.y + element.height), midPoint, SEARCH_CONE_MULTIPLIER, ), midPoint, element.angle, ); - const left = rotatePoint( - scalePointFromOrigin( - [element.x, element.y + element.height / 2], + const left = pointRotateRads( + pointScaleFromOrigin( + point(element.x, element.y + element.height / 2), midPoint, SEARCH_CONE_MULTIPLIER, ), @@ -104,43 +117,62 @@ export const headingForPointFromElement = ( element.angle, ); - if (PointInTriangle(point, top, right, midPoint)) { + if (triangleIncludesPoint([top, right, midPoint] as Triangle, p)) { return headingForDiamond(top, right); - } else if (PointInTriangle(point, right, bottom, midPoint)) { + } else if ( + triangleIncludesPoint([right, bottom, midPoint] as Triangle, p) + ) { return headingForDiamond(right, bottom); - } else if (PointInTriangle(point, bottom, left, midPoint)) { + } else if ( + triangleIncludesPoint([bottom, left, midPoint] as Triangle, p) + ) { return headingForDiamond(bottom, left); } return headingForDiamond(left, top); } - const topLeft = scalePointFromOrigin( - [aabb[0], aabb[1]], + const topLeft = pointScaleFromOrigin( + point(aabb[0], aabb[1]), midPoint, SEARCH_CONE_MULTIPLIER, - ); - const topRight = scalePointFromOrigin( - [aabb[2], aabb[1]], + ) as Point; + const topRight = pointScaleFromOrigin( + point(aabb[2], aabb[1]), midPoint, SEARCH_CONE_MULTIPLIER, - ); - const bottomLeft = scalePointFromOrigin( - [aabb[0], aabb[3]], + ) as Point; + const bottomLeft = pointScaleFromOrigin( + point(aabb[0], aabb[3]), midPoint, SEARCH_CONE_MULTIPLIER, - ); - const bottomRight = scalePointFromOrigin( - [aabb[2], aabb[3]], + ) as Point; + const bottomRight = pointScaleFromOrigin( + point(aabb[2], aabb[3]), midPoint, SEARCH_CONE_MULTIPLIER, - ); + ) as Point; - return PointInTriangle(point, topLeft, topRight, midPoint) + return triangleIncludesPoint( + [topLeft, topRight, midPoint] as Triangle, + p, + ) ? HEADING_UP - : PointInTriangle(point, topRight, bottomRight, midPoint) + : triangleIncludesPoint( + [topRight, bottomRight, midPoint] as Triangle, + p, + ) ? HEADING_RIGHT - : PointInTriangle(point, bottomRight, bottomLeft, midPoint) + : triangleIncludesPoint( + [bottomRight, bottomLeft, midPoint] as Triangle, + p, + ) ? HEADING_DOWN : HEADING_LEFT; }; + +export const flipHeading = (h: Heading): Heading => + [ + h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1, + h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1, + ] as Heading; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index f671e2f2c..7607a2e16 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -11,19 +11,6 @@ import type { FixedPointBinding, SceneElementsMap, } from "./types"; -import { - distance2d, - rotate, - isPathALoop, - getGridPoint, - rotatePoint, - centerPoint, - getControlPointsForBezierCurve, - getBezierXY, - getBezierCurveLength, - mapIntervalToBezierT, - arePointsEqual, -} from "../math"; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import type { Bounds } from "./bounds"; import { @@ -32,7 +19,6 @@ import { getMinMaxXYFromCurvePathOps, } from "./bounds"; import type { - Point, AppState, PointerCoords, InteractiveCanvasAppState, @@ -46,7 +32,7 @@ import { getHoveredElementForBinding, isBindingEnabled, } from "./binding"; -import { toBrandedType, tupleToCoors } from "../utils"; +import { invariant, toBrandedType, tupleToCoors } from "../utils"; import { isBindingElement, isElbowArrow, @@ -60,10 +46,29 @@ import { ShapeCache } from "../scene/ShapeCache"; import type { Store } from "../store"; import { mutateElbowArrow } from "./routing"; import type Scene from "../scene/Scene"; +import type { Radians } from "../../math"; +import { + pointCenter, + point, + pointRotateRads, + pointsEqual, + vector, + type GlobalPoint, + type LocalPoint, + pointDistance, +} from "../../math"; +import { + getBezierCurveLength, + getBezierXY, + getControlPointsForBezierCurve, + isPathALoop, + mapIntervalToBezierT, +} from "../shapes"; +import { getGridPoint } from "../snapping"; const editorMidPointsCache: { version: number | null; - points: (Point | null)[]; + points: (GlobalPoint | null)[]; zoom: number | null; } = { version: null, points: [], zoom: null }; export class LinearElementEditor { @@ -80,7 +85,7 @@ export class LinearElementEditor { lastClickedIsEndPoint: boolean; origin: Readonly<{ x: number; y: number }> | null; segmentMidpoint: { - value: Point | null; + value: GlobalPoint | null; index: number | null; added: boolean; }; @@ -88,7 +93,7 @@ export class LinearElementEditor { /** whether you're dragging a point */ public readonly isDragging: boolean; - public readonly lastUncommittedPoint: Point | null; + public readonly lastUncommittedPoint: LocalPoint | null; public readonly pointerOffset: Readonly<{ x: number; y: number }>; public readonly startBindingElement: | ExcalidrawBindableElement @@ -96,13 +101,13 @@ export class LinearElementEditor { | "keep"; public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; public readonly hoverPointIndex: number; - public readonly segmentMidPointHoveredCoords: Point | null; + public readonly segmentMidPointHoveredCoords: GlobalPoint | null; constructor(element: NonDeleted) { this.elementId = element.id as string & { _brand: "excalidrawLinearElementId"; }; - if (!arePointsEqual(element.points[0], [0, 0])) { + if (!pointsEqual(element.points[0], point(0, 0))) { console.error("Linear element is not normalized", Error().stack); } @@ -280,7 +285,7 @@ export class LinearElementEditor { element, elementsMap, referencePoint, - [scenePointerX, scenePointerY], + point(scenePointerX, scenePointerY), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ); @@ -289,7 +294,10 @@ export class LinearElementEditor { [ { index: selectedIndex, - point: [width + referencePoint[0], height + referencePoint[1]], + point: point( + width + referencePoint[0], + height + referencePoint[1], + ), isDragging: selectedIndex === lastClickedPoint, }, ], @@ -310,7 +318,7 @@ export class LinearElementEditor { LinearElementEditor.movePoints( element, selectedPointsIndices.map((pointIndex) => { - const newPointPosition = + const newPointPosition: LocalPoint = pointIndex === lastClickedPoint ? LinearElementEditor.createPointAt( element, @@ -319,10 +327,10 @@ export class LinearElementEditor { scenePointerY - linearElementEditor.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ) - : ([ + : point( element.points[pointIndex][0] + deltaX, element.points[pointIndex][1] + deltaY, - ] as const); + ); return { index: pointIndex, point: newPointPosition, @@ -515,7 +523,7 @@ export class LinearElementEditor { ); let index = 0; - const midpoints: (Point | null)[] = []; + const midpoints: (GlobalPoint | null)[] = []; while (index < points.length - 1) { if ( LinearElementEditor.isSegmentTooShort( @@ -549,7 +557,7 @@ export class LinearElementEditor { scenePointer: { x: number; y: number }, appState: AppState, elementsMap: ElementsMap, - ) => { + ): GlobalPoint | null => { const { elementId } = linearElementEditor; const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { @@ -579,11 +587,12 @@ export class LinearElementEditor { const existingSegmentMidpointHitCoords = linearElementEditor.segmentMidPointHoveredCoords; if (existingSegmentMidpointHitCoords) { - const distance = distance2d( - existingSegmentMidpointHitCoords[0], - existingSegmentMidpointHitCoords[1], - scenePointer.x, - scenePointer.y, + const distance = pointDistance( + point( + existingSegmentMidpointHitCoords[0], + existingSegmentMidpointHitCoords[1], + ), + point(scenePointer.x, scenePointer.y), ); if (distance <= threshold) { return existingSegmentMidpointHitCoords; @@ -594,11 +603,9 @@ export class LinearElementEditor { LinearElementEditor.getEditorMidPoints(element, elementsMap, appState); while (index < midPoints.length) { if (midPoints[index] !== null) { - const distance = distance2d( - midPoints[index]![0], - midPoints[index]![1], - scenePointer.x, - scenePointer.y, + const distance = pointDistance( + point(midPoints[index]![0], midPoints[index]![1]), + point(scenePointer.x, scenePointer.y), ); if (distance <= threshold) { return midPoints[index]; @@ -612,15 +619,13 @@ export class LinearElementEditor { static isSegmentTooShort( element: NonDeleted, - startPoint: Point, - endPoint: Point, + startPoint: GlobalPoint | LocalPoint, + endPoint: GlobalPoint | LocalPoint, zoom: AppState["zoom"], ) { - let distance = distance2d( - startPoint[0], - startPoint[1], - endPoint[0], - endPoint[1], + let distance = pointDistance( + point(startPoint[0], startPoint[1]), + point(endPoint[0], endPoint[1]), ); if (element.points.length > 2 && element.roundness) { distance = getBezierCurveLength(element, endPoint); @@ -631,12 +636,12 @@ export class LinearElementEditor { static getSegmentMidPoint( element: NonDeleted, - startPoint: Point, - endPoint: Point, + startPoint: GlobalPoint, + endPoint: GlobalPoint, endPointIndex: number, elementsMap: ElementsMap, - ) { - let segmentMidPoint = centerPoint(startPoint, endPoint); + ): GlobalPoint { + let segmentMidPoint = pointCenter(startPoint, endPoint); if (element.points.length > 2 && element.roundness) { const controlPoints = getControlPointsForBezierCurve( element, @@ -649,16 +654,15 @@ export class LinearElementEditor { 0.5, ); - const [tx, ty] = getBezierXY( - controlPoints[0], - controlPoints[1], - controlPoints[2], - controlPoints[3], - t, - ); segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates( element, - [tx, ty], + getBezierXY( + controlPoints[0], + controlPoints[1], + controlPoints[2], + controlPoints[3], + t, + ), elementsMap, ); } @@ -670,7 +674,7 @@ export class LinearElementEditor { static getSegmentMidPointIndex( linearElementEditor: LinearElementEditor, appState: AppState, - midPoint: Point, + midPoint: GlobalPoint, elementsMap: ElementsMap, ) { const element = LinearElementEditor.getElement( @@ -822,11 +826,12 @@ export class LinearElementEditor { const cy = (y1 + y2) / 2; const targetPoint = clickedPointIndex > -1 && - rotate( - element.x + element.points[clickedPointIndex][0], - element.y + element.points[clickedPointIndex][1], - cx, - cy, + pointRotateRads( + point( + element.x + element.points[clickedPointIndex][0], + element.y + element.points[clickedPointIndex][1], + ), + point(cx, cy), element.angle, ); @@ -865,14 +870,17 @@ export class LinearElementEditor { return ret; } - static arePointsEqual(point1: Point | null, point2: Point | null) { + static arePointsEqual( + point1: Point | null, + point2: Point | null, + ) { if (!point1 && !point2) { return true; } if (!point1 || !point2) { return false; } - return arePointsEqual(point1, point2); + return pointsEqual(point1, point2); } static handlePointerMove( @@ -909,7 +917,7 @@ export class LinearElementEditor { }; } - let newPoint: Point; + let newPoint: LocalPoint; if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { const lastCommittedPoint = points[points.length - 2]; @@ -918,14 +926,14 @@ export class LinearElementEditor { element, elementsMap, lastCommittedPoint, - [scenePointerX, scenePointerY], + point(scenePointerX, scenePointerY), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ); - newPoint = [ + newPoint = point( width + lastCommittedPoint[0], height + lastCommittedPoint[1], - ]; + ); } else { newPoint = LinearElementEditor.createPointAt( element, @@ -965,30 +973,36 @@ export class LinearElementEditor { /** scene coords */ static getPointGlobalCoordinates( element: NonDeleted, - point: Point, + p: LocalPoint, elementsMap: ElementsMap, - ) { + ): GlobalPoint { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - let { x, y } = element; - [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle); - return [x, y] as const; + const { x, y } = element; + return pointRotateRads( + point(x + p[0], y + p[1]), + point(cx, cy), + element.angle, + ); } /** scene coords */ static getPointsGlobalCoordinates( element: NonDeleted, elementsMap: ElementsMap, - ): Point[] { + ): GlobalPoint[] { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - return element.points.map((point) => { - let { x, y } = element; - [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle); - return [x, y] as const; + return element.points.map((p) => { + const { x, y } = element; + return pointRotateRads( + point(x + p[0], y + p[1]), + point(cx, cy), + element.angle, + ); }); } @@ -997,7 +1011,7 @@ export class LinearElementEditor { indexMaybeFromEnd: number, // -1 for last element elementsMap: ElementsMap, - ): Point { + ): GlobalPoint { const index = indexMaybeFromEnd < 0 ? element.points.length + indexMaybeFromEnd @@ -1005,35 +1019,36 @@ export class LinearElementEditor { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - - const point = element.points[index]; + const p = element.points[index]; const { x, y } = element; - return point - ? rotate(x + point[0], y + point[1], cx, cy, element.angle) - : rotate(x, y, cx, cy, element.angle); + + return p + ? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle) + : pointRotateRads(point(x, y), point(cx, cy), element.angle); } static pointFromAbsoluteCoords( element: NonDeleted, - absoluteCoords: Point, + absoluteCoords: GlobalPoint, elementsMap: ElementsMap, - ): Point { + ): LocalPoint { if (isElbowArrow(element)) { // No rotation for elbow arrows - return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y]; + return point( + absoluteCoords[0] - element.x, + absoluteCoords[1] - element.y, + ); } const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - const [x, y] = rotate( - absoluteCoords[0], - absoluteCoords[1], - cx, - cy, - -element.angle, + const [x, y] = pointRotateRads( + point(absoluteCoords[0], absoluteCoords[1]), + point(cx, cy), + -element.angle as Radians, ); - return [x - element.x, y - element.y]; + return point(x - element.x, y - element.y); } static getPointIndexUnderCursor( @@ -1052,9 +1067,9 @@ export class LinearElementEditor { // points on the left, thus should take precedence when clicking, if they // overlap while (--idx > -1) { - const point = pointHandles[idx]; + const p = pointHandles[idx]; if ( - distance2d(x, y, point[0], point[1]) * zoom.value < + pointDistance(point(x, y), point(p[0], p[1])) * zoom.value < // +1px to account for outline stroke LinearElementEditor.POINT_HANDLE_SIZE + 1 ) { @@ -1070,20 +1085,18 @@ export class LinearElementEditor { scenePointerX: number, scenePointerY: number, gridSize: NullableGridSize, - ): Point { + ): LocalPoint { const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - const [rotatedX, rotatedY] = rotate( - pointerOnGrid[0], - pointerOnGrid[1], - cx, - cy, - -element.angle, + const [rotatedX, rotatedY] = pointRotateRads( + point(pointerOnGrid[0], pointerOnGrid[1]), + point(cx, cy), + -element.angle as Radians, ); - return [rotatedX - element.x, rotatedY - element.y]; + return point(rotatedX - element.x, rotatedY - element.y); } /** @@ -1091,15 +1104,19 @@ export class LinearElementEditor { * expected in various parts of the codebase. Also returns new x/y to account * for the potential normalization. */ - static getNormalizedPoints(element: ExcalidrawLinearElement) { + static getNormalizedPoints(element: ExcalidrawLinearElement): { + points: LocalPoint[]; + x: number; + y: number; + } { const { points } = element; const offsetX = points[0][0]; const offsetY = points[0][1]; return { - points: points.map((point) => { - return [point[0] - offsetX, point[1] - offsetY] as const; + points: points.map((p) => { + return point(p[0] - offsetX, p[1] - offsetY); }), x: element.x + offsetX, y: element.y + offsetY, @@ -1116,17 +1133,23 @@ export class LinearElementEditor { static duplicateSelectedPoints( appState: AppState, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - ) { - if (!appState.editingLinearElement) { - return false; - } + ): AppState { + invariant( + appState.editingLinearElement, + "Not currently editing a linear element", + ); const { selectedPointsIndices, elementId } = appState.editingLinearElement; const element = LinearElementEditor.getElement(elementId, elementsMap); - if (!element || selectedPointsIndices === null) { - return false; - } + invariant( + element, + "The linear element does not exist in the provided Scene", + ); + invariant( + selectedPointsIndices != null, + "There are no selected points to duplicate", + ); const { points } = element; @@ -1134,9 +1157,9 @@ export class LinearElementEditor { let pointAddedToEnd = false; let indexCursor = -1; - const nextPoints = points.reduce((acc: Point[], point, index) => { + const nextPoints = points.reduce((acc: LocalPoint[], p, index) => { ++indexCursor; - acc.push(point); + acc.push(p); const isSelected = selectedPointsIndices.includes(index); if (isSelected) { @@ -1147,8 +1170,8 @@ export class LinearElementEditor { } acc.push( nextPoint - ? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2] - : [point[0], point[1]], + ? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2) + : point(p[0], p[1]), ); nextSelectedIndices.push(indexCursor + 1); @@ -1169,7 +1192,7 @@ export class LinearElementEditor { [ { index: element.points.length - 1, - point: [lastPoint[0] + 30, lastPoint[1] + 30], + point: point(lastPoint[0] + 30, lastPoint[1] + 30), }, ], elementsMap, @@ -1177,12 +1200,10 @@ export class LinearElementEditor { } return { - appState: { - ...appState, - editingLinearElement: { - ...appState.editingLinearElement, - selectedPointsIndices: nextSelectedIndices, - }, + ...appState, + editingLinearElement: { + ...appState.editingLinearElement, + selectedPointsIndices: nextSelectedIndices, }, }; } @@ -1209,10 +1230,10 @@ export class LinearElementEditor { } } - const nextPoints = element.points.reduce((acc: Point[], point, idx) => { + const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => { if (!pointIndices.includes(idx)) { acc.push( - !acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY], + !acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY), ); } return acc; @@ -1229,7 +1250,7 @@ export class LinearElementEditor { static addPoints( element: NonDeleted, - targetPoints: { point: Point }[], + targetPoints: { point: LocalPoint }[], elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, ) { const offsetX = 0; @@ -1247,7 +1268,7 @@ export class LinearElementEditor { static movePoints( element: NonDeleted, - targetPoints: { index: number; point: Point; isDragging?: boolean }[], + targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[], elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, otherUpdates?: { startBinding?: PointBinding | null; @@ -1277,11 +1298,11 @@ export class LinearElementEditor { selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1]; } - const nextPoints = points.map((point, idx) => { - const selectedPointData = targetPoints.find((p) => p.index === idx); + const nextPoints: LocalPoint[] = points.map((p, idx) => { + const selectedPointData = targetPoints.find((t) => t.index === idx); if (selectedPointData) { if (selectedPointData.index === 0) { - return point; + return p; } const deltaX = @@ -1289,14 +1310,9 @@ export class LinearElementEditor { const deltaY = selectedPointData.point[1] - points[selectedPointData.index][1]; - return [ - point[0] + deltaX - offsetX, - point[1] + deltaY - offsetY, - ] as const; + return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY); } - return offsetX || offsetY - ? ([point[0] - offsetX, point[1] - offsetY] as const) - : point; + return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p; }); LinearElementEditor._updatePoints( @@ -1349,11 +1365,9 @@ export class LinearElementEditor { } const origin = linearElementEditor.pointerDownState.origin!; - const dist = distance2d( - origin.x, - origin.y, - pointerCoords.x, - pointerCoords.y, + const dist = pointDistance( + point(origin.x, origin.y), + point(pointerCoords.x, pointerCoords.y), ); if ( !appState.editingLinearElement && @@ -1418,7 +1432,7 @@ export class LinearElementEditor { private static _updatePoints( element: NonDeleted, - nextPoints: readonly Point[], + nextPoints: readonly LocalPoint[], offsetX: number, offsetY: number, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, @@ -1461,7 +1475,7 @@ export class LinearElementEditor { element, mergedElementsMap, nextPoints, - [offsetX, offsetY], + vector(offsetX, offsetY), bindings, options, ); @@ -1474,7 +1488,11 @@ export class LinearElementEditor { const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2; const dX = prevCenterX - nextCenterX; const dY = prevCenterY - nextCenterY; - const rotated = rotate(offsetX, offsetY, dX, dY, element.angle); + const rotated = pointRotateRads( + point(offsetX, offsetY), + point(dX, dY), + element.angle, + ); mutateElement(element, { ...otherUpdates, points: nextPoints, @@ -1487,8 +1505,8 @@ export class LinearElementEditor { private static _getShiftLockedDelta( element: NonDeleted, elementsMap: ElementsMap, - referencePoint: Point, - scenePointer: Point, + referencePoint: LocalPoint, + scenePointer: GlobalPoint, gridSize: NullableGridSize, ) { const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( @@ -1517,7 +1535,11 @@ export class LinearElementEditor { gridY, ); - return rotatePoint([width, height], [0, 0], -element.angle); + return pointRotateRads( + point(width, height), + point(0, 0), + -element.angle as Radians, + ); } static getBoundTextElementPosition = ( @@ -1548,7 +1570,7 @@ export class LinearElementEditor { let midSegmentMidpoint = editorMidPointsCache.points[index]; if (element.points.length === 2) { - midSegmentMidpoint = centerPoint(points[0], points[1]); + midSegmentMidpoint = pointCenter(points[0], points[1]); } if ( !midSegmentMidpoint || @@ -1585,37 +1607,38 @@ export class LinearElementEditor { ); const boundTextX2 = boundTextX1 + boundTextElement.width; const boundTextY2 = boundTextY1 + boundTextElement.height; + const centerPoint = point(cx, cy); - const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle); - const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle); - - const counterRotateBoundTextTopLeft = rotatePoint( - [boundTextX1, boundTextY1], - - [cx, cy], - - -element.angle, + const topLeftRotatedPoint = pointRotateRads( + point(x1, y1), + centerPoint, + element.angle, ); - const counterRotateBoundTextTopRight = rotatePoint( - [boundTextX2, boundTextY1], - - [cx, cy], - - -element.angle, + const topRightRotatedPoint = pointRotateRads( + point(x2, y1), + centerPoint, + element.angle, ); - const counterRotateBoundTextBottomLeft = rotatePoint( - [boundTextX1, boundTextY2], - [cx, cy], - - -element.angle, + const counterRotateBoundTextTopLeft = pointRotateRads( + point(boundTextX1, boundTextY1), + centerPoint, + -element.angle as Radians, ); - const counterRotateBoundTextBottomRight = rotatePoint( - [boundTextX2, boundTextY2], - - [cx, cy], - - -element.angle, + const counterRotateBoundTextTopRight = pointRotateRads( + point(boundTextX2, boundTextY1), + centerPoint, + -element.angle as Radians, + ); + const counterRotateBoundTextBottomLeft = pointRotateRads( + point(boundTextX1, boundTextY2), + centerPoint, + -element.angle as Radians, + ); + const counterRotateBoundTextBottomRight = pointRotateRads( + point(boundTextX2, boundTextY2), + centerPoint, + -element.angle as Radians, ); if ( diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index de0adeeff..ef84854f9 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -2,7 +2,6 @@ import type { ExcalidrawElement } from "./types"; import Scene from "../scene/Scene"; import { getSizeFromPoints } from "../points"; import { randomInteger } from "../random"; -import type { Point } from "../types"; import { getUpdatedTimestamp } from "../utils"; import type { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; @@ -59,8 +58,8 @@ export const mutateElement = >( let didChangePoints = false; let index = prevPoints.length; while (--index) { - const prevPoint: Point = prevPoints[index]; - const nextPoint: Point = nextPoints[index]; + const prevPoint = prevPoints[index]; + const nextPoint = nextPoints[index]; if ( prevPoint[0] !== nextPoint[0] || prevPoint[1] !== nextPoint[1] diff --git a/packages/excalidraw/element/newElement.test.ts b/packages/excalidraw/element/newElement.test.ts index 3346218b1..770d0d987 100644 --- a/packages/excalidraw/element/newElement.test.ts +++ b/packages/excalidraw/element/newElement.test.ts @@ -4,6 +4,8 @@ import { API } from "../tests/helpers/api"; import { FONT_FAMILY, ROUNDNESS } from "../constants"; import { isPrimitive } from "../utils"; import type { ExcalidrawLinearElement } from "./types"; +import type { LocalPoint } from "../../math"; +import { point } from "../../math"; const assertCloneObjects = (source: any, clone: any) => { for (const key in clone) { @@ -36,10 +38,7 @@ describe("duplicating single elements", () => { element.__proto__ = { hello: "world" }; mutateElement(element, { - points: [ - [1, 2], - [3, 4], - ], + points: [point(1, 2), point(3, 4)], }); const copy = duplicateElement(null, new Map(), element); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 05f722887..a3b259e36 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -30,7 +30,6 @@ import { bumpVersion, newElementWith } from "./mutateElement"; import { getNewGroupIdsForDuplication } from "../groups"; import type { AppState } from "../types"; import { getElementAbsoluteCoords } from "."; -import { adjustXYWithRotation } from "../math"; import { getResizedElementAbsoluteCoords } from "./bounds"; import { measureText, @@ -48,6 +47,7 @@ import { } from "../constants"; import type { MarkOptional, Merge, Mutable } from "../utility-types"; import { getLineHeight } from "../fonts"; +import type { Radians } from "../../math"; export type ElementConstructorOpts = MarkOptional< Omit, @@ -88,7 +88,7 @@ const _newElementBase = ( opacity = DEFAULT_ELEMENT_PROPS.opacity, width = 0, height = 0, - angle = 0, + angle = 0 as Radians, groupIds = [], frameId = null, index = null, @@ -348,6 +348,53 @@ const getAdjustedDimensions = ( }; }; +const adjustXYWithRotation = ( + sides: { + n?: boolean; + e?: boolean; + s?: boolean; + w?: boolean; + }, + x: number, + y: number, + angle: number, + deltaX1: number, + deltaY1: number, + deltaX2: number, + deltaY2: number, +): [number, number] => { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + if (sides.e && sides.w) { + x += deltaX1 + deltaX2; + } else if (sides.e) { + x += deltaX1 * (1 + cos); + y += deltaX1 * sin; + x += deltaX2 * (1 - cos); + y += deltaX2 * -sin; + } else if (sides.w) { + x += deltaX1 * (1 - cos); + y += deltaX1 * -sin; + x += deltaX2 * (1 + cos); + y += deltaX2 * sin; + } + + if (sides.n && sides.s) { + y += deltaY1 + deltaY2; + } else if (sides.n) { + x += deltaY1 * sin; + y += deltaY1 * (1 - cos); + x += deltaY2 * -sin; + y += deltaY2 * (1 + cos); + } else if (sides.s) { + x += deltaY1 * -sin; + y += deltaY1 * (1 + cos); + x += deltaY2 * sin; + y += deltaY2 * (1 - cos); + } + return [x, y]; +}; + export const refreshTextDimensions = ( textElement: ExcalidrawTextElement, container: ExcalidrawTextContainer | null, diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 947e4ed82..3f3f8ef1e 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -1,7 +1,5 @@ import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants"; import { rescalePoints } from "../points"; - -import { rotate, centerPoint, rotatePoint } from "../math"; import type { ExcalidrawLinearElement, ExcalidrawTextElement, @@ -38,7 +36,7 @@ import type { MaybeTransformHandleType, TransformHandleDirection, } from "./transformHandles"; -import type { Point, PointerDownState } from "../types"; +import type { PointerDownState } from "../types"; import Scene from "../scene/Scene"; import { getApproxMinLineWidth, @@ -55,16 +53,15 @@ import { import { LinearElementEditor } from "./linearElementEditor"; import { isInGroup } from "../groups"; import { mutateElbowArrow } from "./routing"; - -export const normalizeAngle = (angle: number): number => { - if (angle < 0) { - return angle + 2 * Math.PI; - } - if (angle >= 2 * Math.PI) { - return angle - 2 * Math.PI; - } - return angle; -}; +import type { GlobalPoint } from "../../math"; +import { + pointCenter, + normalizeRadians, + point, + pointFromPair, + pointRotateRads, + type Radians, +} from "../../math"; // Returns true when transform (resizing/rotation) happened export const transformElements = ( @@ -158,16 +155,17 @@ const rotateSingleElement = ( const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - let angle: number; + let angle: Radians; if (isFrameLikeElement(element)) { - angle = 0; + angle = 0 as Radians; } else { - angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx); + angle = ((5 * Math.PI) / 2 + + Math.atan2(pointerY - cy, pointerX - cx)) as Radians; if (shouldRotateWithDiscreteAngle) { - angle += SHIFT_LOCKING_ANGLE / 2; - angle -= angle % SHIFT_LOCKING_ANGLE; + angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians; + angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians; } - angle = normalizeAngle(angle); + angle = normalizeRadians(angle as Radians); } const boundTextElementId = getBoundTextElementId(element); @@ -240,12 +238,10 @@ const resizeSingleTextElement = ( elementsMap, ); // rotation pointer with reverse angle - const [rotatedX, rotatedY] = rotate( - pointerX, - pointerY, - cx, - cy, - -element.angle, + const [rotatedX, rotatedY] = pointRotateRads( + point(pointerX, pointerY), + point(cx, cy), + -element.angle as Radians, ); let scaleX = 0; let scaleY = 0; @@ -279,20 +275,26 @@ const resizeSingleTextElement = ( const startBottomRight = [x2, y2]; const startCenter = [cx, cy]; - let newTopLeft = [x1, y1] as [number, number]; + let newTopLeft = point(x1, y1); if (["n", "w", "nw"].includes(transformHandleType)) { - newTopLeft = [ + newTopLeft = point( startBottomRight[0] - Math.abs(nextWidth), startBottomRight[1] - Math.abs(nextHeight), - ]; + ); } if (transformHandleType === "ne") { const bottomLeft = [startTopLeft[0], startBottomRight[1]]; - newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)]; + newTopLeft = point( + bottomLeft[0], + bottomLeft[1] - Math.abs(nextHeight), + ); } if (transformHandleType === "sw") { const topRight = [startBottomRight[0], startTopLeft[1]]; - newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]]; + newTopLeft = point( + topRight[0] - Math.abs(nextWidth), + topRight[1], + ); } if (["s", "n"].includes(transformHandleType)) { @@ -308,13 +310,17 @@ const resizeSingleTextElement = ( } const angle = element.angle; - const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle); - const newCenter: Point = [ + const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle); + const newCenter = point( newTopLeft[0] + Math.abs(nextWidth) / 2, newTopLeft[1] + Math.abs(nextHeight) / 2, - ]; - const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle); - newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); + ); + const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle); + newTopLeft = pointRotateRads( + rotatedTopLeft, + rotatedNewCenter, + -angle as Radians, + ); const [nextX, nextY] = newTopLeft; mutateElement(element, { @@ -334,14 +340,14 @@ const resizeSingleTextElement = ( stateAtResizeStart.height, true, ); - const startTopLeft: Point = [x1, y1]; - const startBottomRight: Point = [x2, y2]; - const startCenter: Point = centerPoint(startTopLeft, startBottomRight); + const startTopLeft = point(x1, y1); + const startBottomRight = point(x2, y2); + const startCenter = pointCenter(startTopLeft, startBottomRight); - const rotatedPointer = rotatePoint( - [pointerX, pointerY], + const rotatedPointer = pointRotateRads( + point(pointerX, pointerY), startCenter, - -stateAtResizeStart.angle, + -stateAtResizeStart.angle as Radians, ); const [esx1, , esx2] = getResizedElementAbsoluteCoords( @@ -407,13 +413,21 @@ const resizeSingleTextElement = ( // adjust topLeft to new rotation point const angle = stateAtResizeStart.angle; - const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle); - const newCenter: Point = [ + const rotatedTopLeft = pointRotateRads( + pointFromPair(newTopLeft), + startCenter, + angle, + ); + const newCenter = point( newTopLeft[0] + Math.abs(newBoundsWidth) / 2, newTopLeft[1] + Math.abs(newBoundsHeight) / 2, - ]; - const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle); - newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); + ); + const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle); + newTopLeft = pointRotateRads( + rotatedTopLeft, + rotatedNewCenter, + -angle as Radians, + ); const resizedElement: Partial = { width: Math.abs(newWidth), @@ -446,15 +460,15 @@ export const resizeSingleElement = ( stateAtResizeStart.height, true, ); - const startTopLeft: Point = [x1, y1]; - const startBottomRight: Point = [x2, y2]; - const startCenter: Point = centerPoint(startTopLeft, startBottomRight); + const startTopLeft = point(x1, y1); + const startBottomRight = point(x2, y2); + const startCenter = pointCenter(startTopLeft, startBottomRight); // Calculate new dimensions based on cursor position - const rotatedPointer = rotatePoint( - [pointerX, pointerY], + const rotatedPointer = pointRotateRads( + point(pointerX, pointerY), startCenter, - -stateAtResizeStart.angle, + -stateAtResizeStart.angle as Radians, ); // Get bounds corners rendered on screen @@ -628,13 +642,21 @@ export const resizeSingleElement = ( // adjust topLeft to new rotation point const angle = stateAtResizeStart.angle; - const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle); - const newCenter: Point = [ + const rotatedTopLeft = pointRotateRads( + pointFromPair(newTopLeft), + startCenter, + angle, + ); + const newCenter = point( newTopLeft[0] + Math.abs(newBoundsWidth) / 2, newTopLeft[1] + Math.abs(newBoundsHeight) / 2, - ]; - const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle); - newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); + ); + const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle); + newTopLeft = pointRotateRads( + rotatedTopLeft, + rotatedNewCenter, + -angle as Radians, + ); // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner // So we need to readjust (x,y) to be where the first point should be @@ -793,21 +815,21 @@ export const resizeMultipleElements = ( const direction = transformHandleType; - const anchorsMap: Record = { - ne: [minX, maxY], - se: [minX, minY], - sw: [maxX, minY], - nw: [maxX, maxY], - e: [minX, minY + height / 2], - w: [maxX, minY + height / 2], - n: [minX + width / 2, maxY], - s: [minX + width / 2, minY], + const anchorsMap: Record = { + ne: point(minX, maxY), + se: point(minX, minY), + sw: point(maxX, minY), + nw: point(maxX, maxY), + e: point(minX, minY + height / 2), + w: point(maxX, minY + height / 2), + n: point(minX + width / 2, maxY), + s: point(minX + width / 2, minY), }; // anchor point must be on the opposite side of the dragged selection handle // or be the center of the selection if shouldResizeFromCenter - const [anchorX, anchorY]: Point = shouldResizeFromCenter - ? [midX, midY] + const [anchorX, anchorY] = shouldResizeFromCenter + ? point(midX, midY) : anchorsMap[direction]; const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1; @@ -898,7 +920,9 @@ export const resizeMultipleElements = ( const width = orig.width * scaleX; const height = orig.height * scaleY; - const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY); + const angle = normalizeRadians( + (orig.angle * flipFactorX * flipFactorY) as Radians, + ); const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig); const offsetX = orig.x - anchorX; @@ -1029,12 +1053,10 @@ const rotateMultipleElements = ( const cy = (y1 + y2) / 2; const origAngle = originalElements.get(element.id)?.angle ?? element.angle; - const [rotatedCX, rotatedCY] = rotate( - cx, - cy, - centerX, - centerY, - centerAngle + origAngle - element.angle, + const [rotatedCX, rotatedCY] = pointRotateRads( + point(cx, cy), + point(centerX, centerY), + (centerAngle + origAngle - element.angle) as Radians, ); if (isArrowElement(element) && isElbowArrow(element)) { @@ -1046,7 +1068,7 @@ const rotateMultipleElements = ( { x: element.x + (rotatedCX - cx), y: element.y + (rotatedCY - cy), - angle: normalizeAngle(centerAngle + origAngle), + angle: normalizeRadians((centerAngle + origAngle) as Radians), }, false, ); @@ -1063,7 +1085,7 @@ const rotateMultipleElements = ( { x: boundText.x + (rotatedCX - cx), y: boundText.y + (rotatedCY - cy), - angle: normalizeAngle(centerAngle + origAngle), + angle: normalizeRadians((centerAngle + origAngle) as Radians), }, false, ); @@ -1086,25 +1108,43 @@ export const getResizeOffsetXY = ( : getCommonBounds(selectedElements); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - const angle = selectedElements.length === 1 ? selectedElements[0].angle : 0; - [x, y] = rotate(x, y, cx, cy, -angle); + const angle = ( + selectedElements.length === 1 ? selectedElements[0].angle : 0 + ) as Radians; + [x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians); switch (transformHandleType) { case "n": - return rotate(x - (x1 + x2) / 2, y - y1, 0, 0, angle); + return pointRotateRads( + point(x - (x1 + x2) / 2, y - y1), + point(0, 0), + angle, + ); case "s": - return rotate(x - (x1 + x2) / 2, y - y2, 0, 0, angle); + return pointRotateRads( + point(x - (x1 + x2) / 2, y - y2), + point(0, 0), + angle, + ); case "w": - return rotate(x - x1, y - (y1 + y2) / 2, 0, 0, angle); + return pointRotateRads( + point(x - x1, y - (y1 + y2) / 2), + point(0, 0), + angle, + ); case "e": - return rotate(x - x2, y - (y1 + y2) / 2, 0, 0, angle); + return pointRotateRads( + point(x - x2, y - (y1 + y2) / 2), + point(0, 0), + angle, + ); case "nw": - return rotate(x - x1, y - y1, 0, 0, angle); + return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle); case "ne": - return rotate(x - x2, y - y1, 0, 0, angle); + return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle); case "sw": - return rotate(x - x1, y - y2, 0, 0, angle); + return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle); case "se": - return rotate(x - x2, y - y2, 0, 0, angle); + return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle); default: return [0, 0]; } diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts index 74ebd8e5d..c363f6180 100644 --- a/packages/excalidraw/element/resizeTest.ts +++ b/packages/excalidraw/element/resizeTest.ts @@ -20,13 +20,14 @@ import type { AppState, Device, Zoom } from "../types"; import type { Bounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds"; import { SIDE_RESIZING_THRESHOLD } from "../constants"; -import { - angleToDegrees, - pointOnLine, - pointRotate, -} from "../../utils/geometry/geometry"; -import type { Line, Point } from "../../utils/geometry/shape"; import { isLinearElement } from "./typeChecks"; +import type { GlobalPoint, LineSegment, LocalPoint } from "../../math"; +import { + point, + pointOnLineSegment, + pointRotateRads, + type Radians, +} from "../../math"; const isInsideTransformHandle = ( transformHandle: TransformHandle, @@ -38,7 +39,7 @@ const isInsideTransformHandle = ( y >= transformHandle[1] && y <= transformHandle[1] + transformHandle[3]; -export const resizeTest = ( +export const resizeTest = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, appState: AppState, @@ -91,15 +92,17 @@ export const resizeTest = ( if (!(isLinearElement(element) && element.points.length <= 2)) { const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const sides = getSelectionBorders( - [x1 - SPACING, y1 - SPACING], - [x2 + SPACING, y2 + SPACING], - [cx, cy], - angleToDegrees(element.angle), + point(x1 - SPACING, y1 - SPACING), + point(x2 + SPACING, y2 + SPACING), + point(cx, cy), + element.angle, ); for (const [dir, side] of Object.entries(sides)) { // test to see if x, y are on the line segment - if (pointOnLine([x, y], side as Line, SPACING)) { + if ( + pointOnLineSegment(point(x, y), side as LineSegment, SPACING) + ) { return dir as TransformHandleType; } } @@ -137,7 +140,9 @@ export const getElementWithTransformHandleType = ( }, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null); }; -export const getTransformHandleTypeFromCoords = ( +export const getTransformHandleTypeFromCoords = < + Point extends GlobalPoint | LocalPoint, +>( [x1, y1, x2, y2]: Bounds, scenePointerX: number, scenePointerY: number, @@ -147,7 +152,7 @@ export const getTransformHandleTypeFromCoords = ( ): MaybeTransformHandleType => { const transformHandles = getTransformHandlesFromCoords( [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], - 0, + 0 as Radians, zoom, pointerType, getOmitSidesForDevice(device), @@ -173,15 +178,21 @@ export const getTransformHandleTypeFromCoords = ( const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const sides = getSelectionBorders( - [x1 - SPACING, y1 - SPACING], - [x2 + SPACING, y2 + SPACING], - [cx, cy], - angleToDegrees(0), + point(x1 - SPACING, y1 - SPACING), + point(x2 + SPACING, y2 + SPACING), + point(cx, cy), + 0 as Radians, ); for (const [dir, side] of Object.entries(sides)) { // test to see if x, y are on the line segment - if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) { + if ( + pointOnLineSegment( + point(scenePointerX, scenePointerY), + side as LineSegment, + SPACING, + ) + ) { return dir as TransformHandleType; } } @@ -248,16 +259,16 @@ export const getCursorForResizingElement = (resizingElement: { return cursor ? `${cursor}-resize` : ""; }; -const getSelectionBorders = ( +const getSelectionBorders = ( [x1, y1]: Point, [x2, y2]: Point, center: Point, - angleInDegrees: number, + angle: Radians, ) => { - const topLeft = pointRotate([x1, y1], angleInDegrees, center); - const topRight = pointRotate([x2, y1], angleInDegrees, center); - const bottomLeft = pointRotate([x1, y2], angleInDegrees, center); - const bottomRight = pointRotate([x2, y2], angleInDegrees, center); + const topLeft = pointRotateRads(point(x1, y1), center, angle); + const topRight = pointRotateRads(point(x2, y1), center, angle); + const bottomLeft = pointRotateRads(point(x1, y2), center, angle); + const bottomRight = pointRotateRads(point(x2, y2), center, angle); return { n: [topLeft, topRight], diff --git a/packages/excalidraw/element/routing.test.tsx b/packages/excalidraw/element/routing.test.tsx index f00a52a57..9381541a5 100644 --- a/packages/excalidraw/element/routing.test.tsx +++ b/packages/excalidraw/element/routing.test.tsx @@ -17,6 +17,7 @@ import type { ExcalidrawElbowArrowElement, } from "./types"; import { ARROW_TYPE } from "../constants"; +import { point } from "../../math"; const { h } = window; @@ -31,8 +32,8 @@ describe("elbow arrow routing", () => { }) as ExcalidrawElbowArrowElement; scene.insertElement(arrow); mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [ - [-45 - arrow.x, -100.1 - arrow.y], - [45 - arrow.x, 99.9 - arrow.y], + point(-45 - arrow.x, -100.1 - arrow.y), + point(45 - arrow.x, 99.9 - arrow.y), ]); expect(arrow.points).toEqual([ [0, 0], @@ -68,10 +69,7 @@ describe("elbow arrow routing", () => { y: -100.1, width: 90, height: 200, - points: [ - [0, 0], - [90, 200], - ], + points: [point(0, 0), point(90, 200)], }) as ExcalidrawElbowArrowElement; scene.insertElement(rectangle1); scene.insertElement(rectangle2); @@ -83,10 +81,7 @@ describe("elbow arrow routing", () => { expect(arrow.startBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null); - mutateElbowArrow(arrow, elementsMap, [ - [0, 0], - [90, 200], - ]); + mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]); expect(arrow.points).toEqual([ [0, 0], diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts index bba7f5a9f..07f62ca82 100644 --- a/packages/excalidraw/element/routing.ts +++ b/packages/excalidraw/element/routing.ts @@ -1,16 +1,19 @@ -import { cross } from "../../utils/geometry/geometry"; -import BinaryHeap from "../binaryheap"; +import type { Radians } from "../../math"; import { - aabbForElement, - arePointsEqual, - pointInsideBounds, - pointToVector, - scalePointFromOrigin, - scaleVector, - translatePoint, -} from "../math"; + point, + pointScaleFromOrigin, + pointTranslate, + vector, + vectorCross, + vectorFromPoint, + vectorScale, + type GlobalPoint, + type LocalPoint, + type Vector, +} from "../../math"; +import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; -import type { Point } from "../types"; +import { aabbForElement, pointInsideBounds } from "../shapes"; import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; import { bindPointToSnapToElementOutline, @@ -25,6 +28,8 @@ import { import type { Bounds } from "./bounds"; import type { Heading } from "./heading"; import { + compareHeading, + flipHeading, HEADING_DOWN, HEADING_LEFT, HEADING_RIGHT, @@ -41,6 +46,8 @@ import type { } from "./types"; import type { ElementsMap, ExcalidrawBindableElement } from "./types"; +type GridAddress = [number, number] & { _brand: "gridaddress" }; + type Node = { f: number; g: number; @@ -48,8 +55,8 @@ type Node = { closed: boolean; visited: boolean; parent: Node | null; - pos: Point; - addr: [number, number]; + pos: GlobalPoint; + addr: GridAddress; }; type Grid = { @@ -63,8 +70,8 @@ const BASE_PADDING = 40; export const mutateElbowArrow = ( arrow: ExcalidrawElbowArrowElement, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - nextPoints: readonly Point[], - offset?: Point, + nextPoints: readonly LocalPoint[], + offset?: Vector, otherUpdates?: { startBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null; @@ -75,14 +82,20 @@ export const mutateElbowArrow = ( informMutation?: boolean; }, ) => { - const origStartGlobalPoint = translatePoint(nextPoints[0], [ - arrow.x + (offset ? offset[0] : 0), - arrow.y + (offset ? offset[1] : 0), - ]); - const origEndGlobalPoint = translatePoint(nextPoints[nextPoints.length - 1], [ - arrow.x + (offset ? offset[0] : 0), - arrow.y + (offset ? offset[1] : 0), - ]); + const origStartGlobalPoint: GlobalPoint = pointTranslate( + pointTranslate( + nextPoints[0], + vector(arrow.x, arrow.y), + ), + offset, + ); + const origEndGlobalPoint: GlobalPoint = pointTranslate( + pointTranslate( + nextPoints[nextPoints.length - 1], + vector(arrow.x, arrow.y), + ), + offset, + ); const startElement = arrow.startBinding && @@ -275,7 +288,10 @@ export const mutateElbowArrow = ( ); if (path) { - const points = path.map((node) => [node.pos[0], node.pos[1]]) as Point[]; + const points = path.map((node) => [ + node.pos[0], + node.pos[1], + ]) as GlobalPoint[]; startDongle && points.unshift(startGlobalPoint); endDongle && points.push(endGlobalPoint); @@ -284,7 +300,7 @@ export const mutateElbowArrow = ( { ...otherUpdates, ...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0), - angle: 0, + angle: 0 as Radians, }, options?.informMutation, ); @@ -363,7 +379,7 @@ const astar = ( } // Intersect - const neighborHalfPoint = scalePointFromOrigin( + const neighborHalfPoint = pointScaleFromOrigin( neighbor.pos, current.pos, 0.5, @@ -380,17 +396,17 @@ const astar = ( // We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet. const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3); const previousDirection = current.parent - ? vectorToHeading(pointToVector(current.pos, current.parent.pos)) + ? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos)) : startHeading; // Do not allow going in reverse - const reverseHeading = scaleVector(previousDirection, -1); + const reverseHeading = flipHeading(previousDirection); const neighborIsReverseRoute = - arePointsEqual(reverseHeading, neighborHeading) || - (arePointsEqual(start.addr, neighbor.addr) && - arePointsEqual(neighborHeading, startHeading)) || - (arePointsEqual(end.addr, neighbor.addr) && - arePointsEqual(neighborHeading, endHeading)); + compareHeading(reverseHeading, neighborHeading) || + (gridAddressesEqual(start.addr, neighbor.addr) && + compareHeading(neighborHeading, startHeading)) || + (gridAddressesEqual(end.addr, neighbor.addr) && + compareHeading(neighborHeading, endHeading)); if (neighborIsReverseRoute) { continue; } @@ -444,7 +460,7 @@ const pathTo = (start: Node, node: Node) => { return path; }; -const m_dist = (a: Point, b: Point) => +const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); /** @@ -541,7 +557,12 @@ const generateDynamicAABBs = ( const cX = first[2] + (second[0] - first[2]) / 2; const cY = second[3] + (first[1] - second[3]) / 2; - if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) { + if ( + vectorCross( + vector(a[2] - endCenterX, a[1] - endCenterY), + vector(a[0] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { return [ [first[0], first[1], cX, first[3]], [cX, second[1], second[2], second[3]], @@ -557,7 +578,12 @@ const generateDynamicAABBs = ( const cX = first[2] + (second[0] - first[2]) / 2; const cY = first[3] + (second[1] - first[3]) / 2; - if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) { + if ( + vectorCross( + vector(a[0] - endCenterX, a[1] - endCenterY), + vector(a[2] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { return [ [first[0], first[1], first[2], cY], [second[0], cY, second[2], second[3]], @@ -573,7 +599,12 @@ const generateDynamicAABBs = ( const cX = second[2] + (first[0] - second[2]) / 2; const cY = first[3] + (second[1] - first[3]) / 2; - if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) { + if ( + vectorCross( + vector(a[2] - endCenterX, a[1] - endCenterY), + vector(a[0] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { return [ [cX, first[1], first[2], first[3]], [second[0], second[1], cX, second[3]], @@ -589,7 +620,12 @@ const generateDynamicAABBs = ( const cX = second[2] + (first[0] - second[2]) / 2; const cY = second[3] + (first[1] - second[3]) / 2; - if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) { + if ( + vectorCross( + vector(a[0] - endCenterX, a[1] - endCenterY), + vector(a[2] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { return [ [cX, first[1], first[2], first[3]], [second[0], second[1], cX, second[3]], @@ -615,9 +651,9 @@ const generateDynamicAABBs = ( */ const calculateGrid = ( aabbs: Bounds[], - start: Point, + start: GlobalPoint, startHeading: Heading, - end: Point, + end: GlobalPoint, endHeading: Heading, common: Bounds, ): Grid => { @@ -662,8 +698,8 @@ const calculateGrid = ( closed: false, visited: false, parent: null, - addr: [col, row] as [number, number], - pos: [x, y] as Point, + addr: [col, row] as GridAddress, + pos: [x, y] as GlobalPoint, }), ), ), @@ -673,17 +709,17 @@ const calculateGrid = ( const getDonglePosition = ( bounds: Bounds, heading: Heading, - point: Point, -): Point => { + p: GlobalPoint, +): GlobalPoint => { switch (heading) { case HEADING_UP: - return [point[0], bounds[1]]; + return point(p[0], bounds[1]); case HEADING_RIGHT: - return [bounds[2], point[1]]; + return point(bounds[2], p[1]); case HEADING_DOWN: - return [point[0], bounds[3]]; + return point(p[0], bounds[3]); } - return [bounds[0], point[1]]; + return point(bounds[0], p[1]); }; const estimateSegmentCount = ( @@ -826,7 +862,7 @@ const gridNodeFromAddr = ( /** * Get node for global point on canvas (if exists) */ -const pointToGridNode = (point: Point, grid: Grid): Node | null => { +const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => { for (let col = 0; col < grid.col; col++) { for (let row = 0; row < grid.row; row++) { const candidate = gridNodeFromAddr([col, row], grid); @@ -865,15 +901,24 @@ const getBindableElementForId = ( }; const normalizedArrowElementUpdate = ( - global: Point[], + global: GlobalPoint[], externalOffsetX?: number, externalOffsetY?: number, -) => { +): { + points: LocalPoint[]; + x: number; + y: number; + width: number; + height: number; +} => { const offsetX = global[0][0]; const offsetY = global[0][1]; - const points = global.map( - (point) => [point[0] - offsetX, point[1] - offsetY] as const, + const points = global.map((p) => + pointTranslate( + p, + vectorScale(vectorFromPoint(global[0]), -1), + ), ); return { @@ -885,19 +930,22 @@ const normalizedArrowElementUpdate = ( }; /// If last and current segments have the same heading, skip the middle point -const simplifyElbowArrowPoints = (points: Point[]): Point[] => +const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] => points .slice(2) .reduce( - (result, point) => - arePointsEqual( + (result, p) => + compareHeading( vectorToHeading( - pointToVector(result[result.length - 1], result[result.length - 2]), + vectorFromPoint( + result[result.length - 1], + result[result.length - 2], + ), ), - vectorToHeading(pointToVector(point, result[result.length - 1])), + vectorToHeading(vectorFromPoint(p, result[result.length - 1])), ) - ? [...result.slice(0, -1), point] - : [...result, point], + ? [...result.slice(0, -1), p] + : [...result, p], [points[0] ?? [0, 0], points[1] ?? [1, 0]], ); @@ -915,13 +963,13 @@ const neighborIndexToHeading = (idx: number): Heading => { const getGlobalPoint = ( fixedPointRatio: [number, number] | undefined | null, - initialPoint: Point, - otherPoint: Point, + initialPoint: GlobalPoint, + otherPoint: GlobalPoint, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, boundElement?: ExcalidrawBindableElement | null, hoveredElement?: ExcalidrawBindableElement | null, isDragging?: boolean, -): Point => { +): GlobalPoint => { if (isDragging) { if (hoveredElement) { const snapPoint = getSnapPoint( @@ -956,36 +1004,34 @@ const getGlobalPoint = ( }; const getSnapPoint = ( - point: Point, - otherPoint: Point, + p: GlobalPoint, + otherPoint: GlobalPoint, element: ExcalidrawBindableElement, elementsMap: ElementsMap, ) => bindPointToSnapToElementOutline( - isRectanguloidElement(element) - ? avoidRectangularCorner(element, point) - : point, + isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p, otherPoint, element, elementsMap, ); const getBindPointHeading = ( - point: Point, - otherPoint: Point, + p: GlobalPoint, + otherPoint: GlobalPoint, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, hoveredElement: ExcalidrawBindableElement | null | undefined, - origPoint: Point, + origPoint: GlobalPoint, ) => getHeadingForElbowArrowSnap( - point, + p, otherPoint, hoveredElement, hoveredElement && aabbForElement( hoveredElement, Array(4).fill( - distanceToBindableElement(hoveredElement, point, elementsMap), + distanceToBindableElement(hoveredElement, p, elementsMap), ) as [number, number, number, number], ), elementsMap, @@ -993,8 +1039,8 @@ const getBindPointHeading = ( ); const getHoveredElements = ( - origStartGlobalPoint: Point, - origEndGlobalPoint: Point, + origStartGlobalPoint: GlobalPoint, + origEndGlobalPoint: GlobalPoint, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, ) => { // TODO: Might be a performance bottleneck and the Map type @@ -1018,3 +1064,6 @@ const getHoveredElements = ( ), ]; }; + +const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean => + a[0] === b[0] && a[1] === b[1]; diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx index 7c80e5b54..98063f05b 100644 --- a/packages/excalidraw/element/textWysiwyg.test.tsx +++ b/packages/excalidraw/element/textWysiwyg.test.tsx @@ -19,6 +19,7 @@ import type { import { API } from "../tests/helpers/api"; import { getOriginalContainerHeightFromCache } from "./containerCache"; import { getTextEditor, updateTextEditor } from "../tests/queries/dom"; +import { point } from "../../math"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -41,10 +42,7 @@ describe("textWysiwyg", () => { type: "line", width: 100, height: 0, - points: [ - [0, 0], - [100, 0], - ], + points: [point(0, 0), point(100, 0)], }); const textSize = 20; const text = API.createElement({ diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index 698e0227e..173c9fdc9 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/packages/excalidraw/element/transformHandles.ts @@ -7,7 +7,6 @@ import type { import type { Bounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds"; -import { rotate } from "../math"; import type { Device, InteractiveCanvasAppState, Zoom } from "../types"; import { isElbowArrow, @@ -19,6 +18,8 @@ import { isAndroid, isIOS, } from "../constants"; +import type { Radians } from "../../math"; +import { point, pointRotateRads } from "../../math"; export type TransformHandleDirection = | "n" @@ -91,9 +92,13 @@ const generateTransformHandle = ( height: number, cx: number, cy: number, - angle: number, + angle: Radians, ): TransformHandle => { - const [xx, yy] = rotate(x + width / 2, y + height / 2, cx, cy, angle); + const [xx, yy] = pointRotateRads( + point(x + width / 2, y + height / 2), + point(cx, cy), + angle, + ); return [xx - width / 2, yy - height / 2, width, height]; }; @@ -119,7 +124,7 @@ export const getOmitSidesForDevice = (device: Device) => { export const getTransformHandlesFromCoords = ( [x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number], - angle: number, + angle: Radians, zoom: Zoom, pointerType: PointerType, omitSides: { [T in TransformHandleType]?: boolean } = {}, diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index 78f1a458a..5ba089ab0 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/packages/excalidraw/element/typeChecks.ts @@ -1,6 +1,5 @@ -import type { LineSegment } from "../../utils"; import { ROUNDNESS } from "../constants"; -import type { ElementOrToolType, Point } from "../types"; +import type { ElementOrToolType } from "../types"; import type { MarkNonNullable } from "../utility-types"; import { assertNever } from "../utils"; import type { Bounds } from "./bounds"; @@ -191,7 +190,8 @@ export const isRectangularElement = ( element.type === "iframe" || element.type === "embeddable" || element.type === "frame" || - element.type === "magicframe") + element.type === "magicframe" || + element.type === "freedraw") ); }; @@ -325,10 +325,6 @@ export const isFixedPointBinding = ( return binding.fixedPoint != null; }; -// TODO: Move this to @excalidraw/math -export const isPoint = (point: unknown): point is Point => - Array.isArray(point) && point.length === 2; - // TODO: Move this to @excalidraw/math export const isBounds = (box: unknown): box is Bounds => Array.isArray(box) && @@ -337,10 +333,3 @@ export const isBounds = (box: unknown): box is Bounds => typeof box[1] === "number" && typeof box[2] === "number" && typeof box[3] === "number"; - -// TODO: Move this to @excalidraw/math -export const isLineSegment = (segment: unknown): segment is LineSegment => - Array.isArray(segment) && - segment.length === 2 && - isPoint(segment[0]) && - isPoint(segment[0]); diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 4322cf851..9b0925427 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -1,4 +1,4 @@ -import type { Point } from "../types"; +import type { LocalPoint, Radians } from "../../math"; import type { FONT_FAMILY, ROUNDNESS, @@ -49,7 +49,7 @@ type _ExcalidrawElementBase = Readonly<{ opacity: number; width: number; height: number; - angle: number; + angle: Radians; /** Random integer used to seed shape generation so that the roughjs shape doesn't differ across renders. */ seed: number; @@ -175,6 +175,15 @@ export type ExcalidrawFlowchartNodeElement = | ExcalidrawDiamondElement | ExcalidrawEllipseElement; +export type ExcalidrawRectanguloidElement = + | ExcalidrawRectangleElement + | ExcalidrawImageElement + | ExcalidrawTextElement + | ExcalidrawFreeDrawElement + | ExcalidrawIframeLikeElement + | ExcalidrawFrameLikeElement + | ExcalidrawEmbeddableElement; + /** * ExcalidrawElement should be JSON serializable and (eventually) contain * no computed data. The list of all ExcalidrawElements should be shareable @@ -283,8 +292,8 @@ export type Arrowhead = export type ExcalidrawLinearElement = _ExcalidrawElementBase & Readonly<{ type: "line" | "arrow"; - points: readonly Point[]; - lastCommittedPoint: Point | null; + points: readonly LocalPoint[]; + lastCommittedPoint: LocalPoint | null; startBinding: PointBinding | null; endBinding: PointBinding | null; startArrowhead: Arrowhead | null; @@ -309,10 +318,10 @@ export type ExcalidrawElbowArrowElement = Merge< export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & Readonly<{ type: "freedraw"; - points: readonly Point[]; + points: readonly LocalPoint[]; pressures: readonly number[]; simulatePressure: boolean; - lastCommittedPoint: Point | null; + lastCommittedPoint: LocalPoint | null; }>; export type FileId = string & { _brand: "FileId" }; diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index 469a360ea..fb9a45820 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -11,7 +11,6 @@ import type { NonDeleted, NonDeletedExcalidrawElement, } from "./element/types"; -import { isPointWithinBounds } from "./math"; import { getBoundTextElement, getContainerElement, @@ -30,6 +29,7 @@ import { getElementLineSegments } from "./element/bounds"; import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/"; import { isFrameElement, isFrameLikeElement } from "./element/typeChecks"; import type { ReadonlySetLike } from "./utility-types"; +import { isPointWithinBounds, point } from "../math"; // --------------------------- Frame State ------------------------------------ export const bindElementsToFramesAfterDuplication = ( @@ -159,9 +159,9 @@ export const isCursorInFrame = ( const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap); return isPointWithinBounds( - [fx1, fy1], - [cursorCoords.x, cursorCoords.y], - [fx2, fy2], + point(fx1, fy1), + point(cursorCoords.x, cursorCoords.y), + point(fx2, fy2), ); }; diff --git a/packages/excalidraw/math.test.ts b/packages/excalidraw/math.test.ts deleted file mode 100644 index 0d2342838..000000000 --- a/packages/excalidraw/math.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - isPointOnSymmetricArc, - rangeIntersection, - rangesOverlap, - rotate, -} from "./math"; - -describe("rotate", () => { - it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => { - const x1 = 10; - const y1 = 20; - const x2 = 20; - const y2 = 30; - const angle = Math.PI / 2; - const [rotatedX, rotatedY] = rotate(x1, y1, x2, y2, angle); - expect([rotatedX, rotatedY]).toEqual([30, 20]); - const res2 = rotate(rotatedX, rotatedY, x2, y2, -angle); - expect(res2).toEqual([x1, x2]); - }); -}); - -describe("range overlap", () => { - it("should overlap when range a contains range b", () => { - expect(rangesOverlap([1, 4], [2, 3])).toBe(true); - expect(rangesOverlap([1, 4], [1, 4])).toBe(true); - expect(rangesOverlap([1, 4], [1, 3])).toBe(true); - expect(rangesOverlap([1, 4], [2, 4])).toBe(true); - }); - - it("should overlap when range b contains range a", () => { - expect(rangesOverlap([2, 3], [1, 4])).toBe(true); - expect(rangesOverlap([1, 3], [1, 4])).toBe(true); - expect(rangesOverlap([2, 4], [1, 4])).toBe(true); - }); - - it("should overlap when range a and b intersect", () => { - expect(rangesOverlap([1, 4], [2, 5])).toBe(true); - }); -}); - -describe("range intersection", () => { - it("should intersect completely with itself", () => { - expect(rangeIntersection([1, 4], [1, 4])).toEqual([1, 4]); - }); - - it("should intersect irrespective of order", () => { - expect(rangeIntersection([1, 4], [2, 3])).toEqual([2, 3]); - expect(rangeIntersection([2, 3], [1, 4])).toEqual([2, 3]); - expect(rangeIntersection([1, 4], [3, 5])).toEqual([3, 4]); - expect(rangeIntersection([3, 5], [1, 4])).toEqual([3, 4]); - }); - - it("should intersect at the edge", () => { - expect(rangeIntersection([1, 4], [4, 5])).toEqual([4, 4]); - }); - - it("should not intersect", () => { - expect(rangeIntersection([1, 4], [5, 7])).toEqual(null); - }); -}); - -describe("point on arc", () => { - it("should detect point on simple arc", () => { - expect( - isPointOnSymmetricArc( - { - radius: 1, - startAngle: -Math.PI / 4, - endAngle: Math.PI / 4, - }, - [0.92291667, 0.385], - ), - ).toBe(true); - }); - it("should not detect point outside of a simple arc", () => { - expect( - isPointOnSymmetricArc( - { - radius: 1, - startAngle: -Math.PI / 4, - endAngle: Math.PI / 4, - }, - [-0.92291667, 0.385], - ), - ).toBe(false); - }); - it("should not detect point with good angle but incorrect radius", () => { - expect( - isPointOnSymmetricArc( - { - radius: 1, - startAngle: -Math.PI / 4, - endAngle: Math.PI / 4, - }, - [-0.5, 0.5], - ), - ).toBe(false); - }); -}); diff --git a/packages/excalidraw/math.ts b/packages/excalidraw/math.ts deleted file mode 100644 index 32414efb1..000000000 --- a/packages/excalidraw/math.ts +++ /dev/null @@ -1,715 +0,0 @@ -import type { - NormalizedZoomValue, - NullableGridSize, - Point, - Zoom, -} from "./types"; -import { - DEFAULT_ADAPTIVE_RADIUS, - LINE_CONFIRM_THRESHOLD, - DEFAULT_PROPORTIONAL_RADIUS, - ROUNDNESS, -} from "./constants"; -import type { - ExcalidrawElement, - ExcalidrawLinearElement, - NonDeleted, -} from "./element/types"; -import type { Bounds } from "./element/bounds"; -import { getCurvePathOps } from "./element/bounds"; -import type { Mutable } from "./utility-types"; -import { ShapeCache } from "./scene/ShapeCache"; -import type { Vector } from "../utils/geometry/shape"; - -export const rotate = ( - // target point to rotate - x: number, - y: number, - // point to rotate against - cx: number, - cy: number, - angle: number, -): [number, number] => - // 𝑎′𝑥=(𝑎𝑥−𝑐𝑥)cos𝜃−(𝑎𝑦−𝑐𝑦)sin𝜃+𝑐𝑥 - // 𝑎′𝑦=(𝑎𝑥−𝑐𝑥)sin𝜃+(𝑎𝑦−𝑐𝑦)cos𝜃+𝑐𝑦. - // https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line - [ - (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx, - (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy, - ]; - -export const rotatePoint = ( - point: Point, - center: Point, - angle: number, -): [number, number] => rotate(point[0], point[1], center[0], center[1], angle); - -export const adjustXYWithRotation = ( - sides: { - n?: boolean; - e?: boolean; - s?: boolean; - w?: boolean; - }, - x: number, - y: number, - angle: number, - deltaX1: number, - deltaY1: number, - deltaX2: number, - deltaY2: number, -): [number, number] => { - const cos = Math.cos(angle); - const sin = Math.sin(angle); - if (sides.e && sides.w) { - x += deltaX1 + deltaX2; - } else if (sides.e) { - x += deltaX1 * (1 + cos); - y += deltaX1 * sin; - x += deltaX2 * (1 - cos); - y += deltaX2 * -sin; - } else if (sides.w) { - x += deltaX1 * (1 - cos); - y += deltaX1 * -sin; - x += deltaX2 * (1 + cos); - y += deltaX2 * sin; - } - - if (sides.n && sides.s) { - y += deltaY1 + deltaY2; - } else if (sides.n) { - x += deltaY1 * sin; - y += deltaY1 * (1 - cos); - x += deltaY2 * -sin; - y += deltaY2 * (1 + cos); - } else if (sides.s) { - x += deltaY1 * -sin; - y += deltaY1 * (1 + cos); - x += deltaY2 * sin; - y += deltaY2 * (1 - cos); - } - return [x, y]; -}; - -export const getPointOnAPath = (point: Point, path: Point[]) => { - const [px, py] = point; - const [start, ...other] = path; - let [lastX, lastY] = start; - let kLine: number = 0; - let idx: number = 0; - - // if any item in the array is true, it means that a point is - // on some segment of a line based path - const retVal = other.some(([x2, y2], i) => { - // we always take a line when dealing with line segments - const x1 = lastX; - const y1 = lastY; - - lastX = x2; - lastY = y2; - - // if a point is not within the domain of the line segment - // it is not on the line segment - if (px < x1 || px > x2) { - return false; - } - - // check if all points lie on the same line - // y1 = kx1 + b, y2 = kx2 + b - // y2 - y1 = k(x2 - x2) -> k = (y2 - y1) / (x2 - x1) - - // coefficient for the line (p0, p1) - const kL = (y2 - y1) / (x2 - x1); - - // coefficient for the line segment (p0, point) - const kP1 = (py - y1) / (px - x1); - - // coefficient for the line segment (point, p1) - const kP2 = (py - y2) / (px - x2); - - // because we are basing both lines from the same starting point - // the only option for collinearity is having same coefficients - - // using it for floating point comparisons - const epsilon = 0.3; - - // if coefficient is more than an arbitrary epsilon, - // these lines are nor collinear - if (Math.abs(kP1 - kL) > epsilon && Math.abs(kP2 - kL) > epsilon) { - return false; - } - - // store the coefficient because we are goint to need it - kLine = kL; - idx = i; - - return true; - }); - - // Return a coordinate that is always on the line segment - if (retVal === true) { - return { x: point[0], y: kLine * point[0], segment: idx }; - } - - return null; -}; - -export const distance2d = (x1: number, y1: number, x2: number, y2: number) => { - const xd = x2 - x1; - const yd = y2 - y1; - return Math.hypot(xd, yd); -}; - -export const distanceSq2d = (p1: Point, p2: Point) => { - const xd = p2[0] - p1[0]; - const yd = p2[1] - p1[1]; - return xd * xd + yd * yd; -}; - -export const centerPoint = (a: Point, b: Point): Point => { - return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; -}; - -// Checks if the first and last point are close enough -// to be considered a loop -export const isPathALoop = ( - points: ExcalidrawLinearElement["points"], - /** supply if you want the loop detection to account for current zoom */ - zoomValue: Zoom["value"] = 1 as NormalizedZoomValue, -): boolean => { - if (points.length >= 3) { - const [first, last] = [points[0], points[points.length - 1]]; - const distance = distance2d(first[0], first[1], last[0], last[1]); - - // Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in - // really close we make the threshold smaller, and vice versa. - return distance <= LINE_CONFIRM_THRESHOLD / zoomValue; - } - return false; -}; - -// Draw a line from the point to the right till infiinty -// Check how many lines of the polygon does this infinite line intersects with -// If the number of intersections is odd, point is in the polygon -export const isPointInPolygon = ( - points: Point[], - x: number, - y: number, -): boolean => { - const vertices = points.length; - - // There must be at least 3 vertices in polygon - if (vertices < 3) { - return false; - } - const extreme: Point = [Number.MAX_SAFE_INTEGER, y]; - const p: Point = [x, y]; - let count = 0; - for (let i = 0; i < vertices; i++) { - const current = points[i]; - const next = points[(i + 1) % vertices]; - if (doSegmentsIntersect(current, next, p, extreme)) { - if (orderedColinearOrientation(current, p, next) === 0) { - return isPointWithinBounds(current, p, next); - } - count++; - } - } - // true if count is off - return count % 2 === 1; -}; - -// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`. -// This is an approximation to "does `q` lie on a segment `pr`" check. -export const isPointWithinBounds = (p: Point, q: Point, r: Point) => { - return ( - q[0] <= Math.max(p[0], r[0]) && - q[0] >= Math.min(p[0], r[0]) && - q[1] <= Math.max(p[1], r[1]) && - q[1] >= Math.min(p[1], r[1]) - ); -}; - -// For the ordered points p, q, r, return -// 0 if p, q, r are colinear -// 1 if Clockwise -// 2 if counterclickwise -const orderedColinearOrientation = (p: Point, q: Point, r: Point) => { - const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]); - if (val === 0) { - return 0; - } - return val > 0 ? 1 : 2; -}; - -// Check is p1q1 intersects with p2q2 -const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => { - const o1 = orderedColinearOrientation(p1, q1, p2); - const o2 = orderedColinearOrientation(p1, q1, q2); - const o3 = orderedColinearOrientation(p2, q2, p1); - const o4 = orderedColinearOrientation(p2, q2, q1); - - if (o1 !== o2 && o3 !== o4) { - return true; - } - - // p1, q1 and p2 are colinear and p2 lies on segment p1q1 - if (o1 === 0 && isPointWithinBounds(p1, p2, q1)) { - return true; - } - - // p1, q1 and p2 are colinear and q2 lies on segment p1q1 - if (o2 === 0 && isPointWithinBounds(p1, q2, q1)) { - return true; - } - - // p2, q2 and p1 are colinear and p1 lies on segment p2q2 - if (o3 === 0 && isPointWithinBounds(p2, p1, q2)) { - return true; - } - - // p2, q2 and q1 are colinear and q1 lies on segment p2q2 - if (o4 === 0 && isPointWithinBounds(p2, q1, q2)) { - return true; - } - - return false; -}; - -// TODO: Rounding this point causes some shake when free drawing -export const getGridPoint = ( - x: number, - y: number, - gridSize: NullableGridSize, -): [number, number] => { - if (gridSize) { - return [ - Math.round(x / gridSize) * gridSize, - Math.round(y / gridSize) * gridSize, - ]; - } - return [x, y]; -}; - -export const getCornerRadius = (x: number, element: ExcalidrawElement) => { - if ( - element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS || - element.roundness?.type === ROUNDNESS.LEGACY - ) { - return x * DEFAULT_PROPORTIONAL_RADIUS; - } - - if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) { - const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS; - - const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS; - - if (x <= CUTOFF_SIZE) { - return x * DEFAULT_PROPORTIONAL_RADIUS; - } - - return fixedRadiusSize; - } - - return 0; -}; - -export const getControlPointsForBezierCurve = ( - element: NonDeleted, - endPoint: Point, -) => { - const shape = ShapeCache.generateElementShape(element, null); - if (!shape) { - return null; - } - - const ops = getCurvePathOps(shape[0]); - let currentP: Mutable = [0, 0]; - let index = 0; - let minDistance = Infinity; - let controlPoints: Mutable[] | null = null; - - while (index < ops.length) { - const { op, data } = ops[index]; - if (op === "move") { - currentP = data as unknown as Mutable; - } - if (op === "bcurveTo") { - const p0 = currentP; - const p1 = [data[0], data[1]] as Mutable; - const p2 = [data[2], data[3]] as Mutable; - const p3 = [data[4], data[5]] as Mutable; - const distance = distance2d(p3[0], p3[1], endPoint[0], endPoint[1]); - if (distance < minDistance) { - minDistance = distance; - controlPoints = [p0, p1, p2, p3]; - } - currentP = p3; - } - index++; - } - - return controlPoints; -}; - -export const getBezierXY = ( - p0: Point, - p1: Point, - p2: Point, - p3: Point, - t: number, -) => { - 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 tx = equation(t, 0); - const ty = equation(t, 1); - return [tx, ty]; -}; - -export const getPointsInBezierCurve = ( - element: NonDeleted, - endPoint: Point, -) => { - const controlPoints: Mutable[] = getControlPointsForBezierCurve( - element, - endPoint, - )!; - if (!controlPoints) { - return []; - } - const pointsOnCurve: Mutable[] = []; - let t = 1; - // Take 20 points on curve for better accuracy - while (t > 0) { - const point = getBezierXY( - controlPoints[0], - controlPoints[1], - controlPoints[2], - controlPoints[3], - t, - ); - pointsOnCurve.push([point[0], point[1]]); - t -= 0.05; - } - if (pointsOnCurve.length) { - if (arePointsEqual(pointsOnCurve.at(-1)!, endPoint)) { - pointsOnCurve.push([endPoint[0], endPoint[1]]); - } - } - return pointsOnCurve; -}; - -export const getBezierCurveArcLengths = ( - element: NonDeleted, - endPoint: Point, -) => { - const arcLengths: number[] = []; - arcLengths[0] = 0; - const points = getPointsInBezierCurve(element, endPoint); - let index = 0; - let distance = 0; - while (index < points.length - 1) { - const segmentDistance = distance2d( - points[index][0], - points[index][1], - points[index + 1][0], - points[index + 1][1], - ); - distance += segmentDistance; - arcLengths.push(distance); - index++; - } - - return arcLengths; -}; - -export const getBezierCurveLength = ( - element: NonDeleted, - endPoint: Point, -) => { - const arcLengths = getBezierCurveArcLengths(element, endPoint); - return arcLengths.at(-1) as number; -}; - -// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length -export const mapIntervalToBezierT = ( - element: NonDeleted, - endPoint: Point, - interval: number, // The interval between 0 to 1 for which you want to find the point on the curve, -) => { - const arcLengths = getBezierCurveArcLengths(element, endPoint); - const pointsCount = arcLengths.length - 1; - const curveLength = arcLengths.at(-1) as number; - const targetLength = interval * curveLength; - let low = 0; - let high = pointsCount; - let index = 0; - // Doing a binary search to find the largest length that is less than the target length - while (low < high) { - index = Math.floor(low + (high - low) / 2); - if (arcLengths[index] < targetLength) { - low = index + 1; - } else { - high = index; - } - } - if (arcLengths[index] > targetLength) { - index--; - } - if (arcLengths[index] === targetLength) { - return index / pointsCount; - } - - return ( - 1 - - (index + - (targetLength - arcLengths[index]) / - (arcLengths[index + 1] - arcLengths[index])) / - pointsCount - ); -}; - -export const arePointsEqual = (p1: Point, p2: Point) => { - return p1[0] === p2[0] && p1[1] === p2[1]; -}; - -export const isRightAngle = (angle: number) => { - // if our angles were mathematically accurate, we could just check - // - // angle % (Math.PI / 2) === 0 - // - // but since we're in floating point land, we need to round. - // - // Below, after dividing by Math.PI, a multiple of 0.5 indicates a right - // angle, which we can check with modulo after rounding. - return Math.round((angle / Math.PI) * 10000) % 5000 === 0; -}; - -export const radianToDegree = (r: number) => { - return (r * 180) / Math.PI; -}; - -export const degreeToRadian = (d: number) => { - return (d / 180) * Math.PI; -}; - -// Given two ranges, return if the two ranges overlap with each other -// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5] -export const rangesOverlap = ( - [a0, a1]: [number, number], - [b0, b1]: [number, number], -) => { - if (a0 <= b0) { - return a1 >= b0; - } - - if (a0 >= b0) { - return b1 >= a0; - } - - return false; -}; - -// Given two ranges,return ther intersection of the two ranges if any -// e.g. the intersection of [1, 3] and [2, 4] is [2, 3] -export const rangeIntersection = ( - rangeA: [number, number], - rangeB: [number, number], -): [number, number] | null => { - const rangeStart = Math.max(rangeA[0], rangeB[0]); - const rangeEnd = Math.min(rangeA[1], rangeB[1]); - - if (rangeStart <= rangeEnd) { - return [rangeStart, rangeEnd]; - } - - return null; -}; - -export const isValueInRange = (value: number, min: number, max: number) => { - return value >= min && value <= max; -}; - -export const translatePoint = (p: Point, v: Vector): Point => [ - p[0] + v[0], - p[1] + v[1], -]; - -export const scaleVector = (v: Vector, scalar: number): Vector => [ - v[0] * scalar, - v[1] * scalar, -]; - -export const pointToVector = (p: Point, origin: Point = [0, 0]): Vector => [ - p[0] - origin[0], - p[1] - origin[1], -]; - -export const scalePointFromOrigin = ( - p: Point, - mid: Point, - multiplier: number, -) => translatePoint(mid, scaleVector(pointToVector(p, mid), multiplier)); - -const triangleSign = (p1: Point, p2: Point, p3: Point): number => - (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]); - -export const PointInTriangle = (pt: Point, v1: Point, v2: Point, v3: Point) => { - const d1 = triangleSign(pt, v1, v2); - const d2 = triangleSign(pt, v2, v3); - const d3 = triangleSign(pt, v3, v1); - - const has_neg = d1 < 0 || d2 < 0 || d3 < 0; - const has_pos = d1 > 0 || d2 > 0 || d3 > 0; - - return !(has_neg && has_pos); -}; - -export const magnitudeSq = (vector: Vector) => - vector[0] * vector[0] + vector[1] * vector[1]; - -export const magnitude = (vector: Vector) => Math.sqrt(magnitudeSq(vector)); - -export const normalize = (vector: Vector): Vector => { - const m = magnitude(vector); - - return [vector[0] / m, vector[1] / m]; -}; - -export const addVectors = ( - vec1: Readonly, - vec2: Readonly, -): Vector => [vec1[0] + vec2[0], vec1[1] + vec2[1]]; - -export const subtractVectors = ( - vec1: Readonly, - vec2: Readonly, -): Vector => [vec1[0] - vec2[0], vec1[1] - vec2[1]]; - -export const pointInsideBounds = (p: Point, bounds: Bounds): boolean => - p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; - -/** - * Get the axis-aligned bounding box for a given element - */ -export const aabbForElement = ( - element: Readonly, - offset?: [number, number, number, number], -) => { - const bbox = { - minX: element.x, - minY: element.y, - maxX: element.x + element.width, - maxY: element.y + element.height, - midX: element.x + element.width / 2, - midY: element.y + element.height / 2, - }; - - const center = [bbox.midX, bbox.midY] as Point; - const [topLeftX, topLeftY] = rotatePoint( - [bbox.minX, bbox.minY], - center, - element.angle, - ); - const [topRightX, topRightY] = rotatePoint( - [bbox.maxX, bbox.minY], - center, - element.angle, - ); - const [bottomRightX, bottomRightY] = rotatePoint( - [bbox.maxX, bbox.maxY], - center, - element.angle, - ); - const [bottomLeftX, bottomLeftY] = rotatePoint( - [bbox.minX, bbox.maxY], - center, - element.angle, - ); - - const bounds = [ - Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX), - Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY), - Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX), - Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY), - ] as Bounds; - - if (offset) { - const [topOffset, rightOffset, downOffset, leftOffset] = offset; - return [ - bounds[0] - leftOffset, - bounds[1] - topOffset, - bounds[2] + rightOffset, - bounds[3] + downOffset, - ] as Bounds; - } - - return bounds; -}; - -type PolarCoords = [number, number]; - -/** - * Return the polar coordinates for the given carthesian point represented by - * (x, y) for the center point 0,0 where the first number returned is the radius, - * the second is the angle in radians. - */ -export const carthesian2Polar = ([x, y]: Point): PolarCoords => [ - Math.hypot(x, y), - Math.atan2(y, x), -]; - -/** - * Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle - * corresponds to (1, 0) carthesian coordinates (point), i.e. to the "right". - */ -type SymmetricArc = { radius: number; startAngle: number; endAngle: number }; - -/** - * Determines if a carthesian point lies on a symmetric arc, i.e. an arc which - * is part of a circle contour centered on 0, 0. - */ -export const isPointOnSymmetricArc = ( - { radius: arcRadius, startAngle, endAngle }: SymmetricArc, - point: Point, -): boolean => { - const [radius, angle] = carthesian2Polar(point); - - return startAngle < endAngle - ? Math.abs(radius - arcRadius) < 0.0000001 && - startAngle <= angle && - endAngle >= angle - : startAngle <= angle || endAngle >= angle; -}; - -export const getCenterForBounds = (bounds: Bounds): Point => [ - bounds[0] + (bounds[2] - bounds[0]) / 2, - bounds[1] + (bounds[3] - bounds[1]) / 2, -]; - -export const getCenterForElement = (element: ExcalidrawElement): Point => [ - element.x + element.width / 2, - element.y + element.height / 2, -]; - -export const aabbsOverlapping = (a: Bounds, b: Bounds) => - pointInsideBounds([a[0], a[1]], b) || - pointInsideBounds([a[2], a[1]], b) || - pointInsideBounds([a[2], a[3]], b) || - pointInsideBounds([a[0], a[3]], b) || - pointInsideBounds([b[0], b[1]], a) || - pointInsideBounds([b[2], b[1]], a) || - pointInsideBounds([b[2], b[3]], a) || - pointInsideBounds([b[0], b[3]], a); - -export const clamp = (value: number, min: number, max: number) => { - return Math.min(Math.max(value, min), max); -}; - -export const round = (value: number, precision: number) => { - const multiplier = Math.pow(10, precision); - return Math.round((value + Number.EPSILON) * multiplier) / multiplier; -}; diff --git a/packages/excalidraw/points.ts b/packages/excalidraw/points.ts index e7ff2c47a..5f9480120 100644 --- a/packages/excalidraw/points.ts +++ b/packages/excalidraw/points.ts @@ -1,6 +1,8 @@ -import type { Point } from "./types"; +import { pointFromPair, type GlobalPoint, type LocalPoint } from "../math"; -export const getSizeFromPoints = (points: readonly Point[]) => { +export const getSizeFromPoints = ( + points: readonly (GlobalPoint | LocalPoint)[], +) => { const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); return { @@ -10,7 +12,7 @@ export const getSizeFromPoints = (points: readonly Point[]) => { }; /** @arg dimension, 0 for rescaling only x, 1 for y */ -export const rescalePoints = ( +export const rescalePoints = ( dimension: 0 | 1, newSize: number, points: readonly Point[], @@ -31,7 +33,7 @@ export const rescalePoints = ( if (newCoordinate < nextMinCoordinate) { nextMinCoordinate = newCoordinate; } - return newPoint as unknown as Point; + return newPoint as Point; }); if (!normalize) { @@ -45,11 +47,13 @@ export const rescalePoints = ( const translation = minCoordinate - nextMinCoordinate; - const nextPoints = scaledPoints.map( - (scaledPoint) => + const nextPoints = scaledPoints.map((scaledPoint) => + pointFromPair( scaledPoint.map((value, currentDimension) => { return currentDimension === dimension ? value + translation : value; }) as [number, number], + ), ); + return nextPoints; }; diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 597ab0696..5a27a3312 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -30,7 +30,7 @@ import { shouldShowBoundingBox, } from "../element/transformHandles"; import { arrayToMap, throttleRAF } from "../utils"; -import type { InteractiveCanvasAppState, Point } from "../types"; +import type { InteractiveCanvasAppState } from "../types"; import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants"; import { renderSnaps } from "../renderer/renderSnaps"; @@ -69,7 +69,8 @@ import type { InteractiveSceneRenderConfig, RenderableElementsMap, } from "../scene/types"; -import { getCornerRadius } from "../math"; +import type { GlobalPoint, LocalPoint, Radians } from "../../math"; +import { getCornerRadius } from "../shapes"; const renderLinearElementPointHighlight = ( context: CanvasRenderingContext2D, @@ -101,7 +102,7 @@ const renderLinearElementPointHighlight = ( context.restore(); }; -const highlightPoint = ( +const highlightPoint = ( point: Point, context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -168,7 +169,7 @@ const strokeDiamondWithRotation = ( context.restore(); }; -const renderSingleLinearPoint = ( +const renderSingleLinearPoint = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, point: Point, @@ -499,7 +500,7 @@ const renderLinearPointHandles = ( element, elementsMap, appState, - ).filter((midPoint) => midPoint !== null) as Point[]; + ).filter((midPoint): midPoint is GlobalPoint => midPoint !== null); midPoints.forEach((segmentMidPoint) => { if ( @@ -931,7 +932,7 @@ const _renderInteractiveScene = ({ context.setLineDash(initialLineDash); const transformHandles = getTransformHandlesFromCoords( [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], - 0, + 0 as Radians, appState.zoom, "mouse", isFrameSelected diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 42473fcdc..9995c748a 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -27,7 +27,6 @@ import type { InteractiveCanvasRenderConfig, } from "../scene/types"; import { distance, getFontString, isRTL } from "../utils"; -import { getCornerRadius, isRightAngle } from "../math"; import rough from "roughjs/bin/rough"; import type { AppState, @@ -60,6 +59,8 @@ import { LinearElementEditor } from "../element/linearElementEditor"; import { getContainingFrame } from "../frame"; import { ShapeCache } from "../scene/ShapeCache"; import { getVerticalOffset } from "../fonts"; +import { isRightAngleRads } from "../../math"; +import { getCornerRadius } from "../shapes"; // using a stronger invert (100% vs our regular 93%) and saturate // as a temp hack to make images in dark theme look closer to original @@ -907,7 +908,8 @@ export const renderElement = ( (!element.angle || // or check if angle is a right angle in which case we can still // disable smoothing without adversely affecting the result - isRightAngle(element.angle)) + // We need less-than comparison because of FP artihmetic + isRightAngleRads(element.angle)) ) { // Disabling smoothing makes output much sharper, especially for // text. Unless for non-right angles, where the aliasing is really diff --git a/packages/excalidraw/renderer/renderSnaps.ts b/packages/excalidraw/renderer/renderSnaps.ts index 190a72904..33b57ce68 100644 --- a/packages/excalidraw/renderer/renderSnaps.ts +++ b/packages/excalidraw/renderer/renderSnaps.ts @@ -1,6 +1,7 @@ +import { point, type GlobalPoint, type LocalPoint } from "../../math"; import { THEME } from "../constants"; import type { PointSnapLine, PointerSnapLine } from "../snapping"; -import type { InteractiveCanvasAppState, Point } from "../types"; +import type { InteractiveCanvasAppState } from "../types"; const SNAP_COLOR_LIGHT = "#ff6b6b"; const SNAP_COLOR_DARK = "#ff0000"; @@ -85,7 +86,7 @@ const drawPointerSnapLine = ( } }; -const drawCross = ( +const drawCross = ( [x, y]: Point, appState: InteractiveCanvasAppState, context: CanvasRenderingContext2D, @@ -106,18 +107,18 @@ const drawCross = ( context.restore(); }; -const drawLine = ( +const drawLine = ( from: Point, to: Point, context: CanvasRenderingContext2D, ) => { context.beginPath(); - context.lineTo(...from); - context.lineTo(...to); + context.lineTo(from[0], from[1]); + context.lineTo(to[0], to[1]); context.stroke(); }; -const drawGapLine = ( +const drawGapLine = ( from: Point, to: Point, direction: "horizontal" | "vertical", @@ -138,24 +139,28 @@ const drawGapLine = ( const halfPoint = [(from[0] + to[0]) / 2, from[1]]; // (1) if (!appState.zenModeEnabled) { - drawLine([from[0], from[1] - FULL], [from[0], from[1] + FULL], context); + drawLine( + point(from[0], from[1] - FULL), + point(from[0], from[1] + FULL), + context, + ); } // (3) drawLine( - [halfPoint[0] - QUARTER, halfPoint[1] - HALF], - [halfPoint[0] - QUARTER, halfPoint[1] + HALF], + point(halfPoint[0] - QUARTER, halfPoint[1] - HALF), + point(halfPoint[0] - QUARTER, halfPoint[1] + HALF), context, ); drawLine( - [halfPoint[0] + QUARTER, halfPoint[1] - HALF], - [halfPoint[0] + QUARTER, halfPoint[1] + HALF], + point(halfPoint[0] + QUARTER, halfPoint[1] - HALF), + point(halfPoint[0] + QUARTER, halfPoint[1] + HALF), context, ); if (!appState.zenModeEnabled) { // (4) - drawLine([to[0], to[1] - FULL], [to[0], to[1] + FULL], context); + drawLine(point(to[0], to[1] - FULL), point(to[0], to[1] + FULL), context); // (2) drawLine(from, to, context); @@ -164,24 +169,28 @@ const drawGapLine = ( const halfPoint = [from[0], (from[1] + to[1]) / 2]; // (1) if (!appState.zenModeEnabled) { - drawLine([from[0] - FULL, from[1]], [from[0] + FULL, from[1]], context); + drawLine( + point(from[0] - FULL, from[1]), + point(from[0] + FULL, from[1]), + context, + ); } // (3) drawLine( - [halfPoint[0] - HALF, halfPoint[1] - QUARTER], - [halfPoint[0] + HALF, halfPoint[1] - QUARTER], + point(halfPoint[0] - HALF, halfPoint[1] - QUARTER), + point(halfPoint[0] + HALF, halfPoint[1] - QUARTER), context, ); drawLine( - [halfPoint[0] - HALF, halfPoint[1] + QUARTER], - [halfPoint[0] + HALF, halfPoint[1] + QUARTER], + point(halfPoint[0] - HALF, halfPoint[1] + QUARTER), + point(halfPoint[0] + HALF, halfPoint[1] + QUARTER), context, ); if (!appState.zenModeEnabled) { // (4) - drawLine([to[0] - FULL, to[1]], [to[0] + FULL, to[1]], context); + drawLine(point(to[0] - FULL, to[1]), point(to[0] + FULL, to[1]), context); // (2) drawLine(from, to, context); diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index 19c48ee05..19169d4a9 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -30,13 +30,13 @@ import type { NonDeletedExcalidrawElement, } from "../element/types"; import { getContainingFrame } from "../frame"; -import { getCornerRadius, isPathALoop } from "../math"; import { ShapeCache } from "../scene/ShapeCache"; import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types"; import type { AppState, BinaryFiles } from "../types"; import { getFontFamilyString, isRTL, isTestEnv } from "../utils"; import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; import { getVerticalOffset } from "../fonts"; +import { getCornerRadius, isPathALoop } from "../shapes"; const roughSVGDrawWithPrecision = ( rsvg: RoughSVG, diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts index 4bc92f9c7..fad0f4f93 100644 --- a/packages/excalidraw/scene/Shape.ts +++ b/packages/excalidraw/scene/Shape.ts @@ -1,3 +1,4 @@ +import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Drawable, Options } from "roughjs/bin/core"; import type { RoughGenerator } from "roughjs/bin/generator"; import { getDiamondPoints, getArrowheadPoints } from "../element"; @@ -9,7 +10,6 @@ import type { ExcalidrawLinearElement, Arrowhead, } from "../element/types"; -import { isPathALoop, getCornerRadius, distanceSq2d } from "../math"; import { generateFreeDrawShape } from "../renderer/renderElement"; import { isTransparent, assertNever } from "../utils"; import { simplify } from "points-on-curve"; @@ -23,6 +23,13 @@ import { } from "../element/typeChecks"; import { canChangeRoundness } from "./comparisons"; import type { EmbedsValidationStatus } from "../types"; +import { + point, + pointDistance, + type GlobalPoint, + type LocalPoint, +} from "../../math"; +import { getCornerRadius, isPathALoop } from "../shapes"; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; @@ -399,12 +406,14 @@ export const _generateElementShape = ( // points array can be empty in the beginning, so it is important to add // initial position to it - const points = element.points.length ? element.points : [[0, 0]]; + const points = element.points.length + ? element.points + : [point(0, 0)]; if (isElbowArrow(element)) { shape = [ generator.path( - generateElbowArrowShape(points as [number, number][], 16), + generateElbowArrowShape(points, 16), generateRoughOptions(element, true), ), ]; @@ -412,12 +421,16 @@ export const _generateElementShape = ( // curve is always the first element // this simplifies finding the curve for an element if (options.fill) { - shape = [generator.polygon(points as [number, number][], options)]; + shape = [ + generator.polygon(points as unknown as RoughPoint[], options), + ]; } else { - shape = [generator.linearPath(points as [number, number][], options)]; + shape = [ + generator.linearPath(points as unknown as RoughPoint[], options), + ]; } } else { - shape = [generator.curve(points as [number, number][], options)]; + shape = [generator.curve(points as unknown as RoughPoint[], options)]; } // add lines only in arrow @@ -491,8 +504,8 @@ export const _generateElementShape = ( } }; -const generateElbowArrowShape = ( - points: [number, number][], +const generateElbowArrowShape = ( + points: readonly Point[], radius: number, ) => { const subpoints = [] as [number, number][]; @@ -501,8 +514,8 @@ const generateElbowArrowShape = ( const next = points[i + 1]; const corner = Math.min( radius, - Math.sqrt(distanceSq2d(points[i], next)) / 2, - Math.sqrt(distanceSq2d(points[i], prev)) / 2, + pointDistance(points[i], next) / 2, + pointDistance(points[i], prev) / 2, ); if (prev[0] < points[i][0] && prev[1] === points[i][1]) { diff --git a/packages/excalidraw/scene/normalize.ts b/packages/excalidraw/scene/normalize.ts index 11f68ca9b..a6980c91d 100644 --- a/packages/excalidraw/scene/normalize.ts +++ b/packages/excalidraw/scene/normalize.ts @@ -1,5 +1,5 @@ +import { clamp, round } from "../../math"; import { MAX_ZOOM, MIN_ZOOM } from "../constants"; -import { clamp, round } from "../math"; import type { NormalizedZoomValue } from "../types"; export const getNormalizedZoom = (zoom: number): NormalizedZoomValue => { diff --git a/packages/excalidraw/shapes.tsx b/packages/excalidraw/shapes.tsx index acdca238d..2c935145c 100644 --- a/packages/excalidraw/shapes.tsx +++ b/packages/excalidraw/shapes.tsx @@ -1,5 +1,16 @@ +import { + isPoint, + point, + pointDistance, + pointFromPair, + pointRotateRads, + pointsEqual, + type GlobalPoint, + type LocalPoint, +} from "../math"; import { getClosedCurveShape, + getCurvePathOps, getCurveShape, getEllipseShape, getFreedrawShape, @@ -18,13 +29,27 @@ import { SelectionIcon, TextIcon, } from "./components/icons"; +import { + DEFAULT_ADAPTIVE_RADIUS, + DEFAULT_PROPORTIONAL_RADIUS, + LINE_CONFIRM_THRESHOLD, + ROUNDNESS, +} from "./constants"; import { getElementAbsoluteCoords } from "./element"; +import type { Bounds } from "./element/bounds"; import { shouldTestInside } from "./element/collision"; import { LinearElementEditor } from "./element/linearElementEditor"; import { getBoundTextElement } from "./element/textElement"; -import type { ElementsMap, ExcalidrawElement } from "./element/types"; +import type { + ElementsMap, + ExcalidrawElement, + ExcalidrawLinearElement, + NonDeleted, +} from "./element/types"; import { KEYS } from "./keys"; import { ShapeCache } from "./scene/ShapeCache"; +import type { NormalizedZoomValue, Zoom } from "./types"; +import { invariant } from "./utils"; export const SHAPES = [ { @@ -116,10 +141,10 @@ export const findShapeByKey = (key: string) => { * get the pure geometric shape of an excalidraw element * which is then used for hit detection */ -export const getElementShape = ( +export const getElementShape = ( element: ExcalidrawElement, elementsMap: ElementsMap, -): GeometricShape => { +): GeometricShape => { switch (element.type) { case "rectangle": case "diamond": @@ -139,17 +164,19 @@ export const getElementShape = ( const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); return shouldTestInside(element) - ? getClosedCurveShape( + ? getClosedCurveShape( element, roughShape, - [element.x, element.y], + point(element.x, element.y), element.angle, - [cx, cy], + point(cx, cy), ) - : getCurveShape(roughShape, [element.x, element.y], element.angle, [ - cx, - cy, - ]); + : getCurveShape( + roughShape, + point(element.x, element.y), + element.angle, + point(cx, cy), + ); } case "ellipse": @@ -157,15 +184,19 @@ export const getElementShape = ( case "freedraw": { const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); - return getFreedrawShape(element, [cx, cy], shouldTestInside(element)); + return getFreedrawShape( + element, + point(cx, cy), + shouldTestInside(element), + ); } } }; -export const getBoundTextShape = ( +export const getBoundTextShape = ( element: ExcalidrawElement, elementsMap: ElementsMap, -): GeometricShape | null => { +): GeometricShape | null => { const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { @@ -189,3 +220,274 @@ export const getBoundTextShape = ( return null; }; + +export const getControlPointsForBezierCurve = < + P extends GlobalPoint | LocalPoint, +>( + element: NonDeleted, + endPoint: P, +) => { + const shape = ShapeCache.generateElementShape(element, null); + if (!shape) { + return null; + } + + const ops = getCurvePathOps(shape[0]); + let currentP = point

(0, 0); + let index = 0; + let minDistance = Infinity; + let controlPoints: P[] | null = null; + + while (index < ops.length) { + const { op, data } = ops[index]; + if (op === "move") { + invariant( + isPoint(data), + "The returned ops is not compatible with a point", + ); + currentP = pointFromPair(data); + } + if (op === "bcurveTo") { + const p0 = currentP; + const p1 = point

(data[0], data[1]); + const p2 = point

(data[2], data[3]); + const p3 = point

(data[4], data[5]); + const distance = pointDistance(p3, endPoint); + if (distance < minDistance) { + minDistance = distance; + controlPoints = [p0, p1, p2, p3]; + } + currentP = p3; + } + index++; + } + + return controlPoints; +}; + +export const getBezierXY =

( + p0: P, + p1: P, + p2: P, + p3: P, + t: number, +): P => { + 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 tx = equation(t, 0); + const ty = equation(t, 1); + return point(tx, ty); +}; + +const getPointsInBezierCurve =

( + element: NonDeleted, + endPoint: P, +) => { + const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!; + if (!controlPoints) { + return []; + } + const pointsOnCurve: P[] = []; + let t = 1; + // Take 20 points on curve for better accuracy + while (t > 0) { + const p = getBezierXY( + controlPoints[0], + controlPoints[1], + controlPoints[2], + controlPoints[3], + t, + ); + pointsOnCurve.push(point(p[0], p[1])); + t -= 0.05; + } + if (pointsOnCurve.length) { + if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) { + pointsOnCurve.push(point(endPoint[0], endPoint[1])); + } + } + return pointsOnCurve; +}; + +const getBezierCurveArcLengths =

( + element: NonDeleted, + endPoint: P, +) => { + const arcLengths: number[] = []; + arcLengths[0] = 0; + const points = getPointsInBezierCurve(element, endPoint); + let index = 0; + let distance = 0; + while (index < points.length - 1) { + const segmentDistance = pointDistance(points[index], points[index + 1]); + distance += segmentDistance; + arcLengths.push(distance); + index++; + } + + return arcLengths; +}; + +export const getBezierCurveLength =

( + element: NonDeleted, + endPoint: P, +) => { + const arcLengths = getBezierCurveArcLengths(element, endPoint); + return arcLengths.at(-1) as number; +}; + +// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length +export const mapIntervalToBezierT =

( + element: NonDeleted, + endPoint: P, + interval: number, // The interval between 0 to 1 for which you want to find the point on the curve, +) => { + const arcLengths = getBezierCurveArcLengths(element, endPoint); + const pointsCount = arcLengths.length - 1; + const curveLength = arcLengths.at(-1) as number; + const targetLength = interval * curveLength; + let low = 0; + let high = pointsCount; + let index = 0; + // Doing a binary search to find the largest length that is less than the target length + while (low < high) { + index = Math.floor(low + (high - low) / 2); + if (arcLengths[index] < targetLength) { + low = index + 1; + } else { + high = index; + } + } + if (arcLengths[index] > targetLength) { + index--; + } + if (arcLengths[index] === targetLength) { + return index / pointsCount; + } + + return ( + 1 - + (index + + (targetLength - arcLengths[index]) / + (arcLengths[index + 1] - arcLengths[index])) / + pointsCount + ); +}; + +/** + * Get the axis-aligned bounding box for a given element + */ +export const aabbForElement = ( + element: Readonly, + offset?: [number, number, number, number], +) => { + const bbox = { + minX: element.x, + minY: element.y, + maxX: element.x + element.width, + maxY: element.y + element.height, + midX: element.x + element.width / 2, + midY: element.y + element.height / 2, + }; + + const center = point(bbox.midX, bbox.midY); + const [topLeftX, topLeftY] = pointRotateRads( + point(bbox.minX, bbox.minY), + center, + element.angle, + ); + const [topRightX, topRightY] = pointRotateRads( + point(bbox.maxX, bbox.minY), + center, + element.angle, + ); + const [bottomRightX, bottomRightY] = pointRotateRads( + point(bbox.maxX, bbox.maxY), + center, + element.angle, + ); + const [bottomLeftX, bottomLeftY] = pointRotateRads( + point(bbox.minX, bbox.maxY), + center, + element.angle, + ); + + const bounds = [ + Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX), + Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY), + Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX), + Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY), + ] as Bounds; + + if (offset) { + const [topOffset, rightOffset, downOffset, leftOffset] = offset; + return [ + bounds[0] - leftOffset, + bounds[1] - topOffset, + bounds[2] + rightOffset, + bounds[3] + downOffset, + ] as Bounds; + } + + return bounds; +}; + +export const pointInsideBounds =

( + p: P, + bounds: Bounds, +): boolean => + p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; + +export const aabbsOverlapping = (a: Bounds, b: Bounds) => + pointInsideBounds(point(a[0], a[1]), b) || + pointInsideBounds(point(a[2], a[1]), b) || + pointInsideBounds(point(a[2], a[3]), b) || + pointInsideBounds(point(a[0], a[3]), b) || + pointInsideBounds(point(b[0], b[1]), a) || + pointInsideBounds(point(b[2], b[1]), a) || + pointInsideBounds(point(b[2], b[3]), a) || + pointInsideBounds(point(b[0], b[3]), a); + +export const getCornerRadius = (x: number, element: ExcalidrawElement) => { + if ( + element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS || + element.roundness?.type === ROUNDNESS.LEGACY + ) { + return x * DEFAULT_PROPORTIONAL_RADIUS; + } + + if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) { + const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS; + + const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS; + + if (x <= CUTOFF_SIZE) { + return x * DEFAULT_PROPORTIONAL_RADIUS; + } + + return fixedRadiusSize; + } + + return 0; +}; + +// Checks if the first and last point are close enough +// to be considered a loop +export const isPathALoop = ( + points: ExcalidrawLinearElement["points"], + /** supply if you want the loop detection to account for current zoom */ + zoomValue: Zoom["value"] = 1 as NormalizedZoomValue, +): boolean => { + if (points.length >= 3) { + const [first, last] = [points[0], points[points.length - 1]]; + const distance = pointDistance(first, last); + + // Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in + // really close we make the threshold smaller, and vice versa. + return distance <= LINE_CONFIRM_THRESHOLD / zoomValue; + } + return false; +}; diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index ee19c648b..9da3d74c4 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -1,3 +1,12 @@ +import type { InclusiveRange } from "../math"; +import { + point, + pointRotateRads, + rangeInclusive, + rangeIntersection, + rangesOverlap, + type GlobalPoint, +} from "../math"; import { TOOL_TYPE } from "./constants"; import type { Bounds } from "./element/bounds"; import { @@ -14,7 +23,6 @@ import type { } from "./element/types"; import { getMaximumGroups } from "./groups"; import { KEYS } from "./keys"; -import { rangeIntersection, rangesOverlap, rotatePoint } from "./math"; import { getSelectedElements, getVisibleAndNonSelectedElements, @@ -23,7 +31,7 @@ import type { AppClassProperties, AppState, KeyboardModifiersObject, - Point, + NullableGridSize, } from "./types"; const SNAP_DISTANCE = 8; @@ -42,7 +50,7 @@ type Vector2D = { y: number; }; -type PointPair = [Point, Point]; +type PointPair = [GlobalPoint, GlobalPoint]; export type PointSnap = { type: "point"; @@ -62,9 +70,9 @@ export type Gap = { // ↑ end side startBounds: Bounds; endBounds: Bounds; - startSide: [Point, Point]; - endSide: [Point, Point]; - overlap: [number, number]; + startSide: [GlobalPoint, GlobalPoint]; + endSide: [GlobalPoint, GlobalPoint]; + overlap: InclusiveRange; length: number; }; @@ -88,7 +96,7 @@ export type Snaps = Snap[]; export type PointSnapLine = { type: "points"; - points: Point[]; + points: GlobalPoint[]; }; export type PointerSnapLine = { @@ -108,14 +116,14 @@ export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine; // ----------------------------------------------------------------------------- export class SnapCache { - private static referenceSnapPoints: Point[] | null = null; + private static referenceSnapPoints: GlobalPoint[] | null = null; private static visibleGaps: { verticalGaps: Gap[]; horizontalGaps: Gap[]; } | null = null; - public static setReferenceSnapPoints = (snapPoints: Point[] | null) => { + public static setReferenceSnapPoints = (snapPoints: GlobalPoint[] | null) => { SnapCache.referenceSnapPoints = snapPoints; }; @@ -191,8 +199,8 @@ export const getElementsCorners = ( omitCenter: false, boundingBoxCorners: false, }, -): Point[] => { - let result: Point[] = []; +): GlobalPoint[] => { + let result: GlobalPoint[] = []; if (elements.length === 1) { const element = elements[0]; @@ -219,33 +227,53 @@ export const getElementsCorners = ( (element.type === "diamond" || element.type === "ellipse") && !boundingBoxCorners ) { - const leftMid = rotatePoint( - [x1, y1 + halfHeight], - [cx, cy], + const leftMid = pointRotateRads( + point(x1, y1 + halfHeight), + point(cx, cy), element.angle, ); - const topMid = rotatePoint([x1 + halfWidth, y1], [cx, cy], element.angle); - const rightMid = rotatePoint( - [x2, y1 + halfHeight], - [cx, cy], + const topMid = pointRotateRads( + point(x1 + halfWidth, y1), + point(cx, cy), element.angle, ); - const bottomMid = rotatePoint( - [x1 + halfWidth, y2], - [cx, cy], + const rightMid = pointRotateRads( + point(x2, y1 + halfHeight), + point(cx, cy), element.angle, ); - const center: Point = [cx, cy]; + const bottomMid = pointRotateRads( + point(x1 + halfWidth, y2), + point(cx, cy), + element.angle, + ); + const center = point(cx, cy); result = omitCenter ? [leftMid, topMid, rightMid, bottomMid] : [leftMid, topMid, rightMid, bottomMid, center]; } else { - const topLeft = rotatePoint([x1, y1], [cx, cy], element.angle); - const topRight = rotatePoint([x2, y1], [cx, cy], element.angle); - const bottomLeft = rotatePoint([x1, y2], [cx, cy], element.angle); - const bottomRight = rotatePoint([x2, y2], [cx, cy], element.angle); - const center: Point = [cx, cy]; + const topLeft = pointRotateRads( + point(x1, y1), + point(cx, cy), + element.angle, + ); + const topRight = pointRotateRads( + point(x2, y1), + point(cx, cy), + element.angle, + ); + const bottomLeft = pointRotateRads( + point(x1, y2), + point(cx, cy), + element.angle, + ); + const bottomRight = pointRotateRads( + point(x2, y2), + point(cx, cy), + element.angle, + ); + const center = point(cx, cy); result = omitCenter ? [topLeft, topRight, bottomLeft, bottomRight] @@ -259,18 +287,18 @@ export const getElementsCorners = ( const width = maxX - minX; const height = maxY - minY; - const topLeft: Point = [minX, minY]; - const topRight: Point = [maxX, minY]; - const bottomLeft: Point = [minX, maxY]; - const bottomRight: Point = [maxX, maxY]; - const center: Point = [minX + width / 2, minY + height / 2]; + const topLeft = point(minX, minY); + const topRight = point(maxX, minY); + const bottomLeft = point(minX, maxY); + const bottomRight = point(maxX, maxY); + const center = point(minX + width / 2, minY + height / 2); result = omitCenter ? [topLeft, topRight, bottomLeft, bottomRight] : [topLeft, topRight, bottomLeft, bottomRight, center]; } - return result.map((point) => [round(point[0]), round(point[1])] as Point); + return result.map((p) => point(round(p[0]), round(p[1]))); }; const getReferenceElements = ( @@ -339,23 +367,20 @@ export const getVisibleGaps = ( if ( startMaxX < endMinX && - rangesOverlap([startMinY, startMaxY], [endMinY, endMaxY]) + rangesOverlap( + rangeInclusive(startMinY, startMaxY), + rangeInclusive(endMinY, endMaxY), + ) ) { horizontalGaps.push({ startBounds, endBounds, - startSide: [ - [startMaxX, startMinY], - [startMaxX, startMaxY], - ], - endSide: [ - [endMinX, endMinY], - [endMinX, endMaxY], - ], + startSide: [point(startMaxX, startMinY), point(startMaxX, startMaxY)], + endSide: [point(endMinX, endMinY), point(endMinX, endMaxY)], length: endMinX - startMaxX, overlap: rangeIntersection( - [startMinY, startMaxY], - [endMinY, endMaxY], + rangeInclusive(startMinY, startMaxY), + rangeInclusive(endMinY, endMaxY), )!, }); } @@ -382,23 +407,20 @@ export const getVisibleGaps = ( if ( startMaxY < endMinY && - rangesOverlap([startMinX, startMaxX], [endMinX, endMaxX]) + rangesOverlap( + rangeInclusive(startMinX, startMaxX), + rangeInclusive(endMinX, endMaxX), + ) ) { verticalGaps.push({ startBounds, endBounds, - startSide: [ - [startMinX, startMaxY], - [startMaxX, startMaxY], - ], - endSide: [ - [endMinX, endMinY], - [endMaxX, endMinY], - ], + startSide: [point(startMinX, startMaxY), point(startMaxX, startMaxY)], + endSide: [point(endMinX, endMinY), point(endMaxX, endMinY)], length: endMinY - startMaxY, overlap: rangeIntersection( - [startMinX, startMaxX], - [endMinX, endMaxX], + rangeInclusive(startMinX, startMaxX), + rangeInclusive(endMinX, endMaxX), )!, }); } @@ -441,7 +463,7 @@ const getGapSnaps = ( const centerY = (minY + maxY) / 2; for (const gap of horizontalGaps) { - if (!rangesOverlap([minY, maxY], gap.overlap)) { + if (!rangesOverlap(rangeInclusive(minY, maxY), gap.overlap)) { continue; } @@ -510,7 +532,7 @@ const getGapSnaps = ( } } for (const gap of verticalGaps) { - if (!rangesOverlap([minX, maxX], gap.overlap)) { + if (!rangesOverlap(rangeInclusive(minX, maxX), gap.overlap)) { continue; } @@ -603,7 +625,7 @@ export const getReferenceSnapPoints = ( const getPointSnaps = ( selectedElements: ExcalidrawElement[], - selectionSnapPoints: Point[], + selectionSnapPoints: GlobalPoint[], app: AppClassProperties, event: KeyboardModifiersObject, nearestSnapsX: Snaps, @@ -779,8 +801,8 @@ const round = (x: number) => { return Math.round(x * 10 ** decimalPlaces) / 10 ** decimalPlaces; }; -const dedupePoints = (points: Point[]): Point[] => { - const map = new Map(); +const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => { + const map = new Map(); for (const point of points) { const key = point.join(","); @@ -797,8 +819,8 @@ const createPointSnapLines = ( nearestSnapsX: Snaps, nearestSnapsY: Snaps, ): PointSnapLine[] => { - const snapsX = {} as { [key: string]: Point[] }; - const snapsY = {} as { [key: string]: Point[] }; + const snapsX = {} as { [key: string]: GlobalPoint[] }; + const snapsY = {} as { [key: string]: GlobalPoint[] }; if (nearestSnapsX.length > 0) { for (const snap of nearestSnapsX) { @@ -809,8 +831,8 @@ const createPointSnapLines = ( snapsX[key] = []; } snapsX[key].push( - ...snap.points.map( - (point) => [round(point[0]), round(point[1])] as Point, + ...snap.points.map((p) => + point(round(p[0]), round(p[1])), ), ); } @@ -826,8 +848,8 @@ const createPointSnapLines = ( snapsY[key] = []; } snapsY[key].push( - ...snap.points.map( - (point) => [round(point[0]), round(point[1])] as Point, + ...snap.points.map((p) => + point(round(p[0]), round(p[1])), ), ); } @@ -840,8 +862,8 @@ const createPointSnapLines = ( type: "points", points: dedupePoints( points - .map((point) => { - return [Number(key), point[1]] as Point; + .map((p) => { + return point(Number(key), p[1]); }) .sort((a, b) => a[1] - b[1]), ), @@ -853,8 +875,8 @@ const createPointSnapLines = ( type: "points", points: dedupePoints( points - .map((point) => { - return [point[0], Number(key)] as Point; + .map((p) => { + return point(p[0], Number(key)); }) .sort((a, b) => a[0] - b[0]), ), @@ -898,12 +920,12 @@ const createGapSnapLines = ( const [endMinX, endMinY, endMaxX, endMaxY] = gapSnap.gap.endBounds; const verticalIntersection = rangeIntersection( - [minY, maxY], + rangeInclusive(minY, maxY), gapSnap.gap.overlap, ); const horizontalGapIntersection = rangeIntersection( - [minX, maxX], + rangeInclusive(minX, maxX), gapSnap.gap.overlap, ); @@ -918,16 +940,16 @@ const createGapSnapLines = ( type: "gap", direction: "horizontal", points: [ - [gapSnap.gap.startSide[0][0], gapLineY], - [minX, gapLineY], + point(gapSnap.gap.startSide[0][0], gapLineY), + point(minX, gapLineY), ], }, { type: "gap", direction: "horizontal", points: [ - [maxX, gapLineY], - [gapSnap.gap.endSide[0][0], gapLineY], + point(maxX, gapLineY), + point(gapSnap.gap.endSide[0][0], gapLineY), ], }, ); @@ -944,16 +966,16 @@ const createGapSnapLines = ( type: "gap", direction: "vertical", points: [ - [gapLineX, gapSnap.gap.startSide[0][1]], - [gapLineX, minY], + point(gapLineX, gapSnap.gap.startSide[0][1]), + point(gapLineX, minY), ], }, { type: "gap", direction: "vertical", points: [ - [gapLineX, maxY], - [gapLineX, gapSnap.gap.endSide[0][1]], + point(gapLineX, maxY), + point(gapLineX, gapSnap.gap.endSide[0][1]), ], }, ); @@ -969,18 +991,12 @@ const createGapSnapLines = ( { type: "gap", direction: "horizontal", - points: [ - [startMaxX, gapLineY], - [endMinX, gapLineY], - ], + points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)], }, { type: "gap", direction: "horizontal", - points: [ - [endMaxX, gapLineY], - [minX, gapLineY], - ], + points: [point(endMaxX, gapLineY), point(minX, gapLineY)], }, ); } @@ -995,18 +1011,12 @@ const createGapSnapLines = ( { type: "gap", direction: "horizontal", - points: [ - [maxX, gapLineY], - [startMinX, gapLineY], - ], + points: [point(maxX, gapLineY), point(startMinX, gapLineY)], }, { type: "gap", direction: "horizontal", - points: [ - [startMaxX, gapLineY], - [endMinX, gapLineY], - ], + points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)], }, ); } @@ -1021,18 +1031,12 @@ const createGapSnapLines = ( { type: "gap", direction: "vertical", - points: [ - [gapLineX, maxY], - [gapLineX, startMinY], - ], + points: [point(gapLineX, maxY), point(gapLineX, startMinY)], }, { type: "gap", direction: "vertical", - points: [ - [gapLineX, startMaxY], - [gapLineX, endMinY], - ], + points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)], }, ); } @@ -1047,18 +1051,12 @@ const createGapSnapLines = ( { type: "gap", direction: "vertical", - points: [ - [gapLineX, startMaxY], - [gapLineX, endMinY], - ], + points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)], }, { type: "gap", direction: "vertical", - points: [ - [gapLineX, endMaxY], - [gapLineX, minY], - ], + points: [point(gapLineX, endMaxY), point(gapLineX, minY)], }, ); } @@ -1071,8 +1069,8 @@ const createGapSnapLines = ( gapSnapLines.map((gapSnapLine) => { return { ...gapSnapLine, - points: gapSnapLine.points.map( - (point) => [round(point[0]), round(point[1])] as Point, + points: gapSnapLine.points.map((p) => + point(round(p[0]), round(p[1])), ) as PointPair, }; }), @@ -1117,40 +1115,40 @@ export const snapResizingElements = ( } } - const selectionSnapPoints: Point[] = []; + const selectionSnapPoints: GlobalPoint[] = []; if (transformHandle) { switch (transformHandle) { case "e": { - selectionSnapPoints.push([maxX, minY], [maxX, maxY]); + selectionSnapPoints.push(point(maxX, minY), point(maxX, maxY)); break; } case "w": { - selectionSnapPoints.push([minX, minY], [minX, maxY]); + selectionSnapPoints.push(point(minX, minY), point(minX, maxY)); break; } case "n": { - selectionSnapPoints.push([minX, minY], [maxX, minY]); + selectionSnapPoints.push(point(minX, minY), point(maxX, minY)); break; } case "s": { - selectionSnapPoints.push([minX, maxY], [maxX, maxY]); + selectionSnapPoints.push(point(minX, maxY), point(maxX, maxY)); break; } case "ne": { - selectionSnapPoints.push([maxX, minY]); + selectionSnapPoints.push(point(maxX, minY)); break; } case "nw": { - selectionSnapPoints.push([minX, minY]); + selectionSnapPoints.push(point(minX, minY)); break; } case "se": { - selectionSnapPoints.push([maxX, maxY]); + selectionSnapPoints.push(point(maxX, maxY)); break; } case "sw": { - selectionSnapPoints.push([minX, maxY]); + selectionSnapPoints.push(point(minX, maxY)); break; } } @@ -1192,11 +1190,11 @@ export const snapResizingElements = ( round(bound), ); - const corners: Point[] = [ - [x1, y1], - [x1, y2], - [x2, y1], - [x2, y2], + const corners: GlobalPoint[] = [ + point(x1, y1), + point(x1, y2), + point(x2, y1), + point(x2, y2), ]; getPointSnaps( @@ -1232,8 +1230,8 @@ export const snapNewElement = ( }; } - const selectionSnapPoints: Point[] = [ - [origin.x + dragOffset.x, origin.y + dragOffset.y], + const selectionSnapPoints: GlobalPoint[] = [ + point(origin.x + dragOffset.x, origin.y + dragOffset.y), ]; const snapDistance = getSnapDistance(app.state.zoom.value); @@ -1333,7 +1331,7 @@ export const getSnapLinesAtPointer = ( verticalSnapLines.push({ type: "pointer", - points: [corner, [corner[0], pointer.y]], + points: [corner, point(corner[0], pointer.y)], direction: "vertical", }); @@ -1349,7 +1347,7 @@ export const getSnapLinesAtPointer = ( horizontalSnapLines.push({ type: "pointer", - points: [corner, [pointer.x, corner[1]]], + points: [corner, point(pointer.x, corner[1])], direction: "horizontal", }); @@ -1386,3 +1384,18 @@ export const isActiveToolNonLinearSnappable = ( activeToolType === TOOL_TYPE.text ); }; + +// TODO: Rounding this point causes some shake when free drawing +export const getGridPoint = ( + x: number, + y: number, + gridSize: NullableGridSize, +): [number, number] => { + if (gridSize) { + return [ + Math.round(x / gridSize) * gridSize, + Math.round(y / gridSize) * gridSize, + ]; + } + return [x, y]; +}; diff --git a/packages/excalidraw/tests/binding.test.tsx b/packages/excalidraw/tests/binding.test.tsx index 54ad8006b..4f6d6b56b 100644 --- a/packages/excalidraw/tests/binding.test.tsx +++ b/packages/excalidraw/tests/binding.test.tsx @@ -7,6 +7,7 @@ import { API } from "./helpers/api"; import { KEYS } from "../keys"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; import { arrayToMap } from "../utils"; +import { point } from "../../math"; const { h } = window; @@ -31,12 +32,7 @@ describe("element binding", () => { y: 0, width: 100, height: 1, - points: [ - [0, 0], - [0, 0], - [100, 0], - [100, 0], - ], + points: [point(0, 0), point(0, 0), point(100, 0), point(100, 0)], }); API.setElements([rect, arrow]); expect(arrow.startBinding).toBe(null); @@ -314,10 +310,7 @@ describe("element binding", () => { const arrow1 = API.createElement({ type: "arrow", id: "arrow1", - points: [ - [0, 0], - [0, -87.45777932247563], - ], + points: [point(0, 0), point(0, -87.45777932247563)], startBinding: { elementId: "rectangle1", focus: 0.2, @@ -335,10 +328,7 @@ describe("element binding", () => { const arrow2 = API.createElement({ type: "arrow", id: "arrow2", - points: [ - [0, 0], - [0, -87.45777932247563], - ], + points: [point(0, 0), point(0, -87.45777932247563)], startBinding: { elementId: "text1", focus: 0.2, diff --git a/packages/excalidraw/tests/fixtures/elementFixture.ts b/packages/excalidraw/tests/fixtures/elementFixture.ts index 766c2c7d1..3f93f0713 100644 --- a/packages/excalidraw/tests/fixtures/elementFixture.ts +++ b/packages/excalidraw/tests/fixtures/elementFixture.ts @@ -1,3 +1,4 @@ +import type { Radians } from "../../../math"; import { DEFAULT_FONT_FAMILY } from "../../constants"; import type { ExcalidrawElement } from "../../element/types"; @@ -7,7 +8,7 @@ const elementBase: Omit = { y: 237, width: 214, height: 214, - angle: 0, + angle: 0 as Radians, strokeColor: "#000000", backgroundColor: "#15aabf", fillStyle: "hachure", diff --git a/packages/excalidraw/tests/flip.test.tsx b/packages/excalidraw/tests/flip.test.tsx index 8aedac46f..5cf4cd55c 100644 --- a/packages/excalidraw/tests/flip.test.tsx +++ b/packages/excalidraw/tests/flip.test.tsx @@ -28,6 +28,8 @@ import { KEYS } from "../keys"; import { getBoundTextElementPosition } from "../element/textElement"; import { createPasteEvent } from "../clipboard"; import { arrayToMap, cloneJSON } from "../utils"; +import type { LocalPoint } from "../../math"; +import { point, type Radians } from "../../math"; const { h } = window; const mouse = new Pointer("mouse"); @@ -131,7 +133,7 @@ const createLinearElementWithCurveInsideMinMaxPoints = ( y: -2412.5069664197654, width: 1750.4888916015625, height: 410.51605224609375, - angle: 0, + angle: 0 as Radians, strokeColor: "#000000", backgroundColor: "#fa5252", fillStyle: "hachure", @@ -145,9 +147,9 @@ const createLinearElementWithCurveInsideMinMaxPoints = ( link: null, locked: false, points: [ - [0, 0], - [-922.4761962890625, 300.3277587890625], - [828.0126953125, 410.51605224609375], + point(0, 0), + point(-922.4761962890625, 300.3277587890625), + point(828.0126953125, 410.51605224609375), ], }); }; @@ -423,8 +425,8 @@ describe("arrow", () => { }); it("flips a rotated arrow horizontally with line inside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementWithCurveInsideMinMaxPoints("arrow"); API.setElements([line]); API.setAppState({ @@ -444,8 +446,8 @@ describe("arrow", () => { }); it("flips a rotated arrow vertically with line inside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementWithCurveInsideMinMaxPoints("arrow"); API.setElements([line]); API.setAppState({ @@ -477,8 +479,8 @@ describe("arrow", () => { //TODO: elements with curve outside minMax points have a wrong bounding box!!! it.skip("flips a rotated arrow horizontally with line outside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow"); API.updateElement(line, { angle: originalAngle }); API.setElements([line]); @@ -501,8 +503,8 @@ describe("arrow", () => { //TODO: elements with curve outside minMax points have a wrong bounding box!!! it.skip("flips a rotated arrow vertically with line outside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow"); API.updateElement(line, { angle: originalAngle }); API.setElements([line]); @@ -585,8 +587,8 @@ describe("line", () => { //TODO: elements with curve outside minMax points have a wrong bounding box it.skip("flips a rotated line horizontally with line outside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementsWithCurveOutsideMinMaxPoints("line"); API.updateElement(line, { angle: originalAngle }); API.setElements([line]); @@ -600,8 +602,8 @@ describe("line", () => { //TODO: elements with curve outside minMax points have a wrong bounding box it.skip("flips a rotated line vertically with line outside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementsWithCurveOutsideMinMaxPoints("line"); API.updateElement(line, { angle: originalAngle }); API.setElements([line]); @@ -619,8 +621,8 @@ describe("line", () => { }); it("flips a rotated line horizontally with line inside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementWithCurveInsideMinMaxPoints("line"); API.setElements([line]); API.setAppState({ @@ -640,8 +642,8 @@ describe("line", () => { }); it("flips a rotated line vertically with line inside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementWithCurveInsideMinMaxPoints("line"); API.setElements([line]); API.setAppState({ @@ -772,8 +774,8 @@ describe("image", () => { }); it("flips an rotated image horizontally correctly", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; //paste image await createImage(); await waitFor(() => { @@ -790,8 +792,8 @@ describe("image", () => { }); it("flips an rotated image vertically correctly", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; //paste image await createImage(); await waitFor(() => { diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index 53d66152e..409d5825b 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -27,7 +27,7 @@ import { newImageElement, newMagicFrameElement, } from "../../element/newElement"; -import type { AppState, Point } from "../../types"; +import type { AppState } from "../../types"; import { getSelectedElements } from "../../scene/selection"; import { isLinearElementType } from "../../element/typeChecks"; import type { Mutable } from "../../utility-types"; @@ -36,6 +36,7 @@ import type App from "../../components/App"; import { createTestHook } from "../../components/App"; import type { Action } from "../../actions/types"; import { mutateElement } from "../../element/mutateElement"; +import { point, type LocalPoint, type Radians } from "../../../math"; const readFile = util.promisify(fs.readFile); // so that window.h is available when App.tsx is not imported as well. @@ -171,7 +172,7 @@ export class API { containerId?: T extends "text" ? ExcalidrawTextElement["containerId"] : never; - points?: T extends "arrow" | "line" ? readonly Point[] : never; + points?: T extends "arrow" | "line" ? readonly LocalPoint[] : never; locked?: boolean; fileId?: T extends "image" ? string : never; scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never; @@ -218,7 +219,7 @@ export class API { y, frameId: rest.frameId ?? null, index: rest.index ?? null, - angle: rest.angle ?? 0, + angle: (rest.angle ?? 0) as Radians, strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor, backgroundColor: rest.backgroundColor ?? appState.currentItemBackgroundColor, @@ -293,8 +294,8 @@ export class API { height, type, points: rest.points ?? [ - [0, 0], - [100, 100], + point(0, 0), + point(100, 100), ], elbowed: rest.elbowed ?? false, }); @@ -306,8 +307,8 @@ export class API { height, type, points: rest.points ?? [ - [0, 0], - [100, 100], + point(0, 0), + point(100, 100), ], }); break; diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 693e78333..3c7fe072d 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -1,4 +1,4 @@ -import type { Point, ToolType } from "../../types"; +import type { ToolType } from "../../types"; import type { ExcalidrawElement, ExcalidrawLinearElement, @@ -30,10 +30,11 @@ import { isFrameLikeElement, } from "../../element/typeChecks"; import { getCommonBounds, getElementPointsCoords } from "../../element/bounds"; -import { rotatePoint } from "../../math"; import { getTextEditor } from "../queries/dom"; import { arrayToMap } from "../../utils"; import { createTestHook } from "../../components/App"; +import type { GlobalPoint, LocalPoint, Radians } from "../../../math"; +import { point, pointRotateRads } from "../../../math"; // so that window.h is available when App.tsx is not imported as well. createTestHook(); @@ -131,27 +132,29 @@ export class Keyboard { }; } -const getElementPointForSelection = (element: ExcalidrawElement): Point => { +const getElementPointForSelection = ( + element: ExcalidrawElement, +): GlobalPoint => { const { x, y, width, height, angle } = element; - const target: Point = [ + const target = point( x + (isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2), y, - ]; - let center: Point; + ); + let center: GlobalPoint; if (isLinearElement(element)) { const bounds = getElementPointsCoords(element, element.points); - center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2]; + center = point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2); } else { - center = [x + width / 2, y + height / 2]; + center = point(x + width / 2, y + height / 2); } if (isTextElement(element)) { return center; } - return rotatePoint(target, center, angle); + return pointRotateRads(target, center, angle); }; export class Pointer { @@ -328,7 +331,7 @@ const transform = ( const isFrameSelected = elements.some(isFrameLikeElement); const transformHandles = getTransformHandlesFromCoords( [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], - 0, + 0 as Radians, h.state.zoom, "mouse", isFrameSelected ? OMIT_SIDES_FOR_FRAME : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, @@ -450,7 +453,7 @@ export class UI { width?: number; height?: number; angle?: number; - points?: T extends "line" | "arrow" | "freedraw" ? Point[] : never; + points?: T extends "line" | "arrow" | "freedraw" ? LocalPoint[] : never; } = {}, ): Element & { /** Returns the actual, current element from the elements array, instead @@ -459,9 +462,9 @@ export class UI { } { const width = initialWidth ?? initialHeight ?? size; const height = initialHeight ?? size; - const points: Point[] = initialPoints ?? [ - [0, 0], - [width, height], + const points: LocalPoint[] = initialPoints ?? [ + point(0, 0), + point(width, height), ]; UI.clickTool(type); diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 343dfa428..8e825e414 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -44,6 +44,8 @@ import { queryByText } from "@testing-library/react"; import { HistoryEntry } from "../history"; import { AppStateChange, ElementsChange } from "../change"; import { Snapshot, StoreAction } from "../store"; +import type { LocalPoint, Radians } from "../../math"; +import { point } from "../../math"; const { h } = window; @@ -2038,9 +2040,9 @@ describe("history", () => { width: 178.9000000000001, height: 236.10000000000002, points: [ - [0, 0], - [178.9000000000001, 0], - [178.9000000000001, 236.10000000000002], + point(0, 0), + point(178.9000000000001, 0), + point(178.9000000000001, 236.10000000000002), ], startBinding: { elementId: "KPrBI4g_v9qUB1XxYLgSz", @@ -2156,12 +2158,12 @@ describe("history", () => { elements: [ newElementWith(h.elements[0] as ExcalidrawLinearElement, { points: [ - [0, 0], - [5, 5], - [10, 10], - [15, 15], - [20, 20], - ], + point(0, 0), + point(5, 5), + point(10, 10), + point(15, 15), + point(20, 20), + ] as LocalPoint[], }), ], storeAction: StoreAction.UPDATE, @@ -4003,7 +4005,7 @@ describe("history", () => { newElementWith(h.elements[0], { x: 200, y: 200, - angle: 90, + angle: 90 as Radians, }), ], storeAction: StoreAction.CAPTURE, @@ -4121,7 +4123,7 @@ describe("history", () => { newElementWith(h.elements[0], { x: 205, y: 205, - angle: 90, + angle: 90 as Radians, }), ], storeAction: StoreAction.CAPTURE, diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 4bf88f0e4..06ca24a9c 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -8,7 +8,6 @@ import type { SceneElementsMap, } from "../element/types"; import { Excalidraw, mutateElement } from "../index"; -import { centerPoint } from "../math"; import { reseed } from "../random"; import * as StaticScene from "../renderer/staticScene"; import * as InteractiveCanvas from "../renderer/interactiveScene"; @@ -16,7 +15,6 @@ import * as InteractiveCanvas from "../renderer/interactiveScene"; import { Keyboard, Pointer, UI } from "./helpers/ui"; import { screen, render, fireEvent, GlobalTestState } from "./test-utils"; import { API } from "../tests/helpers/api"; -import type { Point } from "../types"; import { KEYS } from "../keys"; import { LinearElementEditor } from "../element/linearElementEditor"; import { act, queryByTestId, queryByText } from "@testing-library/react"; @@ -29,6 +27,8 @@ import * as textElementUtils from "../element/textElement"; import { ROUNDNESS, VERTICAL_ALIGN } from "../constants"; import { vi } from "vitest"; import { arrayToMap } from "../utils"; +import type { GlobalPoint } from "../../math"; +import { pointCenter, point } from "../../math"; const renderInteractiveScene = vi.spyOn( InteractiveCanvas, @@ -57,9 +57,9 @@ describe("Test Linear Elements", () => { interactiveCanvas = container.querySelector("canvas.interactive")!; }); - const p1: Point = [20, 20]; - const p2: Point = [60, 20]; - const midpoint = centerPoint(p1, p2); + const p1 = point(20, 20); + const p2 = point(60, 20); + const midpoint = pointCenter(p1, p2); const delta = 50; const mouse = new Pointer("mouse"); @@ -75,10 +75,7 @@ describe("Test Linear Elements", () => { height: 0, type, roughness, - points: [ - [0, 0], - [p2[0] - p1[0], p2[1] - p1[1]], - ], + points: [point(0, 0), point(p2[0] - p1[0], p2[1] - p1[1])], roundness, }); API.setElements([line]); @@ -102,9 +99,9 @@ describe("Test Linear Elements", () => { type, roughness, points: [ - [0, 0], - [p3[0], p3[1]], - [p2[0] - p1[0], p2[1] - p1[1]], + point(0, 0), + point(p3[0], p3[1]), + point(p2[0] - p1[0], p2[1] - p1[1]), ], roundness, }); @@ -129,7 +126,7 @@ describe("Test Linear Elements", () => { expect(h.state.editingLinearElement?.elementId).toEqual(line.id); }; - const drag = (startPoint: Point, endPoint: Point) => { + const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => { fireEvent.pointerDown(interactiveCanvas, { clientX: startPoint[0], clientY: startPoint[1], @@ -144,7 +141,7 @@ describe("Test Linear Elements", () => { }); }; - const deletePoint = (point: Point) => { + const deletePoint = (point: GlobalPoint) => { fireEvent.pointerDown(interactiveCanvas, { clientX: point[0], clientY: point[1], @@ -164,7 +161,7 @@ describe("Test Linear Elements", () => { expect(line.points.length).toEqual(2); mouse.clickAt(midpoint[0], midpoint[1]); - drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]); + drag(midpoint, point(midpoint[0] + 1, midpoint[1] + 1)); expect(line.points.length).toEqual(2); @@ -172,7 +169,7 @@ describe("Test Linear Elements", () => { expect(line.y).toBe(originalY); expect(line.points.length).toEqual(2); - drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); + drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta)); expect(line.x).toBe(originalX); expect(line.y).toBe(originalY); expect(line.points.length).toEqual(3); @@ -187,7 +184,7 @@ describe("Test Linear Elements", () => { expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2); // drag line from midpoint - drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); + drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(3); @@ -251,7 +248,7 @@ describe("Test Linear Elements", () => { mouse.clickAt(midpoint[0], midpoint[1]); expect(line.points.length).toEqual(2); - drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]); + drag(midpoint, point(midpoint[0] + 1, midpoint[1] + 1)); expect(line.x).toBe(originalX); expect(line.y).toBe(originalY); expect(line.points.length).toEqual(3); @@ -264,7 +261,7 @@ describe("Test Linear Elements", () => { enterLineEditingMode(line); // drag line from midpoint - drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); + drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); @@ -356,10 +353,13 @@ describe("Test Linear Elements", () => { h.state, ); - const startPoint = centerPoint(points[0], midPoints[0] as Point); + const startPoint = pointCenter(points[0], midPoints[0]!); const deltaX = 50; const deltaY = 20; - const endPoint: Point = [startPoint[0] + deltaX, startPoint[1] + deltaY]; + const endPoint = point( + startPoint[0] + deltaX, + startPoint[1] + deltaY, + ); // Move the element drag(startPoint, endPoint); @@ -399,8 +399,8 @@ describe("Test Linear Elements", () => { // This is the expected midpoint for line with round edge // hence hardcoding it so if later some bug is introduced // this will fail and we can fix it - const firstSegmentMidpoint: Point = [55, 45]; - const lastSegmentMidpoint: Point = [75, 40]; + const firstSegmentMidpoint = point(55, 45); + const lastSegmentMidpoint = point(75, 40); let line: ExcalidrawLinearElement; @@ -414,17 +414,20 @@ describe("Test Linear Elements", () => { it("should allow dragging lines from midpoints in between segments", async () => { // drag line via first segment midpoint - drag(firstSegmentMidpoint, [ - firstSegmentMidpoint[0] + delta, - firstSegmentMidpoint[1] + delta, - ]); + drag( + firstSegmentMidpoint, + point( + firstSegmentMidpoint[0] + delta, + firstSegmentMidpoint[1] + delta, + ), + ); expect(line.points.length).toEqual(4); // drag line from last segment midpoint - drag(lastSegmentMidpoint, [ - lastSegmentMidpoint[0] + delta, - lastSegmentMidpoint[1] + delta, - ]); + drag( + lastSegmentMidpoint, + point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta), + ); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `16`, @@ -472,10 +475,10 @@ describe("Test Linear Elements", () => { h.state, ); - const hitCoords: Point = [points[0][0], points[0][1]]; + const hitCoords = point(points[0][0], points[0][1]); // Drag from first point - drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]); + drag(hitCoords, point(hitCoords[0] - delta, hitCoords[1] - delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, @@ -513,10 +516,10 @@ describe("Test Linear Elements", () => { h.state, ); - const hitCoords: Point = [points[0][0], points[0][1]]; + const hitCoords = point(points[0][0], points[0][1]); // Drag from first point - drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]); + drag(hitCoords, point(hitCoords[0] + delta, hitCoords[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, @@ -551,10 +554,10 @@ describe("Test Linear Elements", () => { ); // dragging line from last segment midpoint - drag(lastSegmentMidpoint, [ - lastSegmentMidpoint[0] + 50, - lastSegmentMidpoint[1] + 50, - ]); + drag( + lastSegmentMidpoint, + point(lastSegmentMidpoint[0] + 50, lastSegmentMidpoint[1] + 50), + ); expect(line.points.length).toEqual(4); const midPoints = LinearElementEditor.getEditorMidPoints( @@ -586,12 +589,14 @@ describe("Test Linear Elements", () => { // This is the expected midpoint for line with round edge // hence hardcoding it so if later some bug is introduced // this will fail and we can fix it - const firstSegmentMidpoint: Point = [ - 55.9697848965255, 47.442326230998205, - ]; - const lastSegmentMidpoint: Point = [ - 76.08587175006699, 43.294165939653226, - ]; + const firstSegmentMidpoint = point( + 55.9697848965255, + 47.442326230998205, + ); + const lastSegmentMidpoint = point( + 76.08587175006699, + 43.294165939653226, + ); let line: ExcalidrawLinearElement; beforeEach(() => { @@ -605,17 +610,20 @@ describe("Test Linear Elements", () => { it("should allow dragging lines from midpoints in between segments", async () => { // drag line from first segment midpoint - drag(firstSegmentMidpoint, [ - firstSegmentMidpoint[0] + delta, - firstSegmentMidpoint[1] + delta, - ]); + drag( + firstSegmentMidpoint, + point( + firstSegmentMidpoint[0] + delta, + firstSegmentMidpoint[1] + delta, + ), + ); expect(line.points.length).toEqual(4); // drag line from last segment midpoint - drag(lastSegmentMidpoint, [ - lastSegmentMidpoint[0] + delta, - lastSegmentMidpoint[1] + delta, - ]); + drag( + lastSegmentMidpoint, + point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta), + ); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `16`, ); @@ -661,10 +669,10 @@ describe("Test Linear Elements", () => { h.state, ); - const hitCoords: Point = [points[0][0], points[0][1]]; + const hitCoords = point(points[0][0], points[0][1]); // Drag from first point - drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]); + drag(hitCoords, point(hitCoords[0] - delta, hitCoords[1] - delta)); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -709,10 +717,10 @@ describe("Test Linear Elements", () => { h.state, ); - const hitCoords: Point = [points[0][0], points[0][1]]; + const hitCoords = point(points[0][0], points[0][1]); // Drag from first point - drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]); + drag(hitCoords, point(hitCoords[0] + delta, hitCoords[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, @@ -741,10 +749,10 @@ describe("Test Linear Elements", () => { it("should update all the midpoints when a point is deleted", async () => { const elementsMap = arrayToMap(h.elements); - drag(lastSegmentMidpoint, [ - lastSegmentMidpoint[0] + delta, - lastSegmentMidpoint[1] + delta, - ]); + drag( + lastSegmentMidpoint, + point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta), + ); expect(line.points.length).toEqual(4); const midPoints = LinearElementEditor.getEditorMidPoints( @@ -803,8 +811,11 @@ describe("Test Linear Elements", () => { API.setSelectedElements([line]); enterLineEditingMode(line, true); drag( - [line.points[0][0] + line.x, line.points[0][1] + line.y], - [dragEndPositionOffset[0] + line.x, dragEndPositionOffset[1] + line.y], + point(line.points[0][0] + line.x, line.points[0][1] + line.y), + point( + dragEndPositionOffset[0] + line.x, + dragEndPositionOffset[1] + line.y, + ), ); expect(line.points).toMatchInlineSnapshot(` [ @@ -916,14 +927,18 @@ describe("Test Linear Elements", () => { // This is the expected midpoint for line with round edge // hence hardcoding it so if later some bug is introduced // this will fail and we can fix it - const firstSegmentMidpoint: Point = [ - 55.9697848965255, 47.442326230998205, - ]; + const firstSegmentMidpoint = point( + 55.9697848965255, + 47.442326230998205, + ); // drag line from first segment midpoint - drag(firstSegmentMidpoint, [ - firstSegmentMidpoint[0] + delta, - firstSegmentMidpoint[1] + delta, - ]); + drag( + firstSegmentMidpoint, + point( + firstSegmentMidpoint[0] + delta, + firstSegmentMidpoint[1] + delta, + ), + ); const position = LinearElementEditor.getBoundTextElementPosition( container, @@ -1136,7 +1151,7 @@ describe("Test Linear Elements", () => { ); // Drag from last point - drag(points[1], [points[1][0] + 300, points[1][1]]); + drag(points[1], point(points[1][0] + 300, points[1][1])); expect({ width: container.width, height: container.height }) .toMatchInlineSnapshot(` @@ -1335,14 +1350,14 @@ describe("Test Linear Elements", () => { [ { index: 0, - point: [line.points[0][0] + 10, line.points[0][1] + 10], + point: point(line.points[0][0] + 10, line.points[0][1] + 10), }, { index: line.points.length - 1, - point: [ + point: point( line.points[line.points.length - 1][0] - 10, line.points[line.points.length - 1][1] - 10, - ], + ), }, ], new Map() as SceneElementsMap, diff --git a/packages/excalidraw/tests/resize.test.tsx b/packages/excalidraw/tests/resize.test.tsx index 34e189cee..d18f5cd49 100644 --- a/packages/excalidraw/tests/resize.test.tsx +++ b/packages/excalidraw/tests/resize.test.tsx @@ -7,7 +7,6 @@ import type { ExcalidrawFreeDrawElement, ExcalidrawLinearElement, } from "../element/types"; -import type { Point } from "../types"; import type { Bounds } from "../element/bounds"; import { getElementPointsCoords } from "../element/bounds"; import { Excalidraw } from "../index"; @@ -16,6 +15,8 @@ import { KEYS } from "../keys"; import { isLinearElement } from "../element/typeChecks"; import { LinearElementEditor } from "../element/linearElementEditor"; import { arrayToMap } from "../utils"; +import type { LocalPoint } from "../../math"; +import { point } from "../../math"; ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -217,18 +218,13 @@ describe("generic element", () => { }); describe.each(["line", "freedraw"] as const)("%s element", (type) => { - const points: Record = { - line: [ - [0, 0], - [60, -20], - [20, 40], - [-40, 0], - ], + const points: Record = { + line: [point(0, 0), point(60, -20), point(20, 40), point(-40, 0)], freedraw: [ - [0, 0], - [-2.474600807561444, 41.021700699972], - [3.6627956000014024, 47.84174560617245], - [40.495224145598115, 47.15909710753482], + point(0, 0), + point(-2.474600807561444, 41.021700699972), + point(3.6627956000014024, 47.84174560617245), + point(40.495224145598115, 47.15909710753482), ], }; @@ -296,11 +292,11 @@ describe("arrow element", () => { it("resizes with a label", async () => { const arrow = UI.createElement("arrow", { points: [ - [0, 0], - [40, 140], - [80, 60], // label's anchor - [180, 20], - [200, 120], + point(0, 0), + point(40, 140), + point(80, 60), // label's anchor + point(180, 20), + point(200, 120), ], }); const label = await UI.editText(arrow, "Hello"); @@ -694,24 +690,24 @@ describe("multiple selection", () => { x: 60, y: 40, points: [ - [0, 0], - [-40, 40], - [-60, 0], - [0, -40], - [40, 20], - [0, 40], + point(0, 0), + point(-40, 40), + point(-60, 0), + point(0, -40), + point(40, 20), + point(0, 40), ], }); const freedraw = UI.createElement("freedraw", { x: 63.56072661326618, y: 100, points: [ - [0, 0], - [-43.56072661326618, 18.15048126846341], - [-43.56072661326618, 29.041198460587566], - [-38.115368017204105, 42.652452795512204], - [-19.964886748740696, 66.24829266003775], - [19.056612930986716, 77.1390098521619], + point(0, 0), + point(-43.56072661326618, 18.15048126846341), + point(-43.56072661326618, 29.041198460587566), + point(-38.115368017204105, 42.652452795512204), + point(-19.964886748740696, 66.24829266003775), + point(19.056612930986716, 77.1390098521619), ], }); @@ -1050,13 +1046,13 @@ describe("multiple selection", () => { x: 60, y: 0, points: [ - [0, 0], - [-40, 40], - [-20, 60], - [20, 20], - [40, 40], - [-20, 100], - [-60, 60], + point(0, 0), + point(-40, 40), + point(-20, 60), + point(20, 20), + point(40, 40), + point(-20, 100), + point(-60, 60), ], }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index c94c5134e..58994e86e 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -24,7 +24,6 @@ import type { ExcalidrawNonSelectionElement, } from "./element/types"; import type { Action } from "./actions/types"; -import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { LinearElementEditor } from "./element/linearElementEditor"; import type { SuggestedBinding } from "./element/binding"; import type { ImportedDataState } from "./data/types"; @@ -43,8 +42,6 @@ import type { SnapLine } from "./snapping"; import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types"; import type { StoreActionType } from "./store"; -export type Point = Readonly; - export type SocketId = string & { _brand: "SocketId" }; export type Collaborator = Readonly<{ diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 0f3d11f71..ef8ab8308 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -1,3 +1,4 @@ +import { average } from "../math"; import { COLOR_PALETTE } from "./colors"; import type { EVENT } from "./constants"; import { @@ -992,10 +993,6 @@ export const isMemberOf = ( export const cloneJSON = (obj: T): T => JSON.parse(JSON.stringify(obj)); -export const isFiniteNumber = (value: any): value is number => { - return typeof value === "number" && Number.isFinite(value); -}; - export const updateStable = >( prevValue: T, nextValue: T, @@ -1079,7 +1076,6 @@ export function addEventListener( }; } -const average = (a: number, b: number) => (a + b) / 2; export function getSvgPathFromStroke(points: number[][], closed = true) { const len = points.length; diff --git a/packages/excalidraw/visualdebug.ts b/packages/excalidraw/visualdebug.ts index f6ab8f744..7181719f7 100644 --- a/packages/excalidraw/visualdebug.ts +++ b/packages/excalidraw/visualdebug.ts @@ -1,7 +1,7 @@ +import { isLineSegment, lineSegment, point, type GlobalPoint } from "../math"; import type { LineSegment } from "../utils"; import type { BoundingBox, Bounds } from "./element/bounds"; -import { isBounds, isLineSegment } from "./element/typeChecks"; -import type { Point } from "./types"; +import { isBounds } from "./element/typeChecks"; // The global data holder to collect the debug operations declare global { @@ -15,18 +15,22 @@ declare global { export type DebugElement = { color: string; - data: LineSegment; + data: LineSegment; permanent: boolean; }; export const debugDrawLine = ( - segment: LineSegment | LineSegment[], + segment: LineSegment | LineSegment[], opts?: { color?: string; permanent?: boolean; }, ) => { - (isLineSegment(segment) ? [segment] : segment).forEach((data) => + const segments = ( + isLineSegment(segment) ? [segment] : segment + ) as LineSegment[]; + + segments.forEach((data) => addToCurrentFrame({ color: opts?.color ?? "red", data, @@ -36,7 +40,7 @@ export const debugDrawLine = ( }; export const debugDrawPoint = ( - point: Point, + p: GlobalPoint, opts?: { color?: string; permanent?: boolean; @@ -47,20 +51,20 @@ export const debugDrawPoint = ( const yOffset = opts?.fuzzy ? Math.random() * 3 : 0; debugDrawLine( - [ - [point[0] + xOffset - 10, point[1] + yOffset - 10], - [point[0] + xOffset + 10, point[1] + yOffset + 10], - ], + lineSegment( + point(p[0] + xOffset - 10, p[1] + yOffset - 10), + point(p[0] + xOffset + 10, p[1] + yOffset + 10), + ), { color: opts?.color ?? "cyan", permanent: opts?.permanent, }, ); debugDrawLine( - [ - [point[0] + xOffset - 10, point[1] + yOffset + 10], - [point[0] + xOffset + 10, point[1] + yOffset - 10], - ], + lineSegment( + point(p[0] + xOffset - 10, p[1] + yOffset + 10), + point(p[0] + xOffset + 10, p[1] + yOffset - 10), + ), { color: opts?.color ?? "cyan", permanent: opts?.permanent, @@ -78,22 +82,22 @@ export const debugDrawBoundingBox = ( (Array.isArray(box) ? box : [box]).forEach((bbox) => debugDrawLine( [ - [ - [bbox.minX, bbox.minY], - [bbox.maxX, bbox.minY], - ], - [ - [bbox.maxX, bbox.minY], - [bbox.maxX, bbox.maxY], - ], - [ - [bbox.maxX, bbox.maxY], - [bbox.minX, bbox.maxY], - ], - [ - [bbox.minX, bbox.maxY], - [bbox.minX, bbox.minY], - ], + lineSegment( + point(bbox.minX, bbox.minY), + point(bbox.maxX, bbox.minY), + ), + lineSegment( + point(bbox.maxX, bbox.minY), + point(bbox.maxX, bbox.maxY), + ), + lineSegment( + point(bbox.maxX, bbox.maxY), + point(bbox.minX, bbox.maxY), + ), + lineSegment( + point(bbox.minX, bbox.maxY), + point(bbox.minX, bbox.minY), + ), ], { color: opts?.color ?? "cyan", @@ -113,22 +117,22 @@ export const debugDrawBounds = ( (isBounds(box) ? [box] : box).forEach((bbox) => debugDrawLine( [ - [ - [bbox[0], bbox[1]], - [bbox[2], bbox[1]], - ], - [ - [bbox[2], bbox[1]], - [bbox[2], bbox[3]], - ], - [ - [bbox[2], bbox[3]], - [bbox[0], bbox[3]], - ], - [ - [bbox[0], bbox[3]], - [bbox[0], bbox[1]], - ], + lineSegment( + point(bbox[0], bbox[1]), + point(bbox[2], bbox[1]), + ), + lineSegment( + point(bbox[2], bbox[1]), + point(bbox[2], bbox[3]), + ), + lineSegment( + point(bbox[2], bbox[3]), + point(bbox[0], bbox[3]), + ), + lineSegment( + point(bbox[0], bbox[3]), + point(bbox[0], bbox[1]), + ), ], { color: opts?.color ?? "green", diff --git a/packages/math/CHANGELOG.md b/packages/math/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/math/README.md b/packages/math/README.md new file mode 100644 index 000000000..eaa163037 --- /dev/null +++ b/packages/math/README.md @@ -0,0 +1,21 @@ +# @excalidraw/math + +## Install + +```bash +npm install @excalidraw/math +``` + +If you prefer Yarn over npm, use this command to install the Excalidraw utils package: + +```bash +yarn add @excalidraw/math +``` + +With PNPM, similarly install the package with this command: + +```bash +pnpm add @excalidraw/math +``` + +## API diff --git a/packages/math/angle.ts b/packages/math/angle.ts new file mode 100644 index 000000000..2dc97a469 --- /dev/null +++ b/packages/math/angle.ts @@ -0,0 +1,47 @@ +import type { + Degrees, + GlobalPoint, + LocalPoint, + PolarCoords, + Radians, +} from "./types"; +import { PRECISION } from "./utils"; + +// TODO: Simplify with modulo and fix for angles beyond 4*Math.PI and - 4*Math.PI +export const normalizeRadians = (angle: Radians): Radians => { + if (angle < 0) { + return (angle + 2 * Math.PI) as Radians; + } + if (angle >= 2 * Math.PI) { + return (angle - 2 * Math.PI) as Radians; + } + return angle; +}; + +/** + * Return the polar coordinates for the given cartesian point represented by + * (x, y) for the center point 0,0 where the first number returned is the radius, + * the second is the angle in radians. + */ +export const cartesian2Polar =

([ + x, + y, +]: P): PolarCoords => [Math.hypot(x, y), Math.atan2(y, x)]; + +export function degreesToRadians(degrees: Degrees): Radians { + return ((degrees * Math.PI) / 180) as Radians; +} + +export function radiansToDegrees(degrees: Radians): Degrees { + return ((degrees * 180) / Math.PI) as Degrees; +} + +/** + * Determines if the provided angle is a right angle. + * + * @param rads The angle to measure + * @returns TRUE if the provided angle is a right angle + */ +export function isRightAngleRads(rads: Radians): boolean { + return Math.abs(Math.sin(2 * rads)) < PRECISION; +} diff --git a/packages/math/arc.test.ts b/packages/math/arc.test.ts new file mode 100644 index 000000000..12e880c9c --- /dev/null +++ b/packages/math/arc.test.ts @@ -0,0 +1,41 @@ +import { isPointOnSymmetricArc } from "./arc"; +import { point } from "./point"; + +describe("point on arc", () => { + it("should detect point on simple arc", () => { + expect( + isPointOnSymmetricArc( + { + radius: 1, + startAngle: -Math.PI / 4, + endAngle: Math.PI / 4, + }, + point(0.92291667, 0.385), + ), + ).toBe(true); + }); + it("should not detect point outside of a simple arc", () => { + expect( + isPointOnSymmetricArc( + { + radius: 1, + startAngle: -Math.PI / 4, + endAngle: Math.PI / 4, + }, + point(-0.92291667, 0.385), + ), + ).toBe(false); + }); + it("should not detect point with good angle but incorrect radius", () => { + expect( + isPointOnSymmetricArc( + { + radius: 1, + startAngle: -Math.PI / 4, + endAngle: Math.PI / 4, + }, + point(-0.5, 0.5), + ), + ).toBe(false); + }); +}); diff --git a/packages/math/arc.ts b/packages/math/arc.ts new file mode 100644 index 000000000..c93830dba --- /dev/null +++ b/packages/math/arc.ts @@ -0,0 +1,20 @@ +import { cartesian2Polar } from "./angle"; +import type { GlobalPoint, LocalPoint, SymmetricArc } from "./types"; +import { PRECISION } from "./utils"; + +/** + * Determines if a cartesian point lies on a symmetric arc, i.e. an arc which + * is part of a circle contour centered on 0, 0. + */ +export const isPointOnSymmetricArc =

( + { radius: arcRadius, startAngle, endAngle }: SymmetricArc, + point: P, +): boolean => { + const [radius, angle] = cartesian2Polar(point); + + return startAngle < endAngle + ? Math.abs(radius - arcRadius) < PRECISION && + startAngle <= angle && + endAngle >= angle + : startAngle <= angle || endAngle >= angle; +}; diff --git a/packages/math/curve.ts b/packages/math/curve.ts new file mode 100644 index 000000000..ca4571057 --- /dev/null +++ b/packages/math/curve.ts @@ -0,0 +1,223 @@ +import { point, pointRotateRads } from "./point"; +import type { Curve, GlobalPoint, LocalPoint, Radians } from "./types"; + +/** + * + * @param a + * @param b + * @param c + * @param d + * @returns + */ +export function curve( + a: Point, + b: Point, + c: Point, + d: Point, +) { + return [a, b, c, d] as Curve; +} + +export const curveRotate = ( + curve: Curve, + angle: Radians, + origin: Point, +) => { + return curve.map((p) => pointRotateRads(p, origin, angle)); +}; + +/** + * + * @param pointsIn + * @param curveTightness + * @returns + */ +export function 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( + point(pointsIn[0][0], pointsIn[0][1]), // Points need to be cloned + point(pointsIn[1][0], pointsIn[1][1]), // Points need to be cloned + point(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned + point(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned + ); + } 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(point(points[0][0], points[0][1])); + for (let i = 1; i + 2 < points.length; i++) { + const cachedVertArray = points[i]; + b[0] = point(cachedVertArray[0], cachedVertArray[1]); + b[1] = point( + 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] = point( + 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] = point(points[i + 1][0], points[i + 1][1]); + out.push(b[1], b[2], b[3]); + } + } + return out; +} + +/** + * + * @param t + * @param controlPoints + * @returns + */ +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 point(x, y); +}; + +/** + * + * @param point + * @param controlPoints + * @returns + */ +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; +}; + +const solveCubic = (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 = solveCubic(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 = solveCubic(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; +}; diff --git a/packages/excalidraw/tests/geometricAlgebra.test.ts b/packages/math/ga/ga.test.ts similarity index 90% rename from packages/excalidraw/tests/geometricAlgebra.test.ts rename to packages/math/ga/ga.test.ts index 59279073d..767b5b65b 100644 --- a/packages/excalidraw/tests/geometricAlgebra.test.ts +++ b/packages/math/ga/ga.test.ts @@ -1,8 +1,8 @@ -import * as GA from "../ga"; -import { point, toString, direction, offset } from "../ga"; -import * as GAPoint from "../gapoints"; -import * as GALine from "../galines"; -import * as GATransform from "../gatransforms"; +import * as GA from "./ga"; +import { point, toString, direction, offset } from "./ga"; +import * as GAPoint from "./gapoints"; +import * as GALine from "./galines"; +import * as GATransform from "./gatransforms"; describe("geometric algebra", () => { describe("points", () => { diff --git a/packages/excalidraw/ga.ts b/packages/math/ga/ga.ts similarity index 100% rename from packages/excalidraw/ga.ts rename to packages/math/ga/ga.ts diff --git a/packages/excalidraw/gadirections.ts b/packages/math/ga/gadirections.ts similarity index 100% rename from packages/excalidraw/gadirections.ts rename to packages/math/ga/gadirections.ts diff --git a/packages/excalidraw/galines.ts b/packages/math/ga/galines.ts similarity index 100% rename from packages/excalidraw/galines.ts rename to packages/math/ga/galines.ts diff --git a/packages/excalidraw/gapoints.ts b/packages/math/ga/gapoints.ts similarity index 100% rename from packages/excalidraw/gapoints.ts rename to packages/math/ga/gapoints.ts diff --git a/packages/excalidraw/gatransforms.ts b/packages/math/ga/gatransforms.ts similarity index 100% rename from packages/excalidraw/gatransforms.ts rename to packages/math/ga/gatransforms.ts diff --git a/packages/math/index.ts b/packages/math/index.ts new file mode 100644 index 000000000..05ec5158f --- /dev/null +++ b/packages/math/index.ts @@ -0,0 +1,12 @@ +export * from "./arc"; +export * from "./angle"; +export * from "./curve"; +export * from "./line"; +export * from "./point"; +export * from "./polygon"; +export * from "./range"; +export * from "./segment"; +export * from "./triangle"; +export * from "./types"; +export * from "./vector"; +export * from "./utils"; diff --git a/packages/math/line.ts b/packages/math/line.ts new file mode 100644 index 000000000..c646e04d4 --- /dev/null +++ b/packages/math/line.ts @@ -0,0 +1,52 @@ +import { pointCenter, pointRotateRads } from "./point"; +import type { GlobalPoint, Line, LocalPoint, Radians } from "./types"; + +/** + * Create a line from two points. + * + * @param points The two points lying on the line + * @returns The line on which the points lie + */ +export function line

(a: P, b: P): Line

{ + return [a, b] as Line

; +} + +/** + * Convenient point creation from an array of two points. + * + * @param param0 The array with the two points to convert to a line + * @returns The created line + */ +export function lineFromPointPair

([a, b]: [ + P, + P, +]): Line

{ + return line(a, b); +} + +/** + * TODO + * + * @param pointArray + * @returns + */ +export function lineFromPointArray

( + pointArray: P[], +): Line

| undefined { + return pointArray.length === 2 + ? line

(pointArray[0], pointArray[1]) + : undefined; +} + +// 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 = ( + l: Line, + angle: Radians, + origin?: Point, +): Line => { + return line( + pointRotateRads(l[0], origin || pointCenter(l[0], l[1]), angle), + pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle), + ); +}; diff --git a/packages/math/package.json b/packages/math/package.json new file mode 100644 index 000000000..b6c87e8f3 --- /dev/null +++ b/packages/math/package.json @@ -0,0 +1,61 @@ +{ + "name": "@excalidraw/math", + "version": "0.1.0", + "main": "./dist/prod/index.js", + "type": "module", + "module": "./dist/prod/index.js", + "exports": { + ".": { + "development": "./dist/dev/index.js", + "default": "./dist/prod/index.js" + } + }, + "types": "./dist/utils/index.d.ts", + "files": [ + "dist/*" + ], + "description": "Excalidraw math functions", + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "keywords": [ + "excalidraw", + "excalidraw-math", + "math", + "vector", + "algebra", + "2d" + ], + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not ie <= 11", + "not op_mini all", + "not safari < 12", + "not kaios <= 2.5", + "not edge < 79", + "not chrome < 70", + "not and_uc < 13", + "not samsung < 10" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "bugs": "https://github.com/excalidraw/excalidraw/issues", + "repository": "https://github.com/excalidraw/excalidraw", + "dependencies": { + "@excalidraw/utils": "*" + }, + "scripts": { + "gen:types": "rm -rf types && tsc", + "build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js", + "build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types", + "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js", + "pack": "yarn build:umd && yarn pack" + } +} diff --git a/packages/math/point.test.ts b/packages/math/point.test.ts new file mode 100644 index 000000000..77ea06c93 --- /dev/null +++ b/packages/math/point.test.ts @@ -0,0 +1,24 @@ +import { point, pointRotateRads } from "./point"; +import type { Radians } from "./types"; + +describe("rotate", () => { + it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => { + const x1 = 10; + const y1 = 20; + const x2 = 20; + const y2 = 30; + const angle = (Math.PI / 2) as Radians; + const [rotatedX, rotatedY] = pointRotateRads( + point(x1, y1), + point(x2, y2), + angle, + ); + expect([rotatedX, rotatedY]).toEqual([30, 20]); + const res2 = pointRotateRads( + point(rotatedX, rotatedY), + point(x2, y2), + -angle as Radians, + ); + expect(res2).toEqual([x1, x2]); + }); +}); diff --git a/packages/math/point.ts b/packages/math/point.ts new file mode 100644 index 000000000..97b574270 --- /dev/null +++ b/packages/math/point.ts @@ -0,0 +1,257 @@ +import { degreesToRadians } from "./angle"; +import type { + LocalPoint, + GlobalPoint, + Radians, + Degrees, + Vector, +} from "./types"; +import { PRECISION } from "./utils"; +import { vectorFromPoint, vectorScale } from "./vector"; + +/** + * Create a properly typed Point instance from the X and Y coordinates. + * + * @param x The X coordinate + * @param y The Y coordinate + * @returns The branded and created point + */ +export function point( + x: number, + y: number, +): Point { + return [x, y] as Point; +} + +/** + * Converts and remaps an array containing a pair of numbers to Point. + * + * @param numberArray The number array to check and to convert to Point + * @returns The point instance + */ +export function pointFromArray( + numberArray: number[], +): Point | undefined { + return numberArray.length === 2 + ? point(numberArray[0], numberArray[1]) + : undefined; +} + +/** + * Converts and remaps a pair of numbers to Point. + * + * @param pair A number pair to convert to Point + * @returns The point instance + */ +export function pointFromPair( + pair: [number, number], +): Point { + return pair as Point; +} + +/** + * Convert a vector to a point. + * + * @param v The vector to convert + * @returns The point the vector points at with origin 0,0 + */ +export function pointFromVector

( + v: Vector, +): P { + return v as unknown as P; +} + +/** + * Checks if the provided value has the shape of a Point. + * + * @param p The value to attempt verification on + * @returns TRUE if the provided value has the shape of a local or global point + */ +export function isPoint(p: unknown): p is LocalPoint | GlobalPoint { + return ( + Array.isArray(p) && + p.length === 2 && + typeof p[0] === "number" && + !isNaN(p[0]) && + typeof p[1] === "number" && + !isNaN(p[1]) + ); +} + +/** + * Compare two points coordinate-by-coordinate and if + * they are closer than INVERSE_PRECISION it returns TRUE. + * + * @param a Point The first point to compare + * @param b Point The second point to compare + * @returns TRUE if the points are sufficiently close to each other + */ +export function pointsEqual( + a: Point, + b: Point, +): boolean { + const abs = Math.abs; + return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION; +} + +/** + * Roate a point by [angle] radians. + * + * @param point The point to rotate + * @param center The point to rotate around, the center point + * @param angle The radians to rotate the point by + * @returns The rotated point + */ +export function pointRotateRads( + [x, y]: Point, + [cx, cy]: Point, + angle: Radians, +): Point { + return point( + (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx, + (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy, + ); +} + +/** + * Roate a point by [angle] degree. + * + * @param point The point to rotate + * @param center The point to rotate around, the center point + * @param angle The degree to rotate the point by + * @returns The rotated point + */ +export function pointRotateDegs( + point: Point, + center: Point, + angle: Degrees, +): Point { + return pointRotateRads(point, center, degreesToRadians(angle)); +} + +/** + * Translate a point by a vector. + * + * WARNING: This is not for translating Excalidraw element points! + * You need to account for rotation on base coordinates + * on your own. + * CONSIDER USING AN APPROPRIATE ELEMENT-AWARE TRANSLATE! + * + * @param p The point to apply the translation on + * @param v The vector to translate by + * @returns + */ +// TODO 99% of use is translating between global and local coords, which need to be formalized +export function pointTranslate< + From extends GlobalPoint | LocalPoint, + To extends GlobalPoint | LocalPoint, +>(p: From, v: Vector = [0, 0] as Vector): To { + return point(p[0] + v[0], p[1] + v[1]); +} + +/** + * Find the center point at equal distance from both points. + * + * @param a One of the points to create the middle point for + * @param b The other point to create the middle point for + * @returns The middle point + */ +export function pointCenter

(a: P, b: P): P { + return point((a[0] + b[0]) / 2, (a[1] + b[1]) / 2); +} + +/** + * Add together two points by their coordinates like you'd apply a translation + * to a point by a vector. + * + * @param a One point to act as a basis + * @param b The other point to act like the vector to translate by + * @returns + */ +export function pointAdd( + a: Point, + b: Point, +): Point { + return point(a[0] + b[0], a[1] + b[1]); +} + +/** + * Subtract a point from another point like you'd translate a point by an + * invese vector. + * + * @param a The point to translate + * @param b The point which will act like a vector + * @returns The resulting point + */ +export function pointSubtract( + a: Point, + b: Point, +): Point { + return point(a[0] - b[0], a[1] - b[1]); +} + +/** + * Calculate the distance between two points. + * + * @param a First point + * @param b Second point + * @returns The euclidean distance between the two points. + */ +export function pointDistance

( + a: P, + b: P, +): number { + return Math.hypot(b[0] - a[0], b[1] - a[1]); +} + +/** + * Calculate the squared distance between two points. + * + * Note: Use this if you only compare distances, it saves a square root. + * + * @param a First point + * @param b Second point + * @returns The euclidean distance between the two points. + */ +export function pointDistanceSq

( + a: P, + b: P, +): number { + return Math.hypot(b[0] - a[0], b[1] - a[1]); +} + +/** + * Scale a point from a given origin by the multiplier. + * + * @param p The point to scale + * @param mid The origin to scale from + * @param multiplier The scaling factor + * @returns + */ +export const pointScaleFromOrigin =

( + p: P, + mid: P, + multiplier: number, +) => pointTranslate(mid, vectorScale(vectorFromPoint(p, mid), multiplier)); + +/** + * Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`. + * This is an approximation to "does `q` lie on a segment `pr`" check. + * + * @param p The first point to compare against + * @param q The actual point this function checks whether is in between + * @param r The other point to compare against + * @returns TRUE if q is indeed between p and r + */ +export const isPointWithinBounds =

( + p: P, + q: P, + r: P, +) => { + return ( + q[0] <= Math.max(p[0], r[0]) && + q[0] >= Math.min(p[0], r[0]) && + q[1] <= Math.max(p[1], r[1]) && + q[1] >= Math.min(p[1], r[1]) + ); +}; diff --git a/packages/math/polygon.ts b/packages/math/polygon.ts new file mode 100644 index 000000000..783bc4cf3 --- /dev/null +++ b/packages/math/polygon.ts @@ -0,0 +1,72 @@ +import { pointsEqual } from "./point"; +import { lineSegment, pointOnLineSegment } from "./segment"; +import type { GlobalPoint, LocalPoint, Polygon } from "./types"; +import { PRECISION } from "./utils"; + +export function polygon( + ...points: Point[] +) { + return polygonClose(points) as Polygon; +} + +export function polygonFromPoints( + points: Point[], +) { + return polygonClose(points) as Polygon; +} + +export const polygonIncludesPoint = ( + 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 = ( + p: Point, + poly: Polygon, + threshold = PRECISION, +) => { + let on = false; + + for (let i = 0, l = poly.length - 1; i < l; i++) { + if (pointOnLineSegment(p, lineSegment(poly[i], poly[i + 1]), threshold)) { + on = true; + break; + } + } + + return on; +}; + +function polygonClose( + polygon: Point[], +) { + return polygonIsClosed(polygon) + ? polygon + : ([...polygon, polygon[0]] as Polygon); +} + +function polygonIsClosed( + polygon: Point[], +) { + return pointsEqual(polygon[0], polygon[polygon.length - 1]); +} diff --git a/packages/math/range.test.ts b/packages/math/range.test.ts new file mode 100644 index 000000000..fb4b6a38d --- /dev/null +++ b/packages/math/range.test.ts @@ -0,0 +1,51 @@ +import { rangeInclusive, rangeIntersection, rangesOverlap } from "./range"; + +describe("range overlap", () => { + const range1_4 = rangeInclusive(1, 4); + + it("should overlap when range a contains range b", () => { + expect(rangesOverlap(range1_4, rangeInclusive(2, 3))).toBe(true); + expect(rangesOverlap(range1_4, range1_4)).toBe(true); + expect(rangesOverlap(range1_4, rangeInclusive(1, 3))).toBe(true); + expect(rangesOverlap(range1_4, rangeInclusive(2, 4))).toBe(true); + }); + + it("should overlap when range b contains range a", () => { + expect(rangesOverlap(rangeInclusive(2, 3), range1_4)).toBe(true); + expect(rangesOverlap(rangeInclusive(1, 3), range1_4)).toBe(true); + expect(rangesOverlap(rangeInclusive(2, 4), range1_4)).toBe(true); + }); + + it("should overlap when range a and b intersect", () => { + expect(rangesOverlap(range1_4, rangeInclusive(2, 5))).toBe(true); + }); +}); + +describe("range intersection", () => { + const range1_4 = rangeInclusive(1, 4); + + it("should intersect completely with itself", () => { + expect(rangeIntersection(range1_4, range1_4)).toEqual(range1_4); + }); + + it("should intersect irrespective of order", () => { + expect(rangeIntersection(range1_4, rangeInclusive(2, 3))).toEqual([2, 3]); + expect(rangeIntersection(rangeInclusive(2, 3), range1_4)).toEqual([2, 3]); + expect(rangeIntersection(range1_4, rangeInclusive(3, 5))).toEqual( + rangeInclusive(3, 4), + ); + expect(rangeIntersection(rangeInclusive(3, 5), range1_4)).toEqual( + rangeInclusive(3, 4), + ); + }); + + it("should intersect at the edge", () => { + expect(rangeIntersection(range1_4, rangeInclusive(4, 5))).toEqual( + rangeInclusive(4, 4), + ); + }); + + it("should not intersect", () => { + expect(rangeIntersection(range1_4, rangeInclusive(5, 7))).toEqual(null); + }); +}); diff --git a/packages/math/range.ts b/packages/math/range.ts new file mode 100644 index 000000000..314d1c8ae --- /dev/null +++ b/packages/math/range.ts @@ -0,0 +1,82 @@ +import { toBrandedType } from "../excalidraw/utils"; +import type { InclusiveRange } from "./types"; + +/** + * Create an inclusive range from the two numbers provided. + * + * @param start Start of the range + * @param end End of the range + * @returns + */ +export function rangeInclusive(start: number, end: number): InclusiveRange { + return toBrandedType([start, end]); +} + +/** + * Turn a number pair into an inclusive range. + * + * @param pair The number pair to convert to an inclusive range + * @returns The new inclusive range + */ +export function rangeInclusiveFromPair(pair: [start: number, end: number]) { + return toBrandedType(pair); +} + +/** + * Given two ranges, return if the two ranges overlap with each other e.g. + * [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]. + * + * @param param0 One of the ranges to compare + * @param param1 The other range to compare against + * @returns TRUE if the ranges overlap + */ +export const rangesOverlap = ( + [a0, a1]: InclusiveRange, + [b0, b1]: InclusiveRange, +): boolean => { + if (a0 <= b0) { + return a1 >= b0; + } + + if (a0 >= b0) { + return b1 >= a0; + } + + return false; +}; + +/** + * Given two ranges,return ther intersection of the two ranges if any e.g. the + * intersection of [1, 3] and [2, 4] is [2, 3]. + * + * @param param0 The first range to compare + * @param param1 The second range to compare + * @returns The inclusive range intersection or NULL if no intersection + */ +export const rangeIntersection = ( + [a0, a1]: InclusiveRange, + [b0, b1]: InclusiveRange, +): InclusiveRange | null => { + const rangeStart = Math.max(a0, b0); + const rangeEnd = Math.min(a1, b1); + + if (rangeStart <= rangeEnd) { + return toBrandedType([rangeStart, rangeEnd]); + } + + return null; +}; + +/** + * Determine if a value is inside a range. + * + * @param value The value to check + * @param range The range + * @returns + */ +export const rangeIncludesValue = ( + value: number, + [min, max]: InclusiveRange, +): boolean => { + return value >= min && value <= max; +}; diff --git a/packages/math/segment.ts b/packages/math/segment.ts new file mode 100644 index 000000000..6c0c2de34 --- /dev/null +++ b/packages/math/segment.ts @@ -0,0 +1,158 @@ +import { + isPoint, + pointCenter, + pointFromVector, + pointRotateRads, +} from "./point"; +import type { GlobalPoint, LineSegment, LocalPoint, Radians } from "./types"; +import { PRECISION } from "./utils"; +import { + vectorAdd, + vectorCross, + vectorFromPoint, + vectorScale, + vectorSubtract, +} from "./vector"; + +/** + * Create a line segment from two points. + * + * @param points The two points delimiting the line segment on each end + * @returns The line segment delineated by the points + */ +export function lineSegment

( + a: P, + b: P, +): LineSegment

{ + return [a, b] as LineSegment

; +} + +export function lineSegmentFromPointArray

( + pointArray: P[], +): LineSegment

| undefined { + return pointArray.length === 2 + ? lineSegment

(pointArray[0], pointArray[1]) + : undefined; +} + +/** + * + * @param segment + * @returns + */ +export const isLineSegment = ( + segment: unknown, +): segment is LineSegment => + Array.isArray(segment) && + segment.length === 2 && + isPoint(segment[0]) && + isPoint(segment[0]); + +/** + * Return the coordinates resulting from rotating the given line about an origin by an angle in radians + * note that when the origin is not given, the midpoint of the given line is used as the origin. + * + * @param l + * @param angle + * @param origin + * @returns + */ +export const lineSegmentRotate = ( + l: LineSegment, + angle: Radians, + origin?: Point, +): LineSegment => { + return lineSegment( + pointRotateRads(l[0], origin || pointCenter(l[0], l[1]), angle), + pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle), + ); +}; + +/** + * Calculates the point two line segments with a definite start and end point + * intersect at. + */ +export const segmentsIntersectAt = ( + a: Readonly>, + b: Readonly>, +): Point | null => { + const a0 = vectorFromPoint(a[0]); + const a1 = vectorFromPoint(a[1]); + const b0 = vectorFromPoint(b[0]); + const b1 = vectorFromPoint(b[1]); + const r = vectorSubtract(a1, a0); + const s = vectorSubtract(b1, b0); + const denominator = vectorCross(r, s); + + if (denominator === 0) { + return null; + } + + const i = vectorSubtract(vectorFromPoint(b[0]), vectorFromPoint(a[0])); + const u = vectorCross(i, r) / denominator; + const t = vectorCross(i, s) / denominator; + + if (u === 0) { + return null; + } + + const p = vectorAdd(a0, vectorScale(r, t)); + + if (t >= 0 && t < 1 && u >= 0 && u < 1) { + return pointFromVector(p); + } + + return null; +}; + +export const pointOnLineSegment = ( + point: Point, + line: LineSegment, + threshold = PRECISION, +) => { + const distance = distanceToLineSegment(point, line); + + if (distance === 0) { + return true; + } + + return distance < threshold; +}; + +export const distanceToLineSegment = ( + point: Point, + line: LineSegment, +) => { + 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); +}; diff --git a/packages/math/triangle.ts b/packages/math/triangle.ts new file mode 100644 index 000000000..bc74372b7 --- /dev/null +++ b/packages/math/triangle.ts @@ -0,0 +1,28 @@ +import type { GlobalPoint, LocalPoint, Triangle } from "./types"; + +// Types + +/** + * Tests if a point lies inside a triangle. This function + * will return FALSE if the point lies exactly on the sides + * of the triangle. + * + * @param triangle The triangle to test the point for + * @param p The point to test whether is in the triangle + * @returns TRUE if the point is inside of the triangle + */ +export function triangleIncludesPoint

( + [a, b, c]: Triangle

, + p: P, +): boolean { + const triangleSign = (p1: P, p2: P, p3: P) => + (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]); + const d1 = triangleSign(p, a, b); + const d2 = triangleSign(p, b, c); + const d3 = triangleSign(p, c, a); + + const has_neg = d1 < 0 || d2 < 0 || d3 < 0; + const has_pos = d1 > 0 || d2 > 0 || d3 > 0; + + return !(has_neg && has_pos); +} diff --git a/packages/math/types.ts b/packages/math/types.ts new file mode 100644 index 000000000..138a44bc0 --- /dev/null +++ b/packages/math/types.ts @@ -0,0 +1,130 @@ +// +// Measurements +// + +/** + * By definition one radian is the angle subtended at the centre + * of a circle by an arc that is equal in length to the radius. + */ +export type Radians = number & { _brand: "excalimath__radian" }; + +/** + * An angle measurement of a plane angle in which one full + * rotation is 360 degrees. + */ +export type Degrees = number & { _brand: "excalimath_degree" }; + +// +// Range +// + +/** + * A number range which includes the start and end numbers in the range. + */ +export type InclusiveRange = [number, number] & { _brand: "excalimath_degree" }; + +// +// Point +// + +/** + * Represents a 2D position in world or canvas space. A + * global coordinate. + */ +export type GlobalPoint = [x: number, y: number] & { + _brand: "excalimath__globalpoint"; +}; + +/** + * Represents a 2D position in whatever local space it's + * needed. A local coordinate. + */ +export type LocalPoint = [x: number, y: number] & { + _brand: "excalimath__localpoint"; +}; + +// Line + +/** + * A line is an infinitely long object with no width, depth, or curvature. + */ +export type Line

= [p: P, q: P] & { + _brand: "excalimath_line"; +}; + +/** + * In geometry, a line segment is a part of a straight + * line that is bounded by two distinct end points, and + * contains every point on the line that is between its endpoints. + */ +export type LineSegment

= [a: P, b: P] & { + _brand: "excalimath_linesegment"; +}; + +// +// Vector +// + +/** + * Represents a 2D vector + */ +export type Vector = [u: number, v: number] & { + _brand: "excalimath__vector"; +}; + +// Triangles + +/** + * A triangle represented by 3 points + */ +export type Triangle

= [ + a: P, + b: P, + c: P, +] & { + _brand: "excalimath__triangle"; +}; + +// +// Polygon +// + +/** + * A polygon is a closed shape by connecting the given points + * rectangles and diamonds are modelled by polygons + */ +export type Polygon = Point[] & { + _brand: "excalimath_polygon"; +}; + +// +// Curve +// + +/** + * Cubic bezier curve with four control points + */ +export type Curve = [ + Point, + Point, + Point, + Point, +] & { + _brand: "excalimath_curve"; +}; + +export type PolarCoords = [ + radius: number, + /** angle in radians */ + angle: number, +]; + +/** + * Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle + * corresponds to (1, 0) cartesian coordinates (point), i.e. to the "right". + */ +export type SymmetricArc = { + radius: number; + startAngle: number; + endAngle: number; +}; diff --git a/packages/math/utils.ts b/packages/math/utils.ts new file mode 100644 index 000000000..f4d90704f --- /dev/null +++ b/packages/math/utils.ts @@ -0,0 +1,17 @@ +export const PRECISION = 10e-5; + +export function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +export function round(value: number, precision: number) { + const multiplier = Math.pow(10, precision); + + return Math.round((value + Number.EPSILON) * multiplier) / multiplier; +} + +export const average = (a: number, b: number) => (a + b) / 2; + +export const isFiniteNumber = (value: any): value is number => { + return typeof value === "number" && Number.isFinite(value); +}; diff --git a/packages/math/vector.test.ts b/packages/math/vector.test.ts new file mode 100644 index 000000000..145c90953 --- /dev/null +++ b/packages/math/vector.test.ts @@ -0,0 +1,12 @@ +import { isVector } from "."; + +describe("Vector", () => { + test("isVector", () => { + expect(isVector([5, 5])).toBe(true); + expect(isVector([-5, -5])).toBe(true); + expect(isVector([5, 0.5])).toBe(true); + expect(isVector(null)).toBe(false); + expect(isVector(undefined)).toBe(false); + expect(isVector([5, NaN])).toBe(false); + }); +}); diff --git a/packages/math/vector.ts b/packages/math/vector.ts new file mode 100644 index 000000000..9b640b243 --- /dev/null +++ b/packages/math/vector.ts @@ -0,0 +1,141 @@ +import type { GlobalPoint, LocalPoint, Vector } from "./types"; + +/** + * Create a vector from the x and y coordiante elements. + * + * @param x The X aspect of the vector + * @param y T Y aspect of the vector + * @returns The constructed vector with X and Y as the coordinates + */ +export function vector( + x: number, + y: number, + originX: number = 0, + originY: number = 0, +): Vector { + return [x - originX, y - originY] as Vector; +} + +/** + * Turn a point into a vector with the origin point. + * + * @param p The point to turn into a vector + * @param origin The origin point in a given coordiante system + * @returns The created vector from the point and the origin + */ +export function vectorFromPoint( + p: Point, + origin: Point = [0, 0] as Point, +): Vector { + return vector(p[0] - origin[0], p[1] - origin[1]); +} + +/** + * Cross product is a binary operation on two vectors in 2D space. + * It results in a vector that is perpendicular to both vectors. + * + * @param a One of the vectors to use for the directed area calculation + * @param b The other vector to use for the directed area calculation + * @returns The directed area value for the two vectos + */ +export function vectorCross(a: Vector, b: Vector): number { + return a[0] * b[1] - b[0] * a[1]; +} + +/** + * Dot product is defined as the sum of the products of the + * two vectors. + * + * @param a One of the vectors for which the sum of products is calculated + * @param b The other vector for which the sum of products is calculated + * @returns The sum of products of the two vectors + */ +export function vectorDot(a: Vector, b: Vector) { + return a[0] * b[0] + a[1] * b[1]; +} + +/** + * Determines if the value has the shape of a Vector. + * + * @param v The value to test + * @returns TRUE if the value has the shape and components of a Vectors + */ +export function isVector(v: unknown): v is Vector { + return ( + Array.isArray(v) && + v.length === 2 && + typeof v[0] === "number" && + !isNaN(v[0]) && + typeof v[1] === "number" && + !isNaN(v[1]) + ); +} + +/** + * Add two vectors by adding their coordinates. + * + * @param a One of the vectors to add + * @param b The other vector to add + * @returns The sum vector of the two provided vectors + */ +export function vectorAdd(a: Readonly, b: Readonly): Vector { + return [a[0] + b[0], a[1] + b[1]] as Vector; +} + +/** + * Add two vectors by adding their coordinates. + * + * @param start One of the vectors to add + * @param end The other vector to add + * @returns The sum vector of the two provided vectors + */ +export function vectorSubtract( + start: Readonly, + end: Readonly, +): Vector { + return [start[0] - end[0], start[1] - end[1]] as Vector; +} + +/** + * Scale vector by a scalar. + * + * @param v The vector to scale + * @param scalar The scalar to multiply the vector components with + * @returns The new scaled vector + */ +export function vectorScale(v: Vector, scalar: number): Vector { + return vector(v[0] * scalar, v[1] * scalar); +} + +/** + * Calculates the sqare magnitude of a vector. Use this if you compare + * magnitudes as it saves you an SQRT. + * + * @param v The vector to measure + * @returns The scalar squared magnitude of the vector + */ +export function vectorMagnitudeSq(v: Vector) { + return v[0] * v[0] + v[1] * v[1]; +} + +/** + * Calculates the magnitude of a vector. + * + * @param v The vector to measure + * @returns The scalar magnitude of the vector + */ +export function vectorMagnitude(v: Vector) { + return Math.sqrt(vectorMagnitudeSq(v)); +} + +/** + * Normalize the vector (i.e. make the vector magnitue equal 1). + * + * @param v The vector to normalize + * @returns The new normalized vector + */ +export const vectorNormalize = (v: Vector): Vector => { + const m = vectorMagnitude(v); + + return vector(v[0] / m, v[1] / m); +}; diff --git a/packages/math/webpack.prod.config.js b/packages/math/webpack.prod.config.js new file mode 100644 index 000000000..410d67510 --- /dev/null +++ b/packages/math/webpack.prod.config.js @@ -0,0 +1,55 @@ +const webpack = require("webpack"); +const path = require("path"); +const BundleAnalyzerPlugin = + require("webpack-bundle-analyzer").BundleAnalyzerPlugin; + +module.exports = { + mode: "production", + entry: { "excalidraw-math.min": "./index.js" }, + output: { + path: path.resolve(__dirname, "dist"), + filename: "[name].js", + library: "ExcalidrawMath", + libraryTarget: "umd", + }, + resolve: { + extensions: [".tsx", ".ts", ".js", ".css", ".scss"], + }, + optimization: { + runtimeChunk: false, + }, + module: { + rules: [ + { + test: /\.(ts|tsx|js)$/, + use: [ + { + loader: "ts-loader", + options: { + transpileOnly: true, + configFile: path.resolve(__dirname, "../tsconfig.prod.json"), + }, + }, + { + loader: "babel-loader", + + options: { + presets: [ + "@babel/preset-env", + ["@babel/preset-react", { runtime: "automatic" }], + "@babel/preset-typescript", + ], + plugins: [["@babel/plugin-transform-runtime"]], + }, + }, + ], + }, + ], + }, + plugins: [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1, + }), + ...(process.env.ANALYZER === "true" ? [new BundleAnalyzerPlugin()] : []), + ], +}; diff --git a/packages/utils/bbox.ts b/packages/utils/bbox.ts index e662a5a8c..933c630c8 100644 --- a/packages/utils/bbox.ts +++ b/packages/utils/bbox.ts @@ -1,9 +1,16 @@ +import { + vectorCross, + vectorFromPoint, + type GlobalPoint, + type LocalPoint, +} from "../math"; import type { Bounds } from "../excalidraw/element/bounds"; -import type { Point } from "../excalidraw/types"; -export type LineSegment = [Point, Point]; +export type LineSegment

= [P, P]; -export function getBBox(line: LineSegment): Bounds { +export function getBBox

( + line: LineSegment

, +): Bounds { return [ Math.min(line[0][0], line[1][0]), Math.min(line[0][1], line[1][1]), @@ -12,40 +19,37 @@ export function getBBox(line: LineSegment): Bounds { ]; } -export function crossProduct(a: Point, b: Point) { - return a[0] * b[1] - b[0] * a[1]; -} - export function doBBoxesIntersect(a: Bounds, b: Bounds) { return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1]; } -export function translate(a: Point, b: Point): Point { - return [a[0] - b[0], a[1] - b[1]]; -} - const EPSILON = 0.000001; -export function isPointOnLine(l: LineSegment, p: Point) { - const p1 = translate(l[1], l[0]); - const p2 = translate(p, l[0]); +export function isPointOnLine

( + l: LineSegment

, + p: P, +) { + const p1 = vectorFromPoint(l[1], l[0]); + const p2 = vectorFromPoint(p, l[0]); - const r = crossProduct(p1, p2); + const r = vectorCross(p1, p2); return Math.abs(r) < EPSILON; } -export function isPointRightOfLine(l: LineSegment, p: Point) { - const p1 = translate(l[1], l[0]); - const p2 = translate(p, l[0]); +export function isPointRightOfLine

( + l: LineSegment

, + p: P, +) { + const p1 = vectorFromPoint(l[1], l[0]); + const p2 = vectorFromPoint(p, l[0]); - return crossProduct(p1, p2) < 0; + return vectorCross(p1, p2) < 0; } -export function isLineSegmentTouchingOrCrossingLine( - a: LineSegment, - b: LineSegment, -) { +export function isLineSegmentTouchingOrCrossingLine< + P extends GlobalPoint | LocalPoint, +>(a: LineSegment

, b: LineSegment

) { return ( isPointOnLine(a, b[0]) || isPointOnLine(a, b[1]) || @@ -56,7 +60,10 @@ export function isLineSegmentTouchingOrCrossingLine( } // https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/ -export function doLineSegmentsIntersect(a: LineSegment, b: LineSegment) { +export function doLineSegmentsIntersect

( + a: LineSegment

, + b: LineSegment

, +) { return ( doBBoxesIntersect(getBBox(a), getBBox(b)) && isLineSegmentTouchingOrCrossingLine(a, b) && diff --git a/packages/utils/collision.test.ts b/packages/utils/collision.test.ts new file mode 100644 index 000000000..398c3cb68 --- /dev/null +++ b/packages/utils/collision.test.ts @@ -0,0 +1,87 @@ +import type { Curve, Degrees, GlobalPoint } from "../math"; +import { + curve, + degreesToRadians, + lineSegment, + lineSegmentRotate, + point, + pointRotateDegs, +} from "../math"; +import { pointOnCurve, pointOnPolyline } from "./collision"; +import type { Polyline } from "./geometry/shape"; + +describe("point and curve", () => { + const c: Curve = curve( + point(1.4, 1.65), + point(1.9, 7.9), + point(5.9, 1.65), + point(6.44, 4.84), + ); + + it("point on curve", () => { + expect(pointOnCurve(c[0], c, 10e-5)).toBe(true); + expect(pointOnCurve(c[3], c, 10e-5)).toBe(true); + + expect(pointOnCurve(point(2, 4), c, 0.1)).toBe(true); + expect(pointOnCurve(point(4, 4.4), c, 0.1)).toBe(true); + expect(pointOnCurve(point(5.6, 3.85), c, 0.1)).toBe(true); + + expect(pointOnCurve(point(5.6, 4), c, 0.1)).toBe(false); + expect(pointOnCurve(c[1], c, 0.1)).toBe(false); + expect(pointOnCurve(c[2], c, 0.1)).toBe(false); + }); +}); + +describe("point and polylines", () => { + const polyline: Polyline = [ + lineSegment(point(1, 0), point(1, 2)), + lineSegment(point(1, 2), point(2, 2)), + lineSegment(point(2, 2), point(2, 1)), + lineSegment(point(2, 1), point(3, 1)), + ]; + + it("point on the line", () => { + expect(pointOnPolyline(point(1, 0), polyline)).toBe(true); + expect(pointOnPolyline(point(1, 2), polyline)).toBe(true); + expect(pointOnPolyline(point(2, 2), polyline)).toBe(true); + expect(pointOnPolyline(point(2, 1), polyline)).toBe(true); + expect(pointOnPolyline(point(3, 1), polyline)).toBe(true); + + expect(pointOnPolyline(point(1, 1), polyline)).toBe(true); + expect(pointOnPolyline(point(2, 1.5), polyline)).toBe(true); + expect(pointOnPolyline(point(2.5, 1), polyline)).toBe(true); + + expect(pointOnPolyline(point(0, 1), polyline)).toBe(false); + expect(pointOnPolyline(point(2.1, 1.5), polyline)).toBe(false); + }); + + it("point on the line with rotation", () => { + const truePoints = [ + point(1, 0), + point(1, 2), + point(2, 2), + point(2, 1), + point(3, 1), + ]; + + truePoints.forEach((p) => { + const rotation = (Math.random() * 360) as Degrees; + const rotatedPoint = pointRotateDegs(p, point(0, 0), rotation); + const rotatedPolyline = polyline.map((line) => + lineSegmentRotate(line, degreesToRadians(rotation), point(0, 0)), + ); + expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true); + }); + + const falsePoints = [point(0, 1), point(2.1, 1.5)]; + + falsePoints.forEach((p) => { + const rotation = (Math.random() * 360) as Degrees; + const rotatedPoint = pointRotateDegs(p, point(0, 0), rotation); + const rotatedPolyline = polyline.map((line) => + lineSegmentRotate(line, degreesToRadians(rotation), point(0, 0)), + ); + expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false); + }); + }); +}); diff --git a/packages/utils/collision.ts b/packages/utils/collision.ts index e36975399..126939737 100644 --- a/packages/utils/collision.ts +++ b/packages/utils/collision.ts @@ -1,20 +1,26 @@ -import type { Point, Polygon, GeometricShape } from "./geometry/shape"; +import type { Polycurve, Polyline } from "./geometry/shape"; import { pointInEllipse, - pointInPolygon, - pointOnCurve, pointOnEllipse, - pointOnLine, - pointOnPolycurve, + type GeometricShape, +} from "./geometry/shape"; +import type { Curve } from "../math"; +import { + lineSegment, + point, + polygonIncludesPoint, + pointOnLineSegment, pointOnPolygon, - pointOnPolyline, - close, -} from "./geometry/geometry"; + polygonFromPoints, + type GlobalPoint, + type LocalPoint, + type Polygon, +} from "../math"; // check if the given point is considered on the given shape's border -export const isPointOnShape = ( +export const isPointOnShape = ( point: Point, - shape: GeometricShape, + shape: GeometricShape, tolerance = 0, ) => { // get the distance from the given point to the given element @@ -25,7 +31,7 @@ export const isPointOnShape = ( case "ellipse": return pointOnEllipse(point, shape.data, tolerance); case "line": - return pointOnLine(point, shape.data, tolerance); + return pointOnLineSegment(point, shape.data, tolerance); case "polyline": return pointOnPolyline(point, shape.data, tolerance); case "curve": @@ -38,10 +44,13 @@ export const isPointOnShape = ( }; // check if the given point is considered inside the element's border -export const isPointInShape = (point: Point, shape: GeometricShape) => { +export const isPointInShape = ( + point: Point, + shape: GeometricShape, +) => { switch (shape.type) { case "polygon": - return pointInPolygon(point, shape.data); + return polygonIncludesPoint(point, shape.data); case "line": return false; case "curve": @@ -49,8 +58,8 @@ export const isPointInShape = (point: Point, shape: GeometricShape) => { case "ellipse": return pointInEllipse(point, shape.data); case "polyline": { - const polygon = close(shape.data.flat()) as Polygon; - return pointInPolygon(point, polygon); + const polygon = polygonFromPoints(shape.data.flat()); + return polygonIncludesPoint(point, polygon); } case "polycurve": { return false; @@ -61,6 +70,67 @@ export const isPointInShape = (point: Point, shape: GeometricShape) => { }; // check if the given element is in the given bounds -export const isPointInBounds = (point: Point, bounds: Polygon) => { - return pointInPolygon(point, bounds); +export const isPointInBounds = ( + point: Point, + bounds: Polygon, +) => { + return polygonIncludesPoint(point, bounds); +}; + +const pointOnPolycurve = ( + point: Point, + polycurve: Polycurve, + tolerance: number, +) => { + return polycurve.some((curve) => pointOnCurve(point, curve, tolerance)); +}; + +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); +}; + +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 = point(equation(t, 0), equation(t, 1)); + lineSegments.push(lineSegment(startingPoint, nextPoint)); + startingPoint = nextPoint; + } + } + + return lineSegments; +}; + +export const pointOnCurve = ( + point: Point, + curve: Curve, + threshold: number, +) => { + return pointOnPolyline(point, polyLineFromCurve(curve), threshold); +}; + +export const pointOnPolyline = ( + point: Point, + polyline: Polyline, + threshold = 10e-5, +) => { + return polyline.some((line) => pointOnLineSegment(point, line, threshold)); }; diff --git a/packages/utils/geometry/geometry.test.ts b/packages/utils/geometry/geometry.test.ts index 0a75aae53..6ee357d70 100644 --- a/packages/utils/geometry/geometry.test.ts +++ b/packages/utils/geometry/geometry.test.ts @@ -1,249 +1,122 @@ +import type { GlobalPoint, LineSegment, Polygon, Radians } from "../../math"; import { - lineIntersectsLine, - lineRotate, - pointInEllipse, - pointInPolygon, - pointLeftofLine, - pointOnCurve, - pointOnEllipse, - pointOnLine, + point, + lineSegment, + polygon, + pointOnLineSegment, pointOnPolygon, - pointOnPolyline, - pointRightofLine, - pointRotate, -} from "./geometry"; -import type { Curve, Ellipse, Line, Point, Polygon, Polyline } from "./shape"; + polygonIncludesPoint, + segmentsIntersectAt, +} from "../../math"; +import { pointInEllipse, pointOnEllipse, type Ellipse } from "./shape"; describe("point and line", () => { - const line: Line = [ - [1, 0], - [1, 2], - ]; + // const l: Line = line(point(1, 0), point(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); + // it("point on left or right of line", () => { + // expect(pointLeftofLine(point(0, 1), l)).toBe(true); + // expect(pointLeftofLine(point(1, 1), l)).toBe(false); + // expect(pointLeftofLine(point(2, 1), l)).toBe(false); - expect(pointRightofLine([0, 1], line)).toBe(false); - expect(pointRightofLine([1, 1], line)).toBe(false); - expect(pointRightofLine([2, 1], line)).toBe(true); - }); + // expect(pointRightofLine(point(0, 1), l)).toBe(false); + // expect(pointRightofLine(point(1, 1), l)).toBe(false); + // expect(pointRightofLine(point(2, 1), l)).toBe(true); + // }); + + const s: LineSegment = lineSegment(point(1, 0), point(1, 2)); 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); - }); + expect(pointOnLineSegment(point(0, 1), s)).toBe(false); + expect(pointOnLineSegment(point(1, 1), s, 0)).toBe(true); + expect(pointOnLineSegment(point(2, 1), s)).toBe(false); }); }); describe("point and polygon", () => { - const polygon: Polygon = [ - [10, 10], - [50, 10], - [50, 50], - [10, 50], - ]; + const poly: Polygon = polygon( + point(10, 10), + point(50, 10), + point(50, 50), + point(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); + expect(pointOnPolygon(point(30, 10), poly)).toBe(true); + expect(pointOnPolygon(point(50, 30), poly)).toBe(true); + expect(pointOnPolygon(point(30, 50), poly)).toBe(true); + expect(pointOnPolygon(point(10, 30), poly)).toBe(true); + expect(pointOnPolygon(point(30, 30), poly)).toBe(false); + expect(pointOnPolygon(point(30, 70), poly)).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); + const poly: Polygon = polygon( + point(0, 0), + point(2, 0), + point(2, 2), + point(0, 2), + ); + expect(polygonIncludesPoint(point(1, 1), poly)).toBe(true); + expect(polygonIncludesPoint(point(3, 3), poly)).toBe(false); }); }); describe("point and ellipse", () => { - const ellipse: Ellipse = { - center: [0, 0], - angle: 0, + const ellipse: Ellipse = { + center: point(0, 0), + angle: 0 as Radians, 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); + [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => { + expect(pointOnEllipse(p, 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(point(-1.4, 0.7), ellipse, 0.1)).toBe(true); + expect(pointOnEllipse(point(-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(point(1.4, 0.7), ellipse, 0.1)).toBe(true); + expect(pointOnEllipse(point(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(point(1, -0.86), ellipse, 0.1)).toBe(true); + expect(pointOnEllipse(point(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(point(-1, -0.86), ellipse, 0.1)).toBe(true); + expect(pointOnEllipse(point(-1, -0.86), ellipse, 0.01)).toBe(true); - expect(pointOnEllipse([-1, 0.8], ellipse)).toBe(false); - expect(pointOnEllipse([1, -0.8], ellipse)).toBe(false); + expect(pointOnEllipse(point(-1, 0.8), ellipse)).toBe(false); + expect(pointOnEllipse(point(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); + [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => { + expect(pointInEllipse(p, ellipse)).toBe(true); }); - expect(pointInEllipse([-1, 0.8], ellipse)).toBe(true); - expect(pointInEllipse([1, -0.8], ellipse)).toBe(true); + expect(pointInEllipse(point(-1, 0.8), ellipse)).toBe(true); + expect(pointInEllipse(point(1, -0.8), ellipse)).toBe(true); - expect(pointInEllipse([-1, 1], ellipse)).toBe(false); - expect(pointInEllipse([-1.4, 0.8], ellipse)).toBe(false); + expect(pointInEllipse(point(-1, 1), ellipse)).toBe(false); + expect(pointInEllipse(point(-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], - ]; + const lineA: LineSegment = lineSegment(point(1, 4), point(3, 4)); + const lineB: LineSegment = lineSegment(point(2, 1), point(2, 7)); + const lineC: LineSegment = lineSegment(point(1, 8), point(3, 8)); + const lineD: LineSegment = lineSegment(point(1, 8), point(3, 8)); + const lineE: LineSegment = lineSegment(point(1, 9), point(3, 9)); + const lineF: LineSegment = lineSegment(point(1, 2), point(3, 4)); + const lineG: LineSegment = lineSegment(point(0, 1), point(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); + expect(segmentsIntersectAt(lineA, lineB)).toEqual([2, 4]); + expect(segmentsIntersectAt(lineA, lineC)).toBe(null); + expect(segmentsIntersectAt(lineB, lineC)).toBe(null); + expect(segmentsIntersectAt(lineC, lineD)).toBe(null); // Line overlapping line is not intersection! + expect(segmentsIntersectAt(lineE, lineD)).toBe(null); + expect(segmentsIntersectAt(lineF, lineG)).toBe(null); }); }); diff --git a/packages/utils/geometry/geometry.ts b/packages/utils/geometry/geometry.ts deleted file mode 100644 index 9274ce746..000000000 --- a/packages/utils/geometry/geometry.ts +++ /dev/null @@ -1,1060 +0,0 @@ -import type { ExcalidrawBindableElement } from "../../excalidraw/element/types"; -import { - addVectors, - distance2d, - rotatePoint, - scaleVector, - subtractVectors, -} from "../../excalidraw/math"; -import type { LineSegment } from "../bbox"; -import { crossProduct } from "../bbox"; -import type { - 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: Readonly, - b: Readonly, - o: Readonly, -) => { - return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); -}; - -export const dot = ( - a: Readonly, - b: Readonly, - o: Readonly, -) => { - return (a[0] - o[0]) * (b[0] - o[0]) + (a[1] - o[1]) * (b[1] - o[1]); -}; - -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) => { - const theta = (angle * 180) / Math.PI; - - return theta < 0 ? 360 + theta : theta; -}; - -// 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 - ); -}; - -/** - * Calculates the point two line segments with a definite start and end point - * intersect at. - */ -export const segmentsIntersectAt = ( - a: Readonly, - b: Readonly, -): Point | null => { - const r = subtractVectors(a[1], a[0]); - const s = subtractVectors(b[1], b[0]); - const denominator = crossProduct(r, s); - - if (denominator === 0) { - return null; - } - - const i = subtractVectors(b[0], a[0]); - const u = crossProduct(i, r) / denominator; - const t = crossProduct(i, s) / denominator; - - if (u === 0) { - return null; - } - - const p = addVectors(a[0], scaleVector(r, t)); - - if (t >= 0 && t < 1 && u >= 0 && u < 1) { - return p; - } - - return null; -}; - -/** - * Determine intersection of a rectangular shaped element and a - * line segment. - * - * @param element The rectangular element to test against - * @param segment The segment intersecting the element - * @param gap Optional value to inflate the shape before testing - * @returns An array of intersections - */ -// TODO: Replace with final rounded rectangle code -export const segmentIntersectRectangleElement = ( - element: ExcalidrawBindableElement, - segment: LineSegment, - gap: number = 0, -): Point[] => { - const bounds = [ - element.x - gap, - element.y - gap, - element.x + element.width + gap, - element.y + element.height + gap, - ]; - const center = [ - (bounds[0] + bounds[2]) / 2, - (bounds[1] + bounds[3]) / 2, - ] as Point; - - return [ - [ - rotatePoint([bounds[0], bounds[1]], center, element.angle), - rotatePoint([bounds[2], bounds[1]], center, element.angle), - ] as LineSegment, - [ - rotatePoint([bounds[2], bounds[1]], center, element.angle), - rotatePoint([bounds[2], bounds[3]], center, element.angle), - ] as LineSegment, - [ - rotatePoint([bounds[2], bounds[3]], center, element.angle), - rotatePoint([bounds[0], bounds[3]], center, element.angle), - ] as LineSegment, - [ - rotatePoint([bounds[0], bounds[3]], center, element.angle), - rotatePoint([bounds[0], bounds[1]], center, element.angle), - ] as LineSegment, - ] - .map((s) => segmentsIntersectAt(segment, s)) - .filter((i): i is Point => !!i); -}; diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts index d14456ea4..f896f2e6f 100644 --- a/packages/utils/geometry/shape.ts +++ b/packages/utils/geometry/shape.ts @@ -12,9 +12,30 @@ * to pure shapes */ +import type { Curve, LineSegment, Polygon, Radians } from "../../math"; +import { + curve, + lineSegment, + point, + pointDistance, + pointFromArray, + pointFromVector, + pointRotateRads, + polygon, + polygonFromPoints, + PRECISION, + segmentsIntersectAt, + vector, + vectorAdd, + vectorFromPoint, + vectorScale, + type GlobalPoint, + type LocalPoint, +} from "../../math"; import { getElementAbsoluteCoords } from "../../excalidraw/element"; import type { ElementsMap, + ExcalidrawBindableElement, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawEllipseElement, @@ -28,67 +49,54 @@ import type { 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]; +import { invariant } from "../../excalidraw/utils"; // 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]; +export type Polyline = + LineSegment[]; // 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[]; +export type Polycurve = Curve[]; // 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 = { +export type Ellipse = { center: Point; - angle: number; + angle: Radians; halfWidth: number; halfHeight: number; }; -export type GeometricShape = +export type GeometricShape = | { type: "line"; - data: Line; + data: LineSegment; } | { type: "polygon"; - data: Polygon; + data: Polygon; } | { type: "curve"; - data: Curve; + data: Curve; } | { type: "ellipse"; - data: Ellipse; + data: Ellipse; } | { type: "polyline"; - data: Polyline; + data: Polyline; } | { type: "polycurve"; - data: Polycurve; + data: Polycurve; }; type RectangularElement = @@ -102,32 +110,32 @@ type RectangularElement = | ExcalidrawSelectionElement; // polygon -export const getPolygonShape = ( +export const getPolygonShape = ( element: RectangularElement, -): GeometricShape => { +): 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]; + const center: Point = point(cx, cy); - let data: Polygon = []; + 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; + data = polygon( + pointRotateRads(point(cx, y), center, angle), + pointRotateRads(point(x + width, cy), center, angle), + pointRotateRads(point(cx, y + height), center, angle), + pointRotateRads(point(x, cy), center, angle), + ); } 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; + data = polygon( + pointRotateRads(point(x, y), center, angle), + pointRotateRads(point(x + width, y), center, angle), + pointRotateRads(point(x + width, y + height), center, angle), + pointRotateRads(point(x, y + height), center, angle), + ); } return { @@ -137,7 +145,7 @@ export const getPolygonShape = ( }; // return the selection box for an element, possibly rotated as well -export const getSelectionBoxShape = ( +export const getSelectionBoxShape = ( element: ExcalidrawElement, elementsMap: ElementsMap, padding = 10, @@ -153,29 +161,29 @@ export const getSelectionBoxShape = ( y1 -= padding; y2 += padding; - const angleInDegrees = angleToDegrees(element.angle); - const center: Point = [cx, cy]; - const topLeft = pointRotate([x1, y1], angleInDegrees, center); - const topRight = pointRotate([x2, y1], angleInDegrees, center); - const bottomLeft = pointRotate([x1, y2], angleInDegrees, center); - const bottomRight = pointRotate([x2, y2], angleInDegrees, center); + //const angleInDegrees = angleToDegrees(element.angle); + const center = point(cx, cy); + const topLeft = pointRotateRads(point(x1, y1), center, element.angle); + const topRight = pointRotateRads(point(x2, y1), center, element.angle); + const bottomLeft = pointRotateRads(point(x1, y2), center, element.angle); + const bottomRight = pointRotateRads(point(x2, y2), center, element.angle); return { type: "polygon", data: [topLeft, topRight, bottomRight, bottomLeft], - } as GeometricShape; + } as GeometricShape; }; // ellipse -export const getEllipseShape = ( +export const getEllipseShape = ( element: ExcalidrawEllipseElement, -): GeometricShape => { +): GeometricShape => { const { width, height, angle, x, y } = element; return { type: "ellipse", data: { - center: [x + width / 2, y + height / 2], + center: point(x + width / 2, y + height / 2), angle, halfWidth: width / 2, halfHeight: height / 2, @@ -193,32 +201,34 @@ export const getCurvePathOps = (shape: Drawable): Op[] => { }; // linear -export const getCurveShape = ( +export const getCurveShape = ( roughShape: Drawable, - startingPoint: Point = [0, 0], - angleInRadian: number, + startingPoint: Point = point(0, 0), + angleInRadian: Radians, center: Point, -): GeometricShape => { - const transform = (p: Point) => - pointRotate( - [p[0] + startingPoint[0], p[1] + startingPoint[1]], - angleToDegrees(angleInRadian), +): GeometricShape => { + const transform = (p: Point): Point => + pointRotateRads( + point(p[0] + startingPoint[0], p[1] + startingPoint[1]), center, + angleInRadian, ); const ops = getCurvePathOps(roughShape); - const polycurve: Polycurve = []; - let p0: Point = [0, 0]; + const polycurve: Polycurve = []; + let p0 = point(0, 0); for (const op of ops) { if (op.op === "move") { - p0 = transform(op.data as Point); + const p = pointFromArray(op.data); + invariant(p != null, "Ops data is not a point"); + p0 = transform(p); } 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]); + const p1 = transform(point(op.data[0], op.data[1])); + const p2 = transform(point(op.data[2], op.data[3])); + const p3 = transform(point(op.data[4], op.data[5])); + polycurve.push(curve(p0, p1, p2, p3)); p0 = p3; } } @@ -229,61 +239,72 @@ export const getCurveShape = ( }; }; -const polylineFromPoints = (points: Point[]) => { - let previousPoint = points[0]; - const polyline: Polyline = []; +const polylineFromPoints = ( + points: Point[], +): Polyline => { + let previousPoint: Point = points[0]; + const polyline: LineSegment[] = []; for (let i = 1; i < points.length; i++) { const nextPoint = points[i]; - polyline.push([previousPoint, nextPoint]); + polyline.push(lineSegment(previousPoint, nextPoint)); previousPoint = nextPoint; } return polyline; }; -export const getFreedrawShape = ( +export const getFreedrawShape = ( element: ExcalidrawFreeDrawElement, center: Point, isClosed: boolean = false, -): GeometricShape => { - const angle = angleToDegrees(element.angle); +): GeometricShape => { const transform = (p: Point) => - pointRotate(pointAdd(p, [element.x, element.y] as Point), angle, center); + pointRotateRads( + pointFromVector( + vectorAdd(vectorFromPoint(p), vector(element.x, element.y)), + ), + center, + element.angle, + ); 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, - }; + return ( + isClosed + ? { + type: "polygon", + data: polygonFromPoints(polyline.flat()), + } + : { + type: "polyline", + data: polyline, + } + ) as GeometricShape; }; -export const getClosedCurveShape = ( +export const getClosedCurveShape = ( element: ExcalidrawLinearElement, roughShape: Drawable, - startingPoint: Point = [0, 0], - angleInRadian: number, + startingPoint: Point = point(0, 0), + angleInRadian: Radians, center: Point, -): GeometricShape => { +): GeometricShape => { const transform = (p: Point) => - pointRotate( - [p[0] + startingPoint[0], p[1] + startingPoint[1]], - angleToDegrees(angleInRadian), + pointRotateRads( + point(p[0] + startingPoint[0], p[1] + startingPoint[1]), center, + angleInRadian, ); if (element.roundness === null) { return { type: "polygon", - data: close(element.points.map((p) => transform(p as Point))), + data: polygonFromPoints( + element.points.map((p) => transform(p as Point)) as Point[], + ), }; } @@ -295,27 +316,218 @@ export const getClosedCurveShape = ( if (operation.op === "move") { odd = !odd; if (odd) { - points.push([operation.data[0], operation.data[1]]); + points.push(point(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]]); + points.push(point(operation.data[0], operation.data[1])); + points.push(point(operation.data[2], operation.data[3])); + points.push(point(operation.data[4], operation.data[5])); } } else if (operation.op === "lineTo") { if (odd) { - points.push([operation.data[0], operation.data[1]]); + points.push(point(operation.data[0], operation.data[1])); } } } const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) => - transform(p), - ); + transform(p as Point), + ) as Point[]; return { type: "polygon", - data: polygonPoints, + data: polygonFromPoints(polygonPoints), }; }; + +/** + * Determine intersection of a rectangular shaped element and a + * line segment. + * + * @param element The rectangular element to test against + * @param segment The segment intersecting the element + * @param gap Optional value to inflate the shape before testing + * @returns An array of intersections + */ +// TODO: Replace with final rounded rectangle code +export const segmentIntersectRectangleElement = < + Point extends LocalPoint | GlobalPoint, +>( + element: ExcalidrawBindableElement, + segment: LineSegment, + gap: number = 0, +): Point[] => { + const bounds = [ + element.x - gap, + element.y - gap, + element.x + element.width + gap, + element.y + element.height + gap, + ]; + const center = point( + (bounds[0] + bounds[2]) / 2, + (bounds[1] + bounds[3]) / 2, + ); + + return [ + lineSegment( + pointRotateRads(point(bounds[0], bounds[1]), center, element.angle), + pointRotateRads(point(bounds[2], bounds[1]), center, element.angle), + ), + lineSegment( + pointRotateRads(point(bounds[2], bounds[1]), center, element.angle), + pointRotateRads(point(bounds[2], bounds[3]), center, element.angle), + ), + lineSegment( + pointRotateRads(point(bounds[2], bounds[3]), center, element.angle), + pointRotateRads(point(bounds[0], bounds[3]), center, element.angle), + ), + lineSegment( + pointRotateRads(point(bounds[0], bounds[3]), center, element.angle), + pointRotateRads(point(bounds[0], bounds[1]), center, element.angle), + ), + ] + .map((s) => segmentsIntersectAt(segment, s)) + .filter((i): i is Point => !!i); +}; + +const distanceToEllipse = ( + p: Point, + ellipse: Ellipse, +) => { + const { angle, halfWidth, halfHeight, center } = ellipse; + const a = halfWidth; + const b = halfHeight; + const translatedPoint = vectorAdd( + vectorFromPoint(p), + vectorScale(vectorFromPoint(center), -1), + ); + const [rotatedPointX, rotatedPointY] = pointRotateRads( + pointFromVector(translatedPoint), + point(0, 0), + -angle as Radians, + ); + + 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 pointDistance(point(rotatedPointX, rotatedPointY), point(minX, minY)); +}; + +export const pointOnEllipse = ( + point: Point, + ellipse: Ellipse, + threshold = PRECISION, +) => { + return distanceToEllipse(point, ellipse) <= threshold; +}; + +export const pointInEllipse = ( + p: Point, + ellipse: Ellipse, +) => { + const { center, angle, halfWidth, halfHeight } = ellipse; + const translatedPoint = vectorAdd( + vectorFromPoint(p), + vectorScale(vectorFromPoint(center), -1), + ); + const [rotatedPointX, rotatedPointY] = pointRotateRads( + pointFromVector(translatedPoint), + point(0, 0), + -angle as Radians, + ); + + return ( + (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) + + (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <= + 1 + ); +}; + +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); + const centerVector = vectorFromPoint(center); + + return [ + vectorAdd(vector(xAtYMax, yMax), centerVector), + vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector), + vectorAdd(vector(xMax, yAtXMax), centerVector), + vectorAdd(vector(xMax, yAtXMax), centerVector), + ]; +}; diff --git a/packages/utils/withinBounds.ts b/packages/utils/withinBounds.ts index 02d316243..1920c15cd 100644 --- a/packages/utils/withinBounds.ts +++ b/packages/utils/withinBounds.ts @@ -11,16 +11,21 @@ import { isLinearElement, isTextElement, } from "../excalidraw/element/typeChecks"; -import { isValueInRange, rotatePoint } from "../excalidraw/math"; -import type { Point } from "../excalidraw/types"; import type { Bounds } from "../excalidraw/element/bounds"; import { getElementBounds } from "../excalidraw/element/bounds"; import { arrayToMap } from "../excalidraw/utils"; +import type { LocalPoint } from "../math"; +import { + rangeIncludesValue, + point, + pointRotateRads, + rangeInclusive, +} from "../math"; type Element = NonDeletedExcalidrawElement; type Elements = readonly NonDeletedExcalidrawElement[]; -type Points = readonly Point[]; +type Points = readonly LocalPoint[]; /** @returns vertices relative to element's top-left [0,0] position */ const getNonLinearElementRelativePoints = ( @@ -28,20 +33,25 @@ const getNonLinearElementRelativePoints = ( Element, ExcalidrawLinearElement | ExcalidrawFreeDrawElement >, -): [TopLeft: Point, TopRight: Point, BottomRight: Point, BottomLeft: Point] => { +): [ + TopLeft: LocalPoint, + TopRight: LocalPoint, + BottomRight: LocalPoint, + BottomLeft: LocalPoint, +] => { if (element.type === "diamond") { return [ - [element.width / 2, 0], - [element.width, element.height / 2], - [element.width / 2, element.height], - [0, element.height / 2], + point(element.width / 2, 0), + point(element.width, element.height / 2), + point(element.width / 2, element.height), + point(0, element.height / 2), ]; } return [ - [0, 0], - [0 + element.width, 0], - [0 + element.width, element.height], - [0, element.height], + point(0, 0), + point(0 + element.width, 0), + point(0 + element.width, element.height), + point(0, element.height), ]; }; @@ -84,10 +94,10 @@ const getRotatedBBox = (element: Element): Bounds => { const points = getElementRelativePoints(element); const { cx, cy } = getMinMaxPoints(points); - const centerPoint: Point = [cx, cy]; + const centerPoint = point(cx, cy); - const rotatedPoints = points.map((point) => - rotatePoint([point[0], point[1]], centerPoint, element.angle), + const rotatedPoints = points.map((p) => + pointRotateRads(p, centerPoint, element.angle), ); const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints); @@ -135,10 +145,16 @@ export const elementPartiallyOverlapsWithOrContainsBBox = ( const elementBBox = getRotatedBBox(element); return ( - (isValueInRange(elementBBox[0], bbox[0], bbox[2]) || - isValueInRange(bbox[0], elementBBox[0], elementBBox[2])) && - (isValueInRange(elementBBox[1], bbox[1], bbox[3]) || - isValueInRange(bbox[1], elementBBox[1], elementBBox[3])) + (rangeIncludesValue(elementBBox[0], rangeInclusive(bbox[0], bbox[2])) || + rangeIncludesValue( + bbox[0], + rangeInclusive(elementBBox[0], elementBBox[2]), + )) && + (rangeIncludesValue(elementBBox[1], rangeInclusive(bbox[1], bbox[3])) || + rangeIncludesValue( + bbox[1], + rangeInclusive(elementBBox[1], elementBBox[3]), + )) ); }; diff --git a/scripts/buildMath.js b/scripts/buildMath.js new file mode 100644 index 000000000..47c191af5 --- /dev/null +++ b/scripts/buildMath.js @@ -0,0 +1,108 @@ +const fs = require("fs"); +const { build } = require("esbuild"); + +const browserConfig = { + entryPoints: ["index.ts"], + bundle: true, + format: "esm", +}; + +// Will be used later for treeshaking + +// function getFiles(dir, files = []) { +// const fileList = fs.readdirSync(dir); +// for (const file of fileList) { +// const name = `${dir}/${file}`; +// if ( +// name.includes("node_modules") || +// name.includes("config") || +// name.includes("package.json") || +// name.includes("main.js") || +// name.includes("index-node.ts") || +// name.endsWith(".d.ts") || +// name.endsWith(".md") +// ) { +// continue; +// } + +// if (fs.statSync(name).isDirectory()) { +// getFiles(name, files); +// } else if ( +// name.match(/\.(sa|sc|c)ss$/) || +// name.match(/\.(woff|woff2|eot|ttf|otf)$/) || +// name.match(/locales\/[^/]+\.json$/) +// ) { +// continue; +// } else { +// files.push(name); +// } +// } +// return files; +// } +const createESMBrowserBuild = async () => { + // Development unminified build with source maps + const browserDev = await build({ + ...browserConfig, + outdir: "dist/browser/dev", + sourcemap: true, + metafile: true, + define: { + "import.meta.env": JSON.stringify({ DEV: true }), + }, + }); + fs.writeFileSync( + "meta-browser-dev.json", + JSON.stringify(browserDev.metafile), + ); + + // production minified build without sourcemaps + const browserProd = await build({ + ...browserConfig, + outdir: "dist/browser/prod", + minify: true, + metafile: true, + define: { + "import.meta.env": JSON.stringify({ PROD: true }), + }, + }); + fs.writeFileSync( + "meta-browser-prod.json", + JSON.stringify(browserProd.metafile), + ); +}; + +const rawConfig = { + entryPoints: ["index.ts"], + bundle: true, + format: "esm", + packages: "external", +}; + +const createESMRawBuild = async () => { + // Development unminified build with source maps + const rawDev = await build({ + ...rawConfig, + outdir: "dist/dev", + sourcemap: true, + metafile: true, + define: { + "import.meta.env": JSON.stringify({ DEV: true }), + }, + }); + fs.writeFileSync("meta-raw-dev.json", JSON.stringify(rawDev.metafile)); + + // production minified build without sourcemaps + const rawProd = await build({ + ...rawConfig, + outdir: "dist/prod", + minify: true, + metafile: true, + define: { + "import.meta.env": JSON.stringify({ PROD: true }), + }, + }); + fs.writeFileSync("meta-raw-prod.json", JSON.stringify(rawProd.metafile)); +}; + +createESMRawBuild(); +createESMBrowserBuild();