js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
227 lines (226 loc) • 10.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.StrokeSmoother = void 0;
const math_1 = require("@js-draw/math");
// Handles stroke smoothing
class StrokeSmoother {
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, onCurveAdded) {
this.startPoint = startPoint;
this.minFitAllowed = minFitAllowed;
this.maxFitAllowed = maxFitAllowed;
this.onCurveAdded = onCurveAdded;
this.isFirstSegment = true;
this.lastExitingVec = null;
this.currentCurve = null;
this.lastPoint = this.startPoint;
this.buffer = [this.startPoint.pos];
this.momentum = math_1.Vec2.zero;
this.currentCurve = null;
this.curveStartWidth = startPoint.width;
this.bbox = new math_1.Rect2(this.startPoint.pos.x, this.startPoint.pos.y, 0, 0);
}
getBBox() {
return this.bbox;
}
preview() {
if (!this.currentCurve) {
return null;
}
return this.currentSegmentToPath();
}
// Returns the distance between the start, control, and end points of the curve.
approxCurrentCurveLength() {
if (!this.currentCurve) {
return 0;
}
const startPt = this.currentCurve.p0;
const controlPt = this.currentCurve.p1;
const endPt = this.currentCurve.p2;
const toControlDist = startPt.distanceTo(controlPt);
const toEndDist = endPt.distanceTo(controlPt);
return toControlDist + toEndDist;
}
finalizeCurrentCurve() {
// Case where no points have been added
if (!this.currentCurve) {
return;
}
this.onCurveAdded(this.currentSegmentToPath());
const lastPoint = this.buffer[this.buffer.length - 1];
this.lastExitingVec = this.currentCurve.p2.minus(this.currentCurve.p1);
console.assert(this.lastExitingVec.magnitude() !== 0, 'lastExitingVec has zero length!');
// Use the last two points to start a new curve (the last point isn't used
// in the current curve and we want connected curves to share end points)
this.buffer = [this.buffer[this.buffer.length - 2], lastPoint];
this.currentCurve = null;
this.isFirstSegment = false;
}
// Returns [upper curve, connector, lower curve]
currentSegmentToPath() {
if (this.currentCurve == null) {
throw new Error('Invalid State: currentCurve is null!');
}
const startVec = this.currentCurve.normal(0).normalized();
if (!isFinite(startVec.magnitude())) {
throw new Error(`startVec(${startVec}) is NaN or ∞`);
}
const startPt = this.currentCurve.at(0);
const endPt = this.currentCurve.at(1);
const controlPoint = this.currentCurve.p1;
return {
startPoint: startPt,
controlPoint,
endPoint: endPt,
startWidth: this.curveStartWidth,
endWidth: this.curveEndWidth,
};
}
// Compute the direction of the velocity at the end of this.buffer
computeExitingVec() {
return this.momentum.normalized().times(this.lastPoint.width / 2);
}
addPoint(newPoint) {
if (this.lastPoint) {
// Ignore points that are identical
const fuzzEq = 1e-10;
const deltaTime = newPoint.time - this.lastPoint.time;
if (newPoint.pos.eq(this.lastPoint.pos, fuzzEq) || deltaTime === 0) {
return;
}
else if (isNaN(newPoint.pos.magnitude())) {
console.warn('Discarding NaN point.', newPoint);
return;
}
const threshold = Math.min(this.lastPoint.width, newPoint.width) / 3;
const shouldSnapToInitial = this.startPoint.pos.distanceTo(newPoint.pos) < threshold && this.isFirstSegment;
// Snap to the starting point if the stroke is contained within a small ball centered
// at the starting point.
// This allows us to create a circle/dot at the start of the stroke.
if (shouldSnapToInitial) {
return;
}
const deltaTimeSeconds = deltaTime / 1000;
const velocity = newPoint.pos.minus(this.lastPoint.pos).times(1 / deltaTimeSeconds);
// TODO: Do we need momentum smoothing? (this.momentum.lerp(velocity, 0.9);)
this.momentum = velocity;
}
const lastPoint = this.lastPoint ?? newPoint;
this.lastPoint = newPoint;
this.buffer.push(newPoint.pos);
const pointRadius = newPoint.width;
const prevEndWidth = this.curveEndWidth;
this.curveEndWidth = pointRadius;
// recompute bbox
this.bbox = this.bbox.grownToPoint(newPoint.pos, pointRadius);
// If the last curve just ended or it's the first curve,
if (this.currentCurve === null) {
const p1 = lastPoint.pos;
const p2 = lastPoint.pos.plus(this.lastExitingVec ?? math_1.Vec2.unitX);
const p3 = newPoint.pos;
// Quadratic Bézier curve
this.currentCurve = new math_1.QuadraticBezier(p1, p2, p3);
console.assert(!isNaN(p1.magnitude()) && !isNaN(p2.magnitude()) && !isNaN(p3.magnitude()), 'Expected !NaN');
if (this.isFirstSegment) {
// The start of a curve often lacks accurate pressure information. Update it.
this.curveStartWidth = (this.curveStartWidth + pointRadius) / 2;
}
else {
this.curveStartWidth = prevEndWidth;
}
}
// If there isn't an entering vector (e.g. because this.isFirstCurve), approximate it.
let enteringVec = this.lastExitingVec;
if (!enteringVec) {
let sampleIdx = Math.ceil(this.buffer.length / 2);
if (sampleIdx === 0 || sampleIdx >= this.buffer.length) {
sampleIdx = this.buffer.length - 1;
}
enteringVec = this.buffer[sampleIdx].minus(this.buffer[0]);
}
let exitingVec = this.computeExitingVec();
// Find the intersection between the entering vector and the exiting vector
const maxRelativeLength = 1.7;
const segmentStart = this.buffer[0];
const segmentEnd = newPoint.pos;
const startEndDist = segmentEnd.distanceTo(segmentStart);
const maxControlPointDist = maxRelativeLength * startEndDist;
// Exit in cases where we would divide by zero
if (maxControlPointDist === 0 ||
exitingVec.magnitude() === 0 ||
!isFinite(exitingVec.magnitude())) {
return;
}
console.assert(isFinite(enteringVec.magnitude()), 'Pre-normalized enteringVec has NaN or ∞ magnitude!');
enteringVec = enteringVec.normalized();
exitingVec = exitingVec.normalized();
console.assert(isFinite(enteringVec.magnitude()), 'Normalized enteringVec has NaN or ∞ magnitude!');
const lineFromStart = new math_1.LineSegment2(segmentStart, segmentStart.plus(enteringVec.times(maxControlPointDist)));
const lineFromEnd = new math_1.LineSegment2(segmentEnd.minus(exitingVec.times(maxControlPointDist)), segmentEnd);
const intersection = lineFromEnd.intersection(lineFromStart);
// Position the control point at this intersection
let controlPoint = null;
if (intersection) {
controlPoint = intersection.point;
}
// No intersection?
if (!controlPoint) {
// Estimate the control point position based on the entering tangent line
controlPoint = segmentStart
.lerp(segmentEnd, 0.5)
.lerp(segmentStart.plus(enteringVec.times(startEndDist)), 0.1);
}
// Equal to an endpoint?
if (segmentStart.eq(controlPoint) || segmentEnd.eq(controlPoint)) {
// Position the control point closer to the first -- the connecting
// segment will be roughly a line.
controlPoint = segmentStart.plus(enteringVec.times(startEndDist / 5));
}
console.assert(!segmentStart.eq(controlPoint, 1e-11), 'Start and control points are equal!');
console.assert(!controlPoint.eq(segmentEnd, 1e-11), 'Control and end points are equal!');
const prevCurve = this.currentCurve;
this.currentCurve = new math_1.QuadraticBezier(segmentStart, controlPoint, segmentEnd);
if (isNaN(this.currentCurve.normal(0).magnitude())) {
console.error('NaN normal at 0. Curve:', this.currentCurve);
this.currentCurve = prevCurve;
}
// Should we start making a new curve? Check whether all buffer points are within
// ±strokeWidth of the curve.
const curveMatchesPoints = (curve) => {
const minFit = Math.min(Math.max(Math.min(this.curveStartWidth, this.curveEndWidth) / 4, this.minFitAllowed), this.maxFitAllowed);
// The sum of distances greater than minFit must not exceed this:
const maxNonMatchingDistSum = minFit;
// Sum of distances greater than minFit.
let nonMatchingDistSum = 0;
for (const point of this.buffer) {
let dist = curve.approximateDistance(point);
if (dist > minFit) {
// Use the more accurate distance function
dist = curve.distance(point);
nonMatchingDistSum += Math.max(0, dist - minFit);
if (nonMatchingDistSum > maxNonMatchingDistSum) {
return false; // false: Curve doesn't match points well enough.
}
}
}
return true;
};
if (this.buffer.length > 3 && this.approxCurrentCurveLength() > this.curveStartWidth / 2) {
if (!curveMatchesPoints(this.currentCurve)) {
// Use a curve that better fits the points
this.currentCurve = prevCurve;
this.curveEndWidth = prevEndWidth;
// Reset the last point -- the current point was not added to the curve.
this.lastPoint = lastPoint;
this.finalizeCurrentCurve();
return;
}
}
}
}
exports.StrokeSmoother = StrokeSmoother;
exports.default = StrokeSmoother;