fix: Fixes and SVG parity
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
@@ -634,8 +634,9 @@ const drawFreeDrawSegments = (
|
|||||||
// samples. Only looks backward [i-R .. i], so a newly-arrived point never
|
// samples. Only looks backward [i-R .. i], so a newly-arrived point never
|
||||||
// retroactively changes the smoothed pressure of any previously rendered
|
// retroactively changes the smoothed pressure of any previously rendered
|
||||||
// segment. This ensures live and final renders are identical at all points.
|
// segment. This ensures live and final renders are identical at all points.
|
||||||
|
// When simulatePressure is true, constant pressure is used for all points.
|
||||||
const getSmoothedPressure = (i: number): number => {
|
const getSmoothedPressure = (i: number): number => {
|
||||||
if (pressures.length === 0) {
|
if (element.simulatePressure || pressures.length === 0) {
|
||||||
return DEFAULT_FREEDRAW_PRESSURE;
|
return DEFAULT_FREEDRAW_PRESSURE;
|
||||||
}
|
}
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
|
|||||||
+240
-44
@@ -28,6 +28,12 @@ import {
|
|||||||
|
|
||||||
import { RoughGenerator } from "roughjs/bin/generator";
|
import { RoughGenerator } from "roughjs/bin/generator";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ElementShape,
|
||||||
|
ElementShapes,
|
||||||
|
SVGPathString,
|
||||||
|
} from "@excalidraw/excalidraw/scene/types";
|
||||||
|
|
||||||
import type { GlobalPoint } from "@excalidraw/math";
|
import type { GlobalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
@@ -36,11 +42,6 @@ import type {
|
|||||||
AppState,
|
AppState,
|
||||||
EmbedsValidationStatus,
|
EmbedsValidationStatus,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
import type {
|
|
||||||
ElementShape,
|
|
||||||
ElementShapes,
|
|
||||||
SVGPathString,
|
|
||||||
} from "@excalidraw/excalidraw/scene/types";
|
|
||||||
|
|
||||||
import { elementWithCanvasCache } from "./renderElement";
|
import { elementWithCanvasCache } from "./renderElement";
|
||||||
|
|
||||||
@@ -980,8 +981,8 @@ const _generateElementShape = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// (2) stroke
|
// (2) stroke — one path element per capsule, mirrors canvas fill() calls
|
||||||
shapes.push(getFreeDrawSvgPath(element));
|
shapes.push(...getFreeDrawCapsulePaths(element));
|
||||||
|
|
||||||
return shapes;
|
return shapes;
|
||||||
}
|
}
|
||||||
@@ -1171,11 +1172,238 @@ export const toggleLinePolygonState = (
|
|||||||
// freedraw shape helper
|
// freedraw shape helper
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// NOTE not cached (-> for SVG export)
|
// ─── Capsule-based SVG export (mirrors renderElement.ts) ─────────────────────
|
||||||
const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
|
// These constants and helpers must be kept in sync with renderElement.ts.
|
||||||
return getSvgPathFromStroke(
|
const FREEDRAW_DEFAULT_PRESSURE = 0.5;
|
||||||
getFreedrawOutlinePoints(element),
|
const FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING = 3;
|
||||||
) as SVGPathString;
|
const FREEDRAW_PRESSURE_SMOOTHING_RADIUS = 6;
|
||||||
|
|
||||||
|
/** Round to 2 dp — sub-pixel accuracy at SVG 96 dpi, much shorter strings. */
|
||||||
|
const r2 = (v: number) => Math.round(v * 100) / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG path `d` string for a single tapered capsule — the SVG counterpart of
|
||||||
|
* `drawTaperedCapsule` in renderElement.ts. Uses clockwise arcs (sweep=1)
|
||||||
|
* so the geometry matches the canvas 2D `arc(..., anticlockwise=false)` calls.
|
||||||
|
*/
|
||||||
|
const freedrawTaperedCapsulePath = (
|
||||||
|
x0: number,
|
||||||
|
y0: number,
|
||||||
|
r0: number,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
r1: number,
|
||||||
|
): string => {
|
||||||
|
const dx = x1 - x0;
|
||||||
|
const dy = y1 - y0;
|
||||||
|
const len = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const r = Math.max(r0, r1);
|
||||||
|
|
||||||
|
if (len < r / 2) {
|
||||||
|
// Degenerate — full circle at midpoint via two clockwise 180° arcs.
|
||||||
|
const cx = r2((x0 + x1) / 2);
|
||||||
|
const cy = r2((y0 + y1) / 2);
|
||||||
|
const rr = r2(r);
|
||||||
|
return (
|
||||||
|
`M ${(cx - rr).toFixed(2)} ${cy.toFixed(2)} ` +
|
||||||
|
`A ${rr} ${rr} 0 1 1 ${(cx + rr).toFixed(2)} ${cy.toFixed(2)} ` +
|
||||||
|
`A ${rr} ${rr} 0 1 1 ${(cx - rr).toFixed(2)} ${cy.toFixed(2)} Z`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const px = -dy / len; // perpendicular unit x
|
||||||
|
const py = dx / len; // perpendicular unit y
|
||||||
|
|
||||||
|
// P0 ± perp·r0 (start / back cap tangent points)
|
||||||
|
const b0x = r2(x0 + px * r0);
|
||||||
|
const b0y = r2(y0 + py * r0);
|
||||||
|
const b1x = r2(x0 - px * r0);
|
||||||
|
const b1y = r2(y0 - py * r0);
|
||||||
|
// P1 ± perp·r1 (end / front cap tangent points)
|
||||||
|
const f0x = r2(x1 - px * r1);
|
||||||
|
const f0y = r2(y1 - py * r1);
|
||||||
|
const f1x = r2(x1 + px * r1);
|
||||||
|
const f1y = r2(y1 + py * r1);
|
||||||
|
const rr0 = r2(r0);
|
||||||
|
const rr1 = r2(r1);
|
||||||
|
|
||||||
|
// Back cap: clockwise 180° arc from (b0) to (b1) around P0.
|
||||||
|
// Front cap: clockwise 180° arc from (f0) to (f1) around P1.
|
||||||
|
return (
|
||||||
|
`M ${b0x.toFixed(2)} ${b0y.toFixed(2)} ` +
|
||||||
|
`A ${rr0.toFixed(2)} ${rr0.toFixed(2)} 0 1 1 ${b1x.toFixed(
|
||||||
|
2,
|
||||||
|
)} ${b1y.toFixed(2)} ` +
|
||||||
|
`L ${f0x.toFixed(2)} ${f0y.toFixed(2)} ` +
|
||||||
|
`A ${rr1.toFixed(2)} ${rr1.toFixed(2)} 0 1 1 ${f1x.toFixed(
|
||||||
|
2,
|
||||||
|
)} ${f1y.toFixed(2)} Z`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catmull-Rom tangent at points[i] — identical math to `getCatmullRomTangent`
|
||||||
|
* in renderElement.ts (predictedPoint is not needed for finalised strokes).
|
||||||
|
*/
|
||||||
|
const freedrawCatmullRomTangent = (
|
||||||
|
points: readonly (readonly [number, number])[],
|
||||||
|
i: number,
|
||||||
|
): [number, number] => {
|
||||||
|
const N = points.length;
|
||||||
|
const cur = points[i];
|
||||||
|
|
||||||
|
let next: readonly [number, number];
|
||||||
|
if (i < N - 1) {
|
||||||
|
next = points[i + 1];
|
||||||
|
} else {
|
||||||
|
const prev2 = i > 0 ? points[i - 1] : cur;
|
||||||
|
next = [2 * cur[0] - prev2[0], 2 * cur[1] - prev2[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx: number;
|
||||||
|
let ty: number;
|
||||||
|
if (i === 0) {
|
||||||
|
tx = (next[0] - cur[0]) * 0.5;
|
||||||
|
ty = (next[1] - cur[1]) * 0.5;
|
||||||
|
} else {
|
||||||
|
const prev = points[i - 1];
|
||||||
|
tx = (next[0] - prev[0]) * 0.5;
|
||||||
|
ty = (next[1] - prev[1]) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chord-length clamping (PCHIP-style).
|
||||||
|
const magSq = tx * tx + ty * ty;
|
||||||
|
if (magSq > 0) {
|
||||||
|
const dNx = next[0] - cur[0];
|
||||||
|
const dNy = next[1] - cur[1];
|
||||||
|
const chordNext = Math.sqrt(dNx * dNx + dNy * dNy);
|
||||||
|
let chordPrev = chordNext;
|
||||||
|
if (i > 0) {
|
||||||
|
const prev = points[i - 1];
|
||||||
|
const dPx = cur[0] - prev[0];
|
||||||
|
const dPy = cur[1] - prev[1];
|
||||||
|
chordPrev = Math.sqrt(dPx * dPx + dPy * dPy);
|
||||||
|
}
|
||||||
|
const maxMag = 3 * Math.min(chordNext, chordPrev);
|
||||||
|
const mag = Math.sqrt(magSq);
|
||||||
|
if (mag > maxMag) {
|
||||||
|
const s = maxMag / mag;
|
||||||
|
tx *= s;
|
||||||
|
ty *= s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [tx, ty];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns one SVG path `d` string per tapered-capsule sub-segment for a
|
||||||
|
* freedraw element, using the same Catmull-Rom Bezier subdivision and pressure
|
||||||
|
* smoothing as the canvas renderer (`drawFreeDrawSegments` in renderElement.ts).
|
||||||
|
* Returning individual paths (rather than a single compound `d` string) avoids
|
||||||
|
* fill-rule artifacts that arise when overlapping subpaths invert the winding
|
||||||
|
* direction inside a compound path.
|
||||||
|
*/
|
||||||
|
const getFreeDrawCapsulePaths = (
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
): SVGPathString[] => {
|
||||||
|
const { points, pressures } = element;
|
||||||
|
const N = points.length;
|
||||||
|
const baseRadius = (element.strokeWidth * 1.25) / 2;
|
||||||
|
|
||||||
|
const getSmoothedPressure = (i: number): number => {
|
||||||
|
if (element.simulatePressure || pressures.length === 0) {
|
||||||
|
return FREEDRAW_DEFAULT_PRESSURE;
|
||||||
|
}
|
||||||
|
let sum = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
for (let k = -FREEDRAW_PRESSURE_SMOOTHING_RADIUS; k <= 0; k++) {
|
||||||
|
const idx = i + k;
|
||||||
|
if (idx < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const p =
|
||||||
|
idx < pressures.length ? pressures[idx] : FREEDRAW_DEFAULT_PRESSURE;
|
||||||
|
const w = FREEDRAW_PRESSURE_SMOOTHING_RADIUS + 1 + k;
|
||||||
|
sum += p * w;
|
||||||
|
totalWeight += w;
|
||||||
|
}
|
||||||
|
return totalWeight > 0 ? sum / totalWeight : FREEDRAW_DEFAULT_PRESSURE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const paths: SVGPathString[] = [];
|
||||||
|
|
||||||
|
if (N === 1) {
|
||||||
|
// Single-point stroke — filled circle.
|
||||||
|
const rr = r2(baseRadius * getSmoothedPressure(0) * 2);
|
||||||
|
const cx = r2(points[0][0]);
|
||||||
|
const cy = r2(points[0][1]);
|
||||||
|
paths.push(
|
||||||
|
`M ${(cx - rr).toFixed(2)} ${cy.toFixed(2)} A ${rr} ${rr} 0 1 1 ${(
|
||||||
|
cx + rr
|
||||||
|
).toFixed(2)} ${cy.toFixed(2)} A ${rr} ${rr} 0 1 1 ${(cx - rr).toFixed(
|
||||||
|
2,
|
||||||
|
)} ${cy.toFixed(2)} Z` as SVGPathString,
|
||||||
|
);
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < N; i++) {
|
||||||
|
const p0 = points[i - 1];
|
||||||
|
const p1 = points[i];
|
||||||
|
const r0 = baseRadius * getSmoothedPressure(i - 1) * 2;
|
||||||
|
const r1 = baseRadius * getSmoothedPressure(i) * 2;
|
||||||
|
|
||||||
|
const t0 = freedrawCatmullRomTangent(points, i - 1);
|
||||||
|
const t1 = freedrawCatmullRomTangent(points, i);
|
||||||
|
|
||||||
|
// Bezier subdivision (mirrors drawSubdividedSegment).
|
||||||
|
const segLen = Math.sqrt((p1[0] - p0[0]) ** 2 + (p1[1] - p0[1]) ** 2);
|
||||||
|
const nSubdiv = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(segLen / FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cp1x = p0[0] + t0[0] / 3;
|
||||||
|
const cp1y = p0[1] + t0[1] / 3;
|
||||||
|
const cp2x = p1[0] - t1[0] / 3;
|
||||||
|
const cp2y = p1[1] - t1[1] / 3;
|
||||||
|
|
||||||
|
let prevX = p0[0];
|
||||||
|
let prevY = p0[1];
|
||||||
|
let prevR = r0;
|
||||||
|
|
||||||
|
for (let k = 1; k <= nSubdiv; k++) {
|
||||||
|
const t = k / nSubdiv;
|
||||||
|
const mt = 1 - t;
|
||||||
|
const mt2 = mt * mt;
|
||||||
|
const t2 = t * t;
|
||||||
|
const mt3 = mt2 * mt;
|
||||||
|
const t3 = t2 * t;
|
||||||
|
|
||||||
|
const x =
|
||||||
|
mt3 * p0[0] + 3 * mt2 * t * cp1x + 3 * mt * t2 * cp2x + t3 * p1[0];
|
||||||
|
const y =
|
||||||
|
mt3 * p0[1] + 3 * mt2 * t * cp1y + 3 * mt * t2 * cp2y + t3 * p1[1];
|
||||||
|
const r = r0 + (r1 - r0) * t;
|
||||||
|
|
||||||
|
paths.push(
|
||||||
|
freedrawTaperedCapsulePath(
|
||||||
|
prevX,
|
||||||
|
prevY,
|
||||||
|
prevR,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
r,
|
||||||
|
) as SVGPathString,
|
||||||
|
);
|
||||||
|
prevX = x;
|
||||||
|
prevY = y;
|
||||||
|
prevR = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFreedrawOutlinePoints = (
|
export const getFreedrawOutlinePoints = (
|
||||||
@@ -1199,36 +1427,4 @@ export const getFreedrawOutlinePoints = (
|
|||||||
}) as [number, number][];
|
}) as [number, number][];
|
||||||
};
|
};
|
||||||
|
|
||||||
const med = (A: number[], B: number[]) => {
|
|
||||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Trim SVG path data so number are each two decimal points. This
|
|
||||||
// improves SVG exports, and prevents rendering errors on points
|
|
||||||
// with long decimals.
|
|
||||||
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
|
||||||
|
|
||||||
const getSvgPathFromStroke = (points: number[][]): string => {
|
|
||||||
if (!points.length) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const max = points.length - 1;
|
|
||||||
|
|
||||||
return points
|
|
||||||
.reduce(
|
|
||||||
(acc, point, i, arr) => {
|
|
||||||
if (i === max) {
|
|
||||||
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
|
||||||
} else {
|
|
||||||
acc.push(point, med(point, arr[i + 1]));
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
["M", points[0], "Q"],
|
|
||||||
)
|
|
||||||
.join(" ")
|
|
||||||
.replace(TO_FIXED_PRECISION, "$1");
|
|
||||||
};
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -377,19 +377,21 @@ const renderElementToSvg = (
|
|||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
const wrapper = svgRoot.ownerDocument.createElementNS(SVG_NS, "g");
|
const wrapper = svgRoot.ownerDocument.createElementNS(SVG_NS, "g");
|
||||||
|
|
||||||
|
// Set fill once on the group so all capsule paths inherit it
|
||||||
|
// instead of repeating the attribute on every child element.
|
||||||
|
wrapper.setAttribute(
|
||||||
|
"fill",
|
||||||
|
renderConfig.theme === THEME.DARK
|
||||||
|
? applyDarkModeFilter(element.strokeColor)
|
||||||
|
: element.strokeColor,
|
||||||
|
);
|
||||||
|
|
||||||
const shapes = ShapeCache.generateElementShape(element, renderConfig);
|
const shapes = ShapeCache.generateElementShape(element, renderConfig);
|
||||||
// always ordered as [background, stroke]
|
// always ordered as [background, stroke]
|
||||||
for (const shape of shapes) {
|
for (const shape of shapes) {
|
||||||
if (typeof shape === "string") {
|
if (typeof shape === "string") {
|
||||||
// stroke (SVGPathString)
|
// stroke (SVGPathString) — fill inherited from wrapper <g>
|
||||||
|
|
||||||
const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path");
|
const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path");
|
||||||
path.setAttribute(
|
|
||||||
"fill",
|
|
||||||
renderConfig.theme === THEME.DARK
|
|
||||||
? applyDarkModeFilter(element.strokeColor)
|
|
||||||
: element.strokeColor,
|
|
||||||
);
|
|
||||||
path.setAttribute("d", shape);
|
path.setAttribute("d", shape);
|
||||||
wrapper.appendChild(path);
|
wrapper.appendChild(path);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user