diff --git a/packages/math/point.ts b/packages/math/point.ts
index 92df18773..b741c4c8e 100644
--- a/packages/math/point.ts
+++ b/packages/math/point.ts
@@ -1,4 +1,4 @@
-import { degreesToRadians } from "./angle";
+import { degreesToRadians, radiansToDegrees } from "./angle";
import type {
LocalPoint,
GlobalPoint,
@@ -7,7 +7,7 @@ import type {
Vector,
} from "./types";
import { PRECISION } from "./utils";
-import { vectorFromPoint, vectorScale } from "./vector";
+import { vectorDot, vectorFromPoint, vectorScale } from "./vector";
/**
* Create a properly typed Point instance from the X and Y coordinates.
@@ -229,3 +229,61 @@ export const isPointWithinBounds =
(
q[1] >= Math.min(p[1], r[1])
);
};
+
+/**
+ * Calculates the perpendicular distance from a point to a line segment defined by two endpoints.
+ *
+ * If the segment is of zero length, the function returns the distance from the point to the start.
+ *
+ * @typeParam P - The point type, restricted to LocalPoint or GlobalPoint.
+ * @param p - The point from which the perpendicular distance is measured.
+ * @param start - The starting point of the line segment.
+ * @param end - The ending point of the line segment.
+ * @returns The perpendicular distance from point p to the line segment defined by start and end.
+ */
+export const perpendicularDistance =
(
+ p: P,
+ start: P,
+ end: P):
+ number => {
+ const dx = end[0] - start[0];
+ const dy = end[1] - start[1];
+ if (dx === 0 && dy === 0) {
+ return Math.hypot(p[0] - start[0], p[1] - start[1]);
+ }
+ // Equation of line distance
+ const numerator = Math.abs(dy * p[0] - dx * p[1] + end[0] * start[1] - end[1] * start[0]);
+ const denom = Math.hypot(dx, dy);
+ return numerator / denom;
+}
+
+/** * Calculates the angle between three points in degrees.
+ * The angle is calculated at the first point (p0) using the second (p1) and third (p2) points.
+ * The angle is measured in degrees and is always positive.
+ * The function uses the dot product and the arccosine function to calculate the angle. * The result is clamped to the range [-1, 1] to avoid precision errors.
+ * @param p0 The first point used to form the angle.
+ * @param p1 The vertex point where the angle is calculated.
+ * @param p2 The second point used to form the angle.
+ * @returns The angle in degrees between the three points.
+**/
+export const angleBetween =
(
+ p0: P,
+ p1: P,
+ p2: P,
+): Degrees => {
+ const v1 = vectorFromPoint(p0, p1);
+ const v2 = vectorFromPoint(p1, p2);
+
+ // dot and cross product
+ const magnitude1 = Math.hypot(v1[0], v1[1]), magnitude2 = Math.hypot(v2[0], v2[1]);
+ if (magnitude1 === 0 || magnitude2 === 0) return 0 as Degrees;
+
+ const dot = vectorDot(v1, v2);
+
+ let cos = dot / (magnitude1 * magnitude2);
+ // Clamp cos to [-1,1] to avoid precision errors
+ cos = Math.max(-1, Math.min(1, cos));
+ const rad = Math.acos(cos) as Radians;
+
+ return radiansToDegrees(rad);
+}
diff --git a/packages/utils/package.json b/packages/utils/package.json
index 79284fd6e..ddda1e7d6 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -48,7 +48,6 @@
]
},
"dependencies": {
- "@amaplex-software/shapeit": "0.1.6",
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.3.1",
"browser-fs-access": "0.29.1",
diff --git a/packages/utils/snapToShape.ts b/packages/utils/snapToShape.ts
index 051001f2f..acbf39ab0 100644
--- a/packages/utils/snapToShape.ts
+++ b/packages/utils/snapToShape.ts
@@ -1,136 +1,267 @@
import type {
- ExcalidrawArrowElement,
- ExcalidrawDiamondElement,
- ExcalidrawElement,
- ExcalidrawEllipseElement,
- ExcalidrawFreeDrawElement,
- ExcalidrawLinearElement,
- ExcalidrawRectangleElement,
- } from "../excalidraw/element/types";
- import type { BoundingBox } from "../excalidraw/element/bounds";
- import { getCommonBoundingBox } from "../excalidraw/element/bounds";
- import { newElement } from "../excalidraw/element";
- // @ts-ignore
- import shapeit from "@amaplex-software/shapeit";
-
- type Shape =
- | ExcalidrawRectangleElement["type"]
- | ExcalidrawEllipseElement["type"]
- | ExcalidrawDiamondElement["type"]
- // | ExcalidrawArrowElement["type"]
- // | ExcalidrawLinearElement["type"]
- | ExcalidrawFreeDrawElement["type"];
-
- interface ShapeRecognitionResult {
- type: Shape;
- confidence: number;
- boundingBox: BoundingBox;
+ ExcalidrawArrowElement,
+ ExcalidrawDiamondElement,
+ ExcalidrawElement,
+ ExcalidrawEllipseElement,
+ ExcalidrawFreeDrawElement,
+ ExcalidrawLinearElement,
+ ExcalidrawRectangleElement,
+} from "../excalidraw/element/types";
+import type { BoundingBox, Bounds } from "../excalidraw/element/bounds";
+import { getCenterForBounds, getCommonBoundingBox } from "../excalidraw/element/bounds";
+import { newArrowElement, newElement, newLinearElement } from "../excalidraw/element";
+import { angleBetween, GlobalPoint, LocalPoint, perpendicularDistance, pointDistance } from "@excalidraw/math";
+import { ROUNDNESS } from "@excalidraw/excalidraw/constants";
+
+type Shape =
+ | ExcalidrawRectangleElement["type"]
+ | ExcalidrawEllipseElement["type"]
+ | ExcalidrawDiamondElement["type"]
+ | ExcalidrawArrowElement["type"]
+ | ExcalidrawLinearElement["type"]
+ | ExcalidrawFreeDrawElement["type"];
+
+interface ShapeRecognitionResult {
+ type: Shape;
+ simplified: readonly LocalPoint[];
+ boundingBox: BoundingBox;
+}
+
+interface ShapeRecognitionOptions {
+ closedDistThreshPercent: number; // Max distance between stroke start/end to consider shape closed
+ cornerAngleThresh: number; // Angle (in degrees) below which a corner is considered "sharp" (for arrow detection)
+ rdpTolerancePercent: number; // RDP simplification tolerance (percentage of bounding box diagonal)
+ rectAngleThresh: number; // Angle (in degrees) to check for rectangle corners
+ rectOrientationThresh: number; // Angle difference (in degrees) to nearest 0/90 orientation to call it rectangle
+}
+
+const DEFAULT_OPTIONS: ShapeRecognitionOptions = {
+ closedDistThreshPercent: 10, // distance between start/end < % of bounding box diagonal
+ cornerAngleThresh: 60, // <60° considered a sharp corner (possible arrow tip)
+ rdpTolerancePercent: 10, // percentage of bounding box diagonal
+ rectAngleThresh: 20, // <20° considered a sharp corner (rectangle)
+ rectOrientationThresh: 10, //
+};
+
+
+/**
+ * Recognizes common shapes from free-draw input
+ * @param element The freedraw element to analyze
+ * @returns Information about the recognized shape, or null if no shape is recognized
+ */
+export const recognizeShape = (
+ element: ExcalidrawFreeDrawElement,
+ opts: Partial = {},
+): ShapeRecognitionResult => {
+ const options = { ...DEFAULT_OPTIONS, ...opts };
+
+ const boundingBox = getCommonBoundingBox([element]);;
+
+ // We need at least a few points to recognize a shape
+ if (!element.points || element.points.length < 3) {
+ return { type: "freedraw", simplified: element.points, boundingBox };
}
-
- /**
- * Recognizes common shapes from free-draw input
- * @param element The freedraw element to analyze
- * @returns Information about the recognized shape, or null if no shape is recognized
- */
- export const recognizeShape = (
- element: ExcalidrawFreeDrawElement,
- ): ShapeRecognitionResult => {
- const boundingBox = getCommonBoundingBox([element]);
-
- // We need at least a few points to recognize a shape
- if (!element.points || element.points.length < 3) {
- return { type: "freedraw", confidence: 1, boundingBox };
+
+ const tolerance = pointDistance(
+ [boundingBox.minX, boundingBox.minY] as LocalPoint,
+ [boundingBox.maxX, boundingBox.maxY] as LocalPoint,
+ ) * options.rdpTolerancePercent / 100;
+ const simplified = simplifyRDP(element.points, tolerance);
+ console.log("Simplified points:", simplified);
+
+ // Check if the original points form a closed shape
+ const start = element.points[0], end = element.points[element.points.length - 1];
+ const closedDist = pointDistance(start, end);
+ const diag = Math.hypot(boundingBox.width, boundingBox.height); // diagonal of bounding box
+ const isClosed = closedDist < Math.max(10, diag * options.closedDistThreshPercent / 100); // e.g., threshold: 10px or % of size
+ console.log("Closed shape:", isClosed);
+
+ let bestShape: Shape = 'freedraw'; // TODO: Should this even be possible in this mode?
+
+ const boundingBoxCenter = getCenterForBounds([
+ boundingBox.minX,
+ boundingBox.minY,
+ boundingBox.maxX,
+ boundingBox.maxY,
+ ] as Bounds);
+
+ // **Line** (open shape with low deviation from a straight line)
+ if (!isClosed && simplified.length == 2) {
+ bestShape = 'line';
+ }
+
+ // **Arrow** (open shape with a sharp angle indicating an arrowhead)
+ if (!isClosed && simplified.length == 5) {
+ // The last two segments will make an arrowhead
+ console.log("Simplified points:", simplified);
+ const arrow_start = simplified[2], arrow_tip = simplified[3], arrow_end = simplified[4];
+ const tipAngle = angleBetween(arrow_tip, arrow_start, arrow_end); // angle at the second-last point (potential arrow tip)
+ // Lengths of the last two segments
+
+ const seg1Len = pointDistance(arrow_start, arrow_tip);
+ const seg2Len = pointDistance(arrow_tip, arrow_end);
+ // Length of the rest of the stroke (approx arrow shaft length)
+ const shaftLen = pointDistance(simplified[0], simplified[1])
+ // Heuristic checks for arrowhead: sharp angle and short segments relative to shaft
+ console.log("Arrow tip angle:", tipAngle);
+ if (tipAngle > 30 && tipAngle < 150 && seg1Len < shaftLen * 0.8 && seg2Len < shaftLen * 0.8) {
+ bestShape = 'arrow';
}
-
- console.log("Recognizing shape from points:", element.points);
-
- const shapethat = shapeit.new({
- atlas: {},
- output: {},
- thresholds: {},
- });
-
- const shape = shapethat(element.points);
-
- console.log("Shape recognized:", shape);
-
- const mappedShape = (name: string): Shape => {
- switch (name) {
- case "rectangle":
- return "rectangle";
- case "square":
- return "rectangle";
- case "circle":
- return "ellipse";
- case "open polygon":
- return "diamond";
- default:
- return "freedraw";
+ }
+
+ // **Rectangle or Diamond** (closed shape with 4 corners - RDP might include last point
+ if (isClosed && (simplified.length == 4 || simplified.length == 5)) {
+ const vertices = simplified.slice(); // copy
+ if (simplified.length === 5) {
+ vertices.pop(); // remove last point if RDP included it
+ }
+
+ // Compute angles at each corner
+ console.log("Vertices:", vertices);
+ var angles = []
+ for (let i = 0; i < vertices.length; i++) {
+ angles.push(angleBetween(vertices[i], vertices[(i + 1) % vertices.length], vertices[(i + 2) % vertices.length]));
+ }
+
+ console.log("Angles:", angles);
+ console.log("Angles sum:", angles.reduce((a, b) => a + b, 0));
+
+ // All angles are sharp enough, so we can check for rectangle/diamond
+ if (angles.every(a => (a > options.rectAngleThresh && a < 180 - options.rectAngleThresh))) {
+ // Determine orientation by checking the slope of each segment
+ interface Segment { length: number; angleDeg: number; }
+ const segments: Segment[] = [];
+ for (let i = 0; i < 4; i++) {
+ const p1 = simplified[i];
+ const p2 = simplified[(i + 1) % (simplified.length)];
+ const dx = p2[0] - p1[0];
+ const dy = p2[1] - p1[1];
+ const length = Math.hypot(dx, dy);
+ // angle of segment in degrees from horizontal
+ let segAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
+ if (segAngle < 0) segAngle += 360;
+ if (segAngle > 180) segAngle -= 180; // use [0,180] range for undirected line
+ segments.push({ length, angleDeg: segAngle });
+ }
+ // Check for axis-aligned orientation
+ const hasAxisAlignedSide = segments.some(seg => {
+ const angle = seg.angleDeg;
+ const distToHoriz = Math.min(Math.abs(angle - 0), Math.abs(angle - 180));
+ const distToVert = Math.abs(angle - 90);
+ return (distToHoriz < options.rectOrientationThresh) || (distToVert < options.rectOrientationThresh);
+ });
+ if (hasAxisAlignedSide) {
+ bestShape = "rectangle";
+ } else {
+ // Not near axis-aligned, likely a rotated shape -> diamond
+ bestShape = "diamond";
}
- };
-
- const recognizedShape: ShapeRecognitionResult = {
- type: mappedShape(shape.name),
- confidence: 0.8,
- boundingBox,
- };
-
- return recognizedShape;
- };
-
- /**
- * Creates a new element based on the recognized shape from a freedraw element
- * @param freedrawElement The original freedraw element
- * @param recognizedShape The recognized shape information
- * @returns A new element of the recognized shape type
- */
- export const createElementFromRecognizedShape = (
- freedrawElement: ExcalidrawFreeDrawElement,
- recognizedShape: ShapeRecognitionResult,
- ): ExcalidrawElement => {
- if (!recognizedShape.type || recognizedShape.type === "freedraw") {
- return freedrawElement;
}
-
- // if (recognizedShape.type === "rectangle") {
- return newElement({
- ...freedrawElement,
- type: recognizedShape.type,
- x: recognizedShape.boundingBox.minX,
- y: recognizedShape.boundingBox.minY,
- width: recognizedShape.boundingBox.width!,
- height: recognizedShape.boundingBox.height!,
- });
- };
-
- /**
- * Determines if shape recognition should be applied based on app state
- * @param element The freedraw element to potentially snap
- * @param minConfidence The minimum confidence level required to apply snapping
- * @returns Whether to apply shape snapping
- */
- export const shouldApplyShapeSnapping = (
- recognizedShape: ShapeRecognitionResult,
- minConfidence: number = 0.75,
- ): boolean => {
- return (
- !!recognizedShape.type && (recognizedShape.confidence || 0) >= minConfidence
- );
- };
-
- /**
- * Converts a freedraw element to the detected shape
- */
- export const convertToShape = (
- freeDrawElement: ExcalidrawFreeDrawElement,
- ): ExcalidrawElement => {
- const recognizedShape = recognizeShape(freeDrawElement);
-
- if (shouldApplyShapeSnapping(recognizedShape)) {
- return createElementFromRecognizedShape(freeDrawElement, recognizedShape);
+ } else {
+ const aspectRatio = boundingBox.width && boundingBox.height ? Math.min(boundingBox.width, boundingBox.height) / Math.max(boundingBox.width, boundingBox.height) : 1;
+ // If aspect ratio ~1 (nearly square) and simplified has few corners, good for circle
+ if (aspectRatio > 0.8) {
+ // Measure radius variance
+ const cx = boundingBoxCenter[0];
+ const cy = boundingBoxCenter[1];
+ let totalDist = 0, maxDist = 0, minDist = Infinity;
+ for (const p of simplified) {
+ const d = Math.hypot(p[0] - cx, p[1] - cy);
+ totalDist += d;
+ maxDist = Math.max(maxDist, d);
+ minDist = Math.min(minDist, d);
+ }
+ const avgDist = totalDist / simplified.length;
+ const radiusVar = (maxDist - minDist) / (avgDist || 1);
+ // If variance in radius is small, shape is round
+ if (radiusVar < 0.3) {
+ bestShape = 'ellipse';
+ }
}
-
- // Add more shape conversions as needed
- return freeDrawElement;
- };
-
\ No newline at end of file
+ }
+
+ return {
+ type: bestShape,
+ simplified,
+ boundingBox
+ } as ShapeRecognitionResult;
+};
+
+/**
+ * Simplify a polyline using Ramer-Douglas-Peucker algorithm.
+ * @param points Array of points [x,y] representing the stroke.
+ * @param epsilon Tolerance for simplification (higher = more simplification).
+ * @returns Simplified list of points.
+ */
+function simplifyRDP(points: readonly LocalPoint[], epsilon: number): readonly LocalPoint[] {
+ if (points.length < 3) return points;
+ // Find the point with the maximum distance from the line between first and last
+ const first = points[0], last = points[points.length - 1];
+ let index = -1;
+ let maxDist = 0;
+ for (let i = 1; i < points.length - 1; i++) {
+ // Perpendicular distance from points[i] to line (first-last)
+ const dist = perpendicularDistance(points[i], first, last);
+ if (dist > maxDist) {
+ maxDist = dist;
+ index = i;
+ }
+ }
+ // If max distance is greater than epsilon, recursively simplify
+ if (maxDist > epsilon && index !== -1) {
+ const left = simplifyRDP(points.slice(0, index + 1), epsilon);
+ const right = simplifyRDP(points.slice(index), epsilon);
+ // Concatenate results (omit duplicate point at junction)
+ return left.slice(0, -1).concat(right);
+ } else {
+ // Not enough deviation, return straight line (keep only endpoints)
+ return [first, last];
+ }
+}
+
+/**
+ * Converts a freedraw element to the detected shape
+ */
+export const convertToShape = (
+ freeDrawElement: ExcalidrawFreeDrawElement,
+): ExcalidrawElement => {
+ const recognizedShape = recognizeShape(freeDrawElement);
+
+ switch (recognizedShape.type) {
+ case "rectangle": case "diamond": case "ellipse": {
+ return newElement({
+ ...freeDrawElement,
+ roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
+ type: recognizedShape.type,
+ x: recognizedShape.boundingBox.minX,
+ y: recognizedShape.boundingBox.minY,
+ width: recognizedShape.boundingBox.width!,
+ height: recognizedShape.boundingBox.height!,
+ });
+ }
+ case "arrow": {
+ return newArrowElement({
+ ...freeDrawElement,
+ type: recognizedShape.type,
+ endArrowhead: "arrow", // TODO: Get correct state
+ points: [
+ recognizedShape.simplified[0],
+ recognizedShape.simplified[recognizedShape.simplified.length - 2]
+ ],
+ roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }
+ });
+ }
+ case "line": {
+ return newLinearElement({
+ ...freeDrawElement,
+ type: recognizedShape.type,
+ points: [
+ recognizedShape.simplified[0],
+ recognizedShape.simplified[recognizedShape.simplified.length - 1]
+ ],
+ roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }
+ });
+ }
+ default: return freeDrawElement
+ }
+};