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( + 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(coord[0], coord[1])), - ); -} - /** * Generate the outline of a freedraw element using the ovoid-union approach. * @@ -257,21 +393,21 @@ export function generateFreeDrawOvoidOutline( // Debug draw the raw segments from the freedraw element points const colors = ["red", "green", "blue", "orange", "purple"]; - // points.forEach((pt, i) => { - // if (i === points.length - 1) { - // return; - // } - // debugDrawLine( - // lineSegment( - // pointFrom(x + pt[0], y + pt[1]), - // pointFrom(x + points[i + 1][0], y + points[i + 1][1]), - // ), - // { - // color: colors[i % colors.length], - // permanent: true, - // }, - // ); - // }); + points.forEach((pt, i) => { + if (i === points.length - 1) { + return; + } + debugDrawLine( + lineSegment( + pointFrom(x + pt[0], y + pt[1]), + pointFrom(x + points[i + 1][0], y + points[i + 1][1]), + ), + { + color: colors[i % colors.length], + permanent: true, + }, + ); + }); if (points.length === 0) { return []; @@ -292,7 +428,7 @@ export function generateFreeDrawOvoidOutline( } // Generate ovoids for each consecutive pair of points - const ovoids: Polygon[] = []; + const ovoids: Polygon[] = []; for (let i = 0; i < points.length - 1; i++) { const p1 = points[i] as LocalPoint; @@ -309,7 +445,7 @@ export function generateFreeDrawOvoidOutline( const isFirstSegment = i === 0; const isLastSegment = i === points.length - 2; - const ovoidPoints = createOvoid( + const ovoidPoints = createOvoid( p1, r1, p2, @@ -328,7 +464,7 @@ export function generateFreeDrawOvoidOutline( ); if (ovoidPoints.length >= 3) { - ovoids.push(toClipperPolygon(ovoidPoints)); + ovoids.push(ovoidPoints); } } @@ -337,38 +473,24 @@ export function generateFreeDrawOvoidOutline( } // Union all ovoids together - const result = polygonClipping.union( - [ovoids[0]], - ...ovoids.slice(1).map((ovoid) => [ovoid]), + const result = chainOvoidsIntoSinglePolygon( + ovoids.map((poly, i) => ({ + polygon: poly, + firstPoint: points[i], + secondPoint: points[i + 1], + })), ); - // let result: MultiPolygon = ovoids[0] ? [ovoids[0]] : []; - - // for (let i = 1; i < ovoids.length; i++) { - // if (ovoids[i]) { - // try { - // result = polygonClipping.union(result, [ovoids[i]]); - // } catch { - // // If union fails for this ovoid, skip it - // continue; - // } - // } - // } - - if (result.length === 0) { + if (result === null) { return []; } - // Convert back to point array - // Take the outer ring of the first (largest) polygon - const outlinePolygons = fromClipperResult(result); - - if (outlinePolygons.length === 0 || outlinePolygons[0].length === 0) { + if (result === null) { return []; } // Return the first (outer) polygon - return outlinePolygons[0].map((p) => [p[0], p[1]]); + return result; } /**