@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user