mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-07-31 07:57:08 -04:00
* 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>
328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import type { GlobalPoint } from "@excalidraw/math";
|
|
|
|
import { recognizeShape } from "../src/convertToShape";
|
|
|
|
// Deterministic jitter, so a run that passes keeps passing.
|
|
let seed = 1;
|
|
const random = () => {
|
|
seed = (seed * 1103515245 + 12345) % 2147483648;
|
|
return seed / 2147483648 - 0.5;
|
|
};
|
|
|
|
// Draw the shape somewhere else, at another size, turned by `angle`, with a
|
|
// wobbly hand.
|
|
const sketch = (
|
|
points: readonly [number, number][],
|
|
{ angle = 0, scale = 1, noise = 0 } = {},
|
|
): GlobalPoint[] =>
|
|
points.map(([x, y]) => {
|
|
const nx = x * scale + random() * noise;
|
|
const ny = y * scale + random() * noise;
|
|
return [
|
|
nx * Math.cos(angle) - ny * Math.sin(angle) + 600,
|
|
nx * Math.sin(angle) + ny * Math.cos(angle) + 600,
|
|
] as GlobalPoint;
|
|
});
|
|
|
|
const edge = (
|
|
from: readonly [number, number],
|
|
to: readonly [number, number],
|
|
steps: number,
|
|
): [number, number][] =>
|
|
Array.from({ length: steps }, (_, i) => [
|
|
from[0] + ((to[0] - from[0]) * i) / steps,
|
|
from[1] + ((to[1] - from[1]) * i) / steps,
|
|
]);
|
|
|
|
const outline = (corners: readonly [number, number][]): [number, number][] => [
|
|
...corners.flatMap((corner, i) => edge(corner, corners[(i + 1) % 4], 18)),
|
|
corners[0],
|
|
];
|
|
|
|
const rectangle = (w: number, h: number) =>
|
|
outline([
|
|
[-w / 2, -h / 2],
|
|
[w / 2, -h / 2],
|
|
[w / 2, h / 2],
|
|
[-w / 2, h / 2],
|
|
]);
|
|
|
|
const diamond = (w: number, h: number) =>
|
|
outline([
|
|
[0, -h / 2],
|
|
[w / 2, 0],
|
|
[0, h / 2],
|
|
[-w / 2, 0],
|
|
]);
|
|
|
|
const ellipse = (rx: number, ry: number): [number, number][] => {
|
|
const points: [number, number][] = [];
|
|
for (let a = 0; a <= Math.PI * 2 + 0.05; a += 0.1) {
|
|
points.push([Math.cos(a) * rx, Math.sin(a) * ry]);
|
|
}
|
|
return points;
|
|
};
|
|
|
|
// Moving-average smoothing: rounds every corner the way a relaxed pen does.
|
|
const smooth = (
|
|
pts: readonly [number, number][],
|
|
window: number,
|
|
): [number, number][] =>
|
|
pts.map((_, i) => {
|
|
let sx = 0;
|
|
let sy = 0;
|
|
for (let k = -window; k <= window; k++) {
|
|
const [x, y] = pts[(i + k + pts.length) % pts.length];
|
|
sx += x;
|
|
sy += y;
|
|
}
|
|
return [sx / (2 * window + 1), sy / (2 * window + 1)];
|
|
});
|
|
|
|
// A sloppy hand-drawn "rectangle" that tapers, bucket-like: wider at the top
|
|
// than the bottom. Its hull fill ratio, (topW + bottomW) / (2 * topW), lands
|
|
// right at an ellipse's PI/4 — only its sharp corners tell the two apart.
|
|
const taperedRectangle = (
|
|
topW: number,
|
|
bottomW: number,
|
|
h: number,
|
|
): [number, number][] =>
|
|
outline([
|
|
[-topW / 2, -h / 2],
|
|
[topW / 2, -h / 2],
|
|
[bottomW / 2, h / 2],
|
|
[-bottomW / 2, h / 2],
|
|
]);
|
|
|
|
// A rectangle drawn with broad, pen-like rounded corners. Its outline is a
|
|
// superellipse midway between an ellipse (exponent 2) and a sharp rectangle.
|
|
const roundedRectangle = (rx: number, ry: number): [number, number][] => {
|
|
const points: [number, number][] = [];
|
|
for (let a = 0; a <= Math.PI * 2 + 0.05; a += 0.1) {
|
|
const cos = Math.cos(a);
|
|
const sin = Math.sin(a);
|
|
points.push([
|
|
Math.sign(cos) * Math.sqrt(Math.abs(cos)) * rx,
|
|
Math.sign(sin) * Math.sqrt(Math.abs(sin)) * ry,
|
|
]);
|
|
}
|
|
return points;
|
|
};
|
|
|
|
const line = (length: number) => edge([-length / 2, 0], [length / 2, 0], 40);
|
|
|
|
/** A shaft, then a V head traced back from the tip and over itself. */
|
|
const arrow = (length: number, headRatio: number): [number, number][] => {
|
|
const tip: [number, number] = [length / 2, 0];
|
|
const headLength = length * headRatio;
|
|
const arm = (side: number): [number, number] => [
|
|
tip[0] - headLength * Math.cos(Math.PI / 6),
|
|
tip[1] - side * headLength * Math.sin(Math.PI / 6),
|
|
];
|
|
return [
|
|
...edge([-length / 2, 0], tip, 24),
|
|
...edge(tip, arm(1), 8),
|
|
...edge(arm(1), tip, 8),
|
|
...edge(tip, arm(-1), 8),
|
|
];
|
|
};
|
|
|
|
const SCALES = [0.5, 1, 2, 4];
|
|
|
|
describe("recognizeShape", () => {
|
|
describe("is invariant to where, how big and how rotated a shape is drawn", () => {
|
|
// A rectangle turned 45° *is* a diamond, so these two are only swept over
|
|
// the right angles that map them onto themselves. Every other class is
|
|
// swept over arbitrary angles.
|
|
const RIGHT_ANGLES = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2];
|
|
// 4.7 rad is within a degree of vertical — the size gate compares the
|
|
// larger bounding-box dimension, so thin (near-)axis-aligned strokes are
|
|
// recognized like any other.
|
|
const ANY_ANGLES = [0, 0.3, Math.PI / 4, 1.2, Math.PI / 2, 2.5, 4.7];
|
|
|
|
const cases: [string, [number, number][], number[], string][] = [
|
|
["a square", rectangle(200, 200), RIGHT_ANGLES, "rectangle"],
|
|
["a wide rectangle", rectangle(300, 150), RIGHT_ANGLES, "rectangle"],
|
|
["a thin rectangle", rectangle(500, 100), RIGHT_ANGLES, "rectangle"],
|
|
[
|
|
"a tapered rectangle",
|
|
taperedRectangle(170, 100, 200),
|
|
RIGHT_ANGLES,
|
|
"rectangle",
|
|
],
|
|
["a diamond", diamond(200, 200), RIGHT_ANGLES, "diamond"],
|
|
["a wide diamond", diamond(300, 150), RIGHT_ANGLES, "diamond"],
|
|
["a circle", ellipse(100, 100), ANY_ANGLES, "ellipse"],
|
|
["an ellipse", ellipse(150, 75), ANY_ANGLES, "ellipse"],
|
|
["a line", line(300), ANY_ANGLES, "line"],
|
|
["an arrow with a small head", arrow(300, 0.15), ANY_ANGLES, "arrow"],
|
|
["an arrow with a large head", arrow(300, 0.45), ANY_ANGLES, "arrow"],
|
|
];
|
|
|
|
it.each(cases)("recognizes %s", (_name, points, angles, expected) => {
|
|
for (const angle of angles) {
|
|
for (const scale of SCALES) {
|
|
for (const noise of [0, 3]) {
|
|
expect({
|
|
angle,
|
|
scale,
|
|
noise,
|
|
type: recognizeShape(
|
|
sketch(points, { angle, scale, noise }),
|
|
null,
|
|
).type,
|
|
}).toEqual({ angle, scale, noise, type: expected });
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
it("tells an arrow from a line by the points its head piles up", () => {
|
|
const options = { angle: 0.3 };
|
|
expect(recognizeShape(sketch(line(300), options), null).type).toBe("line");
|
|
expect(recognizeShape(sketch(arrow(300, 0.3), options), null).type).toBe(
|
|
"arrow",
|
|
);
|
|
});
|
|
|
|
it("recognizes an outline the user didn't quite close", () => {
|
|
const gapped = rectangle(200, 200).slice(0, -6);
|
|
|
|
expect(recognizeShape(sketch(gapped), null).type).toBe("rectangle");
|
|
});
|
|
|
|
it("recognizes a pen-drawn outline with rounded corners as a rectangle", () => {
|
|
expect(recognizeShape(sketch(roundedRectangle(100, 70)), null).type).toBe(
|
|
"rectangle",
|
|
);
|
|
|
|
// ...while a true ellipse of the same proportions remains an ellipse
|
|
expect(recognizeShape(sketch(ellipse(100, 70)), null).type).toBe("ellipse");
|
|
});
|
|
|
|
it("tells a tapered, bucket-like rectangle from an ellipse by its corners", () => {
|
|
// proportions traced from real sketches that used to recognize as
|
|
// ellipses: hull fill ≈ 0.8 ≈ PI/4, so corner sharpness must decide
|
|
const buckets: [number, number, number][] = [
|
|
[110, 65, 220],
|
|
[110, 65, 130],
|
|
[75, 50, 115],
|
|
[120, 60, 250],
|
|
];
|
|
|
|
for (const [topW, bottomW, h] of buckets) {
|
|
// lightly rounded corners + wobble, like the real strokes
|
|
const points = sketch(smooth(taperedRectangle(topW, bottomW, h), 2), {
|
|
noise: 2,
|
|
});
|
|
|
|
expect({
|
|
bucket: [topW, bottomW, h],
|
|
type: recognizeShape(points, null).type,
|
|
}).toEqual({ bucket: [topW, bottomW, h], type: "rectangle" });
|
|
}
|
|
});
|
|
|
|
it("does not read sharply bent open strokes as lines or arrows", () => {
|
|
const semicircle: [number, number][] = Array.from(
|
|
{ length: 40 },
|
|
(_, i) => {
|
|
const a = (Math.PI * i) / 39;
|
|
return [Math.cos(a) * 150, Math.sin(a) * 150];
|
|
},
|
|
);
|
|
const sawtooth: [number, number][] = Array.from({ length: 31 }, (_, i) => [
|
|
i * 10,
|
|
(i % 2) * 60,
|
|
]);
|
|
|
|
const bent: [string, [number, number][]][] = [
|
|
[
|
|
"a right-angle elbow",
|
|
[...edge([0, 0], [110, 110], 20), ...edge([110, 110], [220, 0], 20)],
|
|
],
|
|
[
|
|
"an L shape",
|
|
[...edge([0, 0], [0, 150], 20), ...edge([0, 150], [150, 150], 20)],
|
|
],
|
|
[
|
|
"a checkmark",
|
|
[...edge([0, 60], [60, 120], 12), ...edge([60, 120], [200, 0], 24)],
|
|
],
|
|
["a semicircular arc", semicircle],
|
|
["a sawtooth", sawtooth],
|
|
];
|
|
|
|
for (const [name, points] of bent) {
|
|
expect({
|
|
name,
|
|
type: recognizeShape(sketch(points, { noise: 2 }), null).type,
|
|
}).toEqual({ name, type: "freedraw" });
|
|
}
|
|
});
|
|
|
|
it("still reads a lazily bowed or squiggly stroke as a line", () => {
|
|
const bowed: [number, number][] = Array.from({ length: 40 }, (_, i) => {
|
|
const t = i / 39;
|
|
return [t * 300, Math.sin(t * Math.PI) * 24];
|
|
});
|
|
const squiggly: [number, number][] = Array.from({ length: 40 }, (_, i) => {
|
|
const t = i / 39;
|
|
return [t * 300, Math.sin(t * Math.PI * 4) * 12];
|
|
});
|
|
|
|
expect(recognizeShape(sketch(bowed, { noise: 2 }), null).type).toBe("line");
|
|
expect(recognizeShape(sketch(squiggly, { noise: 2 }), null).type).toBe(
|
|
"line",
|
|
);
|
|
});
|
|
|
|
it("leaves a stroke that resembles no known shape as freedraw", () => {
|
|
const squiggle: [number, number][] = Array.from({ length: 60 }, (_, i) => [
|
|
i * 4 - 120,
|
|
Math.sin(i / 2) * 70 + random() * 20,
|
|
]);
|
|
|
|
expect(recognizeShape(sketch(squiggle), null).type).toBe("freedraw");
|
|
});
|
|
|
|
it("only extends an arrow with another arrow", () => {
|
|
const previous = { type: "arrow" } as Parameters<typeof recognizeShape>[1];
|
|
|
|
expect(
|
|
recognizeShape(sketch(arrow(300, 0.3), { angle: 0.3 }), previous).type,
|
|
).toBe("arrow");
|
|
expect(recognizeShape(sketch(rectangle(200, 200)), previous).type).toBe(
|
|
"freedraw",
|
|
);
|
|
});
|
|
|
|
it("ignores a stroke too small to be a shape", () => {
|
|
const tiny: [number, number][] = [
|
|
[0, 0],
|
|
[1, 1],
|
|
[2, 0],
|
|
[1, -1],
|
|
];
|
|
|
|
expect(recognizeShape(sketch(tiny), null).type).toBe("freedraw");
|
|
});
|
|
|
|
it("gates on apparent (screen) size, so zoom decides what is too small", () => {
|
|
const smallCircle = ellipse(8, 8); // 16 scene units across
|
|
|
|
expect(recognizeShape(sketch(smallCircle), null).type).toBe("freedraw");
|
|
expect(recognizeShape(sketch(smallCircle), null, 10).type).toBe("ellipse");
|
|
|
|
// 300 apparent px at 0.1 zoom
|
|
expect(
|
|
recognizeShape(sketch(line(3000), { angle: 0.3 }), null, 0.1).type,
|
|
).toBe("line");
|
|
// 10 apparent px at 0.1 zoom
|
|
expect(
|
|
recognizeShape(sketch(line(100), { angle: 0.3 }), null, 0.1).type,
|
|
).toBe("freedraw");
|
|
});
|
|
});
|