Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e01cd73a7 | |||
| c92ae37f50 | |||
| 071043adae | |||
| 9616e63e23 |
@@ -75,6 +75,39 @@ const renderCubicBezier = (
|
|||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPolygon = (data: any): data is GlobalPoint[] => {
|
||||||
|
return (
|
||||||
|
Array.isArray(data) &&
|
||||||
|
data.every((point) => Array.isArray(point) && point.length === 2)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPolygon = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
zoom: number,
|
||||||
|
points: GlobalPoint[],
|
||||||
|
color: string,
|
||||||
|
) => {
|
||||||
|
if (points.length < 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.globalAlpha = 0.3;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(points[0][0] * zoom, points[0][1] * zoom);
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
context.lineTo(points[i][0] * zoom, points[i][1] * zoom);
|
||||||
|
}
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
context.globalAlpha = 1.0;
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
||||||
context.strokeStyle = "#888";
|
context.strokeStyle = "#888";
|
||||||
context.save();
|
context.save();
|
||||||
@@ -280,6 +313,14 @@ const render = (
|
|||||||
el.color,
|
el.color,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case isPolygon(el.data):
|
||||||
|
renderPolygon(
|
||||||
|
context,
|
||||||
|
appState.zoom.value,
|
||||||
|
el.data as GlobalPoint[],
|
||||||
|
el.color,
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown element type ${JSON.stringify(el)}`);
|
throw new Error(`Unknown element type ${JSON.stringify(el)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Polygon = GlobalPoint[];
|
||||||
|
|
||||||
export type DebugElement = {
|
export type DebugElement = {
|
||||||
color: string;
|
color: string;
|
||||||
data: LineSegment<GlobalPoint> | Curve<GlobalPoint>;
|
data: LineSegment<GlobalPoint> | Curve<GlobalPoint> | Polygon;
|
||||||
permanent: boolean;
|
permanent: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,6 +131,20 @@ export const debugDrawBounds = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const debugDrawPolygon = (
|
||||||
|
points: GlobalPoint[],
|
||||||
|
opts?: {
|
||||||
|
color?: string;
|
||||||
|
permanent?: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
addToCurrentFrame({
|
||||||
|
color: opts?.color ?? "blue",
|
||||||
|
data: points,
|
||||||
|
permanent: !!opts?.permanent,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const debugDrawPoints = (
|
export const debugDrawPoints = (
|
||||||
{
|
{
|
||||||
x,
|
x,
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
import {
|
||||||
|
pointFrom,
|
||||||
|
pointDistance,
|
||||||
|
type LocalPoint,
|
||||||
|
vectorFromPoint,
|
||||||
|
vectorNormalize,
|
||||||
|
lineSegment,
|
||||||
|
type GlobalPoint,
|
||||||
|
type Polygon,
|
||||||
|
polygonFromPoints,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
import { debugDrawLine, debugDrawPolygon } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import type { ExcalidrawFreeDrawElement } from "./types";
|
||||||
|
|
||||||
|
// Number of segments to approximate each semicircular cap
|
||||||
|
const CAP_SEGMENTS = 20;
|
||||||
|
|
||||||
|
// Minimum radius to avoid degenerate shapes
|
||||||
|
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.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<P extends LocalPoint | GlobalPoint>(
|
||||||
|
records: {
|
||||||
|
polygon: Polygon<P>;
|
||||||
|
firstPoint: P;
|
||||||
|
secondPoint: P;
|
||||||
|
}[],
|
||||||
|
): Polygon<P> | 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<P>(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<P>([
|
||||||
|
...leftChain,
|
||||||
|
...rightChainReversed,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return polygonFromPoints<P>(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.
|
||||||
|
*/
|
||||||
|
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<P extends LocalPoint | GlobalPoint>(
|
||||||
|
center: LocalPoint,
|
||||||
|
radius: number,
|
||||||
|
startAngle: number,
|
||||||
|
endAngle: number,
|
||||||
|
segments: number,
|
||||||
|
): P[] {
|
||||||
|
const points: P[] = [];
|
||||||
|
const angleStep = (endAngle - startAngle) / segments;
|
||||||
|
|
||||||
|
for (let i = 0; i <= segments; i++) {
|
||||||
|
const angle = startAngle + i * angleStep;
|
||||||
|
points.push(
|
||||||
|
pointFrom<P>(
|
||||||
|
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)
|
||||||
|
* @param forcePerpendicularCap1 - Force cap1 to be perpendicular (for stroke start)
|
||||||
|
* @param forcePerpendicularCap2 - Force cap2 to be perpendicular (for stroke end)
|
||||||
|
* @returns Array of points forming the ovoid polygon
|
||||||
|
*/
|
||||||
|
function createOvoid<P extends LocalPoint | GlobalPoint>(
|
||||||
|
p1: LocalPoint,
|
||||||
|
r1: number,
|
||||||
|
p2: LocalPoint,
|
||||||
|
r2: number,
|
||||||
|
forcePerpendicularCap1: boolean = false,
|
||||||
|
forcePerpendicularCap2: boolean = false,
|
||||||
|
): Polygon<P> {
|
||||||
|
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 polygonFromPoints<P>(
|
||||||
|
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;
|
||||||
|
const purePerpAngle = baseAngle + Math.PI / 2;
|
||||||
|
|
||||||
|
// Cap at p1 (semicircle facing AWAY from p2, i.e., toward baseAngle + PI)
|
||||||
|
// Use perpendicular cap for stroke start, otherwise use tangent-adjusted
|
||||||
|
const p1PerpAngle = forcePerpendicularCap1 ? purePerpAngle : tangentPerpAngle;
|
||||||
|
const p1RightAngle = p1PerpAngle;
|
||||||
|
const p1LeftAngle = p1PerpAngle + Math.PI;
|
||||||
|
const cap1Points = generateArcPoints<P>(
|
||||||
|
p1,
|
||||||
|
r1,
|
||||||
|
p1RightAngle,
|
||||||
|
p1LeftAngle,
|
||||||
|
CAP_SEGMENTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cap at p2 (semicircle facing AWAY from p1, i.e., toward baseAngle)
|
||||||
|
// Use perpendicular cap for stroke end, otherwise use tangent-adjusted
|
||||||
|
const p2PerpAngle = forcePerpendicularCap2 ? purePerpAngle : tangentPerpAngle;
|
||||||
|
const p2LeftAngle = p2PerpAngle + Math.PI;
|
||||||
|
const p2RightAngle = p2LeftAngle + Math.PI;
|
||||||
|
const cap2Points = generateArcPoints<P>(
|
||||||
|
p2,
|
||||||
|
r2,
|
||||||
|
p2LeftAngle,
|
||||||
|
p2RightAngle,
|
||||||
|
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: P[] = [
|
||||||
|
...cap1Points, // p1's back cap
|
||||||
|
...cap2Points, // p2's front cap
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter out near-duplicate consecutive points to avoid numerical issues
|
||||||
|
return polygonFromPoints<P>(filterNearDuplicates<P>(ovoidPoints));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter out consecutive points that are too close together.
|
||||||
|
* This prevents numerical instability in polygon boolean operations.
|
||||||
|
*/
|
||||||
|
function filterNearDuplicates<P extends LocalPoint | GlobalPoint>(
|
||||||
|
points: P[],
|
||||||
|
): P[] {
|
||||||
|
if (points.length < 2) {
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered: P[] = [points[0]];
|
||||||
|
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const prev = filtered[filtered.length - 1];
|
||||||
|
const curr = points[i];
|
||||||
|
const dx = curr[0] - prev[0];
|
||||||
|
const dy = curr[1] - prev[1];
|
||||||
|
const distSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
// Only add if far enough from previous point
|
||||||
|
if (distSq > EPSILON * EPSILON) {
|
||||||
|
filtered.push(curr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if last point is too close to first point
|
||||||
|
if (filtered.length > 2) {
|
||||||
|
const first = filtered[0];
|
||||||
|
const last = filtered[filtered.length - 1];
|
||||||
|
const dx = last[0] - first[0];
|
||||||
|
const dy = last[1] - first[1];
|
||||||
|
const distSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (distSq < EPSILON * EPSILON) {
|
||||||
|
filtered.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.length >= 3 ? filtered : points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 { x, y, points, pressures, simulatePressure, strokeWidth } = element;
|
||||||
|
|
||||||
|
// 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<GlobalPoint>(x + pt[0], y + pt[1]),
|
||||||
|
pointFrom<GlobalPoint>(x + points[i + 1][0], y + points[i + 1][1]),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
color: colors[i % colors.length],
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<LocalPoint>[] = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Force perpendicular caps at stroke endpoints
|
||||||
|
const isFirstSegment = i === 0;
|
||||||
|
const isLastSegment = i === points.length - 2;
|
||||||
|
|
||||||
|
const ovoidPoints = createOvoid<LocalPoint>(
|
||||||
|
p1,
|
||||||
|
r1,
|
||||||
|
p2,
|
||||||
|
r2,
|
||||||
|
isFirstSegment, // Force perpendicular cap at stroke start
|
||||||
|
isLastSegment, // Force perpendicular cap at stroke end
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw the ovoid with different colors for debugging
|
||||||
|
debugDrawPolygon(
|
||||||
|
ovoidPoints.map((p) => pointFrom<GlobalPoint>(x + p[0], y + p[1])),
|
||||||
|
{
|
||||||
|
color: colors[i % colors.length],
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ovoidPoints.length >= 3) {
|
||||||
|
ovoids.push(ovoidPoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ovoids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union all ovoids together
|
||||||
|
const result = chainOvoidsIntoSinglePolygon<LocalPoint>(
|
||||||
|
ovoids.map((poly, i) => ({
|
||||||
|
polygon: poly,
|
||||||
|
firstPoint: points[i],
|
||||||
|
secondPoint: points[i + 1],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the first (outer) polygon
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
getElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import { shouldTestInside } from "./collision";
|
import { shouldTestInside } from "./collision";
|
||||||
|
import { generateFreeDrawOvoidSvgPath } from "./freedraw";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -78,6 +79,10 @@ import type {
|
|||||||
import type { Drawable, Options } from "roughjs/bin/core";
|
import type { Drawable, Options } from "roughjs/bin/core";
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
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 {
|
export class ShapeCache {
|
||||||
private static rg = new RoughGenerator();
|
private static rg = new RoughGenerator();
|
||||||
private static cache = new WeakMap<
|
private static cache = new WeakMap<
|
||||||
@@ -852,8 +857,12 @@ const _generateElementShape = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// (2) stroke
|
// (2) stroke - use new ovoid renderer or legacy perfect-freehand
|
||||||
shapes.push(getFreeDrawSvgPath(element));
|
if (USE_NEW_FREEDRAW_RENDERER) {
|
||||||
|
shapes.push(generateFreeDrawOvoidSvgPath(element) as SVGPathString);
|
||||||
|
} else {
|
||||||
|
shapes.push(getFreeDrawSvgPath(element));
|
||||||
|
}
|
||||||
|
|
||||||
return shapes;
|
return shapes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7882,6 +7882,14 @@ points-on-path@^0.2.1:
|
|||||||
path-data-parser "0.1.0"
|
path-data-parser "0.1.0"
|
||||||
points-on-curve "0.2.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:
|
portfinder@^1.0.28:
|
||||||
version "1.0.33"
|
version "1.0.33"
|
||||||
resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.33.tgz#03dbc51455aa8f83ad9fb86af8345e063bb51101"
|
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"
|
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
|
||||||
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
|
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:
|
split.js@^1.6.0:
|
||||||
version "1.6.5"
|
version "1.6.5"
|
||||||
resolved "https://registry.yarnpkg.com/split.js/-/split.js-1.6.5.tgz#f7f61da1044c9984cb42947df4de4fadb5a3f300"
|
resolved "https://registry.yarnpkg.com/split.js/-/split.js-1.6.5.tgz#f7f61da1044c9984cb42947df4de4fadb5a3f300"
|
||||||
|
|||||||
Reference in New Issue
Block a user