From 6ab3b6c029d5d4a70891e301c5e8761792dba8ba Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 15 May 2025 18:20:47 +0200 Subject: [PATCH] State --- packages/element/src/Shape.ts | 207 +--- packages/element/src/binding.ts | 10 +- packages/element/src/bounds.ts | 213 +--- packages/element/src/collision.ts | 498 ++++++++- packages/element/src/distance.ts | 14 +- packages/element/src/frame.ts | 12 +- packages/element/src/linearElementEditor.ts | 4 +- packages/element/src/shapes.ts | 101 +- packages/element/src/utils.ts | 398 ++----- packages/excalidraw/components/App.tsx | 4 +- packages/excalidraw/eraser/index.ts | 51 +- packages/excalidraw/lasso/index.ts | 7 +- packages/excalidraw/lasso/utils.ts | 43 +- packages/excalidraw/tests/lasso.test.tsx | 4 +- packages/math/src/index.ts | 1 + packages/utils/src/shape.ts | 1042 +++++++++---------- packages/utils/tests/geometry.test.ts | 93 +- 17 files changed, 1166 insertions(+), 1536 deletions(-) diff --git a/packages/element/src/Shape.ts b/packages/element/src/Shape.ts index 0ab0b0fb7..7a313d662 100644 --- a/packages/element/src/Shape.ts +++ b/packages/element/src/Shape.ts @@ -1,17 +1,8 @@ import { simplify } from "points-on-curve"; -import { - pointFrom, - pointDistance, - type LocalPoint, - pointRotateRads, -} from "@excalidraw/math"; +import { pointFrom, type LocalPoint } from "@excalidraw/math"; import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common"; -import { RoughGenerator } from "roughjs/bin/generator"; - -import type { GlobalPoint, Radians } from "@excalidraw/math"; - import type { Mutable } from "@excalidraw/common/utility-types"; import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types"; @@ -25,15 +16,11 @@ import { isLinearElement, } from "./typeChecks"; import { getCornerRadius, isPathALoop } from "./shapes"; -import { headingForPointIsHorizontal } from "./heading"; +import { generateElbowArrowRougJshPathCommands } from "./utils"; import { canChangeRoundness } from "./comparisons"; import { generateFreeDrawShape } from "./renderElement"; -import { - getArrowheadPoints, - getDiamondPoints, - getElementBounds, -} from "./bounds"; +import { getArrowheadPoints, getDiamondPoints } from "./bounds"; import type { ExcalidrawElement, @@ -41,11 +28,11 @@ import type { ExcalidrawSelectionElement, ExcalidrawLinearElement, Arrowhead, - ExcalidrawFreeDrawElement, } from "./types"; import type { Drawable, Options } from "roughjs/bin/core"; import type { Point as RoughPoint } from "roughjs/bin/geometry"; +import type { RoughGenerator } from "roughjs/bin/generator"; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; @@ -316,125 +303,6 @@ const getArrowheadShapes = ( } }; -export const generateLinearCollisionShape = ( - element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, -) => { - const generator = new RoughGenerator(); - const options: Options = { - seed: element.seed, - disableMultiStroke: true, - disableMultiStrokeFill: true, - roughness: 0, - preserveVertices: true, - }; - - switch (element.type) { - case "line": - case "arrow": { - // 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 - : [pointFrom(0, 0)]; - const [x1, y1, x2, y2] = getElementBounds( - { - ...element, - angle: 0 as Radians, - }, - new Map(), - ); - const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); - - if (isElbowArrow(element)) { - return generator.path(generateElbowArrowShape(points, 16), options) - .sets[0].ops; - } else if (!element.roundness) { - return points.map((point, idx) => { - const p = pointRotateRads( - pointFrom(element.x + point[0], element.y + point[1]), - center, - element.angle, - ); - - return { - op: idx === 0 ? "move" : "lineTo", - data: pointFrom(p[0] - element.x, p[1] - element.y), - }; - }); - } - - return generator - .curve(points as unknown as RoughPoint[], options) - .sets[0].ops.slice(0, element.points.length) - .map((op, i, arr) => { - if (i === 0) { - const p = pointRotateRads( - pointFrom( - element.x + op.data[0], - element.y + op.data[1], - ), - center, - element.angle, - ); - - return { - op: "move", - data: pointFrom(p[0] - element.x, p[1] - element.y), - }; - } - - return { - op: "bcurveTo", - data: [ - pointRotateRads( - pointFrom( - element.x + op.data[0], - element.y + op.data[1], - ), - center, - element.angle, - ), - pointRotateRads( - pointFrom( - element.x + op.data[2], - element.y + op.data[3], - ), - center, - element.angle, - ), - pointRotateRads( - pointFrom( - element.x + op.data[4], - element.y + op.data[5], - ), - center, - element.angle, - ), - ] - .map((p) => - pointFrom(p[0] - element.x, p[1] - element.y), - ) - .flat(), - }; - }); - } - case "freedraw": { - if (element.points.length < 2) { - return []; - } - - const simplifiedPoints = simplify( - element.points as Mutable, - 0.75, - ); - - return generator - .curve(simplifiedPoints as [number, number][], options) - .sets[0].ops.slice(0, element.points.length); - } - } -}; - /** * Generates the roughjs shape for given element. * @@ -584,7 +452,7 @@ export const _generateElementShape = ( } else { shape = [ generator.path( - generateElbowArrowShape(points, 16), + generateElbowArrowRougJshPathCommands(points, 16), generateRoughOptions(element, true), ), ]; @@ -678,68 +546,3 @@ export const _generateElementShape = ( } } }; - -const generateElbowArrowShape = ( - points: readonly LocalPoint[], - radius: number, -) => { - const subpoints = [] as [number, number][]; - for (let i = 1; i < points.length - 1; i += 1) { - const prev = points[i - 1]; - const next = points[i + 1]; - const point = points[i]; - const prevIsHorizontal = headingForPointIsHorizontal(point, prev); - const nextIsHorizontal = headingForPointIsHorizontal(next, point); - const corner = Math.min( - radius, - pointDistance(points[i], next) / 2, - pointDistance(points[i], prev) / 2, - ); - - if (prevIsHorizontal) { - if (prev[0] < point[0]) { - // LEFT - subpoints.push([points[i][0] - corner, points[i][1]]); - } else { - // RIGHT - subpoints.push([points[i][0] + corner, points[i][1]]); - } - } else if (prev[1] < point[1]) { - // UP - subpoints.push([points[i][0], points[i][1] - corner]); - } else { - subpoints.push([points[i][0], points[i][1] + corner]); - } - - subpoints.push(points[i] as [number, number]); - - if (nextIsHorizontal) { - if (next[0] < point[0]) { - // LEFT - subpoints.push([points[i][0] - corner, points[i][1]]); - } else { - // RIGHT - subpoints.push([points[i][0] + corner, points[i][1]]); - } - } else if (next[1] < point[1]) { - // UP - subpoints.push([points[i][0], points[i][1] - corner]); - } else { - // DOWN - subpoints.push([points[i][0], points[i][1] + corner]); - } - } - - const d = [`M ${points[0][0]} ${points[0][1]}`]; - for (let i = 0; i < subpoints.length; i += 3) { - d.push(`L ${subpoints[i][0]} ${subpoints[i][1]}`); - d.push( - `Q ${subpoints[i + 1][0]} ${subpoints[i + 1][1]}, ${ - subpoints[i + 2][0] - } ${subpoints[i + 2][1]}`, - ); - } - d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`); - - return d.join(" "); -}; diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index f3a28cffd..f6bd122ff 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -226,10 +226,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = ( : linearElement.endBinding?.elementId; if (elementId) { const element = elementsMap.get(elementId); - if ( - isBindableElement(element) && - bindingBorderTest(element, coors, elementsMap, zoom) - ) { + if (isBindableElement(element) && bindingBorderTest(element, coors, zoom)) { return element; } } @@ -559,7 +556,6 @@ export const getHoveredElementForBinding = ( bindingBorderTest( element, pointerCoords, - elementsMap, zoom, (fullShape || !isBindingFallthroughEnabled( @@ -592,7 +588,7 @@ export const getHoveredElementForBinding = ( // Prefer the shape with the border being tested (if any) const borderTestElements = candidateElements.filter((element) => - bindingBorderTest(element, pointerCoords, elementsMap, zoom, false), + bindingBorderTest(element, pointerCoords, zoom, false), ); if (borderTestElements.length === 1) { return borderTestElements[0]; @@ -613,7 +609,6 @@ export const getHoveredElementForBinding = ( bindingBorderTest( element, pointerCoords, - elementsMap, zoom, // disable fullshape snapping for frame elements so we // can bind to frame children @@ -1545,7 +1540,6 @@ const newBoundElements = ( export const bindingBorderTest = ( element: NonDeleted, { x, y }: { x: number; y: number }, - elementsMap: NonDeletedSceneElementsMap, zoom?: AppState["zoom"], fullShape?: boolean, ): boolean => { diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index a5b91922b..89eeadece 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -9,29 +9,22 @@ import { import { degreesToRadians, - lineSegment, pointDistance, pointFrom, pointFromArray, pointRotateRads, } from "@excalidraw/math"; -import { getCurvePathOps } from "@excalidraw/utils/shape"; - -import { pointsOnBezierCurves } from "points-on-curve"; - import type { - Curve, Degrees, GlobalPoint, - LineSegment, LocalPoint, Radians, } from "@excalidraw/math"; import type { AppState } from "@excalidraw/excalidraw/types"; -import type { Mutable } from "@excalidraw/common/utility-types"; +import { getCurvePathOps } from "./utils"; import { generateRoughOptions } from "./Shape"; import { ShapeCache } from "./ShapeCache"; @@ -45,13 +38,6 @@ import { isTextElement, } from "./typeChecks"; -import { getElementShape } from "./shapes"; - -import { - deconstructDiamondElement, - deconstructRectanguloidElement, -} from "./utils"; - import type { Drawable, Op } from "roughjs/bin/core"; import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { @@ -59,10 +45,8 @@ import type { ElementsMap, ElementsMapOrArray, ExcalidrawElement, - ExcalidrawEllipseElement, ExcalidrawFreeDrawElement, ExcalidrawLinearElement, - ExcalidrawRectanguloidElement, ExcalidrawTextElementWithContainer, NonDeleted, } from "./types"; @@ -267,199 +251,6 @@ export const getElementAbsoluteCoords = ( ]; }; -/* - * for a given element, `getElementLineSegments` returns line segments - * that can be used for visual collision detection (useful for frames) - * as opposed to bounding box collision detection - */ -/** - * Given an element, return the line segments that make up the element. - * - * Uses helpers from /math - */ -export const getElementLineSegments = ( - element: ExcalidrawElement, - elementsMap: ElementsMap, -): LineSegment[] => { - const shape = getElementShape(element, elementsMap); - const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( - element, - elementsMap, - ); - const center = pointFrom(cx, cy); - - if (shape.type === "polycurve") { - const curves = shape.data; - const points = curves - .map((curve) => pointsOnBezierCurves(curve, 10)) - .flat(); - let i = 0; - const segments: LineSegment[] = []; - while (i < points.length - 1) { - segments.push( - lineSegment( - pointFrom(points[i][0], points[i][1]), - pointFrom(points[i + 1][0], points[i + 1][1]), - ), - ); - i++; - } - - return segments; - } else if (shape.type === "polyline") { - return shape.data as LineSegment[]; - } else if (_isRectanguloidElement(element)) { - const [sides, corners] = deconstructRectanguloidElement(element); - const cornerSegments: LineSegment[] = corners - .map((corner) => getSegmentsOnCurve(corner, center, element.angle)) - .flat(); - const rotatedSides = getRotatedSides(sides, center, element.angle); - return [...rotatedSides, ...cornerSegments]; - } else if (element.type === "diamond") { - const [sides, corners] = deconstructDiamondElement(element); - const cornerSegments = corners - .map((corner) => getSegmentsOnCurve(corner, center, element.angle)) - .flat(); - const rotatedSides = getRotatedSides(sides, center, element.angle); - - return [...rotatedSides, ...cornerSegments]; - } else if (shape.type === "polygon") { - if (isTextElement(element)) { - const container = getContainerElement(element, elementsMap); - if (container && isLinearElement(container)) { - const segments: LineSegment[] = [ - lineSegment(pointFrom(x1, y1), pointFrom(x2, y1)), - lineSegment(pointFrom(x2, y1), pointFrom(x2, y2)), - lineSegment(pointFrom(x2, y2), pointFrom(x1, y2)), - lineSegment(pointFrom(x1, y2), pointFrom(x1, y1)), - ]; - return segments; - } - } - - const points = shape.data as GlobalPoint[]; - const segments: LineSegment[] = []; - for (let i = 0; i < points.length - 1; i++) { - segments.push(lineSegment(points[i], points[i + 1])); - } - return segments; - } else if (shape.type === "ellipse") { - return getSegmentsOnEllipse(element as ExcalidrawEllipseElement); - } - - const [nw, ne, sw, se, , , w, e] = ( - [ - [x1, y1], - [x2, y1], - [x1, y2], - [x2, y2], - [cx, y1], - [cx, y2], - [x1, cy], - [x2, cy], - ] as GlobalPoint[] - ).map((point) => pointRotateRads(point, center, element.angle)); - - return [ - lineSegment(nw, ne), - lineSegment(sw, se), - lineSegment(nw, sw), - lineSegment(ne, se), - lineSegment(nw, e), - lineSegment(sw, e), - lineSegment(ne, w), - lineSegment(se, w), - ]; -}; - -const _isRectanguloidElement = ( - element: ExcalidrawElement, -): element is ExcalidrawRectanguloidElement => { - return ( - element != null && - (element.type === "rectangle" || - element.type === "image" || - element.type === "iframe" || - element.type === "embeddable" || - element.type === "frame" || - element.type === "magicframe" || - (element.type === "text" && !element.containerId)) - ); -}; - -const getRotatedSides = ( - sides: LineSegment[], - center: GlobalPoint, - angle: Radians, -) => { - return sides.map((side) => { - return lineSegment( - pointRotateRads(side[0], center, angle), - pointRotateRads(side[1], center, angle), - ); - }); -}; - -const getSegmentsOnCurve = ( - curve: Curve, - center: GlobalPoint, - angle: Radians, -): LineSegment[] => { - const points = pointsOnBezierCurves(curve, 10); - let i = 0; - const segments: LineSegment[] = []; - while (i < points.length - 1) { - segments.push( - lineSegment( - pointRotateRads( - pointFrom(points[i][0], points[i][1]), - center, - angle, - ), - pointRotateRads( - pointFrom(points[i + 1][0], points[i + 1][1]), - center, - angle, - ), - ), - ); - i++; - } - - return segments; -}; - -const getSegmentsOnEllipse = ( - ellipse: ExcalidrawEllipseElement, -): LineSegment[] => { - const center = pointFrom( - ellipse.x + ellipse.width / 2, - ellipse.y + ellipse.height / 2, - ); - - const a = ellipse.width / 2; - const b = ellipse.height / 2; - - const segments: LineSegment[] = []; - const points: GlobalPoint[] = []; - const n = 90; - const deltaT = (Math.PI * 2) / n; - - for (let i = 0; i < n; i++) { - const t = i * deltaT; - const x = center[0] + a * Math.cos(t); - const y = center[1] + b * Math.sin(t); - points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle)); - } - - for (let i = 0; i < points.length - 1; i++) { - segments.push(lineSegment(points[i], points[i + 1])); - } - - segments.push(lineSegment(points[points.length - 1], points[0])); - return segments; -}; - /** * Scene -> Scene coords, but in x1,x2,y1,y2 format. * @@ -868,7 +659,7 @@ const generateLinearElementShape = ( })(); return generator[method]( - element.points as Mutable[] as RoughPoint[], + element.points as LocalPoint[] as RoughPoint[], options, ); }; diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index fd6f63c85..44f7b70a2 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -1,3 +1,7 @@ +import { simplify } from "points-on-curve"; + +import { RoughGenerator } from "roughjs/bin/generator"; + import { isTransparent, elementCenterPoint, @@ -17,43 +21,43 @@ import { vectorFromPoint, vectorNormalize, vectorScale, -} from "@excalidraw/math"; - -import { + curve, + curveCatmullRomCubicApproxPoints, + curveOffsetPoints, + pointFromArray, + rectangle, ellipse, ellipseSegmentInterceptPoints, -} from "@excalidraw/math/ellipse"; +} from "@excalidraw/math"; import type { Curve, GlobalPoint, LineSegment, + LocalPoint, Radians, } from "@excalidraw/math"; - import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; -import { isPathALoop } from "./shapes"; -import { getElementBounds } from "./bounds"; +import { getCornerRadius, isPathALoop } from "./shapes"; +import { getDiamondPoints, getElementBounds } from "./bounds"; +import { getBoundTextElement } from "./textElement"; +import { LinearElementEditor } from "./linearElementEditor"; +import { distanceToElement } from "./distance"; +import { generateElbowArrowRougJshPathCommands } from "./utils"; + import { hasBoundTextElement, + isElbowArrow, isFreeDrawElement, isIframeLikeElement, isImageElement, isLinearElement, isTextElement, } from "./typeChecks"; -import { - deconstructDiamondElement, - deconstructLinearOrFreeDrawElement, - deconstructRectanguloidElement, -} from "./utils"; -import { getBoundTextElement } from "./textElement"; - -import { LinearElementEditor } from "./linearElementEditor"; - -import { distanceToElement } from "./distance"; +import type { Options } from "roughjs/bin/core"; +import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { ElementsMap, @@ -111,9 +115,9 @@ export const hitElementItself = ({ ? shouldTestInside(element) ? // Since `inShape` tests STRICTLY againt the insides of a shape // we would need `onShape` as well to include the "borders" - isPointInShape(point, element) || - isPointOnShape(point, element, threshold) - : isPointOnShape(point, element, threshold) + isPointInElement(point, element) || + isPointOnElementOutline(point, element, threshold) + : isPointOnElementOutline(point, element, threshold) : false; // hit test against a frame's name @@ -177,7 +181,7 @@ export const hitElementBoundText = ( } : boundTextElementCandidate; - return isPointInShape(point, boundTextElement); + return isPointInElement(point, boundTextElement); }; /** @@ -218,19 +222,17 @@ const intersectLinearOrFreeDrawWithLineSegment = ( element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, segment: LineSegment, ): GlobalPoint[] => { - const shapes = deconstructLinearOrFreeDrawElement(element); + const shapes = deconstructLinearOrFreeDrawElementForCollision(element); const intersections: GlobalPoint[] = []; for (const shape of shapes) { switch (true) { case isCurve(shape): - //debugDrawCubicBezier(shape); intersections.push( ...curveIntersectLineSegment(shape as Curve, segment), ); continue; case isLineSegment(shape): - //debugDrawLine(shape); const point = lineSegmentIntersectionPoints( segment, shape as LineSegment, @@ -267,7 +269,10 @@ const intersectRectanguloidWithLineSegment = ( ); // Get the element's building components we can test against - const [sides, corners] = deconstructRectanguloidElement(element, offset); + const [sides, corners] = deconstructRectanguloidElementForCollision( + element, + offset, + ); return ( // Test intersection against the sides, keep only the valid @@ -318,7 +323,10 @@ const intersectDiamondWithLineSegment = ( const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); - const [sides, curves] = deconstructDiamondElement(element, offset); + const [sides, curves] = deconstructDiamondElementForCollision( + element, + offset, + ); return ( sides @@ -371,14 +379,14 @@ const intersectEllipseWithLineSegment = ( }; // check if the given point is considered on the given shape's border -const isPointOnShape = ( +const isPointOnElementOutline = ( point: GlobalPoint, element: ExcalidrawElement, tolerance = 1, ) => distanceToElement(element, point) <= tolerance; // check if the given point is considered inside the element's border -export const isPointInShape = ( +export const isPointInElement = ( point: GlobalPoint, element: ExcalidrawElement, ) => { @@ -407,3 +415,437 @@ export const isPointInShape = ( return intersections.length % 2 === 1; }; + +export function deconstructLinearOrFreeDrawElementForCollision( + element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, +): (Curve | LineSegment)[] { + const ops = generateLinearShapesForCollision(element) as { + op: string; + data: number[]; + }[]; + const components = []; + + for (let idx = 0; idx < ops.length; idx += 1) { + const op = ops[idx]; + const prevPoint = + ops[idx - 1] && pointFromArray(ops[idx - 1].data.slice(-2)); + switch (op.op) { + case "move": + continue; + case "lineTo": + if (!prevPoint) { + throw new Error("prevPoint is undefined"); + } + + components.push( + lineSegment( + pointFrom( + element.x + prevPoint[0], + element.y + prevPoint[1], + ), + pointFrom( + element.x + op.data[0], + element.y + op.data[1], + ), + ), + ); + continue; + case "bcurveTo": + if (!prevPoint) { + throw new Error("prevPoint is undefined"); + } + + components.push( + curve( + pointFrom( + element.x + prevPoint[0], + element.y + prevPoint[1], + ), + pointFrom( + element.x + op.data[0], + element.y + op.data[1], + ), + pointFrom( + element.x + op.data[2], + element.y + op.data[3], + ), + pointFrom( + element.x + op.data[4], + element.y + op.data[5], + ), + ), + ); + continue; + default: { + console.error("Unknown op type", op.op); + } + } + } + + return components; +} + +/** + * Get the building components of a rectanguloid element in the form of + * line segments and curves. + * + * @param element Target rectanguloid element + * @param offset Optional offset to expand the rectanguloid shape + * @returns Tuple of line segments (0) and curves (1) + */ +export function deconstructRectanguloidElementForCollision( + element: ExcalidrawRectanguloidElement, + offset: number = 0, +): [LineSegment[], Curve[]] { + let radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + + if (radius === 0) { + radius = 0.01; + } + + const r = rectangle( + pointFrom(element.x, element.y), + pointFrom(element.x + element.width, element.y + element.height), + ); + + const top = lineSegment( + pointFrom(r[0][0] + radius, r[0][1]), + pointFrom(r[1][0] - radius, r[0][1]), + ); + const right = lineSegment( + pointFrom(r[1][0], r[0][1] + radius), + pointFrom(r[1][0], r[1][1] - radius), + ); + const bottom = lineSegment( + pointFrom(r[0][0] + radius, r[1][1]), + pointFrom(r[1][0] - radius, r[1][1]), + ); + const left = lineSegment( + pointFrom(r[0][0], r[1][1] - radius), + pointFrom(r[0][0], r[0][1] + radius), + ); + + const baseCorners = [ + curve( + left[1], + pointFrom( + left[1][0] + (2 / 3) * (r[0][0] - left[1][0]), + left[1][1] + (2 / 3) * (r[0][1] - left[1][1]), + ), + pointFrom( + top[0][0] + (2 / 3) * (r[0][0] - top[0][0]), + top[0][1] + (2 / 3) * (r[0][1] - top[0][1]), + ), + top[0], + ), // TOP LEFT + curve( + top[1], + pointFrom( + top[1][0] + (2 / 3) * (r[1][0] - top[1][0]), + top[1][1] + (2 / 3) * (r[0][1] - top[1][1]), + ), + pointFrom( + right[0][0] + (2 / 3) * (r[1][0] - right[0][0]), + right[0][1] + (2 / 3) * (r[0][1] - right[0][1]), + ), + right[0], + ), // TOP RIGHT + curve( + right[1], + pointFrom( + right[1][0] + (2 / 3) * (r[1][0] - right[1][0]), + right[1][1] + (2 / 3) * (r[1][1] - right[1][1]), + ), + pointFrom( + bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]), + bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]), + ), + bottom[1], + ), // BOTTOM RIGHT + curve( + bottom[0], + pointFrom( + bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]), + bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]), + ), + pointFrom( + left[0][0] + (2 / 3) * (r[0][0] - left[0][0]), + left[0][1] + (2 / 3) * (r[1][1] - left[0][1]), + ), + left[0], + ), // BOTTOM LEFT + ]; + + const corners = + offset > 0 + ? baseCorners.map( + (corner) => + curveCatmullRomCubicApproxPoints( + curveOffsetPoints(corner, offset), + )!, + ) + : [ + [baseCorners[0]], + [baseCorners[1]], + [baseCorners[2]], + [baseCorners[3]], + ]; + + const sides = [ + lineSegment( + corners[0][corners[0].length - 1][3], + corners[1][0][0], + ), + lineSegment( + corners[1][corners[1].length - 1][3], + corners[2][0][0], + ), + lineSegment( + corners[2][corners[2].length - 1][3], + corners[3][0][0], + ), + lineSegment( + corners[3][corners[3].length - 1][3], + corners[0][0][0], + ), + ]; + + return [sides, corners.flat()]; +} + +/** + * Get the building components of a diamond element in the form of + * line segments and curves as a tuple, in this order. + * + * @param element The element to deconstruct + * @param offset An optional offset + * @returns Tuple of line segments (0) and curves (1) + */ +export function deconstructDiamondElementForCollision( + element: ExcalidrawDiamondElement, + offset: number = 0, +): [LineSegment[], Curve[]] { + const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = + getDiamondPoints(element); + const verticalRadius = element.roundness + ? getCornerRadius(Math.abs(topX - leftX), element) + : (topX - leftX) * 0.01; + const horizontalRadius = element.roundness + ? getCornerRadius(Math.abs(rightY - topY), element) + : (rightY - topY) * 0.01; + + const [top, right, bottom, left]: GlobalPoint[] = [ + pointFrom(element.x + topX, element.y + topY), + pointFrom(element.x + rightX, element.y + rightY), + pointFrom(element.x + bottomX, element.y + bottomY), + pointFrom(element.x + leftX, element.y + leftY), + ]; + + const baseCorners = [ + curve( + pointFrom( + right[0] - verticalRadius, + right[1] - horizontalRadius, + ), + right, + right, + pointFrom( + right[0] - verticalRadius, + right[1] + horizontalRadius, + ), + ), // RIGHT + curve( + pointFrom( + bottom[0] + verticalRadius, + bottom[1] - horizontalRadius, + ), + bottom, + bottom, + pointFrom( + bottom[0] - verticalRadius, + bottom[1] - horizontalRadius, + ), + ), // BOTTOM + curve( + pointFrom( + left[0] + verticalRadius, + left[1] + horizontalRadius, + ), + left, + left, + pointFrom( + left[0] + verticalRadius, + left[1] - horizontalRadius, + ), + ), // LEFT + curve( + pointFrom( + top[0] - verticalRadius, + top[1] + horizontalRadius, + ), + top, + top, + pointFrom( + top[0] + verticalRadius, + top[1] + horizontalRadius, + ), + ), // TOP + ]; + + const corners = + offset > 0 + ? baseCorners.map( + (corner) => + curveCatmullRomCubicApproxPoints( + curveOffsetPoints(corner, offset), + )!, + ) + : [ + [baseCorners[0]], + [baseCorners[1]], + [baseCorners[2]], + [baseCorners[3]], + ]; + + const sides = [ + lineSegment( + corners[0][corners[0].length - 1][3], + corners[1][0][0], + ), + lineSegment( + corners[1][corners[1].length - 1][3], + corners[2][0][0], + ), + lineSegment( + corners[2][corners[2].length - 1][3], + corners[3][0][0], + ), + lineSegment( + corners[3][corners[3].length - 1][3], + corners[0][0][0], + ), + ]; + + return [sides, corners.flat()]; +} + +const generateLinearShapesForCollision = ( + element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, +) => { + const generator = new RoughGenerator(); + const options: Options = { + seed: element.seed, + disableMultiStroke: true, + disableMultiStrokeFill: true, + roughness: 0, + preserveVertices: true, + }; + + switch (element.type) { + case "line": + case "arrow": { + // 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 + : [pointFrom(0, 0)]; + const [x1, y1, x2, y2] = getElementBounds( + { + ...element, + angle: 0 as Radians, + }, + new Map(), + ); + const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); + + if (isElbowArrow(element)) { + return generator.path( + generateElbowArrowRougJshPathCommands(points, 16), + options, + ).sets[0].ops; + } else if (!element.roundness) { + return points.map((point, idx) => { + const p = pointRotateRads( + pointFrom(element.x + point[0], element.y + point[1]), + center, + element.angle, + ); + + return { + op: idx === 0 ? "move" : "lineTo", + data: pointFrom(p[0] - element.x, p[1] - element.y), + }; + }); + } + + return generator + .curve(points as unknown as RoughPoint[], options) + .sets[0].ops.slice(0, element.points.length) + .map((op, i, arr) => { + if (i === 0) { + const p = pointRotateRads( + pointFrom( + element.x + op.data[0], + element.y + op.data[1], + ), + center, + element.angle, + ); + + return { + op: "move", + data: pointFrom(p[0] - element.x, p[1] - element.y), + }; + } + + return { + op: "bcurveTo", + data: [ + pointRotateRads( + pointFrom( + element.x + op.data[0], + element.y + op.data[1], + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + element.x + op.data[2], + element.y + op.data[3], + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + element.x + op.data[4], + element.y + op.data[5], + ), + center, + element.angle, + ), + ] + .map((p) => + pointFrom(p[0] - element.x, p[1] - element.y), + ) + .flat(), + }; + }); + } + case "freedraw": { + if (element.points.length < 2) { + return []; + } + + const simplifiedPoints = simplify(element.points as LocalPoint[], 0.75); + + return generator + .curve(simplifiedPoints as [number, number][], options) + .sets[0].ops.slice(0, element.points.length); + } + } +}; diff --git a/packages/element/src/distance.ts b/packages/element/src/distance.ts index 43482c87e..37e789b9a 100644 --- a/packages/element/src/distance.ts +++ b/packages/element/src/distance.ts @@ -18,10 +18,10 @@ import type { } from "@excalidraw/math"; import { - deconstructDiamondElement, - deconstructLinearOrFreeDrawElement, - deconstructRectanguloidElement, -} from "./utils"; + deconstructDiamondElementForCollision, + deconstructLinearOrFreeDrawElementForCollision, + deconstructRectanguloidElementForCollision, +} from "./collision"; import type { ExcalidrawDiamondElement, @@ -75,7 +75,7 @@ const distanceToRectanguloidElement = ( const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); // Get the element's building components we can test against - const [sides, corners] = deconstructRectanguloidElement(element); + const [sides, corners] = deconstructRectanguloidElementForCollision(element); return Math.min( ...sides.map((s) => distanceToLineSegment(rotatedPoint, s)), @@ -103,7 +103,7 @@ const distanceToDiamondElement = ( // points. It's all the same distance-wise. const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); - const [sides, curves] = deconstructDiamondElement(element); + const [sides, curves] = deconstructDiamondElementForCollision(element); return Math.min( ...sides.map((s) => distanceToLineSegment(rotatedPoint, s)), @@ -137,7 +137,7 @@ const distanceToLinearOrFreeDraElement = ( element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, p: GlobalPoint, ) => { - const shapes = deconstructLinearOrFreeDrawElement(element); + const shapes = deconstructLinearOrFreeDrawElementForCollision(element); let distance = Infinity; for (const shape of shapes) { diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts index 3c8209954..2825f3e94 100644 --- a/packages/element/src/frame.ts +++ b/packages/element/src/frame.ts @@ -15,7 +15,7 @@ import { getElementsWithinSelection, getSelectedElements } from "./selection"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; import { - getElementLineSegments, + approximateElementWithLineSegments, getCommonBounds, getElementAbsoluteCoords, } from "./bounds"; @@ -69,9 +69,15 @@ export function isElementIntersectingFrame( frame: ExcalidrawFrameLikeElement, elementsMap: ElementsMap, ) { - const frameLineSegments = getElementLineSegments(frame, elementsMap); + const frameLineSegments = approximateElementWithLineSegments( + frame, + elementsMap, + ); - const elementLineSegments = getElementLineSegments(element, elementsMap); + const elementLineSegments = approximateElementWithLineSegments( + element, + elementsMap, + ); const intersecting = frameLineSegments.some((frameLineSegment) => elementLineSegments.some((elementLineSegment) => diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index eec3fc7a0..8d197b109 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -9,8 +9,6 @@ import { vectorFromPoint, } from "@excalidraw/math"; -import { getCurvePathOps } from "@excalidraw/utils/shape"; - import { DRAGGING_THRESHOLD, KEYS, @@ -20,7 +18,7 @@ import { tupleToCoors, } from "@excalidraw/common"; -import type { Store } from "@excalidraw/element"; +import { getCurvePathOps, type Store } from "@excalidraw/element"; import type { Radians } from "@excalidraw/math"; diff --git a/packages/element/src/shapes.ts b/packages/element/src/shapes.ts index 96542c538..eb1a9292e 100644 --- a/packages/element/src/shapes.ts +++ b/packages/element/src/shapes.ts @@ -16,116 +16,21 @@ import { type GlobalPoint, type LocalPoint, } from "@excalidraw/math"; -import { - getClosedCurveShape, - getCurvePathOps, - getCurveShape, - getEllipseShape, - getFreedrawShape, - getPolygonShape, - type GeometricShape, -} from "@excalidraw/utils/shape"; import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types"; -import { shouldTestInside } from "./collision"; -import { LinearElementEditor } from "./linearElementEditor"; -import { getBoundTextElement } from "./textElement"; import { ShapeCache } from "./ShapeCache"; -import { getElementAbsoluteCoords, type Bounds } from "./bounds"; +import { getCurvePathOps } from "./utils"; + +import type { Bounds } from "./bounds"; import type { - ElementsMap, ExcalidrawElement, ExcalidrawLinearElement, NonDeleted, } from "./types"; -/** - * get the pure geometric shape of an excalidraw elementw - * which is then used for hit detection - */ -export const getElementShape = ( - element: ExcalidrawElement, - elementsMap: ElementsMap, -): GeometricShape => { - switch (element.type) { - case "rectangle": - case "diamond": - case "frame": - case "magicframe": - case "embeddable": - case "image": - case "iframe": - case "text": - case "selection": - return getPolygonShape(element); - case "arrow": - case "line": { - const roughShape = - ShapeCache.get(element)?.[0] ?? - ShapeCache.generateElementShape(element, null)[0]; - const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); - - return shouldTestInside(element) - ? getClosedCurveShape( - element, - roughShape, - pointFrom(element.x, element.y), - element.angle, - pointFrom(cx, cy), - ) - : getCurveShape( - roughShape, - pointFrom(element.x, element.y), - element.angle, - pointFrom(cx, cy), - ); - } - - case "ellipse": - return getEllipseShape(element); - - case "freedraw": { - const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); - return getFreedrawShape( - element, - pointFrom(cx, cy), - shouldTestInside(element), - ); - } - } -}; - -export const getBoundTextShape = ( - element: ExcalidrawElement, - elementsMap: ElementsMap, -): GeometricShape | null => { - const boundTextElement = getBoundTextElement(element, elementsMap); - - if (boundTextElement) { - if (element.type === "arrow") { - return getElementShape( - { - ...boundTextElement, - // arrow's bound text accurate position is not stored in the element's property - // but rather calculated and returned from the following static method - ...LinearElementEditor.getBoundTextElementPosition( - element, - boundTextElement, - elementsMap, - ), - }, - elementsMap, - ); - } - return getElementShape(boundTextElement, elementsMap); - } - - return null; -}; - export const getControlPointsForBezierCurve = < P extends GlobalPoint | LocalPoint, >( diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts index ccc41c11f..918cb8663 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -1,341 +1,81 @@ -import { - curve, - curveCatmullRomCubicApproxPoints, - curveOffsetPoints, - lineSegment, - pointFrom, - pointFromArray, - rectangle, - type GlobalPoint, -} from "@excalidraw/math"; +import { pointDistance } from "@excalidraw/math"; -import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math"; +import type { LocalPoint } from "@excalidraw/math"; -import { getCornerRadius } from "./shapes"; +import { headingForPointIsHorizontal } from "./heading"; -import { getDiamondPoints } from "./bounds"; +import type { Drawable, Op } from "roughjs/bin/core"; -import { generateLinearCollisionShape } from "./Shape"; +export const getCurvePathOps = (shape: Drawable): Op[] => { + for (const set of shape.sets) { + if (set.type === "path") { + return set.ops; + } + } + return shape.sets[0].ops; +}; -import type { - ExcalidrawDiamondElement, - ExcalidrawFreeDrawElement, - ExcalidrawLinearElement, - ExcalidrawRectanguloidElement, -} from "./types"; +export const generateElbowArrowRougJshPathCommands = ( + points: readonly LocalPoint[], + radius: number, +) => { + const subpoints = [] as [number, number][]; + for (let i = 1; i < points.length - 1; i += 1) { + const prev = points[i - 1]; + const next = points[i + 1]; + const point = points[i]; + const prevIsHorizontal = headingForPointIsHorizontal(point, prev); + const nextIsHorizontal = headingForPointIsHorizontal(next, point); + const corner = Math.min( + radius, + pointDistance(points[i], next) / 2, + pointDistance(points[i], prev) / 2, + ); -export function deconstructLinearOrFreeDrawElement( - element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, -): (Curve | LineSegment)[] { - const ops = generateLinearCollisionShape(element) as { - op: string; - data: number[]; - }[]; - const components = []; - - for (let idx = 0; idx < ops.length; idx += 1) { - const op = ops[idx]; - const prevPoint = - ops[idx - 1] && pointFromArray(ops[idx - 1].data.slice(-2)); - switch (op.op) { - case "move": - continue; - case "lineTo": - if (!prevPoint) { - throw new Error("prevPoint is undefined"); - } - - components.push( - lineSegment( - pointFrom( - element.x + prevPoint[0], - element.y + prevPoint[1], - ), - pointFrom( - element.x + op.data[0], - element.y + op.data[1], - ), - ), - ); - continue; - case "bcurveTo": - if (!prevPoint) { - throw new Error("prevPoint is undefined"); - } - - components.push( - curve( - pointFrom( - element.x + prevPoint[0], - element.y + prevPoint[1], - ), - pointFrom( - element.x + op.data[0], - element.y + op.data[1], - ), - pointFrom( - element.x + op.data[2], - element.y + op.data[3], - ), - pointFrom( - element.x + op.data[4], - element.y + op.data[5], - ), - ), - ); - continue; - default: { - console.error("Unknown op type", op.op); + if (prevIsHorizontal) { + if (prev[0] < point[0]) { + // LEFT + subpoints.push([points[i][0] - corner, points[i][1]]); + } else { + // RIGHT + subpoints.push([points[i][0] + corner, points[i][1]]); } + } else if (prev[1] < point[1]) { + // UP + subpoints.push([points[i][0], points[i][1] - corner]); + } else { + subpoints.push([points[i][0], points[i][1] + corner]); + } + + subpoints.push(points[i] as [number, number]); + + if (nextIsHorizontal) { + if (next[0] < point[0]) { + // LEFT + subpoints.push([points[i][0] - corner, points[i][1]]); + } else { + // RIGHT + subpoints.push([points[i][0] + corner, points[i][1]]); + } + } else if (next[1] < point[1]) { + // UP + subpoints.push([points[i][0], points[i][1] - corner]); + } else { + // DOWN + subpoints.push([points[i][0], points[i][1] + corner]); } } - return components; -} - -/** - * Get the building components of a rectanguloid element in the form of - * line segments and curves. - * - * @param element Target rectanguloid element - * @param offset Optional offset to expand the rectanguloid shape - * @returns Tuple of line segments (0) and curves (1) - */ -export function deconstructRectanguloidElement( - element: ExcalidrawRectanguloidElement, - offset: number = 0, -): [LineSegment[], Curve[]] { - let radius = getCornerRadius( - Math.min(element.width, element.height), - element, - ); - - if (radius === 0) { - radius = 0.01; + const d = [`M ${points[0][0]} ${points[0][1]}`]; + for (let i = 0; i < subpoints.length; i += 3) { + d.push(`L ${subpoints[i][0]} ${subpoints[i][1]}`); + d.push( + `Q ${subpoints[i + 1][0]} ${subpoints[i + 1][1]}, ${ + subpoints[i + 2][0] + } ${subpoints[i + 2][1]}`, + ); } + d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`); - const r = rectangle( - pointFrom(element.x, element.y), - pointFrom(element.x + element.width, element.y + element.height), - ); - - const top = lineSegment( - pointFrom(r[0][0] + radius, r[0][1]), - pointFrom(r[1][0] - radius, r[0][1]), - ); - const right = lineSegment( - pointFrom(r[1][0], r[0][1] + radius), - pointFrom(r[1][0], r[1][1] - radius), - ); - const bottom = lineSegment( - pointFrom(r[0][0] + radius, r[1][1]), - pointFrom(r[1][0] - radius, r[1][1]), - ); - const left = lineSegment( - pointFrom(r[0][0], r[1][1] - radius), - pointFrom(r[0][0], r[0][1] + radius), - ); - - const baseCorners = [ - curve( - left[1], - pointFrom( - left[1][0] + (2 / 3) * (r[0][0] - left[1][0]), - left[1][1] + (2 / 3) * (r[0][1] - left[1][1]), - ), - pointFrom( - top[0][0] + (2 / 3) * (r[0][0] - top[0][0]), - top[0][1] + (2 / 3) * (r[0][1] - top[0][1]), - ), - top[0], - ), // TOP LEFT - curve( - top[1], - pointFrom( - top[1][0] + (2 / 3) * (r[1][0] - top[1][0]), - top[1][1] + (2 / 3) * (r[0][1] - top[1][1]), - ), - pointFrom( - right[0][0] + (2 / 3) * (r[1][0] - right[0][0]), - right[0][1] + (2 / 3) * (r[0][1] - right[0][1]), - ), - right[0], - ), // TOP RIGHT - curve( - right[1], - pointFrom( - right[1][0] + (2 / 3) * (r[1][0] - right[1][0]), - right[1][1] + (2 / 3) * (r[1][1] - right[1][1]), - ), - pointFrom( - bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]), - bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]), - ), - bottom[1], - ), // BOTTOM RIGHT - curve( - bottom[0], - pointFrom( - bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]), - bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]), - ), - pointFrom( - left[0][0] + (2 / 3) * (r[0][0] - left[0][0]), - left[0][1] + (2 / 3) * (r[1][1] - left[0][1]), - ), - left[0], - ), // BOTTOM LEFT - ]; - - const corners = - offset > 0 - ? baseCorners.map( - (corner) => - curveCatmullRomCubicApproxPoints( - curveOffsetPoints(corner, offset), - )!, - ) - : [ - [baseCorners[0]], - [baseCorners[1]], - [baseCorners[2]], - [baseCorners[3]], - ]; - - const sides = [ - lineSegment( - corners[0][corners[0].length - 1][3], - corners[1][0][0], - ), - lineSegment( - corners[1][corners[1].length - 1][3], - corners[2][0][0], - ), - lineSegment( - corners[2][corners[2].length - 1][3], - corners[3][0][0], - ), - lineSegment( - corners[3][corners[3].length - 1][3], - corners[0][0][0], - ), - ]; - - return [sides, corners.flat()]; -} - -/** - * Get the building components of a diamond element in the form of - * line segments and curves as a tuple, in this order. - * - * @param element The element to deconstruct - * @param offset An optional offset - * @returns Tuple of line segments (0) and curves (1) - */ -export function deconstructDiamondElement( - element: ExcalidrawDiamondElement, - offset: number = 0, -): [LineSegment[], Curve[]] { - const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = - getDiamondPoints(element); - const verticalRadius = element.roundness - ? getCornerRadius(Math.abs(topX - leftX), element) - : (topX - leftX) * 0.01; - const horizontalRadius = element.roundness - ? getCornerRadius(Math.abs(rightY - topY), element) - : (rightY - topY) * 0.01; - - const [top, right, bottom, left]: GlobalPoint[] = [ - pointFrom(element.x + topX, element.y + topY), - pointFrom(element.x + rightX, element.y + rightY), - pointFrom(element.x + bottomX, element.y + bottomY), - pointFrom(element.x + leftX, element.y + leftY), - ]; - - const baseCorners = [ - curve( - pointFrom( - right[0] - verticalRadius, - right[1] - horizontalRadius, - ), - right, - right, - pointFrom( - right[0] - verticalRadius, - right[1] + horizontalRadius, - ), - ), // RIGHT - curve( - pointFrom( - bottom[0] + verticalRadius, - bottom[1] - horizontalRadius, - ), - bottom, - bottom, - pointFrom( - bottom[0] - verticalRadius, - bottom[1] - horizontalRadius, - ), - ), // BOTTOM - curve( - pointFrom( - left[0] + verticalRadius, - left[1] + horizontalRadius, - ), - left, - left, - pointFrom( - left[0] + verticalRadius, - left[1] - horizontalRadius, - ), - ), // LEFT - curve( - pointFrom( - top[0] - verticalRadius, - top[1] + horizontalRadius, - ), - top, - top, - pointFrom( - top[0] + verticalRadius, - top[1] + horizontalRadius, - ), - ), // TOP - ]; - - const corners = - offset > 0 - ? baseCorners.map( - (corner) => - curveCatmullRomCubicApproxPoints( - curveOffsetPoints(corner, offset), - )!, - ) - : [ - [baseCorners[0]], - [baseCorners[1]], - [baseCorners[2]], - [baseCorners[3]], - ]; - - const sides = [ - lineSegment( - corners[0][corners[0].length - 1][3], - corners[1][0][0], - ), - lineSegment( - corners[1][corners[1].length - 1][3], - corners[2][0][0], - ), - lineSegment( - corners[2][corners[2].length - 1][3], - corners[3][0][0], - ), - lineSegment( - corners[3][corners[3].length - 1][3], - corners[0][0][0], - ), - ]; - - return [sides, corners.flat()]; -} + return d.join(" "); +}; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 3ef3ce84a..ee018d463 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -130,7 +130,7 @@ import { refreshTextDimensions, deepCopyElement, duplicateElements, - isPointInShape, + isPointInElement, hasBoundTextElement, isArrowElement, isBindingElement, @@ -5164,7 +5164,7 @@ class App extends React.Component { ) { // if hitting the bounding box, return early // but if not, we should check for other cases as well (e.g. frame name) - if (isPointInShape(pointFrom(x, y), element)) { + if (isPointInElement(pointFrom(x, y), element)) { return true; } } diff --git a/packages/excalidraw/eraser/index.ts b/packages/excalidraw/eraser/index.ts index 7adc2668c..6ba9d156a 100644 --- a/packages/excalidraw/eraser/index.ts +++ b/packages/excalidraw/eraser/index.ts @@ -1,10 +1,10 @@ import { arrayToMap, easeOut, THEME } from "@excalidraw/common"; -import { getElementLineSegments, isPointInShape } from "@excalidraw/element"; import { - lineSegment, - lineSegmentIntersectionPoints, - pointFrom, -} from "@excalidraw/math"; + getBoundTextElement, + intersectElementWithLineSegment, + isPointInElement, +} from "@excalidraw/element"; +import { lineSegment, pointFrom } from "@excalidraw/math"; import { getElementsInGroup } from "@excalidraw/element"; @@ -12,12 +12,7 @@ import { shouldTestInside } from "@excalidraw/element"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; import { getBoundTextElementId } from "@excalidraw/element"; -import type { GeometricShape } from "@excalidraw/utils/shape"; -import type { - ElementsSegmentsMap, - GlobalPoint, - LineSegment, -} from "@excalidraw/math/types"; +import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import { AnimatedTrail } from "../animated-trail"; @@ -33,8 +28,6 @@ export class EraserTrail extends AnimatedTrail { private elementsToErase: Set = new Set(); private groupsToErase: Set = new Set(); private segmentsCache: Map[]> = new Map(); - private geometricShapesCache: Map> = - new Map(); constructor(animationFrameHandler: AnimationFrameHandler, app: App) { super(animationFrameHandler, app, { @@ -110,8 +103,6 @@ export class EraserTrail extends AnimatedTrail { const intersects = eraserTest( pathSegments, element, - this.segmentsCache, - this.geometricShapesCache, candidateElementsMap, this.app, ); @@ -148,8 +139,6 @@ export class EraserTrail extends AnimatedTrail { const intersects = eraserTest( pathSegments, element, - this.segmentsCache, - this.geometricShapesCache, candidateElementsMap, this.app, ); @@ -201,31 +190,23 @@ export class EraserTrail extends AnimatedTrail { const eraserTest = ( pathSegments: LineSegment[], element: ExcalidrawElement, - elementsSegments: ElementsSegmentsMap, - shapesCache: Map>, elementsMap: ElementsMap, app: App, ): boolean => { const lastPoint = pathSegments[pathSegments.length - 1][1]; - if (shouldTestInside(element) && isPointInShape(lastPoint, element)) { + if (shouldTestInside(element) && isPointInElement(lastPoint, element)) { return true; } - let elementSegments = elementsSegments.get(element.id); + const offset = app.getElementHitThreshold(); + const boundTextElement = getBoundTextElement(element, elementsMap); - if (!elementSegments) { - elementSegments = getElementLineSegments(element, elementsMap); - elementsSegments.set(element.id, elementSegments); - } - - return pathSegments.some((pathSegment) => - elementSegments?.some( - (elementSegment) => - lineSegmentIntersectionPoints( - pathSegment, - elementSegment, - app.getElementHitThreshold(), - ) !== null, - ), + return pathSegments.some( + (pathSegment) => + intersectElementWithLineSegment(element, pathSegment, offset).length > + 0 || + (boundTextElement && + intersectElementWithLineSegment(boundTextElement, pathSegment, offset) + .length > 0), ); }; diff --git a/packages/excalidraw/lasso/index.ts b/packages/excalidraw/lasso/index.ts index 163a8b7a9..79cd89a9e 100644 --- a/packages/excalidraw/lasso/index.ts +++ b/packages/excalidraw/lasso/index.ts @@ -4,7 +4,7 @@ import { pointFrom, } from "@excalidraw/math"; -import { getElementLineSegments } from "@excalidraw/element"; +import { approximateElementWithLineSegments } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element"; import { isFrameLikeElement, @@ -190,7 +190,10 @@ export class LassoTrail extends AnimatedTrail { this.elementsSegments = new Map(); const visibleElementsMap = arrayToMap(this.app.visibleElements); for (const element of this.app.visibleElements) { - const segments = getElementLineSegments(element, visibleElementsMap); + const segments = approximateElementWithLineSegments( + element, + visibleElementsMap, + ); this.elementsSegments.set(element.id, segments); } } diff --git a/packages/excalidraw/lasso/utils.ts b/packages/excalidraw/lasso/utils.ts index d05f39998..9ff8c6657 100644 --- a/packages/excalidraw/lasso/utils.ts +++ b/packages/excalidraw/lasso/utils.ts @@ -13,11 +13,12 @@ import type { LineSegment, } from "@excalidraw/math/types"; import type { ExcalidrawElement } from "@excalidraw/element/types"; +import { intersectElementWithLineSegment } from "@excalidraw/element"; +import App from "../components/App"; export const getLassoSelectedElementIds = (input: { lassoPath: GlobalPoint[]; elements: readonly ExcalidrawElement[]; - elementsSegments: ElementsSegmentsMap; intersectedElements: Set; enclosedElements: Set; simplifyDistance?: number; @@ -27,7 +28,6 @@ export const getLassoSelectedElementIds = (input: { const { lassoPath, elements, - elementsSegments, intersectedElements, enclosedElements, simplifyDistance, @@ -48,7 +48,7 @@ export const getLassoSelectedElementIds = (input: { if (enclosed) { enclosedElements.add(element.id); } else { - const intersects = intersectionTest(path, element, elementsSegments); + const intersects = intersectionTest(path, element, app); if (intersects) { intersectedElements.add(element.id); } @@ -66,13 +66,13 @@ export const getLassoSelectedElementIds = (input: { const enclosureTest = ( lassoPath: GlobalPoint[], element: ExcalidrawElement, - elementsSegments: ElementsSegmentsMap, + app: App, ): boolean => { - const lassoPolygon = polygonFromPoints(lassoPath); - const segments = elementsSegments.get(element.id); - if (!segments) { - return false; - } + const lassoSegments = lassoPath + .slice(1) + .map((point, index) => lineSegment(lassoPath[index], point)) + .concat(lineSegment(lassoPath[lassoPath.length - 1], lassoPath[0])); + const offset = app.getElementHitThreshold(); return segments.some((segment) => { return segment.some((point) => @@ -84,26 +84,15 @@ const enclosureTest = ( const intersectionTest = ( lassoPath: GlobalPoint[], element: ExcalidrawElement, - elementsSegments: ElementsSegmentsMap, + app: App, ): boolean => { - const elementSegments = elementsSegments.get(element.id); - if (!elementSegments) { - return false; - } - - const lassoSegments = lassoPath.reduce((acc, point, index) => { - if (index === 0) { - return acc; - } - acc.push(lineSegment(lassoPath[index - 1], point)); - return acc; - }, [] as LineSegment[]); + const lassoSegments = lassoPath + .slice(1) + .map((point, index) => lineSegment(lassoPath[index], point)) + .concat(lineSegment(lassoPath[lassoPath.length - 1], lassoPath[0])); + const offset = app.getElementHitThreshold(); return lassoSegments.some((lassoSegment) => - elementSegments.some( - (elementSegment) => - // introduce a bit of tolerance to account for roughness and simplification of paths - lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null, - ), + intersectElementWithLineSegment(element, lassoSegment, offset), ); }; diff --git a/packages/excalidraw/tests/lasso.test.tsx b/packages/excalidraw/tests/lasso.test.tsx index 7e67d9b5b..6c07f38b3 100644 --- a/packages/excalidraw/tests/lasso.test.tsx +++ b/packages/excalidraw/tests/lasso.test.tsx @@ -22,7 +22,7 @@ import { type ElementsSegmentsMap, } from "@excalidraw/math"; -import { getElementLineSegments } from "@excalidraw/element"; +import { approximateElementWithLineSegments } from "@excalidraw/element"; import type { ExcalidrawElement } from "@excalidraw/element/types"; @@ -56,7 +56,7 @@ const updatePath = (startPoint: GlobalPoint, points: LocalPoint[]) => { const elementsSegments: ElementsSegmentsMap = new Map(); for (const element of h.elements) { - const segments = getElementLineSegments( + const segments = approximateElementWithLineSegments( element, h.app.scene.getElementsMapIncludingDeleted(), ); diff --git a/packages/math/src/index.ts b/packages/math/src/index.ts index d00ab469d..e487ac333 100644 --- a/packages/math/src/index.ts +++ b/packages/math/src/index.ts @@ -1,5 +1,6 @@ export * from "./angle"; export * from "./curve"; +export * from "./ellipse"; export * from "./line"; export * from "./point"; export * from "./polygon"; diff --git a/packages/utils/src/shape.ts b/packages/utils/src/shape.ts index 5d50f4b47..4860be7d5 100644 --- a/packages/utils/src/shape.ts +++ b/packages/utils/src/shape.ts @@ -13,532 +13,516 @@ */ import { pointsOnBezierCurves } from "points-on-curve"; -import { invariant } from "@excalidraw/common"; -import { - curve, - lineSegment, - pointFrom, - pointDistance, - pointFromArray, - pointFromVector, - pointRotateRads, - polygon, - polygonFromPoints, - PRECISION, - segmentsIntersectAt, - vector, - vectorAdd, - vectorFromPoint, - vectorScale, - type GlobalPoint, - type LocalPoint, -} from "@excalidraw/math"; - -import { getElementAbsoluteCoords } from "@excalidraw/element"; - -import type { - ElementsMap, - ExcalidrawBindableElement, - ExcalidrawDiamondElement, - ExcalidrawElement, - ExcalidrawEllipseElement, - ExcalidrawEmbeddableElement, - ExcalidrawFrameLikeElement, - ExcalidrawFreeDrawElement, - ExcalidrawIframeElement, - ExcalidrawImageElement, - ExcalidrawLinearElement, - ExcalidrawRectangleElement, - ExcalidrawSelectionElement, - ExcalidrawTextElement, -} from "@excalidraw/element/types"; -import type { Curve, LineSegment, Polygon, Radians } from "@excalidraw/math"; - -import type { Drawable, Op } from "roughjs/bin/core"; - -// 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 = - LineSegment[]; - -// a polycurve is a curve consisting of ther curves, this corresponds to a complex -// curve on the canvas -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 = { - center: Point; - angle: Radians; - halfWidth: number; - halfHeight: number; -}; - -export type GeometricShape = - | { - type: "line"; - data: LineSegment; - } - | { - type: "polygon"; - data: Polygon; - } - | { - type: "curve"; - data: Curve; - } - | { - type: "ellipse"; - data: Ellipse; - } - | { - type: "polyline"; - data: Polyline; - } - | { - type: "polycurve"; - data: Polycurve; - }; - -type RectangularElement = - | ExcalidrawRectangleElement - | ExcalidrawDiamondElement - | ExcalidrawFrameLikeElement - | ExcalidrawEmbeddableElement - | ExcalidrawImageElement - | ExcalidrawIframeElement - | ExcalidrawTextElement - | ExcalidrawSelectionElement; - -// polygon -export const getPolygonShape = ( - element: RectangularElement, -): GeometricShape => { - const { angle, width, height, x, y } = element; - - const cx = x + width / 2; - const cy = y + height / 2; - - const center: Point = pointFrom(cx, cy); - - let data: Polygon; - - if (element.type === "diamond") { - data = polygon( - pointRotateRads(pointFrom(cx, y), center, angle), - pointRotateRads(pointFrom(x + width, cy), center, angle), - pointRotateRads(pointFrom(cx, y + height), center, angle), - pointRotateRads(pointFrom(x, cy), center, angle), - ); - } else { - data = polygon( - pointRotateRads(pointFrom(x, y), center, angle), - pointRotateRads(pointFrom(x + width, y), center, angle), - pointRotateRads(pointFrom(x + width, y + height), center, angle), - pointRotateRads(pointFrom(x, y + height), center, angle), - ); - } - - return { - type: "polygon", - data, - }; -}; - -// return the selection box for an element, possibly rotated as well -export const getSelectionBoxShape = ( - element: ExcalidrawElement, - elementsMap: ElementsMap, - padding = 10, -) => { - let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( - element, - elementsMap, - true, - ); - - x1 -= padding; - x2 += padding; - y1 -= padding; - y2 += padding; - - //const angleInDegrees = angleToDegrees(element.angle); - const center = pointFrom(cx, cy); - const topLeft = pointRotateRads(pointFrom(x1, y1), center, element.angle); - const topRight = pointRotateRads(pointFrom(x2, y1), center, element.angle); - const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, element.angle); - const bottomRight = pointRotateRads(pointFrom(x2, y2), center, element.angle); - - return { - type: "polygon", - data: [topLeft, topRight, bottomRight, bottomLeft], - } as GeometricShape; -}; - -// ellipse -export const getEllipseShape = ( - element: ExcalidrawEllipseElement, -): GeometricShape => { - const { width, height, angle, x, y } = element; - - return { - type: "ellipse", - data: { - center: pointFrom(x + width / 2, y + height / 2), - angle, - halfWidth: width / 2, - halfHeight: height / 2, - }, - }; -}; - -export const getCurvePathOps = (shape: Drawable): Op[] => { - // NOTE (mtolmacs): Temporary fix for extremely large elements - if (!shape) { - return []; - } - - for (const set of shape.sets) { - if (set.type === "path") { - return set.ops; - } - } - return shape.sets[0].ops; -}; - -// linear -export const getCurveShape = ( - roughShape: Drawable, - startingPoint: Point = pointFrom(0, 0), - angleInRadian: Radians, - center: Point, -): GeometricShape => { - const transform = (p: Point): Point => - pointRotateRads( - pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]), - center, - angleInRadian, - ); - - const ops = getCurvePathOps(roughShape); - const polycurve: Polycurve = []; - let p0 = pointFrom(0, 0); - - for (const op of ops) { - if (op.op === "move") { - const p = pointFromArray(op.data); - invariant(p != null, "Ops data is not a point"); - p0 = transform(p); - } - if (op.op === "bcurveTo") { - const p1 = transform(pointFrom(op.data[0], op.data[1])); - const p2 = transform(pointFrom(op.data[2], op.data[3])); - const p3 = transform(pointFrom(op.data[4], op.data[5])); - polycurve.push(curve(p0, p1, p2, p3)); - p0 = p3; - } - } - - return { - type: "polycurve", - data: polycurve, - }; -}; - -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(lineSegment(previousPoint, nextPoint)); - previousPoint = nextPoint; - } - - return polyline; -}; - -export const getFreedrawShape = ( - element: ExcalidrawFreeDrawElement, - center: Point, - isClosed: boolean = false, -): GeometricShape => { - const transform = (p: Point) => - 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: polygonFromPoints(polyline.flat()), - } - : { - type: "polyline", - data: polyline, - } - ) as GeometricShape; -}; - -export const getClosedCurveShape = ( - element: ExcalidrawLinearElement, - roughShape: Drawable, - startingPoint: Point = pointFrom(0, 0), - angleInRadian: Radians, - center: Point, -): GeometricShape => { - const transform = (p: Point) => - pointRotateRads( - pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]), - center, - angleInRadian, - ); - - if (element.roundness === null) { - return { - type: "polygon", - data: polygonFromPoints( - element.points.map((p) => transform(p as Point)) as Point[], - ), - }; - } - - const ops = getCurvePathOps(roughShape); - - const points: Point[] = []; - let odd = false; - for (const operation of ops) { - if (operation.op === "move") { - odd = !odd; - if (odd) { - points.push(pointFrom(operation.data[0], operation.data[1])); - } - } else if (operation.op === "bcurveTo") { - if (odd) { - points.push(pointFrom(operation.data[0], operation.data[1])); - points.push(pointFrom(operation.data[2], operation.data[3])); - points.push(pointFrom(operation.data[4], operation.data[5])); - } - } else if (operation.op === "lineTo") { - if (odd) { - points.push(pointFrom(operation.data[0], operation.data[1])); - } - } - } - - const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) => - transform(p as Point), - ) as Point[]; - - return { - type: "polygon", - 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 = pointFrom( - (bounds[0] + bounds[2]) / 2, - (bounds[1] + bounds[3]) / 2, - ); - - return [ - lineSegment( - pointRotateRads(pointFrom(bounds[0], bounds[1]), center, element.angle), - pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle), - ), - lineSegment( - pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle), - pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle), - ), - lineSegment( - pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle), - pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle), - ), - lineSegment( - pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle), - pointRotateRads(pointFrom(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), - pointFrom(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( - pointFrom(rotatedPointX, rotatedPointY), - pointFrom(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), - pointFrom(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), - ]; -}; +// import { invariant } from "@excalidraw/common"; +// import { +// curve, +// lineSegment, +// pointFrom, +// pointDistance, +// pointFromArray, +// pointFromVector, +// pointRotateRads, +// polygon, +// polygonFromPoints, +// PRECISION, +// segmentsIntersectAt, +// vector, +// vectorAdd, +// vectorFromPoint, +// vectorScale, +// type GlobalPoint, +// type LocalPoint, +// } from "@excalidraw/math"; + +// import { getElementAbsoluteCoords } from "@excalidraw/element"; + +// import type { +// ElementsMap, +// ExcalidrawBindableElement, +// ExcalidrawDiamondElement, +// ExcalidrawElement, +// ExcalidrawEllipseElement, +// ExcalidrawEmbeddableElement, +// ExcalidrawFrameLikeElement, +// ExcalidrawFreeDrawElement, +// ExcalidrawIframeElement, +// ExcalidrawImageElement, +// ExcalidrawLinearElement, +// ExcalidrawRectangleElement, +// ExcalidrawSelectionElement, +// ExcalidrawTextElement, +// } from "@excalidraw/element/types"; +// import type { Curve, LineSegment, Polygon, Radians } from "@excalidraw/math"; + +// // 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 = +// LineSegment[]; + +// // a polycurve is a curve consisting of ther curves, this corresponds to a complex +// // curve on the canvas +// 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 = { +// center: Point; +// angle: Radians; +// halfWidth: number; +// halfHeight: number; +// }; + +// export type GeometricShape = +// | { +// type: "line"; +// data: LineSegment; +// } +// | { +// type: "polygon"; +// data: Polygon; +// } +// | { +// type: "curve"; +// data: Curve; +// } +// | { +// type: "ellipse"; +// data: Ellipse; +// } +// | { +// type: "polyline"; +// data: Polyline; +// } +// | { +// type: "polycurve"; +// data: Polycurve; +// }; + +// type RectangularElement = +// | ExcalidrawRectangleElement +// | ExcalidrawDiamondElement +// | ExcalidrawFrameLikeElement +// | ExcalidrawEmbeddableElement +// | ExcalidrawImageElement +// | ExcalidrawIframeElement +// | ExcalidrawTextElement +// | ExcalidrawSelectionElement; + +// // polygon +// export const getPolygonShape = ( +// element: RectangularElement, +// ): GeometricShape => { +// const { angle, width, height, x, y } = element; + +// const cx = x + width / 2; +// const cy = y + height / 2; + +// const center: Point = pointFrom(cx, cy); + +// let data: Polygon; + +// if (element.type === "diamond") { +// data = polygon( +// pointRotateRads(pointFrom(cx, y), center, angle), +// pointRotateRads(pointFrom(x + width, cy), center, angle), +// pointRotateRads(pointFrom(cx, y + height), center, angle), +// pointRotateRads(pointFrom(x, cy), center, angle), +// ); +// } else { +// data = polygon( +// pointRotateRads(pointFrom(x, y), center, angle), +// pointRotateRads(pointFrom(x + width, y), center, angle), +// pointRotateRads(pointFrom(x + width, y + height), center, angle), +// pointRotateRads(pointFrom(x, y + height), center, angle), +// ); +// } + +// return { +// type: "polygon", +// data, +// }; +// }; + +// // return the selection box for an element, possibly rotated as well +// export const getSelectionBoxShape = ( +// element: ExcalidrawElement, +// elementsMap: ElementsMap, +// padding = 10, +// ) => { +// let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( +// element, +// elementsMap, +// true, +// ); + +// x1 -= padding; +// x2 += padding; +// y1 -= padding; +// y2 += padding; + +// //const angleInDegrees = angleToDegrees(element.angle); +// const center = pointFrom(cx, cy); +// const topLeft = pointRotateRads(pointFrom(x1, y1), center, element.angle); +// const topRight = pointRotateRads(pointFrom(x2, y1), center, element.angle); +// const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, element.angle); +// const bottomRight = pointRotateRads(pointFrom(x2, y2), center, element.angle); + +// return { +// type: "polygon", +// data: [topLeft, topRight, bottomRight, bottomLeft], +// } as GeometricShape; +// }; + +// // ellipse +// export const getEllipseShape = ( +// element: ExcalidrawEllipseElement, +// ): GeometricShape => { +// const { width, height, angle, x, y } = element; + +// return { +// type: "ellipse", +// data: { +// center: pointFrom(x + width / 2, y + height / 2), +// angle, +// halfWidth: width / 2, +// halfHeight: height / 2, +// }, +// }; +// }; + +// // linear +// export const getCurveShape = ( +// roughShape: Drawable, +// startingPoint: Point = pointFrom(0, 0), +// angleInRadian: Radians, +// center: Point, +// ): GeometricShape => { +// const transform = (p: Point): Point => +// pointRotateRads( +// pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]), +// center, +// angleInRadian, +// ); + +// const ops = getCurvePathOps(roughShape); +// const polycurve: Polycurve = []; +// let p0 = pointFrom(0, 0); + +// for (const op of ops) { +// if (op.op === "move") { +// const p = pointFromArray(op.data); +// invariant(p != null, "Ops data is not a point"); +// p0 = transform(p); +// } +// if (op.op === "bcurveTo") { +// const p1 = transform(pointFrom(op.data[0], op.data[1])); +// const p2 = transform(pointFrom(op.data[2], op.data[3])); +// const p3 = transform(pointFrom(op.data[4], op.data[5])); +// polycurve.push(curve(p0, p1, p2, p3)); +// p0 = p3; +// } +// } + +// return { +// type: "polycurve", +// data: polycurve, +// }; +// }; + +// 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(lineSegment(previousPoint, nextPoint)); +// previousPoint = nextPoint; +// } + +// return polyline; +// }; + +// export const getFreedrawShape = ( +// element: ExcalidrawFreeDrawElement, +// center: Point, +// isClosed: boolean = false, +// ): GeometricShape => { +// const transform = (p: Point) => +// 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: polygonFromPoints(polyline.flat()), +// } +// : { +// type: "polyline", +// data: polyline, +// } +// ) as GeometricShape; +// }; + +// export const getClosedCurveShape = ( +// element: ExcalidrawLinearElement, +// roughShape: Drawable, +// startingPoint: Point = pointFrom(0, 0), +// angleInRadian: Radians, +// center: Point, +// ): GeometricShape => { +// const transform = (p: Point) => +// pointRotateRads( +// pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]), +// center, +// angleInRadian, +// ); + +// if (element.roundness === null) { +// return { +// type: "polygon", +// data: polygonFromPoints( +// element.points.map((p) => transform(p as Point)) as Point[], +// ), +// }; +// } + +// const ops = getCurvePathOps(roughShape); + +// const points: Point[] = []; +// let odd = false; +// for (const operation of ops) { +// if (operation.op === "move") { +// odd = !odd; +// if (odd) { +// points.push(pointFrom(operation.data[0], operation.data[1])); +// } +// } else if (operation.op === "bcurveTo") { +// if (odd) { +// points.push(pointFrom(operation.data[0], operation.data[1])); +// points.push(pointFrom(operation.data[2], operation.data[3])); +// points.push(pointFrom(operation.data[4], operation.data[5])); +// } +// } else if (operation.op === "lineTo") { +// if (odd) { +// points.push(pointFrom(operation.data[0], operation.data[1])); +// } +// } +// } + +// const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) => +// transform(p as Point), +// ) as Point[]; + +// return { +// type: "polygon", +// 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 = pointFrom( +// (bounds[0] + bounds[2]) / 2, +// (bounds[1] + bounds[3]) / 2, +// ); + +// return [ +// lineSegment( +// pointRotateRads(pointFrom(bounds[0], bounds[1]), center, element.angle), +// pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle), +// ), +// lineSegment( +// pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle), +// pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle), +// ), +// lineSegment( +// pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle), +// pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle), +// ), +// lineSegment( +// pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle), +// pointRotateRads(pointFrom(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), +// pointFrom(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( +// pointFrom(rotatedPointX, rotatedPointY), +// pointFrom(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), +// pointFrom(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/tests/geometry.test.ts b/packages/utils/tests/geometry.test.ts index 8a2f95d3f..54ffa3ae2 100644 --- a/packages/utils/tests/geometry.test.ts +++ b/packages/utils/tests/geometry.test.ts @@ -8,14 +8,7 @@ import { segmentsIntersectAt, } from "@excalidraw/math"; -import type { - GlobalPoint, - LineSegment, - Polygon, - Radians, -} from "@excalidraw/math"; - -import { pointInEllipse, pointOnEllipse, type Ellipse } from "../src/shape"; +import type { GlobalPoint, LineSegment, Polygon } from "@excalidraw/math"; describe("point and line", () => { // const l: Line = line(point(1, 0), point(1, 2)); @@ -71,56 +64,56 @@ describe("point and polygon", () => { }); }); -describe("point and ellipse", () => { - const ellipse: Ellipse = { - center: pointFrom(0, 0), - angle: 0 as Radians, - halfWidth: 2, - halfHeight: 1, - }; +// describe("point and ellipse", () => { +// const ellipse: Ellipse = { +// center: pointFrom(0, 0), +// angle: 0 as Radians, +// halfWidth: 2, +// halfHeight: 1, +// }; - it("point on ellipse", () => { - [ - pointFrom(0, 1), - pointFrom(0, -1), - pointFrom(2, 0), - pointFrom(-2, 0), - ].forEach((p) => { - expect(pointOnEllipse(p, ellipse)).toBe(true); - }); - expect(pointOnEllipse(pointFrom(-1.4, 0.7), ellipse, 0.1)).toBe(true); - expect(pointOnEllipse(pointFrom(-1.4, 0.71), ellipse, 0.01)).toBe(true); +// it("point on ellipse", () => { +// [ +// pointFrom(0, 1), +// pointFrom(0, -1), +// pointFrom(2, 0), +// pointFrom(-2, 0), +// ].forEach((p) => { +// expect(pointOnEllipse(p, ellipse)).toBe(true); +// }); +// expect(pointOnEllipse(pointFrom(-1.4, 0.7), ellipse, 0.1)).toBe(true); +// expect(pointOnEllipse(pointFrom(-1.4, 0.71), ellipse, 0.01)).toBe(true); - expect(pointOnEllipse(pointFrom(1.4, 0.7), ellipse, 0.1)).toBe(true); - expect(pointOnEllipse(pointFrom(1.4, 0.71), ellipse, 0.01)).toBe(true); +// expect(pointOnEllipse(pointFrom(1.4, 0.7), ellipse, 0.1)).toBe(true); +// expect(pointOnEllipse(pointFrom(1.4, 0.71), ellipse, 0.01)).toBe(true); - expect(pointOnEllipse(pointFrom(1, -0.86), ellipse, 0.1)).toBe(true); - expect(pointOnEllipse(pointFrom(1, -0.86), ellipse, 0.01)).toBe(true); +// expect(pointOnEllipse(pointFrom(1, -0.86), ellipse, 0.1)).toBe(true); +// expect(pointOnEllipse(pointFrom(1, -0.86), ellipse, 0.01)).toBe(true); - expect(pointOnEllipse(pointFrom(-1, -0.86), ellipse, 0.1)).toBe(true); - expect(pointOnEllipse(pointFrom(-1, -0.86), ellipse, 0.01)).toBe(true); +// expect(pointOnEllipse(pointFrom(-1, -0.86), ellipse, 0.1)).toBe(true); +// expect(pointOnEllipse(pointFrom(-1, -0.86), ellipse, 0.01)).toBe(true); - expect(pointOnEllipse(pointFrom(-1, 0.8), ellipse)).toBe(false); - expect(pointOnEllipse(pointFrom(1, -0.8), ellipse)).toBe(false); - }); +// expect(pointOnEllipse(pointFrom(-1, 0.8), ellipse)).toBe(false); +// expect(pointOnEllipse(pointFrom(1, -0.8), ellipse)).toBe(false); +// }); - it("point in ellipse", () => { - [ - pointFrom(0, 1), - pointFrom(0, -1), - pointFrom(2, 0), - pointFrom(-2, 0), - ].forEach((p) => { - expect(pointInEllipse(p, ellipse)).toBe(true); - }); +// it("point in ellipse", () => { +// [ +// pointFrom(0, 1), +// pointFrom(0, -1), +// pointFrom(2, 0), +// pointFrom(-2, 0), +// ].forEach((p) => { +// expect(pointInEllipse(p, ellipse)).toBe(true); +// }); - expect(pointInEllipse(pointFrom(-1, 0.8), ellipse)).toBe(true); - expect(pointInEllipse(pointFrom(1, -0.8), ellipse)).toBe(true); +// expect(pointInEllipse(pointFrom(-1, 0.8), ellipse)).toBe(true); +// expect(pointInEllipse(pointFrom(1, -0.8), ellipse)).toBe(true); - expect(pointInEllipse(pointFrom(-1, 1), ellipse)).toBe(false); - expect(pointInEllipse(pointFrom(-1.4, 0.8), ellipse)).toBe(false); - }); -}); +// expect(pointInEllipse(pointFrom(-1, 1), ellipse)).toBe(false); +// expect(pointInEllipse(pointFrom(-1.4, 0.8), ellipse)).toBe(false); +// }); +// }); describe("line and line", () => { const lineA: LineSegment = lineSegment(