diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 0e069e2893..922bd773f1 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -634,8 +634,9 @@ const drawFreeDrawSegments = ( // samples. Only looks backward [i-R .. i], so a newly-arrived point never // retroactively changes the smoothed pressure of any previously rendered // 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 => { - if (pressures.length === 0) { + if (element.simulatePressure || pressures.length === 0) { return DEFAULT_FREEDRAW_PRESSURE; } let sum = 0; diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts index 158ace7519..b90bf83e2d 100644 --- a/packages/element/src/shape.ts +++ b/packages/element/src/shape.ts @@ -28,6 +28,12 @@ import { import { RoughGenerator } from "roughjs/bin/generator"; +import type { + ElementShape, + ElementShapes, + SVGPathString, +} from "@excalidraw/excalidraw/scene/types"; + import type { GlobalPoint } from "@excalidraw/math"; import type { Mutable } from "@excalidraw/common/utility-types"; @@ -36,11 +42,6 @@ import type { AppState, EmbedsValidationStatus, } from "@excalidraw/excalidraw/types"; -import type { - ElementShape, - ElementShapes, - SVGPathString, -} from "@excalidraw/excalidraw/scene/types"; import { elementWithCanvasCache } from "./renderElement"; @@ -980,8 +981,8 @@ const _generateElementShape = ( ); } - // (2) stroke - shapes.push(getFreeDrawSvgPath(element)); + // (2) stroke — one path element per capsule, mirrors canvas fill() calls + shapes.push(...getFreeDrawCapsulePaths(element)); return shapes; } @@ -1171,11 +1172,238 @@ export const toggleLinePolygonState = ( // freedraw shape helper // ----------------------------------------------------------------------------- -// NOTE not cached (-> for SVG export) -const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => { - return getSvgPathFromStroke( - getFreedrawOutlinePoints(element), - ) as SVGPathString; +// ─── Capsule-based SVG export (mirrors renderElement.ts) ───────────────────── +// These constants and helpers must be kept in sync with renderElement.ts. +const FREEDRAW_DEFAULT_PRESSURE = 0.5; +const FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING = 3; +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 = ( @@ -1199,36 +1427,4 @@ export const getFreedrawOutlinePoints = ( }) 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"); -}; - // ----------------------------------------------------------------------------- diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index de9be13c83..2cc632411c 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -377,19 +377,21 @@ const renderElementToSvg = ( case "freedraw": { 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); // always ordered as [background, stroke] for (const shape of shapes) { if (typeof shape === "string") { - // stroke (SVGPathString) - + // stroke (SVGPathString) — fill inherited from wrapper const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path"); - path.setAttribute( - "fill", - renderConfig.theme === THEME.DARK - ? applyDarkModeFilter(element.strokeColor) - : element.strokeColor, - ); path.setAttribute("d", shape); wrapper.appendChild(path); } else {