From e8f1e10f36da6e426c3cb6aa108fb40efe03eb91 Mon Sep 17 00:00:00 2001 From: are Date: Wed, 28 Jun 2023 22:32:03 +0200 Subject: [PATCH] feat: add approximate elements in bbox detection --- src/element/bounds.ts | 2 +- src/frame.ts | 147 +++-------------------- src/packages/bbox.ts | 72 +++++++++++ src/packages/utils.ts | 5 + src/packages/withinBounds.ts | 224 +++++++++++++++++++++++++++++++++++ 5 files changed, 320 insertions(+), 130 deletions(-) create mode 100644 src/packages/bbox.ts create mode 100644 src/packages/withinBounds.ts diff --git a/src/element/bounds.ts b/src/element/bounds.ts index a2a04439b..859fd9c04 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -437,7 +437,7 @@ export const getMinMaxXYFromCurvePathOps = ( return [minX, minY, maxX, maxY]; }; -const getBoundsFromPoints = ( +export const getBoundsFromPoints = ( points: ExcalidrawFreeDrawElement["points"], ): [number, number, number, number] => { let minX = Infinity; diff --git a/src/frame.ts b/src/frame.ts index 18273cea6..52676ec31 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -23,6 +23,7 @@ import { moveOneRight } from "./zindex"; 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 = ( @@ -56,130 +57,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 = ( @@ -207,10 +99,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[], @@ -236,7 +125,7 @@ export const elementOverlapsWithFrame = ( ) => { return ( elementsAreInFrameBounds([element], frame) || - FrameGeometry.isElementIntersectingFrame(element, frame) || + isElementIntersectingFrame(element, frame) || isElementContainingFrame([frame], element, frame) ); }; @@ -273,7 +162,7 @@ export const groupsAreAtLeastIntersectingTheFrame = ( return !!elementsInGroup.find( (element) => elementsAreInFrameBounds([element], frame) || - FrameGeometry.isElementIntersectingFrame(element, frame), + isElementIntersectingFrame(element, frame), ); }; @@ -294,7 +183,7 @@ export const groupsAreCompletelyOutOfFrame = ( elementsInGroup.find( (element) => elementsAreInFrameBounds([element], frame) || - FrameGeometry.isElementIntersectingFrame(element, frame), + isElementIntersectingFrame(element, frame), ) === undefined ); }; @@ -354,7 +243,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/packages/bbox.ts b/src/packages/bbox.ts new file mode 100644 index 000000000..730784428 --- /dev/null +++ b/src/packages/bbox.ts @@ -0,0 +1,72 @@ +import { Point } from "../types"; + +export type LineSegment = [Point, Point]; +export type BBox = [topLeft: Point, bottomRight: Point]; + +export function bbox(topLeft: Point, bottomRight: Point): BBox { + return [topLeft, bottomRight]; +} + +export function getBBox(line: LineSegment): BBox { + 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: BBox, b: BBox) { + return ( + a[0][0] <= b[1][0] && + a[1][0] >= b[0][0] && + a[0][1] <= b[1][1] && + a[1][1] >= b[0][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/utils.ts b/src/packages/utils.ts index d9365895e..0ec544170 100644 --- a/src/packages/utils.ts +++ b/src/packages/utils.ts @@ -226,6 +226,11 @@ export const exportToClipboard = async ( } }; +export * from "./bbox"; +export { + elementsOverlappingBBox, + isElementOverlappingBBox, +} from "./withinBounds"; export { serializeAsJSON, serializeLibraryAsJSON } from "../data/json"; export { loadFromBlob, diff --git a/src/packages/withinBounds.ts b/src/packages/withinBounds.ts new file mode 100644 index 000000000..7bb528b4b --- /dev/null +++ b/src/packages/withinBounds.ts @@ -0,0 +1,224 @@ +import { BBox, bbox } from "./bbox"; +import { NonDeletedExcalidrawElement } from "../element/types"; +import { + isArrowElement, + isFreeDrawElement, + isLinearElement, + isTextElement, +} from "../element/typeChecks"; +import { getBoundsFromPoints } from "../element/bounds"; +import { rotatePoint } from "../math"; +import { Point } from "../types"; + +type Element = NonDeletedExcalidrawElement; +type Elements = readonly NonDeletedExcalidrawElement[]; + +function getVertices(bbox: BBox): [Point, Point, Point, Point] { + return [bbox[0], [bbox[1][0], bbox[0][1]], bbox[1], [bbox[0][0], bbox[1][1]]]; +} + +function getPrimitiveBBox(element: Element): BBox { + if (isFreeDrawElement(element)) { + const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points); + + return bbox( + [minX + element.x, minY + element.y], + [maxX + element.x, maxY + element.y], + ); + } else if (isLinearElement(element)) { + const { minX, minY, maxX, maxY } = element.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 }, + ); + + return bbox( + [element.x + minX, element.y + minY], + [element.x + maxX, element.y + maxY], + ); + } + + return bbox( + [element.x, element.y], + [element.x + element.width, element.y + element.height], + ); +} + +function getElementBBox(element: Element): BBox { + const primitiveBBox = getPrimitiveBBox(element); + + const centerPoint: Point = [ + primitiveBBox[0][0] + (primitiveBBox[1][0] - primitiveBBox[0][0]) / 2, + primitiveBBox[0][1] + (primitiveBBox[1][1] - primitiveBBox[0][1]) / 2, + ]; + + const [otl, otr, obr, obl] = getVertices(primitiveBBox); + + const rtl = rotatePoint(otl, centerPoint, element.angle); + const rbr = rotatePoint(obr, centerPoint, element.angle); + const rtr = rotatePoint(otr, centerPoint, element.angle); + const rbl = rotatePoint(obl, centerPoint, element.angle); + + return bbox( + [ + Math.min(rtl[0], rbr[0], rtr[0], rbl[0]), + Math.min(rtl[1], rbr[1], rtr[1], rbl[1]), + ], + [ + Math.max(rtl[0], rbr[0], rtr[0], rbl[0]), + Math.max(rtl[1], rbr[1], rtr[1], rbl[1]), + ], + ); +} + +function isElementInsideBBox(element: Element, bbox: BBox): boolean { + const elementBBox = getElementBBox(element); + + return ( + bbox[0][0] < elementBBox[0][0] && + bbox[1][0] > elementBBox[1][0] && + bbox[0][1] < elementBBox[0][1] && + bbox[1][1] > elementBBox[1][1] + ); +} + +function isValueInRange(value: number, min: number, max: number) { + return value >= min && value <= max; +} + +function isElementIntersectingBBox(element: Element, bbox: BBox): boolean { + const elementBBox = getElementBBox(element); + + return ( + bbox[0][0] < elementBBox[0][0] && + bbox[1][0] > elementBBox[1][0] && + bbox[0][1] < elementBBox[0][1] && + bbox[1][1] > elementBBox[1][1] + ); +} + +function isElementContainingBBox(element: Element, bbox: BBox): boolean { + const elementBBox = getElementBBox(element); + + return ( + (isValueInRange(elementBBox[0][0], bbox[0][0], bbox[1][0]) || + isValueInRange(bbox[0][0], elementBBox[0][0], elementBBox[1][0])) && + (isValueInRange(elementBBox[0][1], bbox[0][1], bbox[1][1]) || + isValueInRange(bbox[0][1], elementBBox[0][1], elementBBox[1][1])) + ); +} + +export function isElementOverlappingBBox(element: Element, bbox: BBox) { + return ( + isElementInsideBBox(element, bbox) || + isElementIntersectingBBox(element, bbox) || + isElementContainingBBox(element, bbox) + ); +} + +export const elementsOverlappingBBox = ({ + elements, + bounds, + errorMargin = 0, +}: { + elements: Elements; + bounds: BBox; + errorMargin: number; +}) => { + const adjustedBBox = bbox( + [bounds[0][0] - errorMargin, bounds[0][1] - errorMargin], + [bounds[1][0] + errorMargin, bounds[1][1] + errorMargin], + ); + + const includedElementSet = new Set(); + + for (const element of elements) { + if (includedElementSet.has(element.id)) { + continue; + } + + const isOverlaping = isElementOverlappingBBox(element, adjustedBBox); + + 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)); +}; + +// ### DEBUG + +declare global { + interface Window { + debug: () => void; + } +} + +window.debug = () => { + const boundsIndex = window.h.elements.findIndex( + (e) => e.type === "rectangle" && e.strokeStyle === "dashed", + ); + + if (boundsIndex === -1) { + return; + } + + const boundsElement = window.h.elements[boundsIndex]; + + const boundsBBox = bbox( + [boundsElement.x, boundsElement.y], + [ + boundsElement.x + boundsElement.width, + boundsElement.y + boundsElement.height, + ], + ); + + const allElements = [ + ...window.h.elements.slice(0, boundsIndex), + ...window.h.elements.slice(boundsIndex + 1, window.h.elements.length), + ]; + + const boundedElements = elementsOverlappingBBox({ + elements: allElements, + bounds: boundsBBox, + errorMargin: 0, + }).map((element) => element.id); + + const newElements = allElements.map((element) => { + if (boundedElements.includes(element.id)) { + return { ...element, strokeColor: "#ff0000" }; + } + + return { ...element, strokeColor: "#000000" }; + }); + + window.h.elements = [boundsElement, ...newElements]; +};