diff --git a/packages/math/point.ts b/packages/math/point.ts
index b741c4c8e..9d02f97ba 100644
--- a/packages/math/point.ts
+++ b/packages/math/point.ts
@@ -241,20 +241,20 @@ export const isPointWithinBounds =
(
* @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 =
(
+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;
+ 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.
diff --git a/packages/utils/snapToShape.ts b/packages/utils/snapToShape.ts
index ee9d51024..dbf5506c2 100644
--- a/packages/utils/snapToShape.ts
+++ b/packages/utils/snapToShape.ts
@@ -27,182 +27,67 @@ interface ShapeRecognitionResult {
boundingBox: BoundingBox;
}
-interface ShapeRecognitionOptions {
- shapeIsClosedPercentThreshold: number; // Max distance between stroke start/end to consider shape closed
- arrowTipAngleThreshold: number; // Angle (in degrees) below which a corner is considered "sharp" (for arrow detection)
- rdpTolerancePercent: number; // RDP simplification tolerance (percentage of bounding box diagonal)
- rectangleCornersAngleThreshold: number; // Angle (in degrees) to check for rectangle corners
- rectangleOrientationAngleThreshold: number; // Angle difference (in degrees) to nearest 0/90 orientation to call it rectangle
- ellipseRadiusVarianceThreshold: number; // Variance in radius to consider a shape an ellipse
-}
+const QUADRILATERAL_SIDES = 4;
+const QUADRILATERAL_MIN_POINTS = 4; // RDP simplified vertices
+const QUADRILATERAL_MAX_POINTS = 5; // RDP might include closing point
+const ARROW_EXPECTED_POINTS = 5; // RDP simplified vertices for arrow shape
+const LINE_EXPECTED_POINTS = 2; // RDP simplified vertices for line shape
-const DEFAULT_OPTIONS: ShapeRecognitionOptions = {
+const DEFAULT_OPTIONS = {
+ // Max distance between stroke start/end (as % of bbox diagonal) to consider closed
shapeIsClosedPercentThreshold: 20,
- arrowTipAngleThreshold: 60,
+ // Min distance (px) to consider shape closed (takes precedence if larger than %)
+ shapeIsClosedDistanceThreshold: 10,
+ // RDP simplification tolerance (% of bbox diagonal)
rdpTolerancePercent: 10,
- rectangleCornersAngleThreshold: 20,
+ // Arrow specific thresholds
+ arrowMinTipAngle: 30, // Min angle degrees for the tip
+ arrowMaxTipAngle: 150, // Max angle degrees for the tip
+ arrowHeadMaxShaftRatio: 0.8, // Max length ratio of arrowhead segment to shaft
+ // Quadrilateral specific thresholds
+ rectangleMinCornerAngle: 20, // Min deviation from 180 degrees for a valid corner
+ rectangleMaxCornerAngle: 160, // Max deviation from 0 degrees for a valid corner
+ // Angle difference (degrees) to nearest 0/90 orientation to classify as rectangle
rectangleOrientationAngleThreshold: 10,
- ellipseRadiusVarianceThreshold: 0.5
-};
+ // Max variance in radius (normalized) to consider a shape an ellipse
+ ellipseRadiusVarianceThreshold: 0.5,
+} as const; // Use 'as const' for stricter typing of default values
-/**
- * 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 };
+// Options for shape recognition, allowing partial overrides
+type ShapeRecognitionOptions = typeof DEFAULT_OPTIONS;
+type PartialShapeRecognitionOptions = Partial;
- 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 };
- }
-
- const tolerance = pointDistance(
- [boundingBox.minX, boundingBox.minY] as LocalPoint,
- [boundingBox.maxX, boundingBox.maxY] as LocalPoint,
- ) * options.rdpTolerancePercent / 100;
- const simplified = simplifyRDP(element.points, tolerance);
-
- const start = element.points[0], end = element.points[element.points.length - 1];
- const closedDist = pointDistance(start, end);
- const boundingBoxDiagonal = Math.hypot(boundingBox.width, boundingBox.height);
- // e.g., threshold: 10px or % of size
- const isClosed = closedDist < Math.max(10, boundingBoxDiagonal * options.shapeIsClosedPercentThreshold / 100);
-
- let bestShape: Shape = 'freedraw';
-
- 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';
- }
- }
-
- // **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.rectangleCornersAngleThreshold && a < 180 - options.rectangleCornersAngleThreshold))) {
- // 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.rectangleOrientationAngleThreshold) || (distToVert < options.rectangleOrientationAngleThreshold);
- });
- if (hasAxisAlignedSide) {
- bestShape = "rectangle";
- } else {
- // Not near axis-aligned, likely a rotated shape -> diamond
- bestShape = "diamond";
- }
- }
- } else if (isClosed) { // **Ellipse** (closed shape with few corners)
- // 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 < options.ellipseRadiusVarianceThreshold) {
- bestShape = 'ellipse';
- }
- }
-
- return {
- type: bestShape,
- simplified,
- boundingBox
- } as ShapeRecognitionResult;
-};
+interface Segment {
+ length: number;
+ angleDeg: number; // Angle in degrees [0, 180) representing the line's orientation
+}
/**
* 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];
+function simplifyRDP(
+ points: readonly LocalPoint[],
+ epsilon: number,
+): readonly LocalPoint[] {
+ if (points.length < 3) {
+ return points;
+ }
+
+ const first = points[0];
+ const last = points[points.length - 1];
let index = -1;
let maxDist = 0;
+
+ // Find the point with the maximum distance from the line segment between first and last
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);
@@ -210,11 +95,277 @@ function simplifyRDP(points: readonly LocalPoint[], epsilon: number): readonly L
// Concatenate results (omit duplicate point at junction)
return left.slice(0, -1).concat(right);
} else {
- // Not enough deviation, return straight line (keep only endpoints)
+ // Not enough deviation, return straight line segment (keep only endpoints)
return [first, last];
}
}
+/**
+ * Calculates the properties (length, angle) of segments in a polygon.
+ */
+function calculateSegments(vertices: readonly LocalPoint[]): Segment[] {
+ const segments: Segment[] = [];
+ const numVertices = vertices.length;
+ for (let i = 0; i < numVertices; i++) {
+ const p1 = vertices[i];
+ // Ensure wrapping for the last segment connecting back to the start
+ const p2 = vertices[(i + 1) % numVertices];
+ const dx = p2[0] - p1[0];
+ const dy = p2[1] - p1[1];
+ const length = Math.hypot(dx, dy);
+
+ // Calculate angle in degrees [0, 360)
+ let angleRad = Math.atan2(dy, dx);
+ if (angleRad < 0) {
+ angleRad += 2 * Math.PI;
+ }
+ let angleDeg = (angleRad * 180) / Math.PI;
+
+ // Normalize angle to [0, 180) for undirected line orientation
+ if (angleDeg >= 180) {
+ angleDeg -= 180;
+ }
+
+ segments.push({ length, angleDeg });
+ }
+ return segments;
+}
+
+/**
+ * Checks if the shape is closed based on the distance between start and end points.
+ */
+function isShapeClosed(
+ points: readonly LocalPoint[],
+ boundingBoxDiagonal: number,
+ options: ShapeRecognitionOptions,
+): boolean {
+ const start = points[0];
+ const end = points[points.length - 1];
+ const closedDist = pointDistance(start, end);
+ const closedThreshold = Math.max(
+ options.shapeIsClosedDistanceThreshold,
+ boundingBoxDiagonal * (options.shapeIsClosedPercentThreshold / 100),
+ );
+ return closedDist < closedThreshold;
+}
+
+/**
+ * Checks if a quadrilateral is likely axis-aligned based on its segment angles.
+ */
+function isAxisAligned(
+ segments: Segment[],
+ orientationThreshold: number,
+): boolean {
+ return segments.some((seg) => {
+ const angle = seg.angleDeg;
+ // Distance to horizontal (0 or 180 degrees)
+ const distToHoriz = Math.min(angle, 180 - angle);
+ // Distance to vertical (90 degrees)
+ const distToVert = Math.abs(angle - 90);
+ return (
+ distToHoriz < orientationThreshold || distToVert < orientationThreshold
+ );
+ });
+}
+
+/**
+ * Calculates the variance of the distance from points to a center point.
+ * Returns a normalized variance value (0 = perfectly round).
+ */
+function calculateRadiusVariance(
+ points: readonly LocalPoint[],
+ boundingBox: BoundingBox,
+): number {
+ if (points.length === 0) {
+ return 0; // Or handle as an error/special case
+ }
+
+ const [cx, cy] = getCenterForBounds([
+ boundingBox.minX,
+ boundingBox.minY,
+ boundingBox.maxX,
+ boundingBox.maxY,
+ ] as Bounds);
+
+ let totalDist = 0;
+ let maxDist = 0;
+ let minDist = Infinity;
+
+ for (const p of points) {
+ 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 / points.length;
+
+ // Avoid division by zero if avgDist is 0 (e.g., all points are at the center)
+ if (avgDist === 0) {
+ return 0;
+ }
+
+ const radiusVariance = (maxDist - minDist) / avgDist;
+ return radiusVariance;
+}
+
+/** Checks if the points form a straight line segment. */
+function checkLine(
+ points: readonly LocalPoint[],
+ isClosed: boolean,
+): Shape | null {
+ if (!isClosed && points.length === LINE_EXPECTED_POINTS) {
+ return "line";
+ }
+ return null;
+}
+
+/** Checks if the points form an arrow shape. */
+function checkArrow(
+ points: readonly LocalPoint[],
+ isClosed: boolean,
+ options: ShapeRecognitionOptions,
+): Shape | null {
+ if (isClosed || points.length !== ARROW_EXPECTED_POINTS) {
+ return null;
+ }
+
+ const shaftStart = points[0];
+ const shaftEnd = points[1]; // Assuming RDP simplifies shaft to 2 points
+ const arrowBase = points[2];
+ const arrowTip = points[3];
+ const arrowTailEnd = points[4];
+
+ const tipAngle = angleBetween(arrowTip, arrowBase, arrowTailEnd);
+
+ if (tipAngle <= options.arrowMinTipAngle || tipAngle >= options.arrowMaxTipAngle) {
+ return null;
+ }
+
+ const headSegment1Len = pointDistance(arrowBase, arrowTip);
+ const headSegment2Len = pointDistance(arrowTip, arrowTailEnd);
+ const shaftLen = pointDistance(shaftStart, shaftEnd); // Approx shaft length
+
+ // Heuristic: Arrowhead segments should be significantly shorter than the shaft
+ const isHeadShortEnough =
+ headSegment1Len < shaftLen * options.arrowHeadMaxShaftRatio &&
+ headSegment2Len < shaftLen * options.arrowHeadMaxShaftRatio;
+
+ return isHeadShortEnough ? "arrow" : null;
+}
+
+/** Checks if the points form a rectangle or diamond shape. */
+function checkQuadrilateral(
+ points: readonly LocalPoint[],
+ isClosed: boolean,
+ options: ShapeRecognitionOptions,
+): Shape | null {
+ if (
+ !isClosed ||
+ points.length < QUADRILATERAL_MIN_POINTS ||
+ points.length > QUADRILATERAL_MAX_POINTS
+ ) {
+ return null;
+ }
+
+ // Take the first 4 points as vertices (RDP might add 5th closing point)
+ const vertices = points.slice(0, QUADRILATERAL_SIDES);
+ // console.log("Vertices (Quad Check):", vertices);
+
+ // Calculate internal angles
+ const angles: number[] = [];
+ for (let i = 0; i < QUADRILATERAL_SIDES; i++) {
+ const p1 = vertices[i];
+ const p2 = vertices[(i + 1) % QUADRILATERAL_SIDES];
+ const p3 = vertices[(i + 2) % QUADRILATERAL_SIDES];
+
+ angles.push(angleBetween(p1, p2, p3));
+ }
+
+ const allCornersAreValid = angles.every(
+ (a) =>
+ a > options.rectangleMinCornerAngle &&
+ a < options.rectangleMaxCornerAngle,
+ );
+
+ if (!allCornersAreValid) {
+ return null;
+ }
+
+ const segments = calculateSegments(vertices);
+
+ if (isAxisAligned(segments, options.rectangleOrientationAngleThreshold)) {
+ return "rectangle";
+ } else {
+ // Not axis-aligned, but quadrilateral => classify as diamond
+ return "diamond";
+ }
+}
+
+/** Checks if the points form an ellipse shape. */
+function checkEllipse(
+ points: readonly LocalPoint[],
+ isClosed: boolean,
+ boundingBox: BoundingBox,
+ options: ShapeRecognitionOptions,
+): Shape | null {
+ if (!isClosed) {
+ return null;
+ }
+
+ // Need a minimum number of points for it to be an ellipse
+ if (points.length < QUADRILATERAL_MAX_POINTS) {
+ return null;
+ }
+
+ const radiusVariance = calculateRadiusVariance(points, boundingBox);
+
+ return radiusVariance < options.ellipseRadiusVarianceThreshold
+ ? "ellipse"
+ : null;
+}
+
+/**
+ * Recognizes common shapes from free-draw input points.
+ * @param element The freedraw element to analyze.
+ * @param opts Optional overrides for recognition thresholds.
+ * @returns Information about the recognized shape.
+ */
+export const recognizeShape = (
+ element: ExcalidrawFreeDrawElement,
+ opts: PartialShapeRecognitionOptions = {},
+): ShapeRecognitionResult => {
+ const options = { ...DEFAULT_OPTIONS, ...opts };
+ const { points } = element;
+ const boundingBox = getCommonBoundingBox([element]);
+
+ // Need at least a few points to recognize a shape
+ if (!points || points.length < 3) {
+ return { type: "freedraw", simplified: points, boundingBox };
+ }
+
+ const boundingBoxDiagonal = Math.hypot(boundingBox.width, boundingBox.height);
+ const rdpTolerance = boundingBoxDiagonal * (options.rdpTolerancePercent / 100);
+ const simplifiedPoints = simplifyRDP(points, rdpTolerance);
+
+ const isClosed = isShapeClosed(simplifiedPoints, boundingBoxDiagonal, options);
+
+ // --- Shape check order matters here ---
+ let recognizedType: Shape =
+ checkLine(simplifiedPoints, isClosed) ??
+ checkArrow(simplifiedPoints, isClosed, options) ??
+ checkQuadrilateral(simplifiedPoints, isClosed, options) ??
+ checkEllipse(simplifiedPoints, isClosed, boundingBox, options) ??
+ "freedraw"; // Default if no other shape matches
+
+ return {
+ type: recognizedType,
+ simplified: simplifiedPoints,
+ boundingBox,
+ };
+};
+
+
/**
* Converts a freedraw element to the detected shape
*/