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 + } +};