feat: add approximate elements in bbox detection

This commit is contained in:
are 2023-06-28 22:32:03 +02:00
parent 3d57112480
commit e8f1e10f36
5 changed files with 320 additions and 130 deletions

View File

@ -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;

View File

@ -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);
}

72
src/packages/bbox.ts Normal file
View File

@ -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)
);
}

View File

@ -226,6 +226,11 @@ export const exportToClipboard = async (
}
};
export * from "./bbox";
export {
elementsOverlappingBBox,
isElementOverlappingBBox,
} from "./withinBounds";
export { serializeAsJSON, serializeLibraryAsJSON } from "../data/json";
export {
loadFromBlob,

View File

@ -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<string>();
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];
};