From d5e3f436dc52013d36fe9fffe3e344dcb6e278b9 Mon Sep 17 00:00:00 2001 From: Are Date: Thu, 26 Oct 2023 23:33:00 +0200 Subject: [PATCH] feat: add approximate elements in bbox detection (#6727) Co-authored-by: dwelle --- src/element/Hyperlink.tsx | 2 +- src/element/bounds.ts | 32 ++-- src/element/linearElementEditor.ts | 3 +- src/element/resizeTest.ts | 3 +- src/element/transformHandles.ts | 4 +- src/frame.ts | 147 ++------------- src/math.ts | 4 + src/packages/bbox.ts | 65 +++++++ src/packages/excalidraw/CHANGELOG.md | 2 +- src/packages/excalidraw/index.tsx | 6 + src/packages/utils.ts | 6 + src/packages/withinBounds.test.ts | 262 +++++++++++++++++++++++++++ src/packages/withinBounds.ts | 206 +++++++++++++++++++++ src/scene/export.ts | 8 +- 14 files changed, 597 insertions(+), 153 deletions(-) create mode 100644 src/packages/bbox.ts create mode 100644 src/packages/withinBounds.test.ts create mode 100644 src/packages/withinBounds.ts diff --git a/src/element/Hyperlink.tsx b/src/element/Hyperlink.tsx index 720f1782..0e20661c 100644 --- a/src/element/Hyperlink.tsx +++ b/src/element/Hyperlink.tsx @@ -392,7 +392,7 @@ export const getLinkHandleFromCoords = ( [x1, y1, x2, y2]: Bounds, angle: number, appState: Pick, -): [x: number, y: number, width: number, height: number] => { +): Bounds => { const size = DEFAULT_LINK_SIZE; const linkWidth = size / appState.zoom.value; const linkHeight = size / appState.zoom.value; diff --git a/src/element/bounds.ts b/src/element/bounds.ts index a94e1308..d0d0feba 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -34,7 +34,12 @@ export type RectangleBox = { type MaybeQuadraticSolution = [number | null, number | null] | false; // x and y position of top left corner, x and y position of bottom right corner -export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number]; +export type Bounds = readonly [ + minX: number, + minY: number, + maxX: number, + maxY: number, +]; export class ElementBounds { private static boundsCache = new WeakMap< @@ -63,7 +68,7 @@ export class ElementBounds { } private static calculateBounds(element: ExcalidrawElement): Bounds { - let bounds: [number, number, number, number]; + let bounds: Bounds; const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); @@ -387,7 +392,7 @@ const getCubicBezierCurveBound = ( export const getMinMaxXYFromCurvePathOps = ( ops: Op[], transformXY?: (x: number, y: number) => [number, number], -): [number, number, number, number] => { +): Bounds => { let currentP: Point = [0, 0]; const { minX, minY, maxX, maxY } = ops.reduce( @@ -435,9 +440,9 @@ export const getMinMaxXYFromCurvePathOps = ( return [minX, minY, maxX, maxY]; }; -const getBoundsFromPoints = ( +export const getBoundsFromPoints = ( points: ExcalidrawFreeDrawElement["points"], -): [number, number, number, number] => { +): Bounds => { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; @@ -589,7 +594,7 @@ const getLinearElementRotatedBounds = ( element: ExcalidrawLinearElement, cx: number, cy: number, -): [number, number, number, number] => { +): Bounds => { if (element.points.length < 2) { const [pointX, pointY] = element.points[0]; const [x, y] = rotate( @@ -600,7 +605,7 @@ const getLinearElementRotatedBounds = ( element.angle, ); - let coords: [number, number, number, number] = [x, y, x, y]; + let coords: Bounds = [x, y, x, y]; const boundTextElement = getBoundTextElement(element); if (boundTextElement) { const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( @@ -625,12 +630,7 @@ const getLinearElementRotatedBounds = ( const transformXY = (x: number, y: number) => rotate(element.x + x, element.y + y, cx, cy, element.angle); const res = getMinMaxXYFromCurvePathOps(ops, transformXY); - let coords: [number, number, number, number] = [ - res[0], - res[1], - res[2], - res[3], - ]; + let coords: Bounds = [res[0], res[1], res[2], res[3]]; const boundTextElement = getBoundTextElement(element); if (boundTextElement) { const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( @@ -692,7 +692,7 @@ export const getResizedElementAbsoluteCoords = ( nextWidth: number, nextHeight: number, normalizePoints: boolean, -): [number, number, number, number] => { +): Bounds => { if (!(isLinearElement(element) || isFreeDrawElement(element))) { return [ element.x, @@ -709,7 +709,7 @@ export const getResizedElementAbsoluteCoords = ( normalizePoints, ); - let bounds: [number, number, number, number]; + let bounds: Bounds; if (isFreeDrawElement(element)) { // Free Draw @@ -740,7 +740,7 @@ export const getResizedElementAbsoluteCoords = ( export const getElementPointsCoords = ( element: ExcalidrawLinearElement, points: readonly (readonly [number, number])[], -): [number, number, number, number] => { +): Bounds => { // This might be computationally heavey const gen = rough.generator(); const curve = diff --git a/src/element/linearElementEditor.ts b/src/element/linearElementEditor.ts index adc5aafc..9ee490b3 100644 --- a/src/element/linearElementEditor.ts +++ b/src/element/linearElementEditor.ts @@ -21,6 +21,7 @@ import { } from "../math"; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import { + Bounds, getCurvePathOps, getElementPointsCoords, getMinMaxXYFromCurvePathOps, @@ -1316,7 +1317,7 @@ export class LinearElementEditor { static getMinMaxXYWithBoundText = ( element: ExcalidrawLinearElement, - elementBounds: [number, number, number, number], + elementBounds: Bounds, boundTextElement: ExcalidrawTextElementWithContainer, ): [number, number, number, number, number, number] => { let [x1, y1, x2, y2] = elementBounds; diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts index a3447208..9947a608 100644 --- a/src/element/resizeTest.ts +++ b/src/element/resizeTest.ts @@ -13,6 +13,7 @@ import { MaybeTransformHandleType, } from "./transformHandles"; import { AppState, Zoom } from "../types"; +import { Bounds } from "./bounds"; const isInsideTransformHandle = ( transformHandle: TransformHandle, @@ -87,7 +88,7 @@ export const getElementWithTransformHandleType = ( }; export const getTransformHandleTypeFromCoords = ( - [x1, y1, x2, y2]: readonly [number, number, number, number], + [x1, y1, x2, y2]: Bounds, scenePointerX: number, scenePointerY: number, zoom: Zoom, diff --git a/src/element/transformHandles.ts b/src/element/transformHandles.ts index 1d2ccdca..9ad49c5b 100644 --- a/src/element/transformHandles.ts +++ b/src/element/transformHandles.ts @@ -4,7 +4,7 @@ import { PointerType, } from "./types"; -import { getElementAbsoluteCoords } from "./bounds"; +import { Bounds, getElementAbsoluteCoords } from "./bounds"; import { rotate } from "../math"; import { InteractiveCanvasAppState, Zoom } from "../types"; import { isTextElement } from "."; @@ -23,7 +23,7 @@ export type TransformHandleDirection = export type TransformHandleType = TransformHandleDirection | "rotation"; -export type TransformHandle = [number, number, number, number]; +export type TransformHandle = Bounds; export type TransformHandles = Partial<{ [T in TransformHandleType]: TransformHandle; }>; diff --git a/src/frame.ts b/src/frame.ts index 4d77572c..0e7bc93a 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -22,6 +22,7 @@ import { isFrameElement } from "./element"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import { getElementLineSegments } from "./element/bounds"; +import { doLineSegmentsIntersect } from "./packages/utils"; // --------------------------- Frame State ------------------------------------ export const bindElementsToFramesAfterDuplication = ( @@ -55,130 +56,21 @@ export const bindElementsToFramesAfterDuplication = ( } }; -// --------------------------- Frame Geometry --------------------------------- -class Point { - x: number; - y: number; +export function isElementIntersectingFrame( + element: ExcalidrawElement, + frame: ExcalidrawFrameElement, +) { + const frameLineSegments = getElementLineSegments(frame); - constructor(x: number, y: number) { - this.x = x; - this.y = y; - } -} + const elementLineSegments = getElementLineSegments(element); -class LineSegment { - first: Point; - second: Point; + const intersecting = frameLineSegments.some((frameLineSegment) => + elementLineSegments.some((elementLineSegment) => + doLineSegmentsIntersect(frameLineSegment, elementLineSegment), + ), + ); - constructor(pointA: Point, pointB: Point) { - this.first = pointA; - this.second = pointB; - } - - public getBoundingBox(): [Point, Point] { - return [ - new Point( - Math.min(this.first.x, this.second.x), - Math.min(this.first.y, this.second.y), - ), - new Point( - Math.max(this.first.x, this.second.x), - Math.max(this.first.y, this.second.y), - ), - ]; - } -} - -// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/ -class FrameGeometry { - private static EPSILON = 0.000001; - - private static crossProduct(a: Point, b: Point) { - return a.x * b.y - b.x * a.y; - } - - private static doBoundingBoxesIntersect( - a: [Point, Point], - b: [Point, Point], - ) { - return ( - a[0].x <= b[1].x && - a[1].x >= b[0].x && - a[0].y <= b[1].y && - a[1].y >= b[0].y - ); - } - - private static isPointOnLine(a: LineSegment, b: Point) { - const aTmp = new LineSegment( - new Point(0, 0), - new Point(a.second.x - a.first.x, a.second.y - a.first.y), - ); - const bTmp = new Point(b.x - a.first.x, b.y - a.first.y); - const r = this.crossProduct(aTmp.second, bTmp); - return Math.abs(r) < this.EPSILON; - } - - private static isPointRightOfLine(a: LineSegment, b: Point) { - const aTmp = new LineSegment( - new Point(0, 0), - new Point(a.second.x - a.first.x, a.second.y - a.first.y), - ); - const bTmp = new Point(b.x - a.first.x, b.y - a.first.y); - return this.crossProduct(aTmp.second, bTmp) < 0; - } - - private static lineSegmentTouchesOrCrossesLine( - a: LineSegment, - b: LineSegment, - ) { - return ( - this.isPointOnLine(a, b.first) || - this.isPointOnLine(a, b.second) || - (this.isPointRightOfLine(a, b.first) - ? !this.isPointRightOfLine(a, b.second) - : this.isPointRightOfLine(a, b.second)) - ); - } - - private static doLineSegmentsIntersect( - a: [readonly [number, number], readonly [number, number]], - b: [readonly [number, number], readonly [number, number]], - ) { - const aSegment = new LineSegment( - new Point(a[0][0], a[0][1]), - new Point(a[1][0], a[1][1]), - ); - const bSegment = new LineSegment( - new Point(b[0][0], b[0][1]), - new Point(b[1][0], b[1][1]), - ); - - const box1 = aSegment.getBoundingBox(); - const box2 = bSegment.getBoundingBox(); - return ( - this.doBoundingBoxesIntersect(box1, box2) && - this.lineSegmentTouchesOrCrossesLine(aSegment, bSegment) && - this.lineSegmentTouchesOrCrossesLine(bSegment, aSegment) - ); - } - - public static isElementIntersectingFrame( - element: ExcalidrawElement, - frame: ExcalidrawFrameElement, - ) { - const frameLineSegments = getElementLineSegments(frame); - - const elementLineSegments = getElementLineSegments(element); - - const intersecting = frameLineSegments.some((frameLineSegment) => - elementLineSegments.some((elementLineSegment) => - this.doLineSegmentsIntersect(frameLineSegment, elementLineSegment), - ), - ); - - return intersecting; - } + return intersecting; } export const getElementsCompletelyInFrame = ( @@ -206,10 +98,7 @@ export const isElementContainingFrame = ( export const getElementsIntersectingFrame = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameElement, -) => - elements.filter((element) => - FrameGeometry.isElementIntersectingFrame(element, frame), - ); +) => elements.filter((element) => isElementIntersectingFrame(element, frame)); export const elementsAreInFrameBounds = ( elements: readonly ExcalidrawElement[], @@ -235,7 +124,7 @@ export const elementOverlapsWithFrame = ( ) => { return ( elementsAreInFrameBounds([element], frame) || - FrameGeometry.isElementIntersectingFrame(element, frame) || + isElementIntersectingFrame(element, frame) || isElementContainingFrame([frame], element, frame) ); }; @@ -272,7 +161,7 @@ export const groupsAreAtLeastIntersectingTheFrame = ( return !!elementsInGroup.find( (element) => elementsAreInFrameBounds([element], frame) || - FrameGeometry.isElementIntersectingFrame(element, frame), + isElementIntersectingFrame(element, frame), ); }; @@ -293,7 +182,7 @@ export const groupsAreCompletelyOutOfFrame = ( elementsInGroup.find( (element) => elementsAreInFrameBounds([element], frame) || - FrameGeometry.isElementIntersectingFrame(element, frame), + isElementIntersectingFrame(element, frame), ) === undefined ); }; @@ -353,7 +242,7 @@ export const getElementsInResizingFrame = ( ); for (const element of elementsNotCompletelyInFrame) { - if (!FrameGeometry.isElementIntersectingFrame(element, frame)) { + if (!isElementIntersectingFrame(element, frame)) { if (element.groupIds.length === 0) { nextElementsInFrame.delete(element); } diff --git a/src/math.ts b/src/math.ts index 37605209..a56b97a7 100644 --- a/src/math.ts +++ b/src/math.ts @@ -505,3 +505,7 @@ export const rangeIntersection = ( return null; }; + +export const isValueInRange = (value: number, min: number, max: number) => { + return value >= min && value <= max; +}; diff --git a/src/packages/bbox.ts b/src/packages/bbox.ts new file mode 100644 index 00000000..91549c24 --- /dev/null +++ b/src/packages/bbox.ts @@ -0,0 +1,65 @@ +import { Bounds } from "../element/bounds"; +import { Point } from "../types"; + +export type LineSegment = [Point, Point]; + +export function getBBox(line: LineSegment): Bounds { + return [ + Math.min(line[0][0], line[1][0]), + Math.min(line[0][1], line[1][1]), + Math.max(line[0][0], line[1][0]), + Math.max(line[0][1], line[1][1]), + ]; +} + +export function crossProduct(a: Point, b: Point) { + return a[0] * b[1] - b[0] * a[1]; +} + +export function doBBoxesIntersect(a: Bounds, b: Bounds) { + return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1]; +} + +export function translate(a: Point, b: Point): Point { + return [a[0] - b[0], a[1] - b[1]]; +} + +const EPSILON = 0.000001; + +export function isPointOnLine(l: LineSegment, p: Point) { + const p1 = translate(l[1], l[0]); + const p2 = translate(p, l[0]); + + const r = crossProduct(p1, p2); + + return Math.abs(r) < EPSILON; +} + +export function isPointRightOfLine(l: LineSegment, p: Point) { + const p1 = translate(l[1], l[0]); + const p2 = translate(p, l[0]); + + return crossProduct(p1, p2) < 0; +} + +export function isLineSegmentTouchingOrCrossingLine( + a: LineSegment, + b: LineSegment, +) { + return ( + isPointOnLine(a, b[0]) || + isPointOnLine(a, b[1]) || + (isPointRightOfLine(a, b[0]) + ? !isPointRightOfLine(a, b[1]) + : isPointRightOfLine(a, b[1])) + ); +} + +// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/ +export function doLineSegmentsIntersect(a: LineSegment, b: LineSegment) { + return ( + doBBoxesIntersect(getBBox(a), getBBox(b)) && + isLineSegmentTouchingOrCrossingLine(a, b) && + isLineSegmentTouchingOrCrossingLine(b, a) + ); +} diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index 89796079..3878a681 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -15,8 +15,8 @@ Please add the latest change on the top under the correct section. ### Features +- Export `elementsOverlappingBBox`, `isElementInsideBBox`, `elementPartiallyOverlapsWithOrContainsBBox` helpers for filtering/checking if elements within bounds. [#6727](https://github.com/excalidraw/excalidraw/pull/6727) - Regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping [#7195](https://github.com/excalidraw/excalidraw/pull/7195) - - Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [#7078](https://github.com/excalidraw/excalidraw/pull/7078) ## 0.16.1 (2023-09-21) diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 901785f1..f0e9adc0 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -254,3 +254,9 @@ export { DefaultSidebar } from "../../components/DefaultSidebar"; export { normalizeLink } from "../../data/url"; export { convertToExcalidrawElements } from "../../data/transform"; + +export { + elementsOverlappingBBox, + isElementInsideBBox, + elementPartiallyOverlapsWithOrContainsBBox, +} from "../withinBounds"; diff --git a/src/packages/utils.ts b/src/packages/utils.ts index 8e55a1a0..2d896ed3 100644 --- a/src/packages/utils.ts +++ b/src/packages/utils.ts @@ -229,6 +229,12 @@ export const exportToClipboard = async ( } }; +export * from "./bbox"; +export { + elementsOverlappingBBox, + isElementInsideBBox, + elementPartiallyOverlapsWithOrContainsBBox, +} from "./withinBounds"; export { serializeAsJSON, serializeLibraryAsJSON } from "../data/json"; export { loadFromBlob, diff --git a/src/packages/withinBounds.test.ts b/src/packages/withinBounds.test.ts new file mode 100644 index 00000000..be301f87 --- /dev/null +++ b/src/packages/withinBounds.test.ts @@ -0,0 +1,262 @@ +import { Bounds } from "../element/bounds"; +import { API } from "../tests/helpers/api"; +import { + elementPartiallyOverlapsWithOrContainsBBox, + elementsOverlappingBBox, + isElementInsideBBox, +} from "./withinBounds"; + +const makeElement = (x: number, y: number, width: number, height: number) => + API.createElement({ + type: "rectangle", + x, + y, + width, + height, + }); + +const makeBBox = ( + minX: number, + minY: number, + maxX: number, + maxY: number, +): Bounds => [minX, minY, maxX, maxY]; + +describe("isElementInsideBBox()", () => { + it("should return true if element is fully inside", () => { + const bbox = makeBBox(0, 0, 100, 100); + + // bbox contains element + expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true); + expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true); + }); + + it("should return false if element is only partially overlapping", () => { + const bbox = makeBBox(0, 0, 100, 100); + + // element contains bbox + expect(isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox)).toBe( + false, + ); + + // element overlaps bbox from top-left + expect(isElementInsideBBox(makeElement(-10, -10, 100, 100), bbox)).toBe( + false, + ); + // element overlaps bbox from top-right + expect(isElementInsideBBox(makeElement(90, -10, 100, 100), bbox)).toBe( + false, + ); + // element overlaps bbox from bottom-left + expect(isElementInsideBBox(makeElement(-10, 90, 100, 100), bbox)).toBe( + false, + ); + // element overlaps bbox from bottom-right + expect(isElementInsideBBox(makeElement(90, 90, 100, 100), bbox)).toBe( + false, + ); + }); + + it("should return false if element outside", () => { + const bbox = makeBBox(0, 0, 100, 100); + + // outside diagonally + expect(isElementInsideBBox(makeElement(110, 110, 100, 100), bbox)).toBe( + false, + ); + + // outside on the left + expect(isElementInsideBBox(makeElement(-110, 10, 50, 50), bbox)).toBe( + false, + ); + // outside on the right + expect(isElementInsideBBox(makeElement(110, 10, 50, 50), bbox)).toBe(false); + // outside on the top + expect(isElementInsideBBox(makeElement(10, -110, 50, 50), bbox)).toBe( + false, + ); + // outside on the bottom + expect(isElementInsideBBox(makeElement(10, 110, 50, 50), bbox)).toBe(false); + }); + + it("should return true if bbox contains element and flag enabled", () => { + const bbox = makeBBox(0, 0, 100, 100); + + // element contains bbox + expect( + isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox, true), + ).toBe(true); + + // bbox contains element + expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true); + expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true); + }); +}); + +describe("elementPartiallyOverlapsWithOrContainsBBox()", () => { + it("should return true if element overlaps, is inside, or contains", () => { + const bbox = makeBBox(0, 0, 100, 100); + + // bbox contains element + expect( + elementPartiallyOverlapsWithOrContainsBBox( + makeElement(0, 0, 100, 100), + bbox, + ), + ).toBe(true); + expect( + elementPartiallyOverlapsWithOrContainsBBox( + makeElement(10, 10, 90, 90), + bbox, + ), + ).toBe(true); + + // element contains bbox + expect( + elementPartiallyOverlapsWithOrContainsBBox( + makeElement(-10, -10, 110, 110), + bbox, + ), + ).toBe(true); + + // element overlaps bbox from top-left + expect( + elementPartiallyOverlapsWithOrContainsBBox( + makeElement(-10, -10, 100, 100), + bbox, + ), + ).toBe(true); + // element overlaps bbox from top-right + expect( + elementPartiallyOverlapsWithOrContainsBBox( + makeElement(90, -10, 100, 100), + bbox, + ), + ).toBe(true); + // element overlaps bbox from bottom-left + expect( + elementPartiallyOverlapsWithOrContainsBBox( + makeElement(-10, 90, 100, 100), + bbox, + ), + ).toBe(true); + // element overlaps bbox from bottom-right + expect( + elementPartiallyOverlapsWithOrContainsBBox( + makeElement(90, 90, 100, 100), + bbox, + ), + ).toBe(true); + }); + + it("should return false if element does not overlap", () => { + const bbox = makeBBox(0, 0, 100, 100); + + // outside diagonally + expect( + elementPartiallyOverlapsWithOrContainsBBox( + makeElement(110, 110, 100, 100), + bbox, + ), + ).toBe(false); + + // outside on the left + expect( + elementPartiallyOverlapsWithOrContainsBBox( + makeElement(-110, 10, 50, 50), + bbox, + ), + ).toBe(false); + // outside on the right + expect( + elementPartiallyOverlapsWithOrContainsBBox( + makeElement(110, 10, 50, 50), + bbox, + ), + ).toBe(false); + // outside on the top + expect( + elementPartiallyOverlapsWithOrContainsBBox( + makeElement(10, -110, 50, 50), + bbox, + ), + ).toBe(false); + // outside on the bottom + expect( + elementPartiallyOverlapsWithOrContainsBBox( + makeElement(10, 110, 50, 50), + bbox, + ), + ).toBe(false); + }); +}); + +describe("elementsOverlappingBBox()", () => { + it("should return elements that overlap bbox", () => { + const bbox = makeBBox(0, 0, 100, 100); + + const rectOutside = makeElement(110, 110, 100, 100); + const rectInside = makeElement(10, 10, 90, 90); + const rectContainingBBox = makeElement(-10, -10, 110, 110); + const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50); + + expect( + elementsOverlappingBBox({ + bounds: bbox, + type: "overlap", + elements: [ + rectOutside, + rectInside, + rectContainingBBox, + rectOverlappingTopLeft, + ], + }), + ).toEqual([rectInside, rectContainingBBox, rectOverlappingTopLeft]); + }); + + it("should return elements inside/containing bbox", () => { + const bbox = makeBBox(0, 0, 100, 100); + + const rectOutside = makeElement(110, 110, 100, 100); + const rectInside = makeElement(10, 10, 90, 90); + const rectContainingBBox = makeElement(-10, -10, 110, 110); + const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50); + + expect( + elementsOverlappingBBox({ + bounds: bbox, + type: "contain", + elements: [ + rectOutside, + rectInside, + rectContainingBBox, + rectOverlappingTopLeft, + ], + }), + ).toEqual([rectInside, rectContainingBBox]); + }); + + it("should return elements inside bbox", () => { + const bbox = makeBBox(0, 0, 100, 100); + + const rectOutside = makeElement(110, 110, 100, 100); + const rectInside = makeElement(10, 10, 90, 90); + const rectContainingBBox = makeElement(-10, -10, 110, 110); + const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50); + + expect( + elementsOverlappingBBox({ + bounds: bbox, + type: "inside", + elements: [ + rectOutside, + rectInside, + rectContainingBBox, + rectOverlappingTopLeft, + ], + }), + ).toEqual([rectInside]); + }); + + // TODO test linear, freedraw, and diamond element types (+rotated) +}); diff --git a/src/packages/withinBounds.ts b/src/packages/withinBounds.ts new file mode 100644 index 00000000..75ea10b7 --- /dev/null +++ b/src/packages/withinBounds.ts @@ -0,0 +1,206 @@ +import type { + ExcalidrawElement, + ExcalidrawFreeDrawElement, + ExcalidrawLinearElement, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { + isArrowElement, + isFreeDrawElement, + isLinearElement, + isTextElement, +} from "../element/typeChecks"; +import { isValueInRange, rotatePoint } from "../math"; +import type { Point } from "../types"; +import { Bounds } from "../element/bounds"; + +type Element = NonDeletedExcalidrawElement; +type Elements = readonly NonDeletedExcalidrawElement[]; + +type Points = readonly Point[]; + +/** @returns vertices relative to element's top-left [0,0] position */ +const getNonLinearElementRelativePoints = ( + element: Exclude< + Element, + ExcalidrawLinearElement | ExcalidrawFreeDrawElement + >, +): [TopLeft: Point, TopRight: Point, BottomRight: Point, BottomLeft: Point] => { + if (element.type === "diamond") { + return [ + [element.width / 2, 0], + [element.width, element.height / 2], + [element.width / 2, element.height], + [0, element.height / 2], + ]; + } + return [ + [0, 0], + [0 + element.width, 0], + [0 + element.width, element.height], + [0, element.height], + ]; +}; + +/** @returns vertices relative to element's top-left [0,0] position */ +const getElementRelativePoints = (element: ExcalidrawElement): Points => { + if (isLinearElement(element) || isFreeDrawElement(element)) { + return element.points; + } + return getNonLinearElementRelativePoints(element); +}; + +const getMinMaxPoints = (points: Points) => { + const ret = points.reduce( + (limits, [x, y]) => { + limits.minY = Math.min(limits.minY, y); + limits.minX = Math.min(limits.minX, x); + + limits.maxX = Math.max(limits.maxX, x); + limits.maxY = Math.max(limits.maxY, y); + + return limits; + }, + { + minX: Infinity, + minY: Infinity, + maxX: -Infinity, + maxY: -Infinity, + cx: 0, + cy: 0, + }, + ); + + ret.cx = (ret.maxX + ret.minX) / 2; + ret.cy = (ret.maxY + ret.minY) / 2; + + return ret; +}; + +const getRotatedBBox = (element: Element): Bounds => { + const points = getElementRelativePoints(element); + + const { cx, cy } = getMinMaxPoints(points); + const centerPoint: Point = [cx, cy]; + + const rotatedPoints = points.map((point) => + rotatePoint([point[0], point[1]], centerPoint, element.angle), + ); + const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints); + + return [ + minX + element.x, + minY + element.y, + maxX + element.x, + maxY + element.y, + ]; +}; + +export const isElementInsideBBox = ( + element: Element, + bbox: Bounds, + eitherDirection = false, +): boolean => { + const elementBBox = getRotatedBBox(element); + + const elementInsideBbox = + bbox[0] <= elementBBox[0] && + bbox[2] >= elementBBox[2] && + bbox[1] <= elementBBox[1] && + bbox[3] >= elementBBox[3]; + + if (!eitherDirection) { + return elementInsideBbox; + } + + if (elementInsideBbox) { + return true; + } + + return ( + elementBBox[0] <= bbox[0] && + elementBBox[2] >= bbox[2] && + elementBBox[1] <= bbox[1] && + elementBBox[3] >= bbox[3] + ); +}; + +export const elementPartiallyOverlapsWithOrContainsBBox = ( + element: Element, + bbox: Bounds, +): boolean => { + const elementBBox = getRotatedBBox(element); + + return ( + (isValueInRange(elementBBox[0], bbox[0], bbox[2]) || + isValueInRange(bbox[0], elementBBox[0], elementBBox[2])) && + (isValueInRange(elementBBox[1], bbox[1], bbox[3]) || + isValueInRange(bbox[1], elementBBox[1], elementBBox[3])) + ); +}; + +export const elementsOverlappingBBox = ({ + elements, + bounds, + type, + errorMargin = 0, +}: { + elements: Elements; + bounds: Bounds; + /** safety offset. Defaults to 0. */ + errorMargin?: number; + /** + * - overlap: elements overlapping or inside bounds + * - contain: elements inside bounds or bounds inside elements + * - inside: elements inside bounds + **/ + type: "overlap" | "contain" | "inside"; +}) => { + const adjustedBBox: Bounds = [ + bounds[0] - errorMargin, + bounds[1] - errorMargin, + bounds[2] + errorMargin, + bounds[3] + errorMargin, + ]; + + const includedElementSet = new Set(); + + for (const element of elements) { + if (includedElementSet.has(element.id)) { + continue; + } + + const isOverlaping = + type === "overlap" + ? elementPartiallyOverlapsWithOrContainsBBox(element, adjustedBBox) + : type === "inside" + ? isElementInsideBBox(element, adjustedBBox) + : isElementInsideBBox(element, adjustedBBox, true); + + if (isOverlaping) { + includedElementSet.add(element.id); + + if (element.boundElements) { + for (const boundElement of element.boundElements) { + includedElementSet.add(boundElement.id); + } + } + + if (isTextElement(element) && element.containerId) { + includedElementSet.add(element.containerId); + } + + if (isArrowElement(element)) { + if (element.startBinding) { + includedElementSet.add(element.startBinding.elementId); + } + + if (element.endBinding) { + includedElementSet.add(element.endBinding?.elementId); + } + } + } + } + + return elements.filter((element) => includedElementSet.has(element.id)); +}; diff --git a/src/scene/export.ts b/src/scene/export.ts index ca838f05..b94dc27c 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -1,6 +1,10 @@ import rough from "roughjs/bin/rough"; import { NonDeletedExcalidrawElement } from "../element/types"; -import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds"; +import { + Bounds, + getCommonBounds, + getElementAbsoluteCoords, +} from "../element/bounds"; import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; import { distance, isOnlyExportingSingleFrame } from "../utils"; import { AppState, BinaryFiles } from "../types"; @@ -221,7 +225,7 @@ export const exportToSvg = async ( const getCanvasSize = ( elements: readonly NonDeletedExcalidrawElement[], exportPadding: number, -): [number, number, number, number] => { +): Bounds => { // we should decide if we are exporting the whole canvas // if so, we are not clipping elements in the frame // and therefore, we should not do anything special