scratch-paint/src/helper/blob-tools/broad-brush-helper.js

249 lines
11 KiB
JavaScript
Raw Normal View History

2017-07-20 22:48:07 -04:00
// Broadbrush based on http://paperjs.org/tutorials/interaction/working-with-mouse-vectors/
2017-10-12 18:35:30 -04:00
import paper from '@scratch/paper';
import {styleBlob} from '../../helper/style-path';
import log from '../../log/log';
2017-07-20 22:48:07 -04:00
/**
* Broad brush functions to add as listeners on the mouse. Call them when the corresponding mouse event happens
2017-07-27 17:36:17 -04:00
* to get the broad brush behavior.
*
* Broad brush draws strokes by drawing points equidistant from the mouse event, perpendicular to the
* direction of motion. Shortcomings are that this path can cross itself, and 180 degree turns result
* in a flat edge.
*
2017-07-20 22:48:07 -04:00
* @param {!Tool} tool paper.js mouse object
*/
class BroadBrushHelper {
constructor () {
2018-03-01 14:27:10 -05:00
// Direction vector of the last mouse move
2018-02-02 18:47:25 -05:00
this.lastVec = null;
2018-03-01 14:27:10 -05:00
// End point of the last mouse move
this.lastPoint = null;
2018-03-01 14:27:10 -05:00
// The path of the brush stroke we are building
this.finalPath = null;
2018-03-01 14:27:10 -05:00
// Number of points of finalPath that have already been processed
2018-01-30 18:40:52 -05:00
this.smoothed = 0;
2018-03-01 14:27:10 -05:00
// Number of steps to wait before performing another amortized smooth
2018-02-02 18:47:25 -05:00
this.smoothingThreshold = 20;
2018-03-01 14:27:10 -05:00
// Mouse moves since mouse down
2018-02-02 18:47:25 -05:00
this.steps = 0;
// End caps round out corners and are not merged into the path until the end.
this.endCaps = [];
}
2017-07-20 22:48:07 -04:00
onBroadMouseDown (event, tool, options) {
this.steps = 0;
2018-02-02 18:47:25 -05:00
this.smoothed = 0;
tool.minDistance = Math.min(5, Math.max(2 / paper.view.zoom, options.brushSize / 2));
tool.maxDistance = options.brushSize;
2017-08-29 17:24:40 -04:00
if (event.event.button > 0) return; // only first mouse button
2017-07-20 22:48:07 -04:00
2018-02-28 20:06:51 -05:00
this.finalPath = new paper.Path.Circle({
center: event.point,
radius: options.brushSize / 2
});
styleBlob(this.finalPath, options);
2018-02-28 20:06:51 -05:00
this.lastPoint = event.point;
}
2017-07-20 22:48:07 -04:00
onBroadMouseDrag (event, tool, options) {
2018-02-02 18:47:25 -05:00
this.steps++;
const step = (event.delta).normalize(options.brushSize / 2);
// Add an end cap if the mouse has changed direction very quickly
2018-02-02 18:47:25 -05:00
if (this.lastVec) {
const angle = this.lastVec.getDirectedAngle(step);
if (Math.abs(angle) > 126) {
// This will cause us to skip simplifying this sharp angle. Running simplify on
// sharp angles causes the stroke to blob outwards.
this.simplify(1);
this.smoothed++;
// If the angle is large, the broad brush tends to leave behind a flat edge.
// This code makes a shape to fill in that flat edge with a rounded cap.
2018-02-02 18:47:25 -05:00
const circ = new paper.Path.Circle(this.lastPoint, options.brushSize / 2);
circ.fillColor = options.fillColor;
2018-02-28 20:06:51 -05:00
const rect = new paper.Path.Rectangle(
this.lastPoint.subtract(new paper.Point(-options.brushSize / 2, 0)),
this.lastPoint.subtract(new paper.Point(options.brushSize / 2, this.lastVec.length))
);
rect.fillColor = options.fillColor;
rect.rotate(this.lastVec.angle - 90, this.lastPoint);
const rect2 = new paper.Path.Rectangle(
event.point.subtract(new paper.Point(-options.brushSize / 2, 0)),
event.point.subtract(new paper.Point(options.brushSize / 2, event.delta.length))
);
rect2.fillColor = options.fillColor;
rect2.rotate(step.angle - 90, event.point);
this.endCaps.push(this.union(circ, this.union(rect, rect2)));
2018-02-02 18:47:25 -05:00
}
}
2018-02-28 20:06:51 -05:00
this.lastVec = event.delta;
step.angle += 90;
2017-07-20 22:48:07 -04:00
// Move the first point out away from the drag so that the end of the path is rounded
2018-02-28 20:06:51 -05:00
if (this.steps === 1) {
// Replace circle with path
this.finalPath.remove();
2018-02-28 20:06:51 -05:00
this.finalPath = new paper.Path();
const handleVec = event.delta.normalize(options.brushSize / 2);
this.finalPath.add(new paper.Segment(
this.lastPoint.subtract(handleVec),
handleVec.rotate(-90),
handleVec.rotate(90)
));
2018-02-28 20:06:51 -05:00
styleBlob(this.finalPath, options);
this.finalPath.insert(0, new paper.Segment(this.lastPoint.subtract(step)));
2018-02-28 20:06:51 -05:00
this.finalPath.add(new paper.Segment(this.lastPoint.add(step)));
2017-07-20 22:48:07 -04:00
}
const top = event.middlePoint.add(step);
const bottom = event.middlePoint.subtract(step);
this.finalPath.add(top);
this.finalPath.add(event.point.add(step));
this.finalPath.insert(0, bottom);
this.finalPath.insert(0, event.point.subtract(step));
2018-02-28 20:06:51 -05:00
if (this.finalPath.segments.length > this.smoothed + (this.smoothingThreshold * 2)) {
2018-02-02 18:47:25 -05:00
this.simplify(1);
2018-01-30 18:40:52 -05:00
}
this.lastPoint = event.point;
}
2017-07-20 22:48:07 -04:00
2018-02-28 18:23:54 -05:00
/**
* Simplify the path so that it looks almost the same while trying to have a reasonable number of handles.
* Without this, there would be 2 handles for every mouse move, which would make the path produced basically
* uneditable. This version of simplify keeps track of how much of the path has already been simplified to
* avoid repeating work.
* @param {number} threshold The simplify algorithm must try to stay within this distance of the actual line.
* The algorithm will be faster and able to remove more points the higher this number is.
* Note that 1 is about the lowest this algorithm can do (the result is about the same when 1 is
* passed in as when 0 is passed in)
*/
2018-02-02 18:47:25 -05:00
simplify (threshold) {
2018-02-28 18:23:54 -05:00
// Length of the current path
2018-02-02 18:47:25 -05:00
const length = this.finalPath.segments.length;
2018-02-28 18:23:54 -05:00
// Number of new points added to front and end of path since last simplify
const newPoints = Math.floor((length - this.smoothed) / 2) + 1;
// Where to cut. Don't go past the rounded start of the line (so there's always a tempPathMid)
const firstCutoff = Math.min(newPoints + 1, Math.floor((length / 2)));
2018-02-28 20:06:51 -05:00
const lastCutoff = Math.max(length - 1 - newPoints, Math.floor(length / 2) + 1);
if (firstCutoff <= 1 || lastCutoff >= length - 1) {
// Entire path is simplified already
return;
}
2018-02-28 18:23:54 -05:00
// Cut the path into 3 segments: the 2 ends where the new points are, and the middle, which will be
// staying the same
const tempPath1 = new paper.Path(this.finalPath.segments.slice(1, firstCutoff));
const tempPathMid = new paper.Path(this.finalPath.segments.slice(firstCutoff, lastCutoff));
const tempPath2 = new paper.Path(this.finalPath.segments.slice(lastCutoff, length - 1));
// Run simplify on the new ends. We need to graft the old handles back onto the newly
// simplified paths, since simplify removes the in handle from the start of the path, and
// the out handle from the end of the path it's simplifying.
const oldPath1End = tempPath1.segments[tempPath1.segments.length - 1];
const oldPath2End = tempPath2.segments[0];
tempPath1.simplify(threshold);
tempPath2.simplify(threshold);
const newPath1End = tempPath1.segments[tempPath1.segments.length - 1];
const newPath2End = tempPath2.segments[0];
newPath1End.handleOut = oldPath1End.handleOut;
newPath2End.handleIn = oldPath2End.handleIn;
// Delete the old contents of finalPath and replace it with the newly simplified segments, concatenated
this.finalPath.removeSegments(1, this.finalPath.segments.length - 1);
this.finalPath.insertSegments(1, tempPath1.segments.concat(tempPathMid.segments).concat(tempPath2.segments));
// Remove temp paths
tempPath1.remove();
tempPath2.remove();
tempPathMid.remove();
// Update how many points have been smoothed so far so that we don't redo work when
// simplify is called next time.
this.smoothed = Math.max(2, this.finalPath.segments.length);
2018-02-02 18:47:25 -05:00
}
2018-02-28 20:06:51 -05:00
/**
* Like paper.Path.unite, but it removes the original 2 paths
2018-03-02 10:38:35 -05:00
* @param {paper.Path} path1 to merge
* @param {paper.Path} path2 to merge
* @return {paper.Path} merged path. Original paths 1 and 2 will be removed from the view.
2018-02-28 20:06:51 -05:00
*/
union (path1, path2) {
const temp = path1.unite(path2);
path1.remove();
path2.remove();
return temp;
}
onBroadMouseUp (event, tool, options) {
2018-02-02 18:47:25 -05:00
// If there was only a single click, draw a circle.
if (this.steps === 0) {
this.endCaps.length = 0;
2018-02-02 18:47:25 -05:00
return this.finalPath;
}
2018-02-28 20:06:51 -05:00
2018-02-02 18:47:25 -05:00
// If the mouse up is at the same point as the mouse drag event then we need
// the second to last point to get the right direction vector for the end cap
if (!event.point.equals(this.lastPoint)) {
const step = event.delta.normalize(options.brushSize / 2);
2017-07-20 22:48:07 -04:00
step.angle += 90;
const top = event.point.add(step);
const bottom = event.point.subtract(step);
this.finalPath.add(top);
this.finalPath.insert(0, bottom);
2018-02-02 18:47:25 -05:00
}
2017-07-20 22:48:07 -04:00
2018-02-02 18:47:25 -05:00
// Simplify before adding end cap so cap doesn't get warped
this.simplify(1);
const handleVec = event.delta.normalize(options.brushSize / 2);
this.finalPath.add(new paper.Segment(
event.point.add(handleVec),
handleVec.rotate(90),
handleVec.rotate(-90)
));
2018-02-28 20:06:51 -05:00
this.finalPath.closePath();
2017-07-20 22:48:07 -04:00
// Resolve self-crossings
const newPath =
this.finalPath
2017-07-20 22:48:07 -04:00
.resolveCrossings()
.reorient(true /* nonZero */, true /* clockwise */)
.reduce({simplify: true});
if (newPath !== this.finalPath) {
newPath.copyAttributes(this.finalPath);
newPath.fillColor = this.finalPath.fillColor;
this.finalPath.remove();
this.finalPath = newPath;
}
// Try to merge end caps
for (const cap of this.endCaps) {
const temp = this.union(this.finalPath, cap);
if (temp.area >= this.finalPath.area &&
!(temp instanceof paper.CompoundPath && !(this.finalPath instanceof paper.CompoundPath))) {
this.finalPath = temp;
} else {
// If the union of the two shapes is smaller than the original shape,
// or it caused the path to become a compound path,
// then there must have been a glitch with paperjs's unite function.
// In this case, skip merging that segment. It's not great, but it's
// better than losing the whole path for instance. (Unfortunately, this
// happens reasonably often to scribbles, and this code doesn't catch
// all of the failures.)
this.finalPath.insertAbove(temp);
temp.remove();
log.warn('Skipping a merge.');
}
}
this.endCaps.length = 0;
return this.finalPath;
}
}
2017-07-20 22:48:07 -04:00
export default BroadBrushHelper;