diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/utils/geometry/shape.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/utils/geometry/shape.ts')
| -rw-r--r-- | packages/utils/geometry/shape.ts | 541 |
1 files changed, 541 insertions, 0 deletions
diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts new file mode 100644 index 0000000..10fc06e --- /dev/null +++ b/packages/utils/geometry/shape.ts @@ -0,0 +1,541 @@ +/** + * 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 type { Curve, LineSegment, Polygon, Radians } from "@excalidraw/math"; +import { + curve, + lineSegment, + pointFrom, + pointDistance, + pointFromArray, + pointFromVector, + pointRotateRads, + polygon, + polygonFromPoints, + PRECISION, + segmentsIntersectAt, + vector, + vectorAdd, + vectorFromPoint, + vectorScale, + type GlobalPoint, + type LocalPoint, +} from "@excalidraw/math"; +import { getElementAbsoluteCoords } from "@excalidraw/excalidraw/element"; +import type { + ElementsMap, + ExcalidrawBindableElement, + ExcalidrawDiamondElement, + ExcalidrawElement, + ExcalidrawEllipseElement, + ExcalidrawEmbeddableElement, + ExcalidrawFrameLikeElement, + ExcalidrawFreeDrawElement, + ExcalidrawIframeElement, + ExcalidrawImageElement, + ExcalidrawLinearElement, + ExcalidrawRectangleElement, + ExcalidrawSelectionElement, + ExcalidrawTextElement, +} from "@excalidraw/excalidraw/element/types"; +import { pointsOnBezierCurves } from "points-on-curve"; +import type { Drawable, Op } from "roughjs/bin/core"; +import { invariant } from "@excalidraw/excalidraw/utils"; + +// 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<Point extends GlobalPoint | LocalPoint> = + LineSegment<Point>[]; + +// a polycurve is a curve consisting of ther curves, this corresponds to a complex +// curve on the canvas +export type Polycurve<Point extends GlobalPoint | LocalPoint> = Curve<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<Point extends GlobalPoint | LocalPoint> = { + center: Point; + angle: Radians; + halfWidth: number; + halfHeight: number; +}; + +export type GeometricShape<Point extends GlobalPoint | LocalPoint> = + | { + type: "line"; + data: LineSegment<Point>; + } + | { + type: "polygon"; + data: Polygon<Point>; + } + | { + type: "curve"; + data: Curve<Point>; + } + | { + type: "ellipse"; + data: Ellipse<Point>; + } + | { + type: "polyline"; + data: Polyline<Point>; + } + | { + type: "polycurve"; + data: Polycurve<Point>; + }; + +type RectangularElement = + | ExcalidrawRectangleElement + | ExcalidrawDiamondElement + | ExcalidrawFrameLikeElement + | ExcalidrawEmbeddableElement + | ExcalidrawImageElement + | ExcalidrawIframeElement + | ExcalidrawTextElement + | ExcalidrawSelectionElement; + +// polygon +export const getPolygonShape = <Point extends GlobalPoint | LocalPoint>( + element: RectangularElement, +): GeometricShape<Point> => { + const { angle, width, height, x, y } = element; + + const cx = x + width / 2; + const cy = y + height / 2; + + const center: Point = pointFrom(cx, cy); + + let data: Polygon<Point>; + + if (element.type === "diamond") { + data = polygon( + pointRotateRads(pointFrom(cx, y), center, angle), + pointRotateRads(pointFrom(x + width, cy), center, angle), + pointRotateRads(pointFrom(cx, y + height), center, angle), + pointRotateRads(pointFrom(x, cy), center, angle), + ); + } else { + data = polygon( + pointRotateRads(pointFrom(x, y), center, angle), + pointRotateRads(pointFrom(x + width, y), center, angle), + pointRotateRads(pointFrom(x + width, y + height), center, angle), + pointRotateRads(pointFrom(x, y + height), center, angle), + ); + } + + return { + type: "polygon", + data, + }; +}; + +// return the selection box for an element, possibly rotated as well +export const getSelectionBoxShape = <Point extends GlobalPoint | LocalPoint>( + 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 = pointFrom(cx, cy); + const topLeft = pointRotateRads(pointFrom(x1, y1), center, element.angle); + const topRight = pointRotateRads(pointFrom(x2, y1), center, element.angle); + const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, element.angle); + const bottomRight = pointRotateRads(pointFrom(x2, y2), center, element.angle); + + return { + type: "polygon", + data: [topLeft, topRight, bottomRight, bottomLeft], + } as GeometricShape<Point>; +}; + +// ellipse +export const getEllipseShape = <Point extends GlobalPoint | LocalPoint>( + element: ExcalidrawEllipseElement, +): GeometricShape<Point> => { + const { width, height, angle, x, y } = element; + + return { + type: "ellipse", + data: { + center: pointFrom(x + width / 2, y + height / 2), + angle, + halfWidth: width / 2, + halfHeight: height / 2, + }, + }; +}; + +export const getCurvePathOps = (shape: Drawable): Op[] => { + // NOTE (mtolmacs): Temporary fix for extremely large elements + if (!shape) { + return []; + } + + for (const set of shape.sets) { + if (set.type === "path") { + return set.ops; + } + } + return shape.sets[0].ops; +}; + +// linear +export const getCurveShape = <Point extends GlobalPoint | LocalPoint>( + roughShape: Drawable, + startingPoint: Point = pointFrom(0, 0), + angleInRadian: Radians, + center: Point, +): GeometricShape<Point> => { + const transform = (p: Point): Point => + pointRotateRads( + pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]), + center, + angleInRadian, + ); + + const ops = getCurvePathOps(roughShape); + const polycurve: Polycurve<Point> = []; + let p0 = pointFrom<Point>(0, 0); + + for (const op of ops) { + if (op.op === "move") { + const p = pointFromArray<Point>(op.data); + invariant(p != null, "Ops data is not a point"); + p0 = transform(p); + } + if (op.op === "bcurveTo") { + const p1 = transform(pointFrom<Point>(op.data[0], op.data[1])); + const p2 = transform(pointFrom<Point>(op.data[2], op.data[3])); + const p3 = transform(pointFrom<Point>(op.data[4], op.data[5])); + polycurve.push(curve<Point>(p0, p1, p2, p3)); + p0 = p3; + } + } + + return { + type: "polycurve", + data: polycurve, + }; +}; + +const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>( + points: Point[], +): Polyline<Point> => { + let previousPoint: Point = points[0]; + const polyline: LineSegment<Point>[] = []; + + for (let i = 1; i < points.length; i++) { + const nextPoint = points[i]; + polyline.push(lineSegment<Point>(previousPoint, nextPoint)); + previousPoint = nextPoint; + } + + return polyline; +}; + +export const getFreedrawShape = <Point extends GlobalPoint | LocalPoint>( + element: ExcalidrawFreeDrawElement, + center: Point, + isClosed: boolean = false, +): GeometricShape<Point> => { + const transform = (p: Point) => + pointRotateRads( + pointFromVector( + vectorAdd(vectorFromPoint(p), vector(element.x, element.y)), + ), + center, + element.angle, + ); + + const polyline = polylineFromPoints( + element.points.map((p) => transform(p as Point)), + ); + + return ( + isClosed + ? { + type: "polygon", + data: polygonFromPoints(polyline.flat()), + } + : { + type: "polyline", + data: polyline, + } + ) as GeometricShape<Point>; +}; + +export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>( + element: ExcalidrawLinearElement, + roughShape: Drawable, + startingPoint: Point = pointFrom<Point>(0, 0), + angleInRadian: Radians, + center: Point, +): GeometricShape<Point> => { + const transform = (p: Point) => + pointRotateRads( + pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]), + center, + angleInRadian, + ); + + if (element.roundness === null) { + return { + type: "polygon", + data: polygonFromPoints( + element.points.map((p) => transform(p as Point)) 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(pointFrom(operation.data[0], operation.data[1])); + } + } else if (operation.op === "bcurveTo") { + if (odd) { + points.push(pointFrom(operation.data[0], operation.data[1])); + points.push(pointFrom(operation.data[2], operation.data[3])); + points.push(pointFrom(operation.data[4], operation.data[5])); + } + } else if (operation.op === "lineTo") { + if (odd) { + points.push(pointFrom(operation.data[0], operation.data[1])); + } + } + } + + const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) => + transform(p as Point), + ) as Point[]; + + return { + type: "polygon", + data: polygonFromPoints<Point>(polygonPoints), + }; +}; + +/** + * Determine intersection of a rectangular shaped element and a + * line segment. + * + * @param element The rectangular element to test against + * @param segment The segment intersecting the element + * @param gap Optional value to inflate the shape before testing + * @returns An array of intersections + */ +// TODO: Replace with final rounded rectangle code +export const segmentIntersectRectangleElement = < + Point extends LocalPoint | GlobalPoint, +>( + element: ExcalidrawBindableElement, + segment: LineSegment<Point>, + gap: number = 0, +): Point[] => { + const bounds = [ + element.x - gap, + element.y - gap, + element.x + element.width + gap, + element.y + element.height + gap, + ]; + const center = pointFrom( + (bounds[0] + bounds[2]) / 2, + (bounds[1] + bounds[3]) / 2, + ); + + return [ + lineSegment( + pointRotateRads(pointFrom(bounds[0], bounds[1]), center, element.angle), + pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle), + ), + lineSegment( + pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle), + pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle), + ), + lineSegment( + pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle), + pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle), + ), + lineSegment( + pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle), + pointRotateRads(pointFrom(bounds[0], bounds[1]), center, element.angle), + ), + ] + .map((s) => segmentsIntersectAt(segment, s)) + .filter((i): i is Point => !!i); +}; + +const distanceToEllipse = <Point extends LocalPoint | GlobalPoint>( + p: Point, + ellipse: Ellipse<Point>, +) => { + const { angle, halfWidth, halfHeight, center } = ellipse; + const a = halfWidth; + const b = halfHeight; + const translatedPoint = vectorAdd( + vectorFromPoint(p), + vectorScale(vectorFromPoint(center), -1), + ); + const [rotatedPointX, rotatedPointY] = pointRotateRads( + pointFromVector(translatedPoint), + pointFrom(0, 0), + -angle as Radians, + ); + + const px = Math.abs(rotatedPointX); + const py = Math.abs(rotatedPointY); + + let tx = 0.707; + let ty = 0.707; + + for (let i = 0; i < 3; i++) { + const x = a * tx; + const y = b * ty; + + const ex = ((a * a - b * b) * tx ** 3) / a; + const ey = ((b * b - a * a) * ty ** 3) / b; + + const rx = x - ex; + const ry = y - ey; + + const qx = px - ex; + const qy = py - ey; + + const r = Math.hypot(ry, rx); + const q = Math.hypot(qy, qx); + + tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); + ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); + const t = Math.hypot(ty, tx); + tx /= t; + ty /= t; + } + + const [minX, minY] = [ + a * tx * Math.sign(rotatedPointX), + b * ty * Math.sign(rotatedPointY), + ]; + + return pointDistance( + pointFrom(rotatedPointX, rotatedPointY), + pointFrom(minX, minY), + ); +}; + +export const pointOnEllipse = <Point extends LocalPoint | GlobalPoint>( + point: Point, + ellipse: Ellipse<Point>, + threshold = PRECISION, +) => { + return distanceToEllipse(point, ellipse) <= threshold; +}; + +export const pointInEllipse = <Point extends LocalPoint | GlobalPoint>( + p: Point, + ellipse: Ellipse<Point>, +) => { + const { center, angle, halfWidth, halfHeight } = ellipse; + const translatedPoint = vectorAdd( + vectorFromPoint(p), + vectorScale(vectorFromPoint(center), -1), + ); + const [rotatedPointX, rotatedPointY] = pointRotateRads( + pointFromVector(translatedPoint), + pointFrom(0, 0), + -angle as Radians, + ); + + return ( + (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) + + (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <= + 1 + ); +}; + +export const ellipseAxes = <Point extends LocalPoint | GlobalPoint>( + ellipse: Ellipse<Point>, +) => { + const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight; + + const majorAxis = widthGreaterThanHeight + ? ellipse.halfWidth * 2 + : ellipse.halfHeight * 2; + const minorAxis = widthGreaterThanHeight + ? ellipse.halfHeight * 2 + : ellipse.halfWidth * 2; + + return { + majorAxis, + minorAxis, + }; +}; + +export const ellipseFocusToCenter = <Point extends LocalPoint | GlobalPoint>( + ellipse: Ellipse<Point>, +) => { + const { majorAxis, minorAxis } = ellipseAxes(ellipse); + + return Math.sqrt(majorAxis ** 2 - minorAxis ** 2); +}; + +export const ellipseExtremes = <Point extends LocalPoint | GlobalPoint>( + ellipse: Ellipse<Point>, +) => { + const { center, angle } = ellipse; + const { majorAxis, minorAxis } = ellipseAxes(ellipse); + + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + const sqSum = majorAxis ** 2 + minorAxis ** 2; + const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle); + + const yMax = Math.sqrt((sqSum - sqDiff) / 2); + const xAtYMax = + (yMax * sqSum * sin * cos) / + (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2); + + const xMax = Math.sqrt((sqSum + sqDiff) / 2); + const yAtXMax = + (xMax * sqSum * sin * cos) / + (majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2); + const centerVector = vectorFromPoint(center); + + return [ + vectorAdd(vector(xAtYMax, yMax), centerVector), + vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector), + vectorAdd(vector(xMax, yAtXMax), centerVector), + vectorAdd(vector(xMax, yAtXMax), centerVector), + ]; +}; |
