feat: Ovoids

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-01-05 10:25:40 +00:00
parent c158187f20
commit 9616e63e23
4 changed files with 356 additions and 3 deletions
+2 -1
View File
@@ -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"
}
}
+330
View File
@@ -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<LocalPoint>(
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<LocalPoint>(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;
}
+11 -2
View File
@@ -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;
}
+13
View File
@@ -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"