scratch-paint/src/helper/blob-tools/blob.js
2017-10-19 11:48:14 -04:00

405 lines
17 KiB
JavaScript

import paper from '@scratch/paper';
import log from '../../log/log';
import BroadBrushHelper from './broad-brush-helper';
import SegmentBrushHelper from './segment-brush-helper';
import {MIXED, styleCursorPreview} from '../../helper/style-path';
import {clearSelection} from '../../helper/selection';
import {getGuideLayer} from '../../helper/layer';
/**
* Shared code for the brush and eraser mode. Adds functions on the paper tool object
* to handle mouse events, which are delegated to broad-brush-helper and segment-brush-helper
* based on the brushSize in the state.
*/
class Blobbiness {
static get BROAD () {
return 'broadbrush';
}
static get SEGMENT () {
return 'segmentbrush';
}
// If brush size >= threshold use segment brush, else use broadbrush
// Segment brush has performance issues at low threshold, but broad brush has weird corners
// which get more obvious the bigger it is
static get THRESHOLD () {
return 9;
}
/**
* @param {function} onUpdateSvg call when the drawing has changed to let listeners know
* @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state
*/
constructor (onUpdateSvg, clearSelectedItems) {
this.broadBrushHelper = new BroadBrushHelper();
this.segmentBrushHelper = new SegmentBrushHelper();
this.onUpdateSvg = onUpdateSvg;
this.clearSelectedItems = clearSelectedItems;
// The following are stored to check whether these have changed and the cursor preview needs to be redrawn.
this.strokeColor = null;
this.brushSize = null;
this.fillColor = null;
}
/**
* Set configuration options for a blob
* @param {!object} options Configuration
* @param {!number} options.brushSize Width of blob marking made by mouse
* @param {!boolean} options.isEraser Whether the stroke should be treated as an erase path. If false,
* the stroke is an additive path.
* @param {?string} options.fillColor Color of the brush stroke.
* @param {?string} options.strokeColor Color of the brush outline.
* @param {?number} options.strokeWidth Width of the brush outline.
*/
setOptions (options) {
const oldFillColor = this.options ? this.options.fillColor : 'black';
const oldStrokeColor = this.options ? this.options.strokeColor : null;
const oldStrokeWidth = this.options ? this.options.strokeWidth : null;
// If values are mixed, it means the color was set by a selection contained multiple values.
// In this case keep drawing with the previous values if any. (For stroke width, null indicates
// mixed, because stroke width is required to be a number)
this.options = {
...options,
fillColor: options.fillColor === MIXED ? oldFillColor : options.fillColor,
strokeColor: options.strokeColor === MIXED ? oldStrokeColor : options.strokeColor,
strokeWidth: options.strokeWidth === null ? oldStrokeWidth : options.strokeWidth
};
this.resizeCursorIfNeeded();
}
/**
* Adds handlers on the mouse tool to draw blobs. Initialize with configuration options for a blob.
* @param {!object} options Configuration
* @param {!number} options.brushSize Width of blob marking made by mouse
* @param {!boolean} options.isEraser Whether the stroke should be treated as an erase path. If false,
* the stroke is an additive path.
* @param {?string} options.fillColor Color of the brush stroke.
* @param {?string} options.strokeColor Color of the brush outline.
* @param {?number} options.strokeWidth Width of the brush outline.
*/
activateTool (options) {
this.tool = new paper.Tool();
this.cursorPreviewLastPoint = new paper.Point(-10000, -10000);
this.setOptions(options);
this.tool.fixedDistance = 1;
const blob = this;
this.tool.onMouseMove = function (event) {
blob.resizeCursorIfNeeded(event.point);
styleCursorPreview(blob.cursorPreview, blob.options);
blob.cursorPreview.bringToFront();
blob.cursorPreview.position = event.point;
};
this.tool.onMouseDown = function (event) {
blob.resizeCursorIfNeeded(event.point);
if (event.event.button > 0) return; // only first mouse button
if (blob.options.brushSize < Blobbiness.THRESHOLD) {
blob.brush = Blobbiness.BROAD;
blob.broadBrushHelper.onBroadMouseDown(event, blob.tool, blob.options);
} else {
blob.brush = Blobbiness.SEGMENT;
blob.segmentBrushHelper.onSegmentMouseDown(event, blob.tool, blob.options);
}
blob.cursorPreview.bringToFront();
blob.cursorPreview.position = event.point;
paper.view.draw();
};
this.tool.onMouseDrag = function (event) {
blob.resizeCursorIfNeeded(event.point);
if (event.event.button > 0) return; // only first mouse button
if (blob.brush === Blobbiness.BROAD) {
blob.broadBrushHelper.onBroadMouseDrag(event, blob.tool, blob.options);
} else if (blob.brush === Blobbiness.SEGMENT) {
blob.segmentBrushHelper.onSegmentMouseDrag(event, blob.tool, blob.options);
} else {
log.warn(`Brush type does not exist: ${blob.brush}`);
}
blob.cursorPreview.bringToFront();
blob.cursorPreview.position = event.point;
paper.view.draw();
};
this.tool.onMouseUp = function (event) {
blob.resizeCursorIfNeeded(event.point);
if (event.event.button > 0) return; // only first mouse button
let lastPath;
if (blob.brush === Blobbiness.BROAD) {
lastPath = blob.broadBrushHelper.onBroadMouseUp(event, blob.tool, blob.options);
} else if (blob.brush === Blobbiness.SEGMENT) {
lastPath = blob.segmentBrushHelper.onSegmentMouseUp(event, blob.tool, blob.options);
} else {
log.warn(`Brush type does not exist: ${blob.brush}`);
}
if (blob.options.isEraser) {
blob.mergeEraser(lastPath);
} else {
blob.mergeBrush(lastPath);
}
blob.cursorPreview.visible = false;
blob.onUpdateSvg();
blob.cursorPreview.visible = true;
blob.cursorPreview.bringToFront();
blob.cursorPreview.position = event.point;
// Reset
blob.brush = null;
this.fixedDistance = 1;
};
this.tool.activate();
}
resizeCursorIfNeeded (point) {
if (!this.options) {
return;
}
if (typeof point === 'undefined') {
point = this.cursorPreviewLastPoint;
} else {
this.cursorPreviewLastPoint = point;
}
if (this.cursorPreview && this.cursorPreview.parent &&
this.brushSize === this.options.brushSize &&
this.fillColor === this.options.fillColor &&
this.strokeColor === this.options.strokeColor) {
return;
}
const newPreview = new paper.Path.Circle({
center: point,
radius: this.options.brushSize / 2
});
newPreview.parent = getGuideLayer();
newPreview.data.isHelperItem = true;
if (this.cursorPreview) {
this.cursorPreview.remove();
}
this.brushSize = this.options.brushSize;
this.fillColor = this.options.fillColor;
this.strokeColor = this.options.strokeColor;
this.cursorPreview = newPreview;
styleCursorPreview(this.cursorPreview, this.options);
}
mergeBrush (lastPath) {
const blob = this;
// Get all path items to merge with
const paths = paper.project.getItems({
match: function (item) {
return blob.isMergeable(lastPath, item) &&
item.parent instanceof paper.Layer; // don't merge with nested in group
}
});
let mergedPath = lastPath;
let i;
// Move down z order to first overlapping item
for (i = paths.length - 1; i >= 0 && !this.touches(paths[i], lastPath); i--) {
continue;
}
let mergedPathIndex = i;
for (; i >= 0; i--) {
if (!this.touches(paths[i], lastPath)) {
continue;
}
if (!paths[i].getFillColor()) {
// Ignore for merge. Paths without fill need to be in paths though,
// since they can visibly change if z order changes
} else if (this.colorMatch(paths[i], lastPath)) {
// Make sure the new shape isn't overlapped by anything that would
// visibly change if we change its z order
for (let j = mergedPathIndex; j > i; j--) {
if (this.touches(paths[j], paths[i])) {
continue;
}
}
// Merge same fill color
const tempPath = mergedPath.unite(paths[i]);
tempPath.strokeColor = paths[i].strokeColor;
tempPath.strokeWidth = paths[i].strokeWidth;
if (mergedPath === lastPath) {
tempPath.insertAbove(paths[i]); // First intersected path determines z position of the new path
} else {
tempPath.insertAbove(mergedPath); // Rest of merges join z index of merged path
mergedPathIndex--; // Removed an item, so the merged path index decreases
}
mergedPath.remove();
mergedPath = tempPath;
paths[i].remove();
paths.splice(i, 1);
}
}
}
mergeEraser (lastPath) {
const blob = this;
// Get all path items to merge with
// If there are selected items, try to erase from amongst those.
let items = paper.project.getItems({
match: function (item) {
return item.selected && blob.isMergeable(lastPath, item) && blob.touches(lastPath, item);
}
});
// Eraser didn't hit anything selected, so assume they meant to erase from all instead of from subset
// and deselect the selection
if (items.length === 0) {
clearSelection(this.clearSelectedItems);
items = paper.project.getItems({
match: function (item) {
return blob.isMergeable(lastPath, item) && blob.touches(lastPath, item);
}
});
}
for (let i = items.length - 1; i >= 0; i--) {
// TODO handle compound paths
if (items[i] instanceof paper.Path && (!items[i].fillColor || items[i].fillColor._alpha === 0)) {
// Gather path segments
const subpaths = [];
const firstSeg = items[i];
const intersections = firstSeg.getIntersections(lastPath);
for (let j = intersections.length - 1; j >= 0; j--) {
const split = firstSeg.splitAt(intersections[j]);
if (split) {
split.insertAbove(firstSeg);
subpaths.push(split);
}
}
subpaths.push(firstSeg);
// Remove the ones that are within the eraser stroke boundary
for (let k = subpaths.length - 1; k >= 0; k--) {
const segMidpoint = subpaths[k].getLocationAt(subpaths[k].length / 2).point;
if (lastPath.contains(segMidpoint)) {
subpaths[k].remove();
subpaths.splice(k, 1);
}
}
lastPath.remove();
continue;
}
// Erase
const newPath = items[i].subtract(lastPath);
newPath.insertBelow(items[i]);
// Gather path segments
const subpaths = [];
// TODO: Handle compound path
if (items[i] instanceof paper.Path && !items[i].closed) {
const firstSeg = items[i].clone();
const intersections = firstSeg.getIntersections(lastPath);
// keep first and last segments
for (let j = intersections.length - 1; j >= 0; j--) {
const split = firstSeg.splitAt(intersections[j]);
split.insertAbove(firstSeg);
subpaths.push(split);
}
subpaths.push(firstSeg);
}
// Remove the ones that are within the eraser stroke boundary, or are already part of new path.
// This way subpaths only remain if they didn't get turned into a shape by subtract.
for (let k = subpaths.length - 1; k >= 0; k--) {
const segMidpoint = subpaths[k].getLocationAt(subpaths[k].length / 2).point;
if (lastPath.contains(segMidpoint) || newPath.contains(segMidpoint)) {
subpaths[k].remove();
subpaths.splice(k, 1);
}
}
// Divide topologically separate shapes into their own compound paths, instead of
// everything being stuck together.
// Assume that result of erase operation returns clockwise paths for positive shapes
const clockwiseChildren = [];
const ccwChildren = [];
if (newPath.children) {
for (let j = newPath.children.length - 1; j >= 0; j--) {
const child = newPath.children[j];
if (child.isClockwise()) {
clockwiseChildren.push(child);
} else {
ccwChildren.push(child);
}
}
for (let j = 0; j < clockwiseChildren.length; j++) {
const cw = clockwiseChildren[j];
cw.copyAttributes(newPath);
cw.fillColor = newPath.fillColor;
cw.strokeColor = newPath.strokeColor;
cw.strokeWidth = newPath.strokeWidth;
cw.insertAbove(items[i]);
// Go backward since we are deleting elements
let newCw = cw;
for (let k = ccwChildren.length - 1; k >= 0; k--) {
const ccw = ccwChildren[k];
if (this.firstEnclosesSecond(ccw, cw) || this.firstEnclosesSecond(cw, ccw)) {
const temp = newCw.subtract(ccw);
temp.insertAbove(newCw);
newCw.remove();
newCw = temp;
ccw.remove();
ccwChildren.splice(k, 1);
}
}
}
newPath.remove();
}
items[i].remove();
}
lastPath.remove();
}
colorMatch (existingPath, addedPath) {
// Note: transparent fill colors do notdetect as touching
return existingPath.getFillColor().equals(addedPath.getFillColor()) &&
(addedPath.getStrokeColor() === existingPath.getStrokeColor() || // both null
(addedPath.getStrokeColor() &&
addedPath.getStrokeColor().equals(existingPath.getStrokeColor()))) &&
addedPath.getStrokeWidth() === existingPath.getStrokeWidth() &&
this.touches(existingPath, addedPath);
}
touches (path1, path2) {
// Two shapes are touching if their paths intersect
if (path1 && path2 && path1.intersects(path2)) {
return true;
}
return this.firstEnclosesSecond(path1, path2) || this.firstEnclosesSecond(path2, path1);
}
firstEnclosesSecond (path1, path2) {
// Two shapes are also touching if one is completely inside the other
if (path1 && path2 && path2.firstSegment && path2.firstSegment.point &&
path1.hitTest(path2.firstSegment.point)) {
return true;
}
// TODO: clean up these no point paths
return false;
}
isMergeable (newPath, existingPath) {
return existingPath instanceof paper.PathItem && // path or compound path
existingPath !== this.cursorPreview && // don't merge with the mouse preview
existingPath !== newPath; // don't merge with self
}
deactivateTool () {
this.cursorPreview.remove();
this.cursorPreview = null;
this.tool.remove();
this.tool = null;
}
}
export default Blobbiness;