UNPKG

js-draw

Version:

Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.

336 lines (335 loc) 14.2 kB
import { Vec2, Rect2, PathCommandType, QuadraticBezier, } from '@js-draw/math'; import Stroke from '../Stroke.mjs'; import Viewport from '../../Viewport.mjs'; import { StrokeSmoother } from '../util/StrokeSmoother.mjs'; import makeShapeFitAutocorrect from './autocorrect/makeShapeFitAutocorrect.mjs'; export const makePressureSensitiveFreehandLineBuilder = makeShapeFitAutocorrect((initialPoint, viewport) => { // Don't smooth if input is more than ± 3 pixels from the true curve, do smooth if // less than ±1 px from the curve. const maxSmoothingDist = viewport.getSizeOfPixelOnCanvas() * 3; const minSmoothingDist = viewport.getSizeOfPixelOnCanvas(); return new PressureSensitiveFreehandLineBuilder(initialPoint, minSmoothingDist, maxSmoothingDist, viewport); }); // Handles stroke smoothing and creates Strokes from user/stylus input. export default class PressureSensitiveFreehandLineBuilder { constructor(startPoint, // Maximum distance from the actual curve (irrespective of stroke width) // for which a point is considered 'part of the curve'. // Note that the maximum will be smaller if the stroke width is less than // [maxFitAllowed]. minFitAllowed, maxFitAllowed, viewport) { this.startPoint = startPoint; this.minFitAllowed = minFitAllowed; this.viewport = viewport; this.isFirstSegment = true; this.pathStartConnector = null; this.mostRecentConnector = null; this.nextCurveStartConnector = null; this.lastUpperBezier = null; this.lastLowerBezier = null; this.parts = []; this.upperSegments = []; this.lowerSegments = []; this.curveFitter = new StrokeSmoother(startPoint, minFitAllowed, maxFitAllowed, (curve) => this.addCurve(curve)); this.curveStartWidth = startPoint.width; this.bbox = new Rect2(this.startPoint.pos.x, this.startPoint.pos.y, 0, 0); } getBBox() { return this.bbox; } getRenderingStyle() { return { fill: this.startPoint.color ?? null, }; } previewCurrentPath(extendWithLatest = true) { const upperPath = this.upperSegments.slice(); const lowerPath = this.lowerSegments.slice(); let lowerToUpperCap; let pathStartConnector; const currentCurve = this.curveFitter.preview(); if (currentCurve && extendWithLatest) { const { upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand } = this.segmentToPath(currentCurve); upperPath.push(upperCurveCommand); lowerPath.push(lowerCurveCommand); lowerToUpperCap = lowerToUpperConnector; pathStartConnector = this.pathStartConnector ?? [upperToLowerConnector]; } else { if (this.mostRecentConnector === null || this.pathStartConnector === null) { return null; } lowerToUpperCap = this.mostRecentConnector; pathStartConnector = this.pathStartConnector; } let startPoint; const lastLowerSegment = lowerPath[lowerPath.length - 1]; if (lastLowerSegment.kind === PathCommandType.LineTo || lastLowerSegment.kind === PathCommandType.MoveTo) { startPoint = lastLowerSegment.point; } else { startPoint = lastLowerSegment.endPoint; } return { // Start at the end of the lower curve: // Start point // ↓ // __/ __/ ← Most recent points on this end // /___ / // ↑ // Oldest points startPoint, commands: [ // Move to the most recent point on the upperPath: // ----→• // __/ __/ // /___ / lowerToUpperCap, // Move to the beginning of the upperPath: // __/ __/ // /___ / // • ←- ...upperPath.reverse(), // Move to the beginning of the lowerPath: // __/ __/ // /___ / // • ...pathStartConnector, // Move back to the start point: // • // __/ __/ // /___ / ...lowerPath, ], style: this.getRenderingStyle(), }; } previewFullPath() { const preview = this.previewCurrentPath(); if (preview) { return [...this.parts, preview]; } return null; } preview(renderer) { const paths = this.previewFullPath(); if (paths) { const approxBBox = this.viewport.visibleRect; renderer.startObject(approxBBox); for (const path of paths) { renderer.drawPath(path); } renderer.endObject(); } } build() { this.curveFitter.finalizeCurrentCurve(); if (this.isFirstSegment) { // Ensure we have something. this.addCurve(null); } return new Stroke(this.previewFullPath()); } roundPoint(point) { let minFit = Math.min(this.minFitAllowed, this.curveStartWidth / 3); if (minFit < 1e-10) { minFit = this.minFitAllowed; } return Viewport.roundPoint(point, minFit); } // Returns true if, due to overlap with previous segments, a new RenderablePathSpec should be created. shouldStartNewSegment(lowerCurve, upperCurve) { if (!this.lastLowerBezier || !this.lastUpperBezier) { return false; } const getIntersection = (curve1, curve2) => { const intersections = curve1.intersectsBezier(curve2); if (!intersections.length) return null; return intersections[0].point; }; const getExitDirection = (curve) => { return curve.p2.minus(curve.p1).normalized(); }; const getEnterDirection = (curve) => { return curve.p1.minus(curve.p0).normalized(); }; // Prevent // / // / / // / / /| // / / | // / | // where the next stroke and the previous stroke are in different directions. // // Are the exit/enter directions of the previous and current curves in different enough directions? if (getEnterDirection(upperCurve).dot(getExitDirection(this.lastUpperBezier)) < 0.35 || getEnterDirection(lowerCurve).dot(getExitDirection(this.lastLowerBezier)) < 0.35 || // Also handle if the curves exit/enter directions differ getEnterDirection(upperCurve).dot(getExitDirection(upperCurve)) < 0 || getEnterDirection(lowerCurve).dot(getExitDirection(lowerCurve)) < 0) { return true; } // Check whether the lower curve intersects the other wall: // / / ← lower // / / / // / / / // // // / / const lowerIntersection = getIntersection(lowerCurve, this.lastUpperBezier); const upperIntersection = getIntersection(upperCurve, this.lastLowerBezier); if (lowerIntersection || upperIntersection) { return true; } return false; } addCurve(curve) { // Case where no points have been added if (!curve) { // Don't create a circle around the initial point if the stroke has more than one point. if (!this.isFirstSegment) { return; } const width = Viewport.roundPoint(this.startPoint.width / 2.2, Math.min(this.minFitAllowed, this.startPoint.width / 4)); const center = this.roundPoint(this.startPoint.pos); // Start on the right, cycle clockwise: // | // ----- ← // | const startPoint = this.startPoint.pos.plus(Vec2.of(width, 0)); // Draw a circle-ish shape around the start point this.lowerSegments.push({ kind: PathCommandType.QuadraticBezierTo, controlPoint: center.plus(Vec2.of(width, width)), // Bottom of the circle // | // ----- // | // ↑ endPoint: center.plus(Vec2.of(0, width)), }, { kind: PathCommandType.QuadraticBezierTo, controlPoint: center.plus(Vec2.of(-width, width)), endPoint: center.plus(Vec2.of(-width, 0)), }, { kind: PathCommandType.QuadraticBezierTo, controlPoint: center.plus(Vec2.of(-width, -width)), endPoint: center.plus(Vec2.of(0, -width)), }, { kind: PathCommandType.QuadraticBezierTo, controlPoint: center.plus(Vec2.of(width, -width)), endPoint: center.plus(Vec2.of(width, 0)), }); const connector = { kind: PathCommandType.LineTo, point: startPoint, }; this.pathStartConnector = [connector]; this.mostRecentConnector = connector; return; } const { upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand, lowerCurve, upperCurve, nextCurveStartConnector, } = this.segmentToPath(curve); let shouldStartNew = this.shouldStartNewSegment(lowerCurve, upperCurve); if (shouldStartNew) { const part = this.previewCurrentPath(false); if (part) { this.parts.push(part); this.upperSegments = []; this.lowerSegments = []; } else { shouldStartNew = false; } } if (this.isFirstSegment || shouldStartNew) { // We draw the upper path (reversed), then the lower path, so we need the // upperToLowerConnector to join the two paths. this.pathStartConnector = this.nextCurveStartConnector ?? [upperToLowerConnector]; this.isFirstSegment = false; } // With the most recent connector, we're joining the end of the lowerPath to the most recent // upperPath: this.mostRecentConnector = lowerToUpperConnector; this.nextCurveStartConnector = nextCurveStartConnector; this.lowerSegments.push(lowerCurveCommand); this.upperSegments.push(upperCurveCommand); this.lastLowerBezier = lowerCurve; this.lastUpperBezier = upperCurve; this.curveStartWidth = curve.startWidth; } // Returns [upper curve, connector, lower curve] segmentToPath(curve) { const bezier = new QuadraticBezier(curve.startPoint, curve.controlPoint, curve.endPoint); let startVec = bezier.normal(0); let endVec = bezier.normal(1); startVec = startVec.times(curve.startWidth / 2); endVec = endVec.times(curve.endWidth / 2); if (!isFinite(startVec.magnitude())) { console.error('Warning: startVec is NaN or ∞', startVec, endVec, curve); startVec = endVec; } const startPt = curve.startPoint; const endPt = curve.endPoint; const controlPoint = curve.controlPoint; // Approximate the normal at the location of the control point const projectionT = bezier.nearestPointTo(controlPoint).parameterValue; const halfVecT = projectionT; const halfVec = bezier .normal(halfVecT) .times((curve.startWidth / 2) * halfVecT + (curve.endWidth / 2) * (1 - halfVecT)); // Each starts at startPt ± startVec const lowerCurveStartPoint = this.roundPoint(startPt.plus(startVec)); const lowerCurveControlPoint = this.roundPoint(controlPoint.plus(halfVec)); const lowerCurveEndPoint = this.roundPoint(endPt.plus(endVec)); const upperCurveControlPoint = this.roundPoint(controlPoint.minus(halfVec)); const upperCurveStartPoint = this.roundPoint(endPt.minus(endVec)); const upperCurveEndPoint = this.roundPoint(startPt.minus(startVec)); const lowerCurveCommand = { kind: PathCommandType.QuadraticBezierTo, controlPoint: lowerCurveControlPoint, endPoint: lowerCurveEndPoint, }; // From the end of the upperCurve to the start of the lowerCurve: const upperToLowerConnector = { kind: PathCommandType.LineTo, point: lowerCurveStartPoint, }; // From the end of lowerCurve to the start of upperCurve: const lowerToUpperConnector = { kind: PathCommandType.LineTo, point: upperCurveStartPoint, }; // The segment to be used to start the next path (to insert to connect the start of its // lower and the end of its upper). const nextCurveStartConnector = [ { kind: PathCommandType.LineTo, point: upperCurveStartPoint, }, { kind: PathCommandType.LineTo, point: lowerCurveEndPoint, }, ]; const upperCurveCommand = { kind: PathCommandType.QuadraticBezierTo, controlPoint: upperCurveControlPoint, endPoint: upperCurveEndPoint, }; const upperCurve = new QuadraticBezier(upperCurveStartPoint, upperCurveControlPoint, upperCurveEndPoint); const lowerCurve = new QuadraticBezier(lowerCurveStartPoint, lowerCurveControlPoint, lowerCurveEndPoint); return { upperCurveCommand, upperToLowerConnector, lowerToUpperConnector, lowerCurveCommand, upperCurve, lowerCurve, nextCurveStartConnector, }; } addPoint(newPoint) { this.curveFitter.addPoint(newPoint); } }