Files
excalidraw/packages/math/tests/polygon.test.ts
Mathias Krafft f179f7ffd2 feat(editor): draw to shape (auto-detection) (#9313)
* Initial commit

* Segments detection

* Remove aspect ratio

* Refactor

* Fix tests

* Toggle shapeSnap

* Fix linting

* debug: live preview

* Rebase fixes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Bind to elements (no hover background)

* Shape closed

* Fix rad calc + rename segments

* Comments

* Fix binding highlighting + lints

* Rename to convert to shape

* fix: Binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Refactor spline simplification

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove unused doBoundsIntersect

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Restore type signature

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Propose new button shapes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove unused types

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Icon shapes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Element cloning

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Element cloning

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove unused

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Shape settings in recognition mode

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove debug

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Actions

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrowheads for freedraw

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Draw shape tool instead of freedraw

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Keyboard shortcut visibility

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Unique cursor for draw shape

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove selection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Shape selection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Improve shape recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Further refine shape recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Preview

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Add hint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Refined shape recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Less ellipse more rectanguloid

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Refined arrow recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Refinerecognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Lower linear threshold

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Rectangle head arrow

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: More arrowhead sizes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow remains an arrow

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Draw line between elements converts to arrow

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* remove pendingDrawShapeElement

* fix: Arrow start point binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Different arrow endpoint heuristic

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Point type

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Various small fixes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Avoid recompute

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Type issues

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* test(editor): cover the drawShape (snap-to-shape) tool

The drawShape tool had no dedicated tests, which let master's toolbar /
active-tool rewrite silently break it during the merge: actionFinalize
reverted to the selection tool after every sketch, because the drawShape
flow finalizes without a `newElement` and so failed master's new
`&& element` guard.

Covers the behavior the merge had to preserve:
- sketch recognition (rectangle, ellipse) and the too-small no-op
- the tool stays active after finalizing, so shapes can be sketched
  consecutively (regression guard for the above)
- activation via the S shortcut and the extra-tools dropdown
- the tool's own cursor

Points are fed into the trail directly rather than through pointermove,
which is rAF-throttled and would drop all but one point per frame — the
same approach lasso.test.tsx uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Add test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Breakout from App.tsx

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Shortcut is now Shift+X

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: No selecting arrows for shape recog

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Double history

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: PCA + feature extraction recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* reduce and normalize min-size gates

* never select newElement when shape recog tool active

* simplify lifecycle & remove double finalize

* make arrows bindings orbit

* funnel though actionFinalize & fix two unrelated cleanup cases

* use crosshair cursor

* show strokeWidth

* switch icons for drawShape & extraTools

* dedupe getBoundsFromPoints

* remove unused helpers

* jsdoc cleanup

* dynamic trail size

* improve rectangle recognition

* disallow linears to deviate too much from a straight line

* put under freedraw tool on tablet/mobile

* tweak label

* rename `drawShape` -> `autoshape`

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:42:42 +02:00

121 lines
3.4 KiB
TypeScript

import { pointFrom } from "../src/point";
import {
convexHull,
polygonArea,
polygonSignedArea,
simplifyConvexPolygon,
} from "../src/polygon";
import type { GlobalPoint } from "../src/types";
const square: GlobalPoint[] = [
pointFrom(0, 0),
pointFrom(10, 0),
pointFrom(10, 10),
pointFrom(0, 10),
];
describe("polygonArea", () => {
it("measures a polygon, whichever way it winds", () => {
expect(polygonArea(square)).toBe(100);
expect(polygonArea([...square].reverse())).toBe(100);
});
it("ignores a repeated closing vertex", () => {
expect(polygonArea([...square, square[0]])).toBe(100);
});
it("reports the winding direction in the sign", () => {
expect(polygonSignedArea(square)).toBe(100);
expect(polygonSignedArea([...square].reverse())).toBe(-100);
});
});
describe("convexHull", () => {
it("drops the points inside the hull", () => {
const hull = convexHull([...square, pointFrom<GlobalPoint>(5, 5)]);
expect(hull).toHaveLength(4);
expect(hull).toEqual(expect.arrayContaining(square));
});
it("drops collinear points", () => {
expect(convexHull([...square, pointFrom<GlobalPoint>(5, 0)])).toHaveLength(
4,
);
});
it("wraps a point cloud whatever order it arrives in", () => {
const cloud: GlobalPoint[] = Array.from({ length: 30 }, (_, i) =>
pointFrom(Math.cos(i * 2.4) * 10, Math.sin(i * 2.4) * 10),
);
const hull = convexHull(cloud);
expect(polygonArea(hull)).toBeCloseTo(
polygonArea(convexHull([...cloud].reverse())),
);
// Every point is inside or on the hull it produced.
expect(polygonArea(hull)).toBeGreaterThan(250);
expect(polygonArea(hull)).toBeLessThanOrEqual(Math.PI * 100);
});
it("returns degenerate input as-is", () => {
const two = square.slice(0, 2);
expect(convexHull(two)).toEqual(two);
});
});
describe("simplifyConvexPolygon", () => {
const circle = (n: number): GlobalPoint[] =>
Array.from({ length: n }, (_, i) =>
pointFrom(
Math.cos((i * 2 * Math.PI) / n) * 100,
Math.sin((i * 2 * Math.PI) / n) * 100,
),
);
it("keeps the corners of a polygon and drops the wobble along its sides", () => {
// A square whose sides bulge slightly outward, as a hand-drawn one would.
const wobbly = convexHull([
...square,
pointFrom<GlobalPoint>(5, -0.2),
pointFrom<GlobalPoint>(10.2, 5),
pointFrom<GlobalPoint>(5, 10.2),
pointFrom<GlobalPoint>(-0.2, 5),
]);
expect(simplifyConvexPolygon(wobbly, (25 * Math.PI) / 180)).toHaveLength(4);
});
it("spreads an ellipse's turn over many corners", () => {
const corners = simplifyConvexPolygon(
convexHull(circle(64)),
(25 * Math.PI) / 180,
);
// A full turn is 360°, emitted in ~25° steps.
expect(corners.length).toBeGreaterThan(10);
expect(corners.length).toBeLessThanOrEqual(15);
});
it("does not depend on where the hull happens to start", () => {
const hull = convexHull([
...square,
pointFrom<GlobalPoint>(5, -0.2),
pointFrom<GlobalPoint>(10.2, 5),
]);
const rotated = [...hull.slice(2), ...hull.slice(0, 2)];
expect(simplifyConvexPolygon(rotated, (25 * Math.PI) / 180)).toHaveLength(
simplifyConvexPolygon(hull, (25 * Math.PI) / 180).length,
);
});
it("returns degenerate input as-is", () => {
const two = square.slice(0, 2);
expect(simplifyConvexPolygon(two, 0.4)).toEqual(two);
});
});