@@ -144,6 +144,12 @@ export interface ExcalidrawElementWithCanvas {
|
|||||||
imageCrop: ExcalidrawImageElement["crop"] | null;
|
imageCrop: ExcalidrawImageElement["crop"] | null;
|
||||||
containingFrameOpacity: number;
|
containingFrameOpacity: number;
|
||||||
boundTextCanvas: HTMLCanvasElement;
|
boundTextCanvas: HTMLCanvasElement;
|
||||||
|
/**
|
||||||
|
* When set, overrides the canvas origin placement in drawElementFromCanvas.
|
||||||
|
* Used by the incremental freedraw rendering path. Scene coordinates.
|
||||||
|
*/
|
||||||
|
canvasOriginSceneX?: number;
|
||||||
|
canvasOriginSceneY?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cappedElementCanvasSize = (
|
const cappedElementCanvasSize = (
|
||||||
@@ -384,6 +390,51 @@ const drawImagePlaceholder = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws freedraw points as individual rounded capsule segments.
|
||||||
|
* Each segment [points[i-1] → points[i]] is an independent stroke with
|
||||||
|
* round lineCap so adjacent caps overlap, producing paint-splat splotches.
|
||||||
|
*
|
||||||
|
* @param fromIndex Draw segments starting from this index (inclusive).
|
||||||
|
* Segments are from max(fromIndex,1) to points.length-1.
|
||||||
|
* Pass 0 to draw everything.
|
||||||
|
*/
|
||||||
|
const drawFreeDrawSegments = (
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
|
fromIndex: number,
|
||||||
|
) => {
|
||||||
|
const { points } = element;
|
||||||
|
const strokeColor =
|
||||||
|
renderConfig.theme === THEME.DARK
|
||||||
|
? applyDarkModeFilter(element.strokeColor)
|
||||||
|
: element.strokeColor;
|
||||||
|
|
||||||
|
context.strokeStyle = strokeColor;
|
||||||
|
context.fillStyle = strokeColor;
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.lineWidth = element.strokeWidth * 4.25;
|
||||||
|
|
||||||
|
if (fromIndex === 0 && points.length === 1) {
|
||||||
|
// Single-point stroke → filled circle (dot)
|
||||||
|
const r = (element.strokeWidth * 4.25) / 2;
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(points[0][0], points[0][1], r, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(fromIndex, 1);
|
||||||
|
for (let i = start; i < points.length; i++) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(points[i - 1][0], points[i - 1][1]);
|
||||||
|
context.lineTo(points[i][0], points[i][1]);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const drawElementOnCanvas = (
|
const drawElementOnCanvas = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
rc: RoughCanvas,
|
rc: RoughCanvas,
|
||||||
@@ -415,23 +466,8 @@ const drawElementOnCanvas = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
// Draw directly to canvas
|
|
||||||
context.save();
|
context.save();
|
||||||
|
drawFreeDrawSegments(element, context, renderConfig, 0);
|
||||||
const shapes = ShapeCache.generateElementShape(element, renderConfig);
|
|
||||||
|
|
||||||
for (const shape of shapes) {
|
|
||||||
if (typeof shape === "string") {
|
|
||||||
context.fillStyle =
|
|
||||||
renderConfig.theme === THEME.DARK
|
|
||||||
? applyDarkModeFilter(element.strokeColor)
|
|
||||||
: element.strokeColor;
|
|
||||||
context.fill(new Path2D(shape));
|
|
||||||
} else {
|
|
||||||
rc.draw(shape);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -605,6 +641,204 @@ export const elementWithCanvasCache = new WeakMap<
|
|||||||
ExcalidrawElementWithCanvas
|
ExcalidrawElementWithCanvas
|
||||||
>();
|
>();
|
||||||
|
|
||||||
|
// ─── Incremental freedraw canvas cache ────────────────────────────────────────
|
||||||
|
// A separate WeakMap that survives ShapeCache.delete() calls so that the raster
|
||||||
|
// accumulates new capsule segments without full regeneration on every added point.
|
||||||
|
|
||||||
|
const FREEDRAW_CANVAS_OVERSHOOT_MIN = 200; // scene units — minimum extra space on each side
|
||||||
|
const FREEDRAW_CANVAS_OVERSHOOT_FACTOR = 0.5; // allocate current_dimension * factor extra on each side
|
||||||
|
|
||||||
|
interface FreeDrawIncrementalCanvas {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
lastRenderedPointCount: number;
|
||||||
|
canvasOriginSceneX: number;
|
||||||
|
canvasOriginSceneY: number;
|
||||||
|
/** Scene bounds guaranteed to be visible inside this canvas (without padding) */
|
||||||
|
canvasAllocX1: number;
|
||||||
|
canvasAllocY1: number;
|
||||||
|
canvasAllocX2: number;
|
||||||
|
canvasAllocY2: number;
|
||||||
|
scale: number;
|
||||||
|
theme: AppState["theme"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const freedrawIncrementalCache = new WeakMap<
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
FreeDrawIncrementalCanvas
|
||||||
|
>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates or incrementally updates a raster canvas for a freedraw element
|
||||||
|
* that is being actively drawn. Unlike the standard element canvas cache, this
|
||||||
|
* cache is NOT cleared on each mutateElement call, allowing new segments to be
|
||||||
|
* appended without full regeneration.
|
||||||
|
*
|
||||||
|
* When element bounds grow beyond the over-allocated canvas, the existing raster
|
||||||
|
* is copied into a new larger canvas before appending the next segments.
|
||||||
|
*/
|
||||||
|
const generateOrUpdateFreeDrawIncrementalCanvas = (
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
|
zoom: Zoom,
|
||||||
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
|
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||||
|
): ExcalidrawElementWithCanvas | null => {
|
||||||
|
const scale = zoom.value;
|
||||||
|
const dpr = window.devicePixelRatio;
|
||||||
|
const padding = getCanvasPadding(element);
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
const containingFrameOpacity =
|
||||||
|
getContainingFrame(element, elementsMap)?.opacity || 100;
|
||||||
|
|
||||||
|
const prevInc = freedrawIncrementalCache.get(element);
|
||||||
|
|
||||||
|
const boundsExceeded =
|
||||||
|
prevInc !== undefined &&
|
||||||
|
(x1 < prevInc.canvasAllocX1 ||
|
||||||
|
y1 < prevInc.canvasAllocY1 ||
|
||||||
|
x2 > prevInc.canvasAllocX2 ||
|
||||||
|
y2 > prevInc.canvasAllocY2);
|
||||||
|
|
||||||
|
const needsAlloc =
|
||||||
|
prevInc === undefined ||
|
||||||
|
boundsExceeded ||
|
||||||
|
prevInc.scale !== scale ||
|
||||||
|
prevInc.theme !== appState.theme;
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
let canvasOriginSceneX: number;
|
||||||
|
let canvasOriginSceneY: number;
|
||||||
|
let fromIndex: number;
|
||||||
|
let canvasScale: number;
|
||||||
|
|
||||||
|
if (needsAlloc) {
|
||||||
|
// Over-allocate proportionally to the current bounding box, like std::vector doubling,
|
||||||
|
// so fast large strokes trigger far fewer reallocations. The over-sized canvas is
|
||||||
|
// discarded on stroke finalisation so the memory waste is only transient.
|
||||||
|
const overshootX = Math.max(
|
||||||
|
FREEDRAW_CANVAS_OVERSHOOT_MIN,
|
||||||
|
(x2 - x1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR,
|
||||||
|
);
|
||||||
|
const overshootY = Math.max(
|
||||||
|
FREEDRAW_CANVAS_OVERSHOOT_MIN,
|
||||||
|
(y2 - y1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR,
|
||||||
|
);
|
||||||
|
const allocX1 = x1 - overshootX;
|
||||||
|
const allocY1 = y1 - overshootY;
|
||||||
|
const allocX2 = x2 + overshootX;
|
||||||
|
const allocY2 = y2 + overshootY;
|
||||||
|
|
||||||
|
canvasOriginSceneX = allocX1 - padding / dpr;
|
||||||
|
canvasOriginSceneY = allocY1 - padding / dpr;
|
||||||
|
|
||||||
|
// Raw canvas pixels before zoom scale
|
||||||
|
const rawW = (allocX2 - allocX1) * dpr + padding * 2;
|
||||||
|
const rawH = (allocY2 - allocY1) * dpr + padding * 2;
|
||||||
|
|
||||||
|
// Respect browser canvas size limits
|
||||||
|
const AREA_LIMIT = 16777216;
|
||||||
|
const WIDTH_HEIGHT_LIMIT = 32767;
|
||||||
|
canvasScale = scale;
|
||||||
|
if (
|
||||||
|
rawW * canvasScale > WIDTH_HEIGHT_LIMIT ||
|
||||||
|
rawH * canvasScale > WIDTH_HEIGHT_LIMIT
|
||||||
|
) {
|
||||||
|
canvasScale = Math.min(
|
||||||
|
WIDTH_HEIGHT_LIMIT / rawW,
|
||||||
|
WIDTH_HEIGHT_LIMIT / rawH,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rawW * rawH * canvasScale * canvasScale > AREA_LIMIT) {
|
||||||
|
canvasScale = Math.sqrt(AREA_LIMIT / (rawW * rawH));
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasWidth = Math.floor(rawW * canvasScale);
|
||||||
|
const canvasHeight = Math.floor(rawH * canvasScale);
|
||||||
|
if (!canvasWidth || !canvasHeight) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas = document.createElement("canvas");
|
||||||
|
canvas.width = canvasWidth;
|
||||||
|
canvas.height = canvasHeight;
|
||||||
|
const context = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
if (
|
||||||
|
prevInc !== undefined &&
|
||||||
|
boundsExceeded &&
|
||||||
|
prevInc.scale === canvasScale &&
|
||||||
|
prevInc.theme === appState.theme
|
||||||
|
) {
|
||||||
|
// Copy existing raster to the new canvas at the correct pixel offset.
|
||||||
|
// The shift is determined by how much the canvas origin moved in scene coords.
|
||||||
|
const copyX =
|
||||||
|
(prevInc.canvasOriginSceneX - canvasOriginSceneX) * dpr * canvasScale;
|
||||||
|
const copyY =
|
||||||
|
(prevInc.canvasOriginSceneY - canvasOriginSceneY) * dpr * canvasScale;
|
||||||
|
context.drawImage(prevInc.canvas, copyX, copyY);
|
||||||
|
fromIndex = prevInc.lastRenderedPointCount;
|
||||||
|
} else {
|
||||||
|
// Full regeneration on first canvas, zoom change, or theme change
|
||||||
|
fromIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
freedrawIncrementalCache.set(element, {
|
||||||
|
canvas,
|
||||||
|
lastRenderedPointCount: fromIndex,
|
||||||
|
canvasOriginSceneX,
|
||||||
|
canvasOriginSceneY,
|
||||||
|
canvasAllocX1: allocX1,
|
||||||
|
canvasAllocY1: allocY1,
|
||||||
|
canvasAllocX2: allocX2,
|
||||||
|
canvasAllocY2: allocY2,
|
||||||
|
scale: canvasScale,
|
||||||
|
theme: appState.theme,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
canvas = prevInc.canvas;
|
||||||
|
canvasOriginSceneX = prevInc.canvasOriginSceneX;
|
||||||
|
canvasOriginSceneY = prevInc.canvasOriginSceneY;
|
||||||
|
fromIndex = prevInc.lastRenderedPointCount;
|
||||||
|
canvasScale = prevInc.scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append only the segments added since the last render
|
||||||
|
if (fromIndex < element.points.length) {
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
const canvasElemOffsetX =
|
||||||
|
(element.x - canvasOriginSceneX) * dpr * canvasScale;
|
||||||
|
const canvasElemOffsetY =
|
||||||
|
(element.y - canvasOriginSceneY) * dpr * canvasScale;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(canvasElemOffsetX, canvasElemOffsetY);
|
||||||
|
ctx.scale(dpr * canvasScale, dpr * canvasScale);
|
||||||
|
drawFreeDrawSegments(element, ctx, renderConfig, fromIndex);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Update lastRenderedPointCount in the cache entry
|
||||||
|
const inc = freedrawIncrementalCache.get(element)!;
|
||||||
|
inc.lastRenderedPointCount = element.points.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inc = freedrawIncrementalCache.get(element)!;
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
canvas,
|
||||||
|
theme: appState.theme,
|
||||||
|
scale: canvasScale,
|
||||||
|
angle: element.angle,
|
||||||
|
zoomValue: zoom.value,
|
||||||
|
canvasOffsetX: 0,
|
||||||
|
canvasOffsetY: 0,
|
||||||
|
boundTextElementVersion: null,
|
||||||
|
imageCrop: null,
|
||||||
|
containingFrameOpacity,
|
||||||
|
boundTextCanvas: document.createElement("canvas"),
|
||||||
|
canvasOriginSceneX: inc.canvasOriginSceneX,
|
||||||
|
canvasOriginSceneY: inc.canvasOriginSceneY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const generateElementWithCanvas = (
|
const generateElementWithCanvas = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
@@ -616,6 +850,24 @@ const generateElementWithCanvas = (
|
|||||||
: {
|
: {
|
||||||
value: 1 as NormalizedZoomValue,
|
value: 1 as NormalizedZoomValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Incremental rendering path for freedraw elements being actively drawn.
|
||||||
|
// ShapeCache.delete() clears elementWithCanvasCache on every added point, so
|
||||||
|
// we bypass that cache entirely and use freedrawIncrementalCache instead.
|
||||||
|
if (
|
||||||
|
isFreeDrawElement(element) &&
|
||||||
|
"newElement" in appState &&
|
||||||
|
appState.newElement?.id === element.id
|
||||||
|
) {
|
||||||
|
return generateOrUpdateFreeDrawIncrementalCanvas(
|
||||||
|
element as ExcalidrawFreeDrawElement,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
|
renderConfig,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const prevElementWithCanvas = elementWithCanvasCache.get(element);
|
const prevElementWithCanvas = elementWithCanvasCache.get(element);
|
||||||
const shouldRegenerateBecauseZoom =
|
const shouldRegenerateBecauseZoom =
|
||||||
prevElementWithCanvas &&
|
prevElementWithCanvas &&
|
||||||
@@ -718,12 +970,22 @@ const drawElementFromCanvas = (
|
|||||||
// revert afterwards we don't have account for it during drawing
|
// revert afterwards we don't have account for it during drawing
|
||||||
context.translate(-cx, -cy);
|
context.translate(-cx, -cy);
|
||||||
|
|
||||||
|
// For the incremental freedraw path, the canvas origin is stored explicitly
|
||||||
|
// because the canvas is over-allocated beyond the tight element bounds.
|
||||||
|
const destX =
|
||||||
|
elementWithCanvas.canvasOriginSceneX !== undefined
|
||||||
|
? (elementWithCanvas.canvasOriginSceneX + appState.scrollX) *
|
||||||
|
window.devicePixelRatio
|
||||||
|
: (x1 + appState.scrollX) * window.devicePixelRatio - padding;
|
||||||
|
const destY =
|
||||||
|
elementWithCanvas.canvasOriginSceneY !== undefined
|
||||||
|
? (elementWithCanvas.canvasOriginSceneY + appState.scrollY) *
|
||||||
|
window.devicePixelRatio
|
||||||
|
: (y1 + appState.scrollY) * window.devicePixelRatio - padding;
|
||||||
context.drawImage(
|
context.drawImage(
|
||||||
elementWithCanvas.canvas!,
|
elementWithCanvas.canvas!,
|
||||||
(x1 + appState.scrollX) * window.devicePixelRatio -
|
destX,
|
||||||
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
destY,
|
||||||
(y1 + appState.scrollY) * window.devicePixelRatio -
|
|
||||||
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
|
||||||
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
|
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
|
||||||
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
|
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
|
||||||
);
|
);
|
||||||
@@ -753,6 +1015,17 @@ const drawElementFromCanvas = (
|
|||||||
// Clear the nested element we appended to the DOM
|
// Clear the nested element we appended to the DOM
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the incremental freedraw canvas for the given element.
|
||||||
|
* Call this when a freedraw stroke is finalised so the next render
|
||||||
|
* produces a fresh tight-bounds canvas instead of the over-allocated one.
|
||||||
|
*/
|
||||||
|
export const invalidateFreeDrawIncrementalCanvas = (
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
) => {
|
||||||
|
freedrawIncrementalCache.delete(element);
|
||||||
|
};
|
||||||
|
|
||||||
export const renderSelectionElement = (
|
export const renderSelectionElement = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
isLineElement,
|
isLineElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { invalidateFreeDrawIncrementalCanvas } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
KEYS,
|
KEYS,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
@@ -313,6 +315,10 @@ export const actionFinalize = register<FormData>({
|
|||||||
}
|
}
|
||||||
: selectedLinearElement;
|
: selectedLinearElement;
|
||||||
|
|
||||||
|
if (element && isFreeDrawElement(element)) {
|
||||||
|
invalidateFreeDrawIncrementalCanvas(element);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
appState: {
|
appState: {
|
||||||
|
|||||||
@@ -10171,23 +10171,46 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newElement.type === "freedraw") {
|
if (newElement.type === "freedraw") {
|
||||||
const points = newElement.points;
|
// Collect all coalesced pointer positions that accumulated between
|
||||||
const dx = pointerCoords.x - newElement.x;
|
// RAF frames (important for high-frequency Apple Pencil / stylus
|
||||||
const dy = pointerCoords.y - newElement.y;
|
// input). getCoalescedEvents() returns the intermediate samples that
|
||||||
|
// would otherwise be discarded by throttleRAF, giving a dense stroke
|
||||||
|
// without extra renders.
|
||||||
|
const coalescedEvents: PointerEvent[] =
|
||||||
|
event.getCoalescedEvents?.() ?? [];
|
||||||
|
// The final event is always included; coalesced list may duplicate it
|
||||||
|
const allEvents =
|
||||||
|
coalescedEvents.length > 0 ? coalescedEvents : [event];
|
||||||
|
|
||||||
const lastPoint = points.length > 0 && points[points.length - 1];
|
const newPoints: LocalPoint[] = [];
|
||||||
const discardPoint =
|
const newPressures: number[] = [];
|
||||||
lastPoint && lastPoint[0] === dx && lastPoint[1] === dy;
|
|
||||||
|
|
||||||
if (!discardPoint) {
|
let lastPoint =
|
||||||
|
newElement.points.length > 0
|
||||||
|
? newElement.points[newElement.points.length - 1]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
for (const ev of allEvents) {
|
||||||
|
const coords = viewportCoordsToSceneCoords(ev, this.state);
|
||||||
|
const dx = coords.x - newElement.x;
|
||||||
|
const dy = coords.y - newElement.y;
|
||||||
|
if (!lastPoint || lastPoint[0] !== dx || lastPoint[1] !== dy) {
|
||||||
|
const pt = pointFrom<LocalPoint>(dx, dy);
|
||||||
|
newPoints.push(pt);
|
||||||
|
newPressures.push(ev.pressure);
|
||||||
|
lastPoint = pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPoints.length > 0) {
|
||||||
const pressures = newElement.simulatePressure
|
const pressures = newElement.simulatePressure
|
||||||
? newElement.pressures
|
? newElement.pressures
|
||||||
: [...newElement.pressures, event.pressure];
|
: [...newElement.pressures, ...newPressures];
|
||||||
|
|
||||||
this.scene.mutateElement(
|
this.scene.mutateElement(
|
||||||
newElement,
|
newElement,
|
||||||
{
|
{
|
||||||
points: [...points, pointFrom<LocalPoint>(dx, dy)],
|
points: [...newElement.points, ...newPoints],
|
||||||
pressures,
|
pressures,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user