322 lines
8.2 KiB
TypeScript

/**
* this file defines pure geometric shapes
*
* for instance, a cubic bezier curve is specified by its four control points and
* an ellipse is defined by its center, angle, semi major axis and semi minor axis
* (but in semi-width and semi-height so it's more relevant to Excalidraw)
*
* the idea with pure shapes is so that we can provide collision and other geoemtric methods not depending on
* the specifics of roughjs or elements in Excalidraw; instead, we can focus on the pure shapes themselves
*
* also included in this file are methods for converting an Excalidraw element or a Drawable from roughjs
* to pure shapes
*/
import { getElementAbsoluteCoords } from "../../excalidraw/element";
import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawEmbeddableElement,
ExcalidrawFrameLikeElement,
ExcalidrawFreeDrawElement,
ExcalidrawIframeElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawRectangleElement,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
} from "../../excalidraw/element/types";
import { angleToDegrees, close, pointAdd, pointRotate } from "./geometry";
import { pointsOnBezierCurves } from "points-on-curve";
import type { Drawable, Op } from "roughjs/bin/core";
// a point is specified by its coordinate (x, y)
export type Point = [number, number];
export type Vector = Point;
// a line (segment) is defined by two endpoints
export type Line = [Point, Point];
// 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 = Line[];
// cubic bezier curve with four control points
export type Curve = [Point, Point, Point, Point];
// a polycurve is a curve consisting of ther curves, this corresponds to a complex
// curve on the canvas
export type Polycurve = Curve[];
// a polygon is a closed shape by connecting the given points
// rectangles and diamonds are modelled by polygons
export type Polygon = Point[];
// 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: number;
halfWidth: number;
halfHeight: number;
};
export type GeometricShape =
| {
type: "line";
data: Line;
}
| {
type: "polygon";
data: Polygon;
}
| {
type: "curve";
data: Curve;
}
| {
type: "ellipse";
data: Ellipse;
}
| {
type: "polyline";
data: Polyline;
}
| {
type: "polycurve";
data: Polycurve;
};
type RectangularElement =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement
| ExcalidrawImageElement
| ExcalidrawIframeElement
| ExcalidrawTextElement
| ExcalidrawSelectionElement;
// polygon
export const getPolygonShape = (
element: RectangularElement,
): GeometricShape => {
const { angle, width, height, x, y } = element;
const angleInDegrees = angleToDegrees(angle);
const cx = x + width / 2;
const cy = y + height / 2;
const center: Point = [cx, cy];
let data: Polygon = [];
if (element.type === "diamond") {
data = [
pointRotate([cx, y], angleInDegrees, center),
pointRotate([x + width, cy], angleInDegrees, center),
pointRotate([cx, y + height], angleInDegrees, center),
pointRotate([x, cy], angleInDegrees, center),
] as Polygon;
} else {
data = [
pointRotate([x, y], angleInDegrees, center),
pointRotate([x + width, y], angleInDegrees, center),
pointRotate([x + width, y + height], angleInDegrees, center),
pointRotate([x, y + height], angleInDegrees, center),
] as Polygon;
}
return {
type: "polygon",
data,
};
};
// return the selection box for an element, possibly rotated as well
export const getSelectionBoxShape = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
padding = 10,
) => {
let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
true,
);
x1 -= padding;
x2 += padding;
y1 -= padding;
y2 += padding;
const angleInDegrees = angleToDegrees(element.angle);
const center: Point = [cx, cy];
const topLeft = pointRotate([x1, y1], angleInDegrees, center);
const topRight = pointRotate([x2, y1], angleInDegrees, center);
const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
return {
type: "polygon",
data: [topLeft, topRight, bottomRight, bottomLeft],
} as GeometricShape;
};
// ellipse
export const getEllipseShape = (
element: ExcalidrawEllipseElement,
): GeometricShape => {
const { width, height, angle, x, y } = element;
return {
type: "ellipse",
data: {
center: [x + width / 2, y + height / 2],
angle,
halfWidth: width / 2,
halfHeight: height / 2,
},
};
};
export const getCurvePathOps = (shape: Drawable): Op[] => {
for (const set of shape.sets) {
if (set.type === "path") {
return set.ops;
}
}
return shape.sets[0].ops;
};
// linear
export const getCurveShape = (
roughShape: Drawable,
startingPoint: Point = [0, 0],
angleInRadian: number,
center: Point,
): GeometricShape => {
const transform = (p: Point) =>
pointRotate(
[p[0] + startingPoint[0], p[1] + startingPoint[1]],
angleToDegrees(angleInRadian),
center,
);
const ops = getCurvePathOps(roughShape);
const polycurve: Polycurve = [];
let p0: Point = [0, 0];
for (const op of ops) {
if (op.op === "move") {
p0 = transform(op.data as Point);
}
if (op.op === "bcurveTo") {
const p1: Point = transform([op.data[0], op.data[1]]);
const p2: Point = transform([op.data[2], op.data[3]]);
const p3: Point = transform([op.data[4], op.data[5]]);
polycurve.push([p0, p1, p2, p3]);
p0 = p3;
}
}
return {
type: "polycurve",
data: polycurve,
};
};
const polylineFromPoints = (points: Point[]) => {
let previousPoint = points[0];
const polyline: Polyline = [];
for (let i = 1; i < points.length; i++) {
const nextPoint = points[i];
polyline.push([previousPoint, nextPoint]);
previousPoint = nextPoint;
}
return polyline;
};
export const getFreedrawShape = (
element: ExcalidrawFreeDrawElement,
center: Point,
isClosed: boolean = false,
): GeometricShape => {
const angle = angleToDegrees(element.angle);
const transform = (p: Point) =>
pointRotate(pointAdd(p, [element.x, element.y] as Point), angle, center);
const polyline = polylineFromPoints(
element.points.map((p) => transform(p as Point)),
);
return isClosed
? {
type: "polygon",
data: close(polyline.flat()) as Polygon,
}
: {
type: "polyline",
data: polyline,
};
};
export const getClosedCurveShape = (
element: ExcalidrawLinearElement,
roughShape: Drawable,
startingPoint: Point = [0, 0],
angleInRadian: number,
center: Point,
): GeometricShape => {
const transform = (p: Point) =>
pointRotate(
[p[0] + startingPoint[0], p[1] + startingPoint[1]],
angleToDegrees(angleInRadian),
center,
);
if (element.roundness === null) {
return {
type: "polygon",
data: close(element.points.map((p) => transform(p as Point))),
};
}
const ops = getCurvePathOps(roughShape);
const points: Point[] = [];
let odd = false;
for (const operation of ops) {
if (operation.op === "move") {
odd = !odd;
if (odd) {
points.push([operation.data[0], operation.data[1]]);
}
} else if (operation.op === "bcurveTo") {
if (odd) {
points.push([operation.data[0], operation.data[1]]);
points.push([operation.data[2], operation.data[3]]);
points.push([operation.data[4], operation.data[5]]);
}
} else if (operation.op === "lineTo") {
if (odd) {
points.push([operation.data[0], operation.data[1]]);
}
}
}
const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) =>
transform(p),
);
return {
type: "polygon",
data: polygonPoints,
};
};