diff --git a/packages/element/src/Shape.ts b/packages/element/src/Shape.ts index 7a313d662..0ab0b0fb7 100644 --- a/packages/element/src/Shape.ts +++ b/packages/element/src/Shape.ts @@ -1,8 +1,17 @@ import { simplify } from "points-on-curve"; -import { pointFrom, type LocalPoint } from "@excalidraw/math"; +import { + pointFrom, + pointDistance, + type LocalPoint, + pointRotateRads, +} 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"; @@ -16,11 +25,15 @@ import { isLinearElement, } from "./typeChecks"; import { getCornerRadius, isPathALoop } from "./shapes"; -import { generateElbowArrowRougJshPathCommands } from "./utils"; +import { headingForPointIsHorizontal } from "./heading"; import { canChangeRoundness } from "./comparisons"; import { generateFreeDrawShape } from "./renderElement"; -import { getArrowheadPoints, getDiamondPoints } from "./bounds"; +import { + getArrowheadPoints, + getDiamondPoints, + getElementBounds, +} from "./bounds"; import type { ExcalidrawElement, @@ -28,11 +41,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]; @@ -303,6 +316,125 @@ 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. * @@ -452,7 +584,7 @@ export const _generateElementShape = ( } else { shape = [ generator.path( - generateElbowArrowRougJshPathCommands(points, 16), + generateElbowArrowShape(points, 16), generateRoughOptions(element, true), ), ]; @@ -546,3 +678,68 @@ 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 f6bd122ff..f3a28cffd 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -226,7 +226,10 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = ( : linearElement.endBinding?.elementId; if (elementId) { const element = elementsMap.get(elementId); - if (isBindableElement(element) && bindingBorderTest(element, coors, zoom)) { + if ( + isBindableElement(element) && + bindingBorderTest(element, coors, elementsMap, zoom) + ) { return element; } } @@ -556,6 +559,7 @@ export const getHoveredElementForBinding = ( bindingBorderTest( element, pointerCoords, + elementsMap, zoom, (fullShape || !isBindingFallthroughEnabled( @@ -588,7 +592,7 @@ export const getHoveredElementForBinding = ( // Prefer the shape with the border being tested (if any) const borderTestElements = candidateElements.filter((element) => - bindingBorderTest(element, pointerCoords, zoom, false), + bindingBorderTest(element, pointerCoords, elementsMap, zoom, false), ); if (borderTestElements.length === 1) { return borderTestElements[0]; @@ -609,6 +613,7 @@ export const getHoveredElementForBinding = ( bindingBorderTest( element, pointerCoords, + elementsMap, zoom, // disable fullshape snapping for frame elements so we // can bind to frame children @@ -1540,6 +1545,7 @@ 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 89eeadece..a5b91922b 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -9,22 +9,29 @@ 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 { getCurvePathOps } from "./utils"; +import type { Mutable } from "@excalidraw/common/utility-types"; import { generateRoughOptions } from "./Shape"; import { ShapeCache } from "./ShapeCache"; @@ -38,6 +45,13 @@ 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 { @@ -45,8 +59,10 @@ import type { ElementsMap, ElementsMapOrArray, ExcalidrawElement, + ExcalidrawEllipseElement, ExcalidrawFreeDrawElement, ExcalidrawLinearElement, + ExcalidrawRectanguloidElement, ExcalidrawTextElementWithContainer, NonDeleted, } from "./types"; @@ -251,6 +267,199 @@ 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. * @@ -659,7 +868,7 @@ const generateLinearElementShape = ( })(); return generator[method]( - element.points as LocalPoint[] as RoughPoint[], + element.points as Mutable[] as RoughPoint[], options, ); }; diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index 44f7b70a2..fd6f63c85 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -1,7 +1,3 @@ -import { simplify } from "points-on-curve"; - -import { RoughGenerator } from "roughjs/bin/generator"; - import { isTransparent, elementCenterPoint, @@ -21,43 +17,43 @@ import { vectorFromPoint, vectorNormalize, vectorScale, - curve, - curveCatmullRomCubicApproxPoints, - curveOffsetPoints, - pointFromArray, - rectangle, +} from "@excalidraw/math"; + +import { ellipse, ellipseSegmentInterceptPoints, -} from "@excalidraw/math"; +} from "@excalidraw/math/ellipse"; import type { Curve, GlobalPoint, LineSegment, - LocalPoint, Radians, } from "@excalidraw/math"; + import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; -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 { isPathALoop } from "./shapes"; +import { getElementBounds } from "./bounds"; import { hasBoundTextElement, - isElbowArrow, isFreeDrawElement, isIframeLikeElement, isImageElement, isLinearElement, isTextElement, } from "./typeChecks"; +import { + deconstructDiamondElement, + deconstructLinearOrFreeDrawElement, + deconstructRectanguloidElement, +} from "./utils"; -import type { Options } from "roughjs/bin/core"; -import type { Point as RoughPoint } from "roughjs/bin/geometry"; +import { getBoundTextElement } from "./textElement"; + +import { LinearElementEditor } from "./linearElementEditor"; + +import { distanceToElement } from "./distance"; import type { ElementsMap, @@ -115,9 +111,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" - isPointInElement(point, element) || - isPointOnElementOutline(point, element, threshold) - : isPointOnElementOutline(point, element, threshold) + isPointInShape(point, element) || + isPointOnShape(point, element, threshold) + : isPointOnShape(point, element, threshold) : false; // hit test against a frame's name @@ -181,7 +177,7 @@ export const hitElementBoundText = ( } : boundTextElementCandidate; - return isPointInElement(point, boundTextElement); + return isPointInShape(point, boundTextElement); }; /** @@ -222,17 +218,19 @@ const intersectLinearOrFreeDrawWithLineSegment = ( element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, segment: LineSegment, ): GlobalPoint[] => { - const shapes = deconstructLinearOrFreeDrawElementForCollision(element); + const shapes = deconstructLinearOrFreeDrawElement(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, @@ -269,10 +267,7 @@ const intersectRectanguloidWithLineSegment = ( ); // Get the element's building components we can test against - const [sides, corners] = deconstructRectanguloidElementForCollision( - element, - offset, - ); + const [sides, corners] = deconstructRectanguloidElement(element, offset); return ( // Test intersection against the sides, keep only the valid @@ -323,10 +318,7 @@ 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] = deconstructDiamondElementForCollision( - element, - offset, - ); + const [sides, curves] = deconstructDiamondElement(element, offset); return ( sides @@ -379,14 +371,14 @@ const intersectEllipseWithLineSegment = ( }; // check if the given point is considered on the given shape's border -const isPointOnElementOutline = ( +const isPointOnShape = ( point: GlobalPoint, element: ExcalidrawElement, tolerance = 1, ) => distanceToElement(element, point) <= tolerance; // check if the given point is considered inside the element's border -export const isPointInElement = ( +export const isPointInShape = ( point: GlobalPoint, element: ExcalidrawElement, ) => { @@ -415,437 +407,3 @@ export const isPointInElement = ( 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 37e789b9a..43482c87e 100644 --- a/packages/element/src/distance.ts +++ b/packages/element/src/distance.ts @@ -18,10 +18,10 @@ import type { } from "@excalidraw/math"; import { - deconstructDiamondElementForCollision, - deconstructLinearOrFreeDrawElementForCollision, - deconstructRectanguloidElementForCollision, -} from "./collision"; + deconstructDiamondElement, + deconstructLinearOrFreeDrawElement, + deconstructRectanguloidElement, +} from "./utils"; 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] = deconstructRectanguloidElementForCollision(element); + const [sides, corners] = deconstructRectanguloidElement(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] = deconstructDiamondElementForCollision(element); + const [sides, curves] = deconstructDiamondElement(element); return Math.min( ...sides.map((s) => distanceToLineSegment(rotatedPoint, s)), @@ -137,7 +137,7 @@ const distanceToLinearOrFreeDraElement = ( element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, p: GlobalPoint, ) => { - const shapes = deconstructLinearOrFreeDrawElementForCollision(element); + const shapes = deconstructLinearOrFreeDrawElement(element); let distance = Infinity; for (const shape of shapes) { diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts index 2825f3e94..3c8209954 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 { - approximateElementWithLineSegments, + getElementLineSegments, getCommonBounds, getElementAbsoluteCoords, } from "./bounds"; @@ -69,15 +69,9 @@ export function isElementIntersectingFrame( frame: ExcalidrawFrameLikeElement, elementsMap: ElementsMap, ) { - const frameLineSegments = approximateElementWithLineSegments( - frame, - elementsMap, - ); + const frameLineSegments = getElementLineSegments(frame, elementsMap); - const elementLineSegments = approximateElementWithLineSegments( - element, - elementsMap, - ); + const elementLineSegments = getElementLineSegments(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 73d2c42b4..9e34b5c27 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -9,6 +9,8 @@ import { vectorFromPoint, } from "@excalidraw/math"; +import { getCurvePathOps } from "@excalidraw/utils/shape"; + import { DRAGGING_THRESHOLD, KEYS, @@ -18,7 +20,7 @@ import { tupleToCoors, } from "@excalidraw/common"; -import { getCurvePathOps, type Store } from "@excalidraw/element"; +import 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 eb1a9292e..96542c538 100644 --- a/packages/element/src/shapes.ts +++ b/packages/element/src/shapes.ts @@ -16,21 +16,116 @@ 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 { getCurvePathOps } from "./utils"; - -import type { Bounds } from "./bounds"; +import { getElementAbsoluteCoords, 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 918cb8663..ccc41c11f 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -1,81 +1,341 @@ -import { pointDistance } from "@excalidraw/math"; +import { + curve, + curveCatmullRomCubicApproxPoints, + curveOffsetPoints, + lineSegment, + pointFrom, + pointFromArray, + rectangle, + type GlobalPoint, +} from "@excalidraw/math"; -import type { LocalPoint } from "@excalidraw/math"; +import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math"; -import { headingForPointIsHorizontal } from "./heading"; +import { getCornerRadius } from "./shapes"; -import type { Drawable, Op } from "roughjs/bin/core"; +import { getDiamondPoints } from "./bounds"; -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 { generateLinearCollisionShape } from "./Shape"; -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, - ); +import type { + ExcalidrawDiamondElement, + ExcalidrawFreeDrawElement, + ExcalidrawLinearElement, + ExcalidrawRectanguloidElement, +} from "./types"; - 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]]); +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); } - } 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 components; +} - return d.join(" "); -}; +/** + * 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 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()]; +} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index ee018d463..3ef3ce84a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -130,7 +130,7 @@ import { refreshTextDimensions, deepCopyElement, duplicateElements, - isPointInElement, + isPointInShape, 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 (isPointInElement(pointFrom(x, y), element)) { + if (isPointInShape(pointFrom(x, y), element)) { return true; } } diff --git a/packages/excalidraw/eraser/index.ts b/packages/excalidraw/eraser/index.ts index 6ba9d156a..7adc2668c 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 { - getBoundTextElement, - intersectElementWithLineSegment, - isPointInElement, -} from "@excalidraw/element"; -import { lineSegment, pointFrom } from "@excalidraw/math"; + lineSegment, + lineSegmentIntersectionPoints, + pointFrom, +} from "@excalidraw/math"; import { getElementsInGroup } from "@excalidraw/element"; @@ -12,7 +12,12 @@ import { shouldTestInside } from "@excalidraw/element"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; import { getBoundTextElementId } from "@excalidraw/element"; -import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; +import type { GeometricShape } from "@excalidraw/utils/shape"; +import type { + ElementsSegmentsMap, + GlobalPoint, + LineSegment, +} from "@excalidraw/math/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import { AnimatedTrail } from "../animated-trail"; @@ -28,6 +33,8 @@ 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, { @@ -103,6 +110,8 @@ export class EraserTrail extends AnimatedTrail { const intersects = eraserTest( pathSegments, element, + this.segmentsCache, + this.geometricShapesCache, candidateElementsMap, this.app, ); @@ -139,6 +148,8 @@ export class EraserTrail extends AnimatedTrail { const intersects = eraserTest( pathSegments, element, + this.segmentsCache, + this.geometricShapesCache, candidateElementsMap, this.app, ); @@ -190,23 +201,31 @@ 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) && isPointInElement(lastPoint, element)) { + if (shouldTestInside(element) && isPointInShape(lastPoint, element)) { return true; } - const offset = app.getElementHitThreshold(); - const boundTextElement = getBoundTextElement(element, elementsMap); + let elementSegments = elementsSegments.get(element.id); - return pathSegments.some( - (pathSegment) => - intersectElementWithLineSegment(element, pathSegment, offset).length > - 0 || - (boundTextElement && - intersectElementWithLineSegment(boundTextElement, pathSegment, offset) - .length > 0), + if (!elementSegments) { + elementSegments = getElementLineSegments(element, elementsMap); + elementsSegments.set(element.id, elementSegments); + } + + return pathSegments.some((pathSegment) => + elementSegments?.some( + (elementSegment) => + lineSegmentIntersectionPoints( + pathSegment, + elementSegment, + app.getElementHitThreshold(), + ) !== null, + ), ); }; diff --git a/packages/excalidraw/lasso/index.ts b/packages/excalidraw/lasso/index.ts index 79cd89a9e..163a8b7a9 100644 --- a/packages/excalidraw/lasso/index.ts +++ b/packages/excalidraw/lasso/index.ts @@ -4,7 +4,7 @@ import { pointFrom, } from "@excalidraw/math"; -import { approximateElementWithLineSegments } from "@excalidraw/element"; +import { getElementLineSegments } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element"; import { isFrameLikeElement, @@ -190,10 +190,7 @@ export class LassoTrail extends AnimatedTrail { this.elementsSegments = new Map(); const visibleElementsMap = arrayToMap(this.app.visibleElements); for (const element of this.app.visibleElements) { - const segments = approximateElementWithLineSegments( - element, - visibleElementsMap, - ); + const segments = getElementLineSegments(element, visibleElementsMap); this.elementsSegments.set(element.id, segments); } } diff --git a/packages/excalidraw/lasso/utils.ts b/packages/excalidraw/lasso/utils.ts index 9ff8c6657..d05f39998 100644 --- a/packages/excalidraw/lasso/utils.ts +++ b/packages/excalidraw/lasso/utils.ts @@ -13,12 +13,11 @@ 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; @@ -28,6 +27,7 @@ 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, app); + const intersects = intersectionTest(path, element, elementsSegments); if (intersects) { intersectedElements.add(element.id); } @@ -66,13 +66,13 @@ export const getLassoSelectedElementIds = (input: { const enclosureTest = ( lassoPath: GlobalPoint[], element: ExcalidrawElement, - app: App, + elementsSegments: ElementsSegmentsMap, ): boolean => { - const lassoSegments = lassoPath - .slice(1) - .map((point, index) => lineSegment(lassoPath[index], point)) - .concat(lineSegment(lassoPath[lassoPath.length - 1], lassoPath[0])); - const offset = app.getElementHitThreshold(); + const lassoPolygon = polygonFromPoints(lassoPath); + const segments = elementsSegments.get(element.id); + if (!segments) { + return false; + } return segments.some((segment) => { return segment.some((point) => @@ -84,15 +84,26 @@ const enclosureTest = ( const intersectionTest = ( lassoPath: GlobalPoint[], element: ExcalidrawElement, - app: App, + elementsSegments: ElementsSegmentsMap, ): boolean => { - const lassoSegments = lassoPath - .slice(1) - .map((point, index) => lineSegment(lassoPath[index], point)) - .concat(lineSegment(lassoPath[lassoPath.length - 1], lassoPath[0])); - const offset = app.getElementHitThreshold(); + 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[]); return lassoSegments.some((lassoSegment) => - intersectElementWithLineSegment(element, lassoSegment, offset), + elementSegments.some( + (elementSegment) => + // introduce a bit of tolerance to account for roughness and simplification of paths + lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null, + ), ); }; diff --git a/packages/excalidraw/tests/lasso.test.tsx b/packages/excalidraw/tests/lasso.test.tsx index 6c07f38b3..7e67d9b5b 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 { approximateElementWithLineSegments } from "@excalidraw/element"; +import { getElementLineSegments } 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 = approximateElementWithLineSegments( + const segments = getElementLineSegments( element, h.app.scene.getElementsMapIncludingDeleted(), ); diff --git a/packages/math/src/index.ts b/packages/math/src/index.ts index e487ac333..d00ab469d 100644 --- a/packages/math/src/index.ts +++ b/packages/math/src/index.ts @@ -1,6 +1,5 @@ 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 4860be7d5..5d50f4b47 100644 --- a/packages/utils/src/shape.ts +++ b/packages/utils/src/shape.ts @@ -13,516 +13,532 @@ */ 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 { 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 { 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 { + 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[]; +import type { Drawable, Op } from "roughjs/bin/core"; -// // a polycurve is a curve consisting of ther curves, this corresponds to a complex -// // curve on the canvas -// export type Polycurve = Curve[]; +// 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[]; -// // 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; -// }; +// a polycurve is a curve consisting of ther curves, this corresponds to a complex +// curve on the canvas +export type Polycurve = Curve[]; -// 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; -// }; +// 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; +}; -// type RectangularElement = -// | ExcalidrawRectangleElement -// | ExcalidrawDiamondElement -// | ExcalidrawFrameLikeElement -// | ExcalidrawEmbeddableElement -// | ExcalidrawImageElement -// | ExcalidrawIframeElement -// | ExcalidrawTextElement -// | ExcalidrawSelectionElement; +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; + }; -// // polygon -// export const getPolygonShape = ( -// element: RectangularElement, -// ): GeometricShape => { -// const { angle, width, height, x, y } = element; +type RectangularElement = + | ExcalidrawRectangleElement + | ExcalidrawDiamondElement + | ExcalidrawFrameLikeElement + | ExcalidrawEmbeddableElement + | ExcalidrawImageElement + | ExcalidrawIframeElement + | ExcalidrawTextElement + | ExcalidrawSelectionElement; -// const cx = x + width / 2; -// const cy = y + height / 2; +// polygon +export const getPolygonShape = ( + element: RectangularElement, +): GeometricShape => { + const { angle, width, height, x, y } = element; -// const center: Point = pointFrom(cx, cy); + const cx = x + width / 2; + const cy = y + height / 2; -// let data: Polygon; + const center: Point = pointFrom(cx, cy); -// 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), -// ); -// } + let data: Polygon; -// return { -// type: "polygon", -// data, -// }; -// }; + 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 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, -// ); + return { + type: "polygon", + data, + }; +}; -// x1 -= padding; -// x2 += padding; -// y1 -= padding; -// y2 += padding; +// 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, + ); -// //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); + x1 -= padding; + x2 += padding; + y1 -= padding; + y2 += padding; -// return { -// type: "polygon", -// data: [topLeft, topRight, bottomRight, bottomLeft], -// } as GeometricShape; -// }; + //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); -// // ellipse -// export const getEllipseShape = ( -// element: ExcalidrawEllipseElement, -// ): GeometricShape => { -// const { width, height, angle, x, y } = element; + return { + type: "polygon", + data: [topLeft, topRight, bottomRight, bottomLeft], + } as GeometricShape; +}; -// return { -// type: "ellipse", -// data: { -// center: pointFrom(x + width / 2, y + height / 2), -// angle, -// halfWidth: width / 2, -// halfHeight: height / 2, -// }, -// }; -// }; +// ellipse +export const getEllipseShape = ( + element: ExcalidrawEllipseElement, +): GeometricShape => { + const { width, height, angle, x, y } = element; -// // 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, -// ); + return { + type: "ellipse", + data: { + center: pointFrom(x + width / 2, y + height / 2), + angle, + halfWidth: width / 2, + halfHeight: height / 2, + }, + }; +}; -// const ops = getCurvePathOps(roughShape); -// const polycurve: Polycurve = []; -// let p0 = pointFrom(0, 0); +export const getCurvePathOps = (shape: Drawable): Op[] => { + // NOTE (mtolmacs): Temporary fix for extremely large elements + if (!shape) { + return []; + } -// 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; -// } -// } + for (const set of shape.sets) { + if (set.type === "path") { + return set.ops; + } + } + return shape.sets[0].ops; +}; -// return { -// type: "polycurve", -// data: polycurve, -// }; -// }; +// 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 polylineFromPoints = ( -// points: Point[], -// ): Polyline => { -// let previousPoint: Point = points[0]; -// const polyline: LineSegment[] = []; + const ops = getCurvePathOps(roughShape); + const polycurve: Polycurve = []; + let p0 = pointFrom(0, 0); -// for (let i = 1; i < points.length; i++) { -// const nextPoint = points[i]; -// polyline.push(lineSegment(previousPoint, nextPoint)); -// previousPoint = nextPoint; -// } + 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 polyline; -// }; + return { + type: "polycurve", + data: polycurve, + }; +}; -// 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 polylineFromPoints = ( + points: Point[], +): Polyline => { + let previousPoint: Point = points[0]; + const polyline: LineSegment[] = []; -// const polyline = polylineFromPoints( -// element.points.map((p) => transform(p as Point)), -// ); + for (let i = 1; i < points.length; i++) { + const nextPoint = points[i]; + polyline.push(lineSegment(previousPoint, nextPoint)); + previousPoint = nextPoint; + } -// return ( -// isClosed -// ? { -// type: "polygon", -// data: polygonFromPoints(polyline.flat()), -// } -// : { -// type: "polyline", -// data: polyline, -// } -// ) as GeometricShape; -// }; + return polyline; +}; -// 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, -// ); +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, + ); -// if (element.roundness === null) { -// return { -// type: "polygon", -// data: polygonFromPoints( -// element.points.map((p) => transform(p as Point)) as Point[], -// ), -// }; -// } + const polyline = polylineFromPoints( + element.points.map((p) => transform(p as Point)), + ); -// const ops = getCurvePathOps(roughShape); + return ( + isClosed + ? { + type: "polygon", + data: polygonFromPoints(polyline.flat()), + } + : { + type: "polyline", + data: polyline, + } + ) as GeometricShape; +}; -// 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])); -// } -// } -// } +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, + ); -// const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) => -// transform(p as Point), -// ) as Point[]; + if (element.roundness === null) { + return { + type: "polygon", + data: polygonFromPoints( + element.points.map((p) => transform(p as Point)) as Point[], + ), + }; + } -// return { -// type: "polygon", -// data: polygonFromPoints(polygonPoints), -// }; -// }; + const ops = getCurvePathOps(roughShape); -// /** -// * 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, -// ); + 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])); + } + } + } -// 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 polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) => + transform(p as Point), + ) as Point[]; -// 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, -// ); + return { + type: "polygon", + data: polygonFromPoints(polygonPoints), + }; +}; -// const px = Math.abs(rotatedPointX); -// const py = Math.abs(rotatedPointY); +/** + * 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, + ); -// let tx = 0.707; -// let ty = 0.707; + 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); +}; -// for (let i = 0; i < 3; i++) { -// const x = a * tx; -// const y = b * ty; +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 ex = ((a * a - b * b) * tx ** 3) / a; -// const ey = ((b * b - a * a) * ty ** 3) / b; + const px = Math.abs(rotatedPointX); + const py = Math.abs(rotatedPointY); -// const rx = x - ex; -// const ry = y - ey; + let tx = 0.707; + let ty = 0.707; -// const qx = px - ex; -// const qy = py - ey; + for (let i = 0; i < 3; i++) { + const x = a * tx; + const y = b * ty; -// const r = Math.hypot(ry, rx); -// const q = Math.hypot(qy, qx); + const ex = ((a * a - b * b) * tx ** 3) / a; + const ey = ((b * b - a * a) * ty ** 3) / b; -// 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 rx = x - ex; + const ry = y - ey; -// const [minX, minY] = [ -// a * tx * Math.sign(rotatedPointX), -// b * ty * Math.sign(rotatedPointY), -// ]; + const qx = px - ex; + const qy = py - ey; -// return pointDistance( -// pointFrom(rotatedPointX, rotatedPointY), -// pointFrom(minX, minY), -// ); -// }; + const r = Math.hypot(ry, rx); + const q = Math.hypot(qy, qx); -// export const pointOnEllipse = ( -// point: Point, -// ellipse: Ellipse, -// threshold = PRECISION, -// ) => { -// return distanceToEllipse(point, ellipse) <= threshold; -// }; + 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; + } -// 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, -// ); + const [minX, minY] = [ + a * tx * Math.sign(rotatedPointX), + b * ty * Math.sign(rotatedPointY), + ]; -// return ( -// (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) + -// (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <= -// 1 -// ); -// }; + return pointDistance( + pointFrom(rotatedPointX, rotatedPointY), + pointFrom(minX, minY), + ); +}; -// export const ellipseAxes = ( -// ellipse: Ellipse, -// ) => { -// const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight; +export const pointOnEllipse = ( + point: Point, + ellipse: Ellipse, + threshold = PRECISION, +) => { + return distanceToEllipse(point, ellipse) <= threshold; +}; -// const majorAxis = widthGreaterThanHeight -// ? ellipse.halfWidth * 2 -// : ellipse.halfHeight * 2; -// const minorAxis = widthGreaterThanHeight -// ? ellipse.halfHeight * 2 -// : ellipse.halfWidth * 2; +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 { -// majorAxis, -// minorAxis, -// }; -// }; + return ( + (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) + + (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <= + 1 + ); +}; -// export const ellipseFocusToCenter = ( -// ellipse: Ellipse, -// ) => { -// const { majorAxis, minorAxis } = ellipseAxes(ellipse); +export const ellipseAxes = ( + ellipse: Ellipse, +) => { + const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight; -// return Math.sqrt(majorAxis ** 2 - minorAxis ** 2); -// }; + const majorAxis = widthGreaterThanHeight + ? ellipse.halfWidth * 2 + : ellipse.halfHeight * 2; + const minorAxis = widthGreaterThanHeight + ? ellipse.halfHeight * 2 + : ellipse.halfWidth * 2; -// export const ellipseExtremes = ( -// ellipse: Ellipse, -// ) => { -// const { center, angle } = ellipse; -// const { majorAxis, minorAxis } = ellipseAxes(ellipse); + return { + majorAxis, + minorAxis, + }; +}; -// const cos = Math.cos(angle); -// const sin = Math.sin(angle); +export const ellipseFocusToCenter = ( + ellipse: Ellipse, +) => { + const { majorAxis, minorAxis } = ellipseAxes(ellipse); -// const sqSum = majorAxis ** 2 + minorAxis ** 2; -// const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle); + return Math.sqrt(majorAxis ** 2 - minorAxis ** 2); +}; -// const yMax = Math.sqrt((sqSum - sqDiff) / 2); -// const xAtYMax = -// (yMax * sqSum * sin * cos) / -// (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2); +export const ellipseExtremes = ( + ellipse: Ellipse, +) => { + const { center, angle } = ellipse; + const { majorAxis, minorAxis } = ellipseAxes(ellipse); -// 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); + const cos = Math.cos(angle); + const sin = Math.sin(angle); -// return [ -// vectorAdd(vector(xAtYMax, yMax), centerVector), -// vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector), -// vectorAdd(vector(xMax, yAtXMax), centerVector), -// vectorAdd(vector(xMax, yAtXMax), centerVector), -// ]; -// }; + 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 54ffa3ae2..8a2f95d3f 100644 --- a/packages/utils/tests/geometry.test.ts +++ b/packages/utils/tests/geometry.test.ts @@ -8,7 +8,14 @@ import { segmentsIntersectAt, } from "@excalidraw/math"; -import type { GlobalPoint, LineSegment, Polygon } from "@excalidraw/math"; +import type { + GlobalPoint, + LineSegment, + Polygon, + Radians, +} from "@excalidraw/math"; + +import { pointInEllipse, pointOnEllipse, type Ellipse } from "../src/shape"; describe("point and line", () => { // const l: Line = line(point(1, 0), point(1, 2)); @@ -64,56 +71,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(