js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
343 lines (342 loc) • 14.8 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.makePressureSensitiveFreehandLineBuilder = void 0;
const math_1 = require("@js-draw/math");
const Stroke_1 = __importDefault(require("../Stroke"));
const Viewport_1 = __importDefault(require("../../Viewport"));
const StrokeSmoother_1 = require("../util/StrokeSmoother");
const makeShapeFitAutocorrect_1 = __importDefault(require("./autocorrect/makeShapeFitAutocorrect"));
exports.makePressureSensitiveFreehandLineBuilder = (0, makeShapeFitAutocorrect_1.default)((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.
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_1.StrokeSmoother(startPoint, minFitAllowed, maxFitAllowed, (curve) => this.addCurve(curve));
this.curveStartWidth = startPoint.width;
this.bbox = new math_1.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 === math_1.PathCommandType.LineTo ||
lastLowerSegment.kind === math_1.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_1.default(this.previewFullPath());
}
roundPoint(point) {
let minFit = Math.min(this.minFitAllowed, this.curveStartWidth / 3);
if (minFit < 1e-10) {
minFit = this.minFitAllowed;
}
return Viewport_1.default.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_1.default.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(math_1.Vec2.of(width, 0));
// Draw a circle-ish shape around the start point
this.lowerSegments.push({
kind: math_1.PathCommandType.QuadraticBezierTo,
controlPoint: center.plus(math_1.Vec2.of(width, width)),
// Bottom of the circle
// |
// -----
// |
// ↑
endPoint: center.plus(math_1.Vec2.of(0, width)),
}, {
kind: math_1.PathCommandType.QuadraticBezierTo,
controlPoint: center.plus(math_1.Vec2.of(-width, width)),
endPoint: center.plus(math_1.Vec2.of(-width, 0)),
}, {
kind: math_1.PathCommandType.QuadraticBezierTo,
controlPoint: center.plus(math_1.Vec2.of(-width, -width)),
endPoint: center.plus(math_1.Vec2.of(0, -width)),
}, {
kind: math_1.PathCommandType.QuadraticBezierTo,
controlPoint: center.plus(math_1.Vec2.of(width, -width)),
endPoint: center.plus(math_1.Vec2.of(width, 0)),
});
const connector = {
kind: math_1.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 math_1.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: math_1.PathCommandType.QuadraticBezierTo,
controlPoint: lowerCurveControlPoint,
endPoint: lowerCurveEndPoint,
};
// From the end of the upperCurve to the start of the lowerCurve:
const upperToLowerConnector = {
kind: math_1.PathCommandType.LineTo,
point: lowerCurveStartPoint,
};
// From the end of lowerCurve to the start of upperCurve:
const lowerToUpperConnector = {
kind: math_1.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: math_1.PathCommandType.LineTo,
point: upperCurveStartPoint,
},
{
kind: math_1.PathCommandType.LineTo,
point: lowerCurveEndPoint,
},
];
const upperCurveCommand = {
kind: math_1.PathCommandType.QuadraticBezierTo,
controlPoint: upperCurveControlPoint,
endPoint: upperCurveEndPoint,
};
const upperCurve = new math_1.QuadraticBezier(upperCurveStartPoint, upperCurveControlPoint, upperCurveEndPoint);
const lowerCurve = new math_1.QuadraticBezier(lowerCurveStartPoint, lowerCurveControlPoint, lowerCurveEndPoint);
return {
upperCurveCommand,
upperToLowerConnector,
lowerToUpperConnector,
lowerCurveCommand,
upperCurve,
lowerCurve,
nextCurveStartConnector,
};
}
addPoint(newPoint) {
this.curveFitter.addPoint(newPoint);
}
}
exports.default = PressureSensitiveFreehandLineBuilder;