import { action, computed, observable } from "mobx"; import { Doc, DocListCast, Field, Opt } from "../../fields/Doc"; import { Document } from "../../fields/documentSchemas"; import { InkField, InkData, PointData, ControlPoint } from "../../fields/InkField"; import { List } from "../../fields/List"; import { listSpec } from "../../fields/Schema"; import { Cast, NumCast } from "../../fields/Types"; import { DocumentType } from "../documents/DocumentTypes"; import { SelectionManager } from "../util/SelectionManager"; import { undoBatch } from "../util/UndoManager"; export class InkStrokeProperties { static Instance: InkStrokeProperties | undefined; @observable _lock = false; @observable _controlButton = false; @observable _currentPoint = -1; constructor() { InkStrokeProperties.Instance = this; } @computed get selectedInk() { const inks = SelectionManager.Views().filter(i => Document(i.rootDoc).type === DocumentType.INK); return inks.length ? inks : undefined; } getField(key: string) { return this.selectedInk?.reduce((p, i) => (p === undefined || (p && p === i.rootDoc[key])) && i.rootDoc[key] !== "0" ? Field.toString(i.rootDoc[key] as Field) : "", undefined as Opt); } /** * 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 = (func: (doc: Doc, ink: InkData, ptsXscale: number, ptsYscale: number) => { X: number, Y: number }[] | undefined, requireCurrPoint: boolean = false) => { var appliedFunc = false; this.selectedInk?.forEach(action(inkView => { if (this.selectedInk?.length === 1 && (!requireCurrPoint || this._currentPoint !== -1)) { const doc = Document(inkView.rootDoc); if (doc.type === DocumentType.INK && doc.width && doc.height) { const ink = Cast(doc.data, 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) / (oldXrange.max - oldXrange.min); const ptsYscale = NumCast(doc._height) / (oldYrange.max - oldYrange.min); const newPoints = func(doc, ink, ptsXscale, ptsYscale); 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; doc._height = (newYrange.max - newYrange.min) * ptsYscale; doc.x = (oldXrange.coord + (newXrange.min - oldXrange.min) * ptsXscale); doc.y = (oldYrange.coord + (newYrange.min - oldYrange.min) * ptsYscale); Doc.GetProto(doc).data = new InkField(newPoints); appliedFunc = true; } } } } })); return appliedFunc; } /** * Adds a new control point to the ink instance when editing its format. * @param index The index of the new point. * @param control The list of all control points of the ink. */ @undoBatch @action addPoints = (x: number, y: number, points: InkData, index: number, controls: { X: number, Y: number }[]) => { this.applyFunction((doc: Doc, ink: InkData) => { const newControl = { X: x, Y: y }; const newPoints: InkData = []; let [counter, start, end] = [0, 0, 0]; for (let k = 0; k < points.length; k++) { if (end === 0) { controls.forEach((control) => { if (control.X === points[k].X && control.Y === points[k].Y) { if (k < index) { counter++; start = k; } else if (k > index) { end = k; } } }); } } if (end === 0) end = points.length - 1; // Index of new control point with regards to the ink data. const newIndex = Math.floor(counter / 2) * 4 + 2; // Creating new ink data with the new control point and handle points inputted. for (let i = 0; i < ink.length; i++) { if (i === newIndex) { const [handleA, handleB] = this.getNewHandlePoints(points.slice(start, index + 1), points.slice(index, end), newControl); newPoints.push(handleA, newControl, newControl, handleB); // Adjusting the magnitude of the left handle line of the right neighboring control point. const [rightControl, rightHandle] = [points[end], ink[i]]; const scaledVector = this.getScaledHandlePoint(false, start, end, index, rightControl, rightHandle); rightHandle && newPoints.push({ X: rightControl.X - scaledVector.X, Y: rightControl.Y - scaledVector.Y }); } else if (i === newIndex - 1) { // Adjusting the magnitude of the right handle line of the left neighboring control point. const [leftControl, leftHandle] = [points[start], ink[i]]; const scaledVector = this.getScaledHandlePoint(true, start, end, index, leftControl, leftHandle); leftHandle && newPoints.push({ X: leftControl.X - scaledVector.X, Y: leftControl.Y - scaledVector.Y }); } else { ink[i] && newPoints.push({ X: ink[i].X, Y: ink[i].Y }); } } let brokenIndices = Cast(doc.brokenInkIndices, listSpec("number")); // Updating the indices of the control points whose handle tangency has been broken. if (brokenIndices) { brokenIndices = new List(brokenIndices.map((control) => { if (control >= newIndex) { return control + 4; } else { return control; } })); } doc.brokenInkIndices = brokenIndices; this._currentPoint = -1; return newPoints; }); } /** * 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 }; const scaledVector = { X: handleVector.X * (newSize / prevSize), Y: handleVector.Y * (newSize / prevSize) }; return scaledVector; } /** * 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((Math.pow(newControl.X - C[0].X, 2)) + (Math.pow(newControl.Y - C[0].Y, 2))); let handleSizeB = Math.sqrt((Math.pow(D[n - 1].X - newControl.X, 2)) + (Math.pow(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. */ @undoBatch @action deletePoints = () => this.applyFunction((doc: Doc, ink: InkData) => { const newPoints: { X: number, Y: number }[] = []; const toRemove = Math.floor(((this._currentPoint + 2) / 4)); for (let i = 0; i < ink.length; i++) { if (Math.floor((i + 2) / 4) !== toRemove && (toRemove !== 0 || i > 3)) { newPoints.push({ X: ink[i].X, Y: ink[i].Y }); } } this._currentPoint = -1; if (newPoints.length < 4) return undefined; if (newPoints.length === 4) { const newerPoints: { X: number, Y: number }[] = []; newerPoints.push({ X: newPoints[0].X, Y: newPoints[0].Y }); newerPoints.push({ X: newPoints[0].X, Y: newPoints[0].Y }); newerPoints.push({ X: newPoints[3].X, Y: newPoints[3].Y }); newerPoints.push({ X: newPoints[3].X, Y: newPoints[3].Y }); return newerPoints; } return newPoints; }, true) /** * Rotates the entire selected ink instance. * @param angle The angle at which to rotate the ink in radians. */ @undoBatch @action rotateInk = (angle: number) => { this.applyFunction((doc: Doc, ink: InkData, xScale: number, yScale: number) => { 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 centerPoint = { X: (oldXrange.min + oldXrange.max) / 2, Y: (oldYrange.min + oldYrange.max) / 2 }; const newPoints: { X: number, Y: number }[] = []; ink.map(i => ({ X: i.X - centerPoint.X, Y: i.Y - centerPoint.Y })).forEach(i => { const newX = Math.cos(angle) * i.X - Math.sin(angle) * i.Y; const newY = Math.sin(angle) * i.X + Math.cos(angle) * i.Y; newPoints.push({ X: newX + centerPoint.X, Y: newY + centerPoint.Y }); }); doc.rotation = NumCast(doc.rotation) + angle; return newPoints; }); } /** * Handles the movement/scaling of a control point. */ @undoBatch @action moveControl = (deltaX: number, deltaY: number, controlIndex: number) => this.applyFunction((doc: Doc, ink: InkData, xScale: number, yScale: number) => { const newPoints: { X: number, Y: number }[] = []; const order = controlIndex % 4; for (var i = 0; i < ink.length; i++) { const leftHandlePoint = order === 0 && i === controlIndex + 1; const rightHandlePoint = order === 0 && controlIndex !== 0 && i === controlIndex - 2; if (controlIndex === i || leftHandlePoint || rightHandlePoint || (order === 0 && controlIndex !== 0 && i === controlIndex - 1) || (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))) { newPoints.push({ X: ink[i].X - deltaX / xScale, Y: ink[i].Y - deltaY / yScale }); } else { newPoints.push({ X: ink[i].X, Y: ink[i].Y }); } } return newPoints; }) /** * 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 = (controlIndex: number, handleIndexA: number, handleIndexB: number) => { this.applyFunction((doc: Doc, ink: InkData) => { const brokenIndices = Cast(doc.brokenInkIndices, listSpec("number")); if (brokenIndices) { const newBrokenIndices = new List; brokenIndices.forEach(brokenIndex => { if (brokenIndex !== controlIndex) { newBrokenIndices.push(brokenIndex); } }); doc.brokenInkIndices = newBrokenIndices; const [controlPoint, handleA, handleB] = [ink[controlIndex], ink[handleIndexA], ink[handleIndexB]]; const oppositeHandleA = this.rotatePoint(handleA, controlPoint, Math.PI); const angleDifference = this.angleChange(handleB, oppositeHandleA, controlPoint); const newHandleB = this.rotatePoint(handleB, controlPoint, angleDifference); ink[handleIndexB] = newHandleB; return ink; } }); } /** * 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; rotatedTarget.X = newX + origin.X; rotatedTarget.Y = newY + origin.Y; return rotatedTarget; } /** * Finds the angle (in radians) between two inputted vectors. * * α = arccos(a·b / |a|·|b|), where a and b are both vectors. */ 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); // Normalizing the vectors. vectorA = { X: vectorA.X / magnitudeA, Y: vectorA.Y / magnitudeA }; vectorB = { X: vectorB.X / magnitudeB, Y: vectorB.Y / magnitudeB }; const dotProduct = vectorB.X * vectorA.X + vectorB.Y * vectorA.Y; return Math.acos(dotProduct); } /** * Finds the angle difference (in radians) between two vectors relative to an arbitrary origin. */ 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 = this.angleBetweenTwoVectors(vectorA, vectorB); return sign * theta; } /** * Handles the movement/scaling of a handle point. */ @undoBatch @action moveHandle = (deltaX: number, deltaY: number, handleIndex: number, oppositeHandleIndex: number, controlIndex: number) => this.applyFunction((doc: Doc, ink: InkData, xScale: number, yScale: number) => { const oldHandlePoint = ink[handleIndex]; let oppositeHandlePoint = ink[oppositeHandleIndex]; const controlPoint = ink[controlIndex]; const newHandlePoint = { X: ink[handleIndex].X - deltaX / xScale, Y: ink[handleIndex].Y - deltaY / yScale }; ink[handleIndex] = newHandlePoint; const brokenIndices = Cast(doc.brokenInkIndices, listSpec("number")); // 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)) && handleIndex !== 1 && handleIndex !== ink.length - 2) { const angle = this.angleChange(oldHandlePoint, newHandlePoint, controlPoint); oppositeHandlePoint = this.rotatePoint(oppositeHandlePoint, controlPoint, angle); ink[oppositeHandleIndex] = oppositeHandlePoint; } return ink; }) }