From 0256559b6809b7ced580d4123311276a11a4e9ed Mon Sep 17 00:00:00 2001 From: dwelle <5153846+dwelle@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:51:41 +0200 Subject: [PATCH] improve balls --- packages/element/src/shape.ts | 49 ++++++++++++++++++++++++++-- packages/element/tests/shape.test.ts | 26 +++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts index a74610bad4..6ee1b0f5ec 100644 --- a/packages/element/src/shape.ts +++ b/packages/element/src/shape.ts @@ -1233,6 +1233,11 @@ const roundPoint = (point: readonly number[]) => const averagePoint = (A: readonly number[], B: readonly number[]) => `${round((A[0] + B[0]) / 2, 2)},${round((A[1] + B[1]) / 2, 2)} `; +const getReadonlyPointDistance = ( + pointA: readonly number[], + pointB: readonly number[], +) => Math.hypot(pointA[0] - pointB[0], pointA[1] - pointB[1]); + // Trim SVG path data so number are each two decimal points. This // improves SVG exports, and prevents rendering errors on points // with long decimals. @@ -1263,6 +1268,9 @@ const FIXED_FREEDRAW_MIN_CORNER_ROUNDING = 0.75; const FIXED_FREEDRAW_MAX_CORNER_ROUNDING = 6; const FIXED_FREEDRAW_CORNER_ROUNDING_FACTOR = 0.35; const FIXED_FREEDRAW_CORNER_ROUNDING_WIDTH_FACTOR = 1.5; +const FIXED_FREEDRAW_MIN_TERMINAL_STUB = 0.75; +const FIXED_FREEDRAW_MAX_TERMINAL_STUB = 2.5; +const FIXED_FREEDRAW_TERMINAL_STUB_WIDTH_FACTOR = 1.5; const shouldSmoothFixedFreeDrawPoint = ( previousPoint: readonly number[], @@ -1344,6 +1352,15 @@ const getFixedFreeDrawRoundedCorner = ( }; }; +const getFixedFreeDrawTerminalStubThreshold = (strokeWidth: number) => + Math.min( + Math.max( + strokeWidth * FIXED_FREEDRAW_TERMINAL_STUB_WIDTH_FACTOR, + FIXED_FREEDRAW_MIN_TERMINAL_STUB, + ), + FIXED_FREEDRAW_MAX_TERMINAL_STUB, + ); + const getSvgPathFromFixedFreeDrawPoints = ( points: ReadonlyArray, strokeWidth: number, @@ -1359,11 +1376,17 @@ const getSvgPathFromFixedFreeDrawPoints = ( } let path = `M${roundPoint(points[0])}`; + const lastPoint = points[len - 1]; + let endsAtLastPoint = false; for (let index = 1; index < len - 1; index++) { const previousPoint = points[index - 1]; const currentPoint = points[index]; const nextPoint = points[index + 1]; + const isLastCurveSegment = index === len - 2; + const terminalStubThreshold = getFixedFreeDrawTerminalStubThreshold( + strokeWidth, + ); const shouldSmooth = shouldSmoothFixedFreeDrawPoint( previousPoint, currentPoint, @@ -1377,17 +1400,37 @@ const getSvgPathFromFixedFreeDrawPoints = ( nextPoint, strokeWidth, ); + const shouldCollapseSmoothTerminalStub = + isLastCurveSegment && + shouldSmooth && + getReadonlyPointDistance(currentPoint, nextPoint) / 2 <= + terminalStubThreshold; + const shouldCollapseRoundedTerminalStub = + isLastCurveSegment && + !!roundedCorner && + getReadonlyPointDistance(roundedCorner.exitPoint, lastPoint) <= + terminalStubThreshold; path += shouldSmooth - ? `Q${roundPoint(currentPoint)}${averagePoint(currentPoint, nextPoint)}` + ? `Q${roundPoint(currentPoint)}${ + shouldCollapseSmoothTerminalStub + ? roundPoint(lastPoint) + : averagePoint(currentPoint, nextPoint) + }` : roundedCorner ? `L${roundPoint(roundedCorner.entryPoint)}Q${roundPoint( currentPoint, - )}${roundPoint(roundedCorner.exitPoint)}` + )}${ + shouldCollapseRoundedTerminalStub + ? roundPoint(lastPoint) + : roundPoint(roundedCorner.exitPoint) + }` : `L${roundPoint(currentPoint)}`; + endsAtLastPoint = + shouldCollapseSmoothTerminalStub || shouldCollapseRoundedTerminalStub; } - return `${path}L${roundPoint(points[len - 1])}`; + return endsAtLastPoint ? path : `${path}L${roundPoint(lastPoint)}`; }; const getSvgPathFromStroke = ( diff --git a/packages/element/tests/shape.test.ts b/packages/element/tests/shape.test.ts index ff606173e4..3acbf4adfb 100644 --- a/packages/element/tests/shape.test.ts +++ b/packages/element/tests/shape.test.ts @@ -56,6 +56,32 @@ describe("freedraw stroke shape", () => { expect(path).toBe("M0,0 L10,0 L9,1 "); }); + it("curves directly to the endpoint for tiny final smooth segments", () => { + const element = API.createElement({ + type: "freedraw", + strokeShape: "fixed", + strokeWidth: 1, + points: [pointFrom(0, 0), pointFrom(0.5, 0.1), pointFrom(0.8, 0.3)], + }); + + const [path] = ShapeCache.generateElementShape(element, null); + + expect(path).toBe("M0,0 Q0.5,0.1 0.8,0.3 "); + }); + + it("curves directly to the endpoint for tiny final rounded corners", () => { + const element = API.createElement({ + type: "freedraw", + strokeShape: "fixed", + strokeWidth: 1, + points: [pointFrom(0, 0), pointFrom(10, 0), pointFrom(10, 1)], + }); + + const [path] = ShapeCache.generateElementShape(element, null); + + expect(path).toBe("M0,0 L9.65,0 Q10,0 10,1 "); + }); + it("smooths dense fixed stroke points without dropping them", () => { const element = API.createElement({ type: "freedraw",