diff --git a/packages/element/src/freedraw.ts b/packages/element/src/freedraw.ts index 6ddd98b9f6..1a5b2f49e7 100644 --- a/packages/element/src/freedraw.ts +++ b/packages/element/src/freedraw.ts @@ -6,28 +6,193 @@ import { vectorNormalize, lineSegment, type GlobalPoint, + type Polygon, + polygonFromPoints, } from "@excalidraw/math"; import { debugDrawLine, debugDrawPolygon } from "@excalidraw/common"; -import polygonClipping from "polygon-clipping"; -import type { Polygon, MultiPolygon, Ring } from "polygon-clipping"; import type { ExcalidrawFreeDrawElement } from "./types"; // Number of segments to approximate each semicircular cap -const CAP_SEGMENTS = 8; +const CAP_SEGMENTS = 20; // Minimum radius to avoid degenerate shapes -const MIN_RADIUS = 0.5; +const MIN_RADIUS = 0.05; // Pressure to radius multiplier (scaled by strokeWidth) const PRESSURE_RADIUS_MULTIPLIER = 2.0; // Minimum distance between points to avoid numerical instability -const MIN_POINT_DISTANCE = 0.1; +const MIN_POINT_DISTANCE = 0.001; // Epsilon for filtering near-duplicate points in polygons const EPSILON = 0.01; +// Simple union implementation taking advantage of the following facts: +// - The ovoids are generated in sequence along the stroke path +// - Each ovoid overlaps only with its immediate neighbors +// - The ovoids are convex shapes + +// Therefore, we can simply stitch together the outer edges of the ovoids +// by taking the first half of the first ovoid and the second half of the last ovoid, +// and connecting them with the outer edges of the intermediate ovoids. The overlapping +// ovoid caps are always the same radius at the shared points, so they align perfectly. +// It should be easy to find the closest point to the side of the previous side segment and +// one of the closest points on the next ovoid's start cap. +function chainOvoidsIntoSinglePolygon
( + records: { + polygon: Polygon
; + firstPoint: P; + secondPoint: P; + }[], +): Polygon
| null { + if (records.length === 0) { + return null; + } + + if (records.length === 1) { + return records[0].polygon; + } + + const capPointCount = CAP_SEGMENTS + 1; + + const isClosedPolygon = (points: P[]) => { + if (points.length < 2) { + return false; + } + const first = points[0]; + const last = points[points.length - 1]; + return first[0] === last[0] && first[1] === last[1]; + }; + + const openPolygon = (points: P[]) => + isClosedPolygon(points) ? points.slice(0, -1) : points.slice(); + + const distanceSq = (a: P, b: P) => { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + return dx * dx + dy * dy; + }; + + const distanceToSegmentSq = (p: P, a: P, b: P) => { + const abx = b[0] - a[0]; + const aby = b[1] - a[1]; + const apx = p[0] - a[0]; + const apy = p[1] - a[1]; + const abLenSq = abx * abx + aby * aby; + + if (abLenSq === 0) { + return distanceSq(p, a); + } + + const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / abLenSq)); + const closest = pointFrom
(a[0] + abx * t, a[1] + aby * t); + return distanceSq(p, closest); + }; + + const closestIndexToSegment = (points: P[], a: P, b: P) => { + let bestIndex = 0; + let bestDistance = Number.POSITIVE_INFINITY; + + for (let i = 0; i < points.length; i++) { + const dist = distanceToSegmentSq(points[i], a, b); + if (dist < bestDistance) { + bestDistance = dist; + bestIndex = i; + } + } + + return bestIndex; + }; + + const pushIfDistinct = (points: P[], point: P) => { + if (points.length === 0) { + points.push(point); + return; + } + if (distanceSq(points[points.length - 1], point) > EPSILON * EPSILON) { + points.push(point); + } + }; + + const ovoids = records.map((record) => { + const open = openPolygon(record.polygon); + + if (open.length < capPointCount * 2) { + return { + cap1: open, + cap2: [] as P[], + p1Right: open[0], + p1Left: open[open.length - 1], + p2Left: open[0], + p2Right: open[open.length - 1], + }; + } + + const cap1 = open.slice(0, capPointCount); + const cap2 = open.slice(capPointCount, capPointCount * 2); + + return { + cap1, + cap2, + p1Right: cap1[0], + p1Left: cap1[cap1.length - 1], + p2Left: cap2[0], + p2Right: cap2[cap2.length - 1], + }; + }); + + const leftChain: P[] = []; + const rightChain: P[] = []; + + ovoids[0].cap1.forEach((point) => pushIfDistinct(leftChain, point)); + pushIfDistinct(rightChain, ovoids[0].p1Right); + + for (let i = 0; i < ovoids.length; i++) { + const current = ovoids[i]; + + pushIfDistinct(leftChain, current.p2Left); + pushIfDistinct(rightChain, current.p2Right); + + if (i + 1 >= ovoids.length) { + continue; + } + + const next = ovoids[i + 1]; + + const leftIndex = closestIndexToSegment( + next.cap1, + current.p1Left, + current.p2Left, + ); + + for (let j = leftIndex; j < next.cap1.length; j++) { + pushIfDistinct(leftChain, next.cap1[j]); + } + + const rightIndex = closestIndexToSegment( + next.cap1, + current.p1Right, + current.p2Right, + ); + + for (let j = rightIndex; j >= 0; j--) { + pushIfDistinct(rightChain, next.cap1[j]); + } + } + + const lastOvoid = ovoids[ovoids.length - 1]; + lastOvoid.cap2.forEach((point) => pushIfDistinct(leftChain, point)); + + const rightChainReversed = rightChain.slice(0, -1).reverse(); + const outline = filterNearDuplicates
([ + ...leftChain, + ...rightChainReversed, + ]); + + return polygonFromPoints
(outline); +} + /** * Compute the radius for a point based on pressure and strokeWidth. * Pressure is typically in [0, 1] range, default to 0.5 if simulating. @@ -50,20 +215,20 @@ function getRadiusForPressure(pressure: number, strokeWidth: number): number { * @param segments - Number of segments to divide the arc into * @returns Array of points along the arc */ -function generateArcPoints( +function generateArcPoints
(
center: LocalPoint,
radius: number,
startAngle: number,
endAngle: number,
segments: number,
-): LocalPoint[] {
- const points: LocalPoint[] = [];
+): P[] {
+ const points: P[] = [];
const angleStep = (endAngle - startAngle) / segments;
for (let i = 0; i <= segments; i++) {
const angle = startAngle + i * angleStep;
points.push(
- pointFrom (
center[0] + radius * Math.cos(angle),
center[1] + radius * Math.sin(angle),
),
@@ -88,20 +253,22 @@ function generateArcPoints(
* @param forcePerpendicularCap2 - Force cap2 to be perpendicular (for stroke end)
* @returns Array of points forming the ovoid polygon
*/
-function createOvoid(
+function createOvoid (
p1: LocalPoint,
r1: number,
p2: LocalPoint,
r2: number,
forcePerpendicularCap1: boolean = false,
forcePerpendicularCap2: boolean = false,
-): LocalPoint[] {
+): Polygon {
const dist = pointDistance(p1, p2);
// If points are too close, create a circle at the midpoint
if (dist < MIN_POINT_DISTANCE) {
const avgRadius = (r1 + r2) / 2;
- return generateArcPoints(p1, avgRadius, 0, Math.PI * 2, CAP_SEGMENTS * 2);
+ return polygonFromPoints (
+ generateArcPoints(p1, avgRadius, 0, Math.PI * 2, CAP_SEGMENTS * 2),
+ );
}
// Direction vector from p1 to p2
@@ -130,7 +297,7 @@ function createOvoid(
const p1PerpAngle = forcePerpendicularCap1 ? purePerpAngle : tangentPerpAngle;
const p1RightAngle = p1PerpAngle;
const p1LeftAngle = p1PerpAngle + Math.PI;
- const cap1Points = generateArcPoints(
+ const cap1Points = generateArcPoints (
p1,
r1,
p1RightAngle,
@@ -143,7 +310,7 @@ function createOvoid(
const p2PerpAngle = forcePerpendicularCap2 ? purePerpAngle : tangentPerpAngle;
const p2LeftAngle = p2PerpAngle + Math.PI;
const p2RightAngle = p2LeftAngle + Math.PI;
- const cap2Points = generateArcPoints(
+ const cap2Points = generateArcPoints (
p2,
r2,
p2LeftAngle,
@@ -154,25 +321,27 @@ function createOvoid(
// Assemble the ovoid polygon:
// cap1 goes around the back of p1, cap2 goes around the front of p2
// The arc endpoints naturally connect with the tangent lines
- const ovoidPoints: LocalPoint[] = [
+ const ovoidPoints: P[] = [
...cap1Points, // p1's back cap
...cap2Points, // p2's front cap
];
// Filter out near-duplicate consecutive points to avoid numerical issues
- return filterNearDuplicates(ovoidPoints);
+ return polygonFromPoints (filterNearDuplicates (ovoidPoints));
}
/**
* Filter out consecutive points that are too close together.
* This prevents numerical instability in polygon boolean operations.
*/
-function filterNearDuplicates(points: LocalPoint[]): LocalPoint[] {
+function filterNearDuplicates (
+ points: P[],
+): P[] {
if (points.length < 2) {
return points;
}
- const filtered: LocalPoint[] = [points[0]];
+ const filtered: P[] = [points[0]];
for (let i = 1; i < points.length; i++) {
const prev = filtered[filtered.length - 1];
@@ -203,39 +372,6 @@ function filterNearDuplicates(points: LocalPoint[]): LocalPoint[] {
return filtered.length >= 3 ? filtered : points;
}
-/**
- * Convert a polygon (array of LocalPoints) to polygon-clipping format.
- * polygon-clipping expects: [[[x,y], [x,y], ...]]
- */
-function toClipperPolygon(points: LocalPoint[]): Polygon {
- if (points.length === 0) {
- return [];
- }
- // Ensure the polygon is closed
- const ring: Ring = points.map((p) => [p[0], p[1]]);
-
- // Close the ring if not already closed
- if (
- ring.length > 0 &&
- (ring[0][0] !== ring[ring.length - 1][0] ||
- ring[0][1] !== ring[ring.length - 1][1])
- ) {
- ring.push([ring[0][0], ring[0][1]]);
- }
-
- return [ring];
-}
-
-/**
- * Convert polygon-clipping result back to LocalPoint arrays.
- * Returns the largest polygon (by point count) from the result.
- */
-function fromClipperResult(result: MultiPolygon): LocalPoint[][] {
- return result.map((polygon) =>
- polygon[0].map((coord) => pointFrom