fix: Endcap fix, but loop still closes
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
@@ -58,7 +58,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/common": "0.18.0",
|
"@excalidraw/common": "0.18.0",
|
||||||
"@excalidraw/math": "0.18.0",
|
"@excalidraw/math": "0.18.0"
|
||||||
"polygon-clipping": "^0.15.7"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import {
|
|||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
vectorFromPoint,
|
vectorFromPoint,
|
||||||
vectorNormalize,
|
vectorNormalize,
|
||||||
|
lineSegment,
|
||||||
|
type GlobalPoint,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
import { debugDrawLine, debugDrawPolygon } from "@excalidraw/common";
|
||||||
import polygonClipping from "polygon-clipping";
|
import polygonClipping from "polygon-clipping";
|
||||||
|
|
||||||
import type { Polygon, MultiPolygon, Ring } from "polygon-clipping";
|
import type { Polygon, MultiPolygon, Ring } from "polygon-clipping";
|
||||||
|
|
||||||
import type { ExcalidrawFreeDrawElement } from "./types";
|
import type { ExcalidrawFreeDrawElement } from "./types";
|
||||||
|
|
||||||
// Number of segments to approximate each semicircular cap
|
// Number of segments to approximate each semicircular cap
|
||||||
@@ -21,6 +22,12 @@ const MIN_RADIUS = 0.5;
|
|||||||
// Pressure to radius multiplier (scaled by strokeWidth)
|
// Pressure to radius multiplier (scaled by strokeWidth)
|
||||||
const PRESSURE_RADIUS_MULTIPLIER = 2.0;
|
const PRESSURE_RADIUS_MULTIPLIER = 2.0;
|
||||||
|
|
||||||
|
// Minimum distance between points to avoid numerical instability
|
||||||
|
const MIN_POINT_DISTANCE = 0.1;
|
||||||
|
|
||||||
|
// Epsilon for filtering near-duplicate points in polygons
|
||||||
|
const EPSILON = 0.01;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the radius for a point based on pressure and strokeWidth.
|
* Compute the radius for a point based on pressure and strokeWidth.
|
||||||
* Pressure is typically in [0, 1] range, default to 0.5 if simulating.
|
* Pressure is typically in [0, 1] range, default to 0.5 if simulating.
|
||||||
@@ -77,6 +84,8 @@ function generateArcPoints(
|
|||||||
* @param r1 - Radius at first point (from pressure)
|
* @param r1 - Radius at first point (from pressure)
|
||||||
* @param p2 - Second point
|
* @param p2 - Second point
|
||||||
* @param r2 - Radius at second point (from pressure)
|
* @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
|
* @returns Array of points forming the ovoid polygon
|
||||||
*/
|
*/
|
||||||
function createOvoid(
|
function createOvoid(
|
||||||
@@ -84,11 +93,13 @@ function createOvoid(
|
|||||||
r1: number,
|
r1: number,
|
||||||
p2: LocalPoint,
|
p2: LocalPoint,
|
||||||
r2: number,
|
r2: number,
|
||||||
|
forcePerpendicularCap1: boolean = false,
|
||||||
|
forcePerpendicularCap2: boolean = false,
|
||||||
): LocalPoint[] {
|
): LocalPoint[] {
|
||||||
const dist = pointDistance(p1, p2);
|
const dist = pointDistance(p1, p2);
|
||||||
|
|
||||||
// If points are too close, create a circle at the midpoint
|
// If points are too close, create a circle at the midpoint
|
||||||
if (dist < 0.001) {
|
if (dist < MIN_POINT_DISTANCE) {
|
||||||
const avgRadius = (r1 + r2) / 2;
|
const avgRadius = (r1 + r2) / 2;
|
||||||
return generateArcPoints(p1, avgRadius, 0, Math.PI * 2, CAP_SEGMENTS * 2);
|
return generateArcPoints(p1, avgRadius, 0, Math.PI * 2, CAP_SEGMENTS * 2);
|
||||||
}
|
}
|
||||||
@@ -112,28 +123,31 @@ function createOvoid(
|
|||||||
// Compute tangent points on each circle
|
// Compute tangent points on each circle
|
||||||
// The perpendicular offset needs to be adjusted for the tangent angle
|
// The perpendicular offset needs to be adjusted for the tangent angle
|
||||||
const tangentPerpAngle = baseAngle + Math.PI / 2 + tangentAngleOffset;
|
const tangentPerpAngle = baseAngle + Math.PI / 2 + tangentAngleOffset;
|
||||||
|
const purePerpAngle = baseAngle + Math.PI / 2;
|
||||||
|
|
||||||
// Cap at p1 (semicircle facing away from p2)
|
// Cap at p1 (semicircle facing AWAY from p2, i.e., toward baseAngle + PI)
|
||||||
// Goes from p1Right to p1Left (counterclockwise, facing back)
|
// Use perpendicular cap for stroke start, otherwise use tangent-adjusted
|
||||||
const cap1StartAngle = tangentPerpAngle + Math.PI;
|
const p1PerpAngle = forcePerpendicularCap1 ? purePerpAngle : tangentPerpAngle;
|
||||||
const cap1EndAngle = cap1StartAngle + Math.PI;
|
const p1RightAngle = p1PerpAngle;
|
||||||
|
const p1LeftAngle = p1PerpAngle + Math.PI;
|
||||||
const cap1Points = generateArcPoints(
|
const cap1Points = generateArcPoints(
|
||||||
p1,
|
p1,
|
||||||
r1,
|
r1,
|
||||||
cap1StartAngle,
|
p1RightAngle,
|
||||||
cap1EndAngle,
|
p1LeftAngle,
|
||||||
CAP_SEGMENTS,
|
CAP_SEGMENTS,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cap at p2 (semicircle facing away from p1)
|
// Cap at p2 (semicircle facing AWAY from p1, i.e., toward baseAngle)
|
||||||
// Goes from p2Left to p2Right (counterclockwise, facing forward)
|
// Use perpendicular cap for stroke end, otherwise use tangent-adjusted
|
||||||
const cap2StartAngle = tangentPerpAngle;
|
const p2PerpAngle = forcePerpendicularCap2 ? purePerpAngle : tangentPerpAngle;
|
||||||
const cap2EndAngle = cap2StartAngle + Math.PI;
|
const p2LeftAngle = p2PerpAngle + Math.PI;
|
||||||
|
const p2RightAngle = p2LeftAngle + Math.PI;
|
||||||
const cap2Points = generateArcPoints(
|
const cap2Points = generateArcPoints(
|
||||||
p2,
|
p2,
|
||||||
r2,
|
r2,
|
||||||
cap2StartAngle,
|
p2LeftAngle,
|
||||||
cap2EndAngle,
|
p2RightAngle,
|
||||||
CAP_SEGMENTS,
|
CAP_SEGMENTS,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -145,7 +159,48 @@ function createOvoid(
|
|||||||
...cap2Points, // p2's front cap
|
...cap2Points, // p2's front cap
|
||||||
];
|
];
|
||||||
|
|
||||||
return ovoidPoints;
|
// Filter out near-duplicate consecutive points to avoid numerical issues
|
||||||
|
return filterNearDuplicates(ovoidPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter out consecutive points that are too close together.
|
||||||
|
* This prevents numerical instability in polygon boolean operations.
|
||||||
|
*/
|
||||||
|
function filterNearDuplicates(points: LocalPoint[]): LocalPoint[] {
|
||||||
|
if (points.length < 2) {
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered: LocalPoint[] = [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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,7 +252,26 @@ function fromClipperResult(result: MultiPolygon): LocalPoint[][] {
|
|||||||
export function generateFreeDrawOvoidOutline(
|
export function generateFreeDrawOvoidOutline(
|
||||||
element: ExcalidrawFreeDrawElement,
|
element: ExcalidrawFreeDrawElement,
|
||||||
): [number, number][] {
|
): [number, number][] {
|
||||||
const { points, pressures, simulatePressure, strokeWidth } = element;
|
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) {
|
if (points.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -231,7 +305,27 @@ export function generateFreeDrawOvoidOutline(
|
|||||||
const r1 = getRadiusForPressure(pressure1, strokeWidth);
|
const r1 = getRadiusForPressure(pressure1, strokeWidth);
|
||||||
const r2 = getRadiusForPressure(pressure2, strokeWidth);
|
const r2 = getRadiusForPressure(pressure2, strokeWidth);
|
||||||
|
|
||||||
const ovoidPoints = createOvoid(p1, r1, p2, r2);
|
// Force perpendicular caps at stroke endpoints
|
||||||
|
const isFirstSegment = i === 0;
|
||||||
|
const isLastSegment = i === points.length - 2;
|
||||||
|
|
||||||
|
const ovoidPoints = createOvoid(
|
||||||
|
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) {
|
if (ovoidPoints.length >= 3) {
|
||||||
ovoids.push(toClipperPolygon(ovoidPoints));
|
ovoids.push(toClipperPolygon(ovoidPoints));
|
||||||
|
|||||||
Reference in New Issue
Block a user