import { Bezier } from 'bezier-js'; import * as fitCurve from 'fit-curve'; import * as _ from 'lodash'; import { action, makeObservable, observable, reaction, runInAction } from 'mobx'; import { Doc, NumListCast, Opt } from '../../fields/Doc'; import { InkData, InkField, InkTool } from '../../fields/InkField'; import { List } from '../../fields/List'; import { listSpec } from '../../fields/Schema'; import { Cast, NumCast } from '../../fields/Types'; import { PointData } from '../../pen-gestures/GestureTypes'; import { Point } from '../../pen-gestures/ndollar'; import { DocumentType } from '../documents/DocumentTypes'; import { undoable } from '../util/UndoManager'; import { FitOneCurve } from '../util/bezierFit'; import { InkingStroke } from './InkingStroke'; import { CollectionFreeFormView } from './collections/collectionFreeForm'; import { DocumentView } from './nodes/DocumentView'; export class InkStrokeProperties { // eslint-disable-next-line no-use-before-define static _Instance: InkStrokeProperties | undefined; public static get Instance() { return this._Instance || new InkStrokeProperties(); } @observable _controlButton = false; @observable _currentPoint = -1; constructor() { InkStrokeProperties._Instance = this; makeObservable(this); reaction( () => this._controlButton, button => { button && (Doc.ActiveTool = InkTool.None); } ); reaction( () => Doc.ActiveTool, tool => { tool !== InkTool.None && (this._controlButton = false); } ); } /** * Helper function that enables other functions to be applied to a particular ink instance. * @param func The inputted function. * @param requireCurrPoint Indicates whether the current selected point is needed. */ applyFunction = ( strokes: Opt, func: (view: DocumentView, ink: InkData, ptsXscale: number, ptsYscale: number, inkStrokeWidth: number) => { X: number; Y: number }[] | undefined, requireCurrPoint: boolean = false ) => { let appliedFunc = false; (strokes instanceof DocumentView ? [strokes] : strokes)?.forEach( action(inkView => { if (!requireCurrPoint || this._currentPoint !== -1) { const doc = inkView.Document; if (doc.type === DocumentType.INK && doc.width && doc.height) { const ink = Cast(doc.stroke, InkField)?.inkData; if (ink) { const oldXrange = (xs => ({ coord: NumCast(doc.x), min: Math.min(...xs), max: Math.max(...xs) }))(ink.map(p => p.X)); const oldYrange = (ys => ({ coord: NumCast(doc.y), min: Math.min(...ys), max: Math.max(...ys) }))(ink.map(p => p.Y)); const ptsXscale = (NumCast(doc._width) - NumCast(doc.stroke_width)) / (oldXrange.max - oldXrange.min || 1) || 1; const ptsYscale = (NumCast(doc._height) - NumCast(doc.stroke_width)) / (oldYrange.max - oldYrange.min || 1) || 1; const newPoints = func(inkView, ink, ptsXscale, ptsYscale, NumCast(doc.stroke_width)); if (newPoints) { const newXrange = (xs => ({ min: Math.min(...xs), max: Math.max(...xs) }))(newPoints.map(p => p.X)); const newYrange = (ys => ({ min: Math.min(...ys), max: Math.max(...ys) }))(newPoints.map(p => p.Y)); doc._width = (newXrange.max - newXrange.min) * ptsXscale + NumCast(doc.stroke_width); doc._height = (newYrange.max - newYrange.min) * ptsYscale + NumCast(doc.stroke_width); doc.x = oldXrange.coord + (newXrange.min - oldXrange.min) * ptsXscale; doc.y = oldYrange.coord + (newYrange.min - oldYrange.min) * ptsYscale; Doc.SetInPlace(doc, 'stroke', new InkField(newPoints), true); appliedFunc = true; } } } } }) ); return appliedFunc; }; /** * Adds a new control point to the ink instance when editing its format. * @param t T-Value of new control point * @param i index of first control point of segment being split * @param control The list of all control points of the ink. */ addPoints = undoable((inkView: DocumentView, t: number, i: number, controls: { X: number; Y: number }[]) => { this.applyFunction(inkView, (view: DocumentView /* , ink: InkData */) => { const doc = view.Document; const array = [controls[i], controls[i + 1], controls[i + 2], controls[i + 3]]; const newsegs = new Bezier(array.map(p => ({ x: p.X, y: p.Y }))).split(t); const splicepts = [...newsegs.left.points, ...newsegs.right.points]; controls.splice(i, 4, ...splicepts.map(p => ({ X: p.x, Y: p.y }))); // Updating the indices of the control points whose handle tangency has been broken. doc.brokenInkIndices = new List(NumListCast(doc.brokenInkIndices).map(control => (control > i ? control + 4 : control))); runInAction(() => { this._currentPoint = -1; }); return controls; }); }, 'add ink points'); /** * Scales a handle point of a control point that is adjacent to a newly added one. * @param isLeft Determines if the current control point is on the left or right side of the newly added one. * @param start Beginning index of curve from the left control point to the newly added one. * @param end Final index of curve from the newly added control point to its right neighbor. */ getScaledHandlePoint(isLeft: boolean, start: number, end: number, index: number, control: PointData, handle: PointData) { const prevSize = end - start; const newSize = isLeft ? index - start : end - index; const handleVector = { X: control.X - handle.X, Y: control.Y - handle.Y }; return { X: handleVector.X * (newSize / prevSize), Y: handleVector.Y * (newSize / prevSize) }; } /** * Determines the position of the handle points of a newly added control point by finding the * tangent vectors to the split curve at the new control. Given the properties of Bézier curves, * the tangent vector to a control point is equivalent to the first/last (depending on the direction * of the curve) leg of the Bézier curve's derivative. * (Source: https://pages.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/bezier-der.html) * * @param C The curve represented by all points from the previous control until the newly added point. * @param D The curve represented by all points from the newly added point to the next control. * @param newControl The newly added control point. */ getNewHandlePoints = (C: PointData[], D: PointData[], newControl: PointData) => { const [m, n] = [C.length, D.length]; let handleSizeA = Math.sqrt((newControl.X - C[0].X) ** 2 + (newControl.Y - C[0].Y) ** 2); let handleSizeB = Math.sqrt((D[n - 1].X - newControl.X) ** 2 + (D[n - 1].Y - newControl.Y) ** 2); // Scaling adjustments to improve the ratio between the magnitudes of the two handle lines. // (Ensures that the new point added doesn't augment the inital shape of the curve much). if (handleSizeA < 75 && handleSizeB < 75) { handleSizeA *= 3; handleSizeB *= 3; } if (Math.abs(handleSizeA - handleSizeB) < 50) { handleSizeA *= 5; handleSizeB *= 5; } else if (Math.abs(handleSizeA - handleSizeB) < 150) { handleSizeA *= 2; handleSizeB *= 2; } // Finding the last leg of the derivative curve of C. const dC = { X: (handleSizeA / n) * (C[m - 1].X - C[m - 2].X), Y: (handleSizeA / n) * (C[m - 1].Y - C[m - 2].Y) }; // Finding the first leg of the derivative curve of D. const dD = { X: (handleSizeB / m) * (D[1].X - D[0].X), Y: (handleSizeB / m) * (D[1].Y - D[0].Y) }; const handleA = { X: newControl.X - dC.X, Y: newControl.Y - dC.Y }; const handleB = { X: newControl.X + dD.X, Y: newControl.Y + dD.Y }; return [handleA, handleB]; }; /** * Deletes the current control point of the selected ink instance. */ deletePoints = undoable((inkView: DocumentView, preserve: boolean) => { this.applyFunction( inkView, (view: DocumentView, ink: InkData) => { const doc = view.Document; const newPoints = ink.slice(); const brokenIndices = NumListCast(doc.brokenInkIndices); if (preserve || this._currentPoint === 0 || this._currentPoint === ink.length - 1 || brokenIndices.includes(this._currentPoint)) { newPoints.splice(this._currentPoint === 0 ? 0 : this._currentPoint === ink.length - 1 ? this._currentPoint - 3 : this._currentPoint - 2, 4); } else { const start = this._currentPoint === 0 ? 0 : this._currentPoint - 4; const splicedPoints = ink.slice(start, start + (this._currentPoint === 0 || this._currentPoint === ink.length - 1 ? 4 : 8)); const samples: Point[] = []; let startDir = { x: 0, y: 0 }; let endDir = { x: 0, y: 0 }; for (let i = 0; i < splicedPoints.length / 4; i++) { const bez = new Bezier(splicedPoints.slice(i * 4, i * 4 + 4).map(p => ({ x: p.X, y: p.Y }))); if (i === 0) startDir = bez.derivative(0); if (i === splicedPoints.length / 4 - 1) endDir = bez.derivative(1); for (let t = 0; t < (i === splicedPoints.length / 4 - 1 ? 1 + 1e-7 : 1); t += 0.05) { const pt = bez.compute(t); samples.push(new Point(pt.x, pt.y)); } } const { finalCtrls, error } = FitOneCurve(samples, { X: startDir.x, Y: startDir.y }, { X: endDir.x, Y: endDir.y }); if (error < 100) { newPoints.splice(this._currentPoint - 4, 8, ...finalCtrls); } else { newPoints.splice(this._currentPoint - 2, 4); } } doc.brokenInkIndices = new List(brokenIndices.map(control => (control >= this._currentPoint ? control - 4 : control))); runInAction(() => { this._currentPoint = -1; }); return newPoints.length < 4 ? undefined : newPoints; }, true ); }, 'delete ink points'); /** * Rotates ink stroke(s) about a point * @param inkStrokes set of ink documentViews to rotate * @param angle The angle at which to rotate the ink in radians. * @param scrpt The center point of the rotation in screen coordinates */ rotateInk = undoable((inkStrokes: DocumentView[], angle: number, scrpt: PointData) => { angle && this.applyFunction(inkStrokes, (view: DocumentView, ink: InkData, xScale: number, yScale: number /* , inkStrokeWidth: number */) => { const inkCenterPt = view.ComponentView?.ptFromScreen?.(scrpt); return !inkCenterPt ? ink : ink.map(i => { const pt = { X: i.X - inkCenterPt.X, Y: i.Y - inkCenterPt.Y }; const newX = Math.cos(angle) * pt.X - (Math.sin(angle) * pt.Y * yScale) / xScale; const newY = (Math.sin(angle) * pt.X * xScale) / yScale + Math.cos(angle) * pt.Y; return { X: newX + inkCenterPt.X, Y: newY + inkCenterPt.Y }; }); }); }, 'rotate ink'); /** * Rotates ink stroke(s) about a point * @param inkStrokes set of ink documentViews to rotate * @param angle The angle at which to rotate the ink in radians. * @param scrpt The center point of the rotation in screen coordinates */ stretchInk = undoable((inkStrokes: DocumentView[], scaling: number, scrpt: PointData, scrVec: PointData, scaleUniformly: boolean) => { this.applyFunction(inkStrokes, (view: DocumentView, ink: InkData) => { const ptFromScreen = view.ComponentView?.ptFromScreen; const ptToScreen = view.ComponentView?.ptToScreen; return !ptToScreen || !ptFromScreen ? ink : ink.map(ptToScreen).map(i => { const pvec = { X: i.X - scrpt.X, Y: i.Y - scrpt.Y }; const svec = pvec.X * scrVec.X * scaling + pvec.Y * scrVec.Y * scaling; const ovec = -pvec.X * scrVec.Y * (scaleUniformly ? scaling : 1) + pvec.Y * scrVec.X * (scaleUniformly ? scaling : 1); const newscrpt = { X: scrpt.X + svec * scrVec.X - ovec * scrVec.Y, Y: scrpt.Y + svec * scrVec.Y + ovec * scrVec.X }; return ptFromScreen(newscrpt); }); }); }, 'stretch ink'); /** * Handles the movement/scaling of a control point. */ moveControlPtHandle = undoable((inkView: DocumentView, deltaX: number, deltaY: number, controlIndex: number, origInk?: InkData) => { inkView && this.applyFunction(inkView, (view: DocumentView, ink: InkData) => { const order = controlIndex % 4; const closed = InkingStroke.IsClosed(ink); const brokenIndices = NumListCast(inkView.Document.brokenInkIndices); if (origInk && this._currentPoint > 0 && this._currentPoint < ink.length - 1 && brokenIndices.findIndex(value => value === controlIndex) === -1) { const cptBefore = ink[controlIndex]; const cpt = { X: cptBefore.X + deltaX, Y: cptBefore.Y + deltaY }; const newink = origInk.slice(); const start = this._currentPoint === 0 ? 0 : this._currentPoint - 4; const splicedPoints = origInk.slice(start, start + (this._currentPoint === 0 || this._currentPoint === ink.length - 1 ? 4 : 8)); const { nearestT, nearestSeg } = InkStrokeProperties.nearestPtToStroke(splicedPoints, cpt); if ((nearestSeg === 0 && nearestT < 1e-1) || (nearestSeg === 4 && 1 - nearestT < 1e-1) || nearestSeg < 0) return ink.slice(); const samplesLeft: Point[] = []; const samplesRight: Point[] = []; let startDir = { x: 0, y: 0 }; let endDir = { x: 0, y: 0 }; for (let i = 0; i < nearestSeg / 4 + 1; i++) { const bez = new Bezier(splicedPoints.slice(i * 4, i * 4 + 4).map(p => ({ x: p.X, y: p.Y }))); if (i === 0) startDir = bez.derivative(_.isEqual(bez.derivative(0), { x: 0, y: 0, t: 0 }) ? 1e-8 : 0); if (i === nearestSeg / 4) endDir = bez.derivative(nearestT); for (let t = 0; t < (i === nearestSeg / 4 ? nearestT + 0.05 : 1); t += 0.05) { const pt = bez.compute(i !== nearestSeg / 4 ? t : Math.min(nearestT, t)); samplesLeft.push(new Point(pt.x, pt.y)); } } let { finalCtrls } = FitOneCurve(samplesLeft, { X: startDir.x, Y: startDir.y }, { X: endDir.x, Y: endDir.y }); for (let i = nearestSeg / 4; i < splicedPoints.length / 4; i++) { const bez = new Bezier(splicedPoints.slice(i * 4, i * 4 + 4).map(p => ({ x: p.X, y: p.Y }))); if (i === nearestSeg / 4) startDir = bez.derivative(nearestT); if (i === splicedPoints.length / 4 - 1) endDir = bez.derivative(_.isEqual(bez.derivative(1), { x: 0, y: 0, t: 1 }) ? 1 - 1e-8 : 1); for (let t = i === nearestSeg / 4 ? nearestT : 0; t < (i === nearestSeg / 4 ? 1 + 0.05 + 1e-7 : 1 + 1e-7); t += 0.05) { const pt = bez.compute(Math.min(1, t)); samplesRight.push(new Point(pt.x, pt.y)); } } const { finalCtrls: rightCtrls /* , error: errorRight */ } = FitOneCurve(samplesRight, { X: startDir.x, Y: startDir.y }, { X: endDir.x, Y: endDir.y }); finalCtrls = finalCtrls.concat(rightCtrls); newink.splice(this._currentPoint - 4, 8, ...finalCtrls); return newink; } return ink.map((pt, i) => { const leftHandlePoint = order === 0 && i === controlIndex + 1; const rightHandlePoint = order === 0 && controlIndex !== 0 && i === controlIndex - 2; if (controlIndex === i || (order === 0 && controlIndex !== 0 && i === controlIndex - 1) || (order === 3 && i === controlIndex - 1)) { return { X: pt.X + deltaX, Y: pt.Y + deltaY }; } if ( controlIndex === i || leftHandlePoint || rightHandlePoint || (order === 0 && controlIndex !== 0 && i === controlIndex - 1) || ((order === 0 || order === 3) && (controlIndex === 0 || controlIndex === ink.length - 1) && (i === 1 || i === ink.length - 2) && closed) || (order === 3 && i === controlIndex - 1) || (order === 3 && controlIndex !== ink.length - 1 && i === controlIndex + 1) || (order === 3 && controlIndex !== ink.length - 1 && i === controlIndex + 2) || (ink[0].X === ink[ink.length - 1].X && ink[0].Y === ink[ink.length - 1].Y && (i === 0 || i === ink.length - 1) && (controlIndex === 0 || controlIndex === ink.length - 1)) ) { return { X: pt.X + deltaX, Y: pt.Y + deltaY }; } return pt; }); }); }, 'move ink ctrl pt'); public static nearestPtToStroke(ctrlPoints: { X: number; Y: number }[], refInkSpacePt: { X: number; Y: number }, excludeSegs?: number[]) { let distance = Number.MAX_SAFE_INTEGER; let nearestT = -1; let nearestSeg = -1; let nearestPt = { X: 0, Y: 0 }; for (let i = 0; i < ctrlPoints.length - 3; i += 4) { if (excludeSegs?.includes(i)) continue; const array = [ctrlPoints[i], ctrlPoints[i + 1], ctrlPoints[i + 2], ctrlPoints[i + 3]]; const point = new Bezier(array.map(p => ({ x: p.X, y: p.Y }))).project({ x: refInkSpacePt.X, y: refInkSpacePt.Y }); if (point.t !== undefined) { const dist = Math.sqrt((point.x - refInkSpacePt.X) * (point.x - refInkSpacePt.X) + (point.y - refInkSpacePt.Y) * (point.y - refInkSpacePt.Y)); if (dist < distance) { distance = dist; nearestT = point.t; nearestSeg = i; nearestPt = { X: point.x, Y: point.y }; } } } return { distance, nearestT, nearestSeg, nearestPt }; } /** * Handles the movement/scaling of a control point. */ snapControl = (inkView: DocumentView, controlIndex: number) => { const inkDoc = inkView.Document; const ink = Cast(inkDoc[Doc.LayoutDataKey(inkDoc)], InkField)?.inkData; if (ink) { const screenDragPt = inkView.ComponentView?.ptToScreen?.(ink[controlIndex]); if (screenDragPt) { if (controlIndex === ink.length - 1) { const firstPtScr = inkView.ComponentView?.ptToScreen?.(ink[0]); if (firstPtScr && Math.sqrt((firstPtScr.X - screenDragPt.X) * (firstPtScr.X - screenDragPt.X) + (firstPtScr.Y - screenDragPt.Y) * (firstPtScr.Y - screenDragPt.Y)) < 7) { const deltaX = ink[0].X - ink[controlIndex].X; const deltaY = ink[0].Y - ink[controlIndex].Y; return this.moveControlPtHandle(inkView, deltaX, deltaY, controlIndex, ink.slice()); } } const snapData = this.snapToAllCurves(screenDragPt, inkView, { nearestPt: { X: 0, Y: 0 }, distance: 10 }, ink, controlIndex); if (snapData.distance < 10) { const deltaX = snapData.nearestPt.X - ink[controlIndex].X; const deltaY = snapData.nearestPt.Y - ink[controlIndex].Y; return this.moveControlPtHandle(inkView, deltaX, deltaY, controlIndex, ink.slice()); } } } return false; }; excludeSelfSnapSegs = (ink: InkData, controlIndex: number) => { const closed = InkingStroke.IsClosed(ink); // figure out which segments we don't want to snap to - avoid the dragged control point's segment and the next and prev segments (when they exist -- ie not for endpoints of unclosed curve) const thisseg = Math.floor(controlIndex / 4) * 4; const which = controlIndex % 4; const nextseg = which > 1 && (closed || controlIndex < ink.length - 1) ? (thisseg + 4) % ink.length : -1; const prevseg = which < 2 && (closed || controlIndex > 0) ? (thisseg - 4 + ink.length) % ink.length : -1; return [thisseg, prevseg, nextseg]; }; snapToAllCurves = (screenDragPt: { X: number; Y: number }, inkView: DocumentView, snapData: { nearestPt: { X: number; Y: number }; distance: number }, ink: InkData, controlIndex: number) => { const containingCollection = CollectionFreeFormView.from(inkView); const containingDocView = containingCollection?.DocumentView?.(); containingCollection?.childDocs .filter(doc => doc.type === DocumentType.INK) .forEach(doc => { const testInkView = DocumentView.getDocumentView(doc, containingDocView); const snapped = testInkView?.ComponentView?.snapPt?.(screenDragPt, doc === inkView.Document ? this.excludeSelfSnapSegs(ink, controlIndex) : []); if (snapped && snapped.distance < snapData.distance) { const snappedInkPt = doc === inkView.Document ? snapped.nearestPt : inkView.ComponentView?.ptFromScreen?.(testInkView?.ComponentView?.ptToScreen?.(snapped.nearestPt) ?? { X: 0, Y: 0 }); // convert from snapped ink coordinate system to dragged ink coordinate system by converting to/from screen space if (snappedInkPt) { // eslint-disable-next-line no-param-reassign snapData = { nearestPt: snappedInkPt, distance: snapped.distance }; } } }); return snapData; }; /** * Snaps a control point with broken tangency back to synced rotation. * @param handleIndexA The handle point that retains its current position. * @param handleIndexB The handle point that is rotated to be 180 degrees from its opposite. */ snapHandleTangent = (inkView: DocumentView, controlIndex: number, handleIndexA: number, handleIndexB: number) => { this.applyFunction(inkView, (view: DocumentView, ink: InkData) => { const doc = view.Document; const brokenIndices = Cast(doc.brokenInkIndices, listSpec('number'), []); const ind = brokenIndices?.findIndex(value => value === controlIndex) ?? -1; if (ind !== -1) { brokenIndices!.splice(ind, 1); const [controlPoint, handleA, handleB] = [ink[controlIndex], ink[handleIndexA], ink[handleIndexB]]; const oppositeHandleA = this.rotatePoint(handleA, controlPoint, Math.PI); const angleDifference = InkStrokeProperties.angleChange(handleB, oppositeHandleA, controlPoint); const inkCopy = ink.slice(); // have to make a new copy of the array to keep from corrupting undo/redo. without slicing, the same array will be stored in each undo step meaning earlier undo steps will be inadvertently updated to store the latest value. inkCopy[handleIndexB] = this.rotatePoint(handleB, controlPoint, angleDifference); return inkCopy; } return undefined; }); }; /** * Rotates the target point about the origin point for a given angle (radians). */ @action rotatePoint = (target: PointData, origin: PointData, angle: number) => { const rotatedTarget = { X: target.X - origin.X, Y: target.Y - origin.Y }; const newX = Math.cos(angle) * rotatedTarget.X - Math.sin(angle) * rotatedTarget.Y; const newY = Math.sin(angle) * rotatedTarget.X + Math.cos(angle) * rotatedTarget.Y; return { X: newX + origin.X, Y: newY + origin.Y }; }; /** * Finds the angle (in radians) between two inputted vectors. * * α = arccos(a·b / |a|·|b|), where a and b are both vectors. */ public static angleBetweenTwoVectors(vectorA: PointData, vectorB: PointData) { const magnitudeA = Math.sqrt(vectorA.X * vectorA.X + vectorA.Y * vectorA.Y); const magnitudeB = Math.sqrt(vectorB.X * vectorB.X + vectorB.Y * vectorB.Y); if (magnitudeA === 0 || magnitudeB === 0) return 0; // Normalizing the vectors. // eslint-disable-next-line no-param-reassign vectorA = { X: vectorA.X / magnitudeA, Y: vectorA.Y / magnitudeA }; // eslint-disable-next-line no-param-reassign vectorB = { X: vectorB.X / magnitudeB, Y: vectorB.Y / magnitudeB }; return Math.acos(vectorB.X * vectorA.X + vectorB.Y * vectorA.Y); } /** * Finds the angle difference (in radians) between two vectors relative to an arbitrary origin. */ public static angleChange(a: PointData, b: PointData, origin: PointData) { // Finding vector representation of inputted points relative to new origin. const vectorA = { X: a.X - origin.X, Y: a.Y - origin.Y }; const vectorB = { X: b.X - origin.X, Y: b.Y - origin.Y }; const crossProduct = vectorB.X * vectorA.Y - vectorB.Y * vectorA.X; // Determining whether rotation is clockwise or counterclockwise. const sign = crossProduct < 0 ? 1 : -1; const theta = InkStrokeProperties.angleBetweenTwoVectors(vectorA, vectorB); return sign * theta; } /** * Handles the movement/scaling of a handle point. */ moveTangentHandle = undoable((inkView: DocumentView, deltaX: number, deltaY: number, handleIndex: number, oppositeHandleIndex: number, controlIndex: number) => { this.applyFunction(inkView, (view: DocumentView, ink: InkData) => { const doc = view.Document; const closed = InkingStroke.IsClosed(ink); const oldHandlePoint = ink[handleIndex]; const oppositeHandlePoint = ink[oppositeHandleIndex]; const controlPoint = ink[controlIndex]; const newHandlePoint = { X: ink[handleIndex].X - deltaX, Y: ink[handleIndex].Y - deltaY }; const inkCopy = ink.slice(); inkCopy[handleIndex] = newHandlePoint; const brokenIndices = Cast(doc.brokenInkIndices, listSpec('number')); const equivIndex = closed ? (controlIndex === 0 ? ink.length - 1 : controlIndex === ink.length - 1 ? 0 : -1) : -1; // Rotate opposite handle if user hasn't held 'Alt' key or not first/final control (which have only 1 handle). if ((!brokenIndices || (!brokenIndices?.includes(controlIndex) && !brokenIndices?.includes(equivIndex))) && (closed || (handleIndex !== 1 && handleIndex !== ink.length - 2))) { const angle = InkStrokeProperties.angleChange(oldHandlePoint, newHandlePoint, controlPoint); inkCopy[oppositeHandleIndex] = this.rotatePoint(oppositeHandlePoint, controlPoint, angle); } return inkCopy; }); }, 'move ink tangent'); sampleBezier = (curves: InkData) => { const polylinePoints = [{ x: curves[0].X, y: curves[0].Y }]; for (let i = 0; i < curves.length / 4; i++) { const bez = new Bezier(curves.slice(i * 4, i * 4 + 4).map(p => ({ x: p.X, y: p.Y }))); for (let t = 0.05; t < 1; t += 0.05) { polylinePoints.push(bez.compute(t)); } polylinePoints.push(bez.points[3]); } return polylinePoints.length > 2 ? polylinePoints : undefined; }; /** * Function that "smooths" ink strokes by sampling the curve, then fitting it with new bezier curves, subject to a * maximum pixel error tolerance * @param inkDocs * @param tolerance how many pixels of error are allowed */ smoothInkStrokes = undoable((inkDocs: Doc[], tolerance = 5) => { inkDocs.forEach(inkDoc => { const inkView = DocumentView.getDocumentView(inkDoc); const inkStroke = inkView?.ComponentView as InkingStroke; const polylinePoints = this.sampleBezier(inkStroke?.inkScaledData().inkData ?? [])?.map(pt => [pt.x, pt.y]); if (polylinePoints) { inkDoc.$stroke = new InkField( fitCurve.default(polylinePoints, tolerance) .reduce((cpts, bez) => ({n: cpts.push(...bez.map(cpt => ({X:cpt[0], Y:cpt[1]}))), cpts}).cpts, [] as {X:number, Y:number}[])); // prettier-ignore } }); }, 'smooth ink stroke'); }