From 9616e63e230489eb165884855655a8a6f6c087c0 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 5 Jan 2026 10:25:40 +0000 Subject: [PATCH] feat: Ovoids Signed-off-by: Mark Tolmacs --- packages/element/package.json | 3 +- packages/element/src/freedraw.ts | 330 +++++++++++++++++++++++++++++++ packages/element/src/shape.ts | 13 +- yarn.lock | 13 ++ 4 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 packages/element/src/freedraw.ts diff --git a/packages/element/package.json b/packages/element/package.json index 88e3ffaaa8..a784e2db48 100644 --- a/packages/element/package.json +++ b/packages/element/package.json @@ -58,6 +58,7 @@ }, "dependencies": { "@excalidraw/common": "0.18.0", - "@excalidraw/math": "0.18.0" + "@excalidraw/math": "0.18.0", + "polygon-clipping": "^0.15.7" } } diff --git a/packages/element/src/freedraw.ts b/packages/element/src/freedraw.ts new file mode 100644 index 0000000000..c3215d83d2 --- /dev/null +++ b/packages/element/src/freedraw.ts @@ -0,0 +1,330 @@ +import { + pointFrom, + pointDistance, + type LocalPoint, + vectorFromPoint, + vectorNormalize, +} from "@excalidraw/math"; + +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; + +// Minimum radius to avoid degenerate shapes +const MIN_RADIUS = 0.5; + +// Pressure to radius multiplier (scaled by strokeWidth) +const PRESSURE_RADIUS_MULTIPLIER = 2.0; + +/** + * Compute the radius for a point based on pressure and strokeWidth. + * Pressure is typically in [0, 1] range, default to 0.5 if simulating. + */ +function getRadiusForPressure(pressure: number, strokeWidth: number): number { + return Math.max( + MIN_RADIUS, + pressure * strokeWidth * PRESSURE_RADIUS_MULTIPLIER, + ); +} + +/** + * Generate points along a semicircular arc (dome/cap). + * The arc goes from startAngle to endAngle (counterclockwise). + * + * @param center - Center point of the arc + * @param radius - Radius of the arc + * @param startAngle - Start angle in radians + * @param endAngle - End angle in radians (counterclockwise from start) + * @param segments - Number of segments to divide the arc into + * @returns Array of points along the arc + */ +function generateArcPoints( + center: LocalPoint, + radius: number, + startAngle: number, + endAngle: number, + segments: number, +): LocalPoint[] { + const points: LocalPoint[] = []; + 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), + ), + ); + } + + return points; +} + +/** + * Create an ovoid shape between two consecutive points. + * The ovoid consists of: + * - A semicircular cap at the first point (facing away from point 2) + * - A semicircular cap at the second point (facing away from point 1) + * - Connecting lines (tangent lines between the two circles) + * + * @param p1 - First point + * @param r1 - Radius at first point (from pressure) + * @param p2 - Second point + * @param r2 - Radius at second point (from pressure) + * @returns Array of points forming the ovoid polygon + */ +function createOvoid( + p1: LocalPoint, + r1: number, + p2: LocalPoint, + r2: number, +): LocalPoint[] { + const dist = pointDistance(p1, p2); + + // If points are too close, create a circle at the midpoint + if (dist < 0.001) { + const avgRadius = (r1 + r2) / 2; + return generateArcPoints(p1, avgRadius, 0, Math.PI * 2, CAP_SEGMENTS * 2); + } + + // Direction vector from p1 to p2 + const dirVec = vectorFromPoint(p2, p1); + const normalizedDir = vectorNormalize(dirVec); + + // Calculate the angle of the direction vector + const baseAngle = Math.atan2(normalizedDir[1], normalizedDir[0]); + + // For connecting the circles with tangent lines when radii differ, + // we need to compute the tangent points + // When r1 != r2, the tangent lines are not perpendicular to the center line + + // Tangent angle offset (when radii differ) + const radiusDiff = r1 - r2; + const tangentAngleOffset = + dist > Math.abs(radiusDiff) ? Math.asin(radiusDiff / dist) : 0; + + // Compute tangent points on each circle + // The perpendicular offset needs to be adjusted for the tangent angle + const tangentPerpAngle = baseAngle + Math.PI / 2 + tangentAngleOffset; + + // Cap at p1 (semicircle facing away from p2) + // Goes from p1Right to p1Left (counterclockwise, facing back) + const cap1StartAngle = tangentPerpAngle + Math.PI; + const cap1EndAngle = cap1StartAngle + Math.PI; + const cap1Points = generateArcPoints( + p1, + r1, + cap1StartAngle, + cap1EndAngle, + CAP_SEGMENTS, + ); + + // Cap at p2 (semicircle facing away from p1) + // Goes from p2Left to p2Right (counterclockwise, facing forward) + const cap2StartAngle = tangentPerpAngle; + const cap2EndAngle = cap2StartAngle + Math.PI; + const cap2Points = generateArcPoints( + p2, + r2, + cap2StartAngle, + cap2EndAngle, + CAP_SEGMENTS, + ); + + // 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[] = [ + ...cap1Points, // p1's back cap + ...cap2Points, // p2's front cap + ]; + + return ovoidPoints; +} + +/** + * 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. + * + * This creates ovoid shapes between each consecutive pair of points, + * where each ovoid is defined by: + * - Semicircular caps at each point (radius based on pressure) + * - Connecting tangent lines between the caps + * + * All ovoids are then unioned together to form the final outline. + * + * @param element - The freedraw element to generate outline for + * @returns Array of [x, y] points representing the outline polygon + */ +export function generateFreeDrawOvoidOutline( + element: ExcalidrawFreeDrawElement, +): [number, number][] { + const { points, pressures, simulatePressure, strokeWidth } = element; + + if (points.length === 0) { + return []; + } + + // Single point: just return a circle + if (points.length === 1) { + const pressure = simulatePressure ? 0.5 : pressures[0] ?? 0.5; + const radius = getRadiusForPressure(pressure, strokeWidth); + const circlePoints = generateArcPoints( + points[0] as LocalPoint, + radius, + 0, + Math.PI * 2, + CAP_SEGMENTS * 2, + ); + return circlePoints.map((p) => [p[0], p[1]]); + } + + // Generate ovoids for each consecutive pair of points + const ovoids: Polygon[] = []; + + for (let i = 0; i < points.length - 1; i++) { + const p1 = points[i] as LocalPoint; + const p2 = points[i + 1] as LocalPoint; + + // Get pressures (use 0.5 as default when simulating) + const pressure1 = simulatePressure ? 0.5 : pressures[i] ?? 0.5; + const pressure2 = simulatePressure ? 0.5 : pressures[i + 1] ?? 0.5; + + const r1 = getRadiusForPressure(pressure1, strokeWidth); + const r2 = getRadiusForPressure(pressure2, strokeWidth); + + const ovoidPoints = createOvoid(p1, r1, p2, r2); + + if (ovoidPoints.length >= 3) { + ovoids.push(toClipperPolygon(ovoidPoints)); + } + } + + if (ovoids.length === 0) { + return []; + } + + // Union all ovoids together + const result = polygonClipping.union( + [ovoids[0]], + ...ovoids.slice(1).map((ovoid) => [ovoid]), + ); + + // 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) { + 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) { + return []; + } + + // Return the first (outer) polygon + return outlinePolygons[0].map((p) => [p[0], p[1]]); +} + +/** + * Convert the ovoid outline to an SVG path string. Uses quadratic curves + * for smoothing. + */ +export function generateFreeDrawOvoidSvgPath( + element: ExcalidrawFreeDrawElement, +): string { + const points = generateFreeDrawOvoidOutline(element); + + if (points.length === 0) { + console.warn("No outline points generated for freedraw element"); + return ""; + } + + if (points.length < 3) { + console.warn("Not enough outline points to form a closed path"); + return ""; + } + + // Use a similar approach to the original getSvgPathFromStroke + // but with the ovoid-generated points + const med = (a: number[], b: number[]) => [ + (a[0] + b[0]) / 2, + (a[1] + b[1]) / 2, + ]; + + const pathData = points + // Build a closed, smoothed SVG path from the outline polygon + .reduce( + (acc: (string | number[])[], point, i, arr) => { + if (i === points.length - 1) { + // For the last point, add a line-to ("L") back to the first point and close + // ("Z"). + acc.push(point, med(point, arr[0]), "L", arr[0], "Z"); + } else { + // Use a single quadratic command ("Q") and then emit point + midpoint pairs + // so each segment curves through the current point toward the midpoint of + // the next segment. + acc.push(point, med(point, arr[i + 1])); + } + return acc; + }, + // Start with a move-to ("M") to the first point. + ["M", points[0], "Q"], + ) + .join(" ") + // Trim excessive float precision to keep the path string compact/stable. + .replace(/(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g, "$1"); + + return pathData; +} diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts index 176455bdf9..69688171c7 100644 --- a/packages/element/src/shape.ts +++ b/packages/element/src/shape.ts @@ -63,6 +63,7 @@ import { getElementAbsoluteCoords, } from "./bounds"; import { shouldTestInside } from "./collision"; +import { generateFreeDrawOvoidSvgPath } from "./freedraw"; import type { ExcalidrawElement, @@ -78,6 +79,10 @@ import type { import type { Drawable, Options } from "roughjs/bin/core"; import type { Point as RoughPoint } from "roughjs/bin/geometry"; +// Toggle between old (perfect-freehand) and new (ovoid-union) freedraw rendering +// Set to true to use the new ovoid-based implementation +export const USE_NEW_FREEDRAW_RENDERER = true; + export class ShapeCache { private static rg = new RoughGenerator(); private static cache = new WeakMap< @@ -852,8 +857,12 @@ const _generateElementShape = ( ); } - // (2) stroke - shapes.push(getFreeDrawSvgPath(element)); + // (2) stroke - use new ovoid renderer or legacy perfect-freehand + if (USE_NEW_FREEDRAW_RENDERER) { + shapes.push(generateFreeDrawOvoidSvgPath(element) as SVGPathString); + } else { + shapes.push(getFreeDrawSvgPath(element)); + } return shapes; } diff --git a/yarn.lock b/yarn.lock index 919192bd7e..6655947a40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7882,6 +7882,14 @@ points-on-path@^0.2.1: path-data-parser "0.1.0" points-on-curve "0.2.0" +polygon-clipping@^0.15.7: + version "0.15.7" + resolved "https://registry.yarnpkg.com/polygon-clipping/-/polygon-clipping-0.15.7.tgz#3823ca1e372566f350795ce9dd9a7b19e97bdaad" + integrity sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA== + dependencies: + robust-predicates "^3.0.2" + splaytree "^3.1.0" + portfinder@^1.0.28: version "1.0.33" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.33.tgz#03dbc51455aa8f83ad9fb86af8345e063bb51101" @@ -8761,6 +8769,11 @@ sourcemap-codec@^1.4.8: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== +splaytree@^3.1.0: + version "3.2.3" + resolved "https://registry.yarnpkg.com/splaytree/-/splaytree-3.2.3.tgz#5e4836bc54cc939344bba8dc826f82fbde453106" + integrity sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w== + split.js@^1.6.0: version "1.6.5" resolved "https://registry.yarnpkg.com/split.js/-/split.js-1.6.5.tgz#f7f61da1044c9984cb42947df4de4fadb5a3f300"