get broad brush working

This commit is contained in:
DD Liu 2017-07-20 22:48:07 -04:00
parent 2846dcebc8
commit 014907ba98
9 changed files with 622 additions and 8 deletions

View file

@ -1,14 +1,20 @@
import PropTypes from 'prop-types';
import React from 'react';
import PaperCanvas from '../containers/paper-canvas.jsx';
import BrushTool from '../containers/tools/brush-tool.jsx';
const PaintEditorComponent = props => (
<PaperCanvas
tool={props.tool}
/>
<div>
<PaperCanvas
canvasId={props.canvasId}
tool={props.tool}
/>
<BrushTool canvasId={props.canvasId} />
</div>
);
PaintEditorComponent.propTypes = {
canvasId: PropTypes.string.isRequired,
tool: PropTypes.shape({
name: PropTypes.string.isRequired
})

View file

@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import PaintEditorComponent from '../components/paint-editor.jsx';
import tools from '../reducers/tools';
import ToolsReducer from '../reducers/tools';
import ToolTypes from '../tools/tool-types.js';
import {connect} from 'react-redux';
@ -16,6 +16,7 @@ class PaintEditor extends React.Component {
render () {
return (
<PaintEditorComponent
canvasId="paper-canvas"
tool={this.props.tool}
/>
);
@ -35,9 +36,9 @@ const mapStateToProps = state => ({
const mapDispatchToProps = dispatch => ({
onKeyPress: e => {
if (e.key === 'e') {
dispatch(tools.changeTool(ToolTypes.ERASER));
dispatch(ToolsReducer.changeTool(ToolTypes.ERASER));
} else if (e.key === 'b') {
dispatch(tools.changeTool(ToolTypes.BRUSH));
dispatch(ToolsReducer.changeTool(ToolTypes.BRUSH));
}
}
});

View file

@ -33,12 +33,13 @@ class PaperCanvas extends React.Component {
}
render () {
return (
<canvas id="paper-canvas" />
<canvas id={this.props.canvasId} />
);
}
}
PaperCanvas.propTypes = {
canvasId: PropTypes.string.isRequired,
tool: PropTypes.shape({
name: PropTypes.string.isRequired
})

View file

@ -0,0 +1,102 @@
import PropTypes from 'prop-types';
import React from 'react';
import {connect} from 'react-redux';
import bindAll from 'lodash.bindall';
import ToolTypes from '../../tools/tool-types.js';
import BlobTool from '../../tools/blob.js';
import BrushToolReducer from '../../reducers/brush-tool';
import paper from 'paper';
class BrushTool extends React.Component {
static get TOOL_TYPE () {
return ToolTypes.BRUSH;
}
constructor (props) {
super(props);
bindAll(this, [
'activateTool',
'deactivateTool',
'onScroll'
]);
this.blob = new BlobTool();
}
componentDidMount () {
if (this.props.tool === BrushTool.TOOL_TYPE) {
this.activateTool();
}
}
componentWillReceiveProps (nextProps) {
if (nextProps.tool === BrushTool.TOOL_TYPE && this.props.tool !== BrushTool.TOOL_TYPE) {
this.activateTool();
} else if (nextProps.tool !== BrushTool.TOOL_TYPE && this.props.tool === BrushTool.TOOL_TYPE) {
this.deactivateTool();
} else if (nextProps.tool === BrushTool.TOOL_TYPE && this.props.tool === BrushTool.TOOL_TYPE) {
this.blob.setOptions(nextProps.brushToolState);
}
}
shouldComponentUpdate () {
return false; // Logic only component
}
activateTool () {
document.getElementById(this.props.canvasId)
.addEventListener('mousewheel', this.onScroll);
const tool = new paper.Tool();
this.blob.activateTool(false /* isEraser */, tool, this.props.brushToolState);
// // Make sure a fill color is set on the brush
// if(!pg.stylebar.getFillColor()) {
// pg.stylebar.setFillColor(pg.stylebar.getStrokeColor());
// pg.stylebar.setStrokeColor(null);
// }
// // setup floating tool options panel in the editor
// pg.toolOptionPanel.setup(options, components, function() {});
tool.activate();
}
deactivateTool () {
document.getElementById(this.props.canvasId)
.removeEventListener('mousewheel', this.onScroll);
this.blob.deactivateTool();
}
onScroll (event) {
if (event.deltaY < 0) {
this.props.changeBrushSize(this.props.brushToolState.brushSize + 1);
} else if (event.deltaY > 0 && this.props.brushToolState.brushSize > 1) {
this.props.changeBrushSize(this.props.brushToolState.brushSize - 1);
}
return false;
}
render () {
return (
<div />
);
}
}
BrushTool.propTypes = {
brushToolState: PropTypes.shape({
brushSize: PropTypes.number.isRequired
}),
canvasId: PropTypes.string.isRequired,
changeBrushSize: PropTypes.func.isRequired,
tool: PropTypes.shape({
name: PropTypes.string.isRequired
})
};
const mapStateToProps = state => ({
brushToolState: state.brushTool,
tool: state.tool
});
const mapDispatchToProps = dispatch => ({
changeBrushSize: brushSize => {
dispatch(BrushToolReducer.changeBrushSize(brushSize));
}
});
module.exports = connect(
mapStateToProps,
mapDispatchToProps
)(BrushTool);

View file

@ -0,0 +1,25 @@
const CHANGE_BRUSH_SIZE = 'scratch-paint/tools/CHANGE_BRUSH_SIZE';
const initialState = {brushSize: 5};
const reducer = function (state, action) {
if (typeof state === 'undefined') state = initialState;
switch (action.type) {
case CHANGE_BRUSH_SIZE:
return {brushSize: Math.max(1, action.brushSize)};
default:
return state;
}
};
// Action creators ==================================
reducer.changeBrushSize = function (brushSize) {
return {
type: CHANGE_BRUSH_SIZE,
brushSize: brushSize,
meta: {
throttle: 30
}
};
};
module.exports = reducer;

View file

@ -1,5 +1,6 @@
import {combineReducers} from 'redux';
module.exports = combineReducers({
tool: require('./tools')
tool: require('./tools'),
brushTool: require('./brush-tool')
});

327
src/tools/blob.js Normal file
View file

@ -0,0 +1,327 @@
const paper = require('paper');
const log = require('../log/log');
const broadBrushHelper = require('./broad-brush-helper');
class BlobTool {
static get BROAD () {
return 'broadbrush';
}
static get SEGMENT () {
return 'segmentbrush';
}
// brush size >= threshold use segment brush, else use broadbrush
// Segment brush has performance issues at low threshold, but broad brush has weird corners
// which are more obvious the bigger it is
static get THRESHOLD () {
return 100000;
}
setOptions (options) {
console.log('setOptions');
this.options = options;
if (this.cursorPreview) {
this.cursorPreview = new paper.Path.Circle({
center: [this.cursorPreview.center.x, this.cursorPreview.center.y],
radius: options.brushSize / 2
});
}
}
activateTool (isEraser, tool, options) {
console.log('activateTool isEraser?'+isEraser);
this.tool = tool;
this.options = options;
let cursorPreview = this.cursorPreview = new paper.Path.Circle({
center: [-10000, -10000],
radius: options.brushSize / 2
});
this.brushSize = options.brushSize;
tool.stylePath = function (path) {
if (isEraser) {
path.fillColor = 'white';
if (path === cursorPreview) {
path.strokeColor = 'cornflowerblue';
path.strokeWidth = 1;
}
} else {
// TODO keep a separate active toolbar style for brush vs pen?
//path = pg.stylebar.applyActiveToolbarStyle(path);
//TODO FIX
path.fillColor = 'black';
if (path === cursorPreview) {
path.strokeColor = 'cornflowerblue';
path.strokeWidth = 1;
}
}
};
tool.stylePath(cursorPreview);
tool.fixedDistance = 1;
broadBrushHelper(tool, options);
// TODO add
//pg.segmentbrushhelper(tool, options);
tool.onMouseMove = function (event) {
if (this.brushSize !== options.brushSize) {
cursorPreview.remove();
cursorPreview = new paper.Path.Circle({
center: event.point,
radius: options.brushSize / 2
});
this.brushSize = options.brushSize;
}
tool.stylePath(cursorPreview);
cursorPreview.bringToFront();
cursorPreview.position = event.point;
};
tool.onMouseDown = function (event) {
if (event.event.button > 0) return; // only first mouse button
if (options.brushSize < BlobTool.THRESHOLD) {
this.brush = BlobTool.BROAD;
this.onBroadMouseDown(event);
} else {
this.brush = BlobTool.SEGMENT;
this.onSegmentMouseDown(event);
}
cursorPreview.bringToFront();
cursorPreview.position = event.point;
paper.view.draw();
};
tool.onMouseDrag = function (event) {
if (event.event.button > 0) return; // only first mouse button
if (this.brush === BlobTool.BROAD) {
this.onBroadMouseDrag(event);
} else if (this.brush === Blob.SEGMENT) {
this.onSegmentMouseDrag(event);
} else {
log.warning(`Brush type does not exist: ${this.brush}`);
}
cursorPreview.bringToFront();
cursorPreview.position = event.point;
paper.view.draw();
};
tool.onMouseUp = function (event) {
if (event.event.button > 0) return; // only first mouse button
let lastPath;
if (this.brush === BlobTool.BROAD) {
lastPath = this.onBroadMouseUp(event);
} else if (this.brush === BlobTool.SEGMENT) {
lastPath = this.onSegmentMouseUp(event);
} else {
log.warning(`Brush type does not exist: ${this.brush}`);
}
if (isEraser) {
tool.mergeEraser(lastPath);
} else {
tool.mergeBrush(lastPath);
}
cursorPreview.bringToFront();
cursorPreview.position = event.point;
// Reset
this.brush = null;
tool.fixedDistance = 1;
};
tool.mergeBrush = function (lastPath) {
// Get all path items to merge with
const paths = paper.project.getItems({
match: function (item) {
return tool.isMergeable(lastPath, item);
}
});
let mergedPath = lastPath;
let i;
// Move down z order to first overlapping item
for (i = paths.length - 1; i >= 0 && !tool.touches(paths[i], lastPath); i--) {
continue;
}
let mergedPathIndex = i;
for (; i >= 0; i--) {
if (!tool.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 (tool.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 (tool.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);
}
}
// TODO FIX
//pg.undo.snapshot('broadbrush');
};
tool.mergeEraser = function (lastPath) {
// 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 && tool.isMergeable(lastPath, item) && tool.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) {
// TODO FIX
//pg.selection.clearSelection();
items = paper.project.getItems({
match: function (item) {
return tool.isMergeable(lastPath, item) && tool.touches(lastPath, item);
}
});
}
for (let i = items.length - 1; i >= 0; i--) {
// Erase
const newPath = items[i].subtract(lastPath);
// Gather path segments
const subpaths = [];
if (items[i] instanceof paper.PathItem && !items[i].closed) {
const firstSeg = items[i].clone();
const intersections = firstSeg.getIntersections(lastPath);
// keep first and last segments
if (intersections.length === 0) {
continue;
}
for (let j = intersections.length - 1; j >= 0; j--) {
subpaths.push(firstSeg.splitAt(intersections[j]));
}
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 (tool.firstEnclosesSecond(ccw, cw) || tool.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();
// TODO FIX
//pg.undo.snapshot('eraser');
};
tool.colorMatch = function (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() &&
tool.touches(existingPath, addedPath);
};
tool.touches = function (path1, path2) {
// Two shapes are touching if their paths intersect
if (path1 && path2 && path1.intersects(path2)) {
return true;
}
return tool.firstEnclosesSecond(path1, path2) || tool.firstEnclosesSecond(path2, path1);
};
tool.firstEnclosesSecond = function (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;
};
tool.isMergeable = function (newPath, existingPath) {
return existingPath instanceof paper.PathItem && // path or compound path
existingPath !== cursorPreview && // don't merge with the mouse preview
existingPath !== newPath && // don't merge with self
existingPath.parent instanceof paper.Layer; // don't merge with nested in group
};
}
deactivateTool () {
console.log('deactivateTool');
this.cursorPreview.remove();
}
}
module.exports = BlobTool;

View file

@ -0,0 +1,107 @@
// Broadbrush based on http://paperjs.org/tutorials/interaction/working-with-mouse-vectors/
const paper = require('paper');
/**
* Applies segment brush functions to the tool.
* @param {!Tool} tool paper.js mouse object
* @param {!options} options brush tool state object
* @param {!options.brushSize} brush tool diameter
*/
const broadBrushHelper = function (tool, options) {
let lastPoint;
let secondLastPoint;
let finalPath;
tool.onBroadMouseDown = function (event) {
tool.minDistance = options.brushSize / 4;
tool.maxDistance = options.brushSize;
if (event.event.button > 0) return; // only first mouse button
finalPath = new paper.Path();
tool.stylePath(finalPath);
finalPath.add(event.point);
lastPoint = secondLastPoint = event.point;
};
tool.onBroadMouseDrag = function (event) {
const step = (event.delta).normalize(options.brushSize / 2);
// Move the first point out away from the drag so that the end of the path is rounded
if (finalPath.segments && finalPath.segments.length === 1) {
const removedPoint = finalPath.removeSegment(0).point;
// Add handles to round the end caps
const handleVec = step.clone();
handleVec.length = options.brushSize / 2;
handleVec.angle += 90;
finalPath.add(new paper.Segment(removedPoint.subtract(step), -handleVec, handleVec));
}
step.angle += 90;
const top = event.middlePoint.add(step);
const bottom = event.middlePoint.subtract(step);
if (finalPath.segments.length > 3) {
finalPath.removeSegment(finalPath.segments.length - 1);
finalPath.removeSegment(0);
}
finalPath.add(top);
finalPath.add(event.point.add(step));
finalPath.insert(0, bottom);
finalPath.insert(0, event.point.subtract(step));
if (finalPath.segments.length === 5) {
// Flatten is necessary to prevent smooth from getting rid of the effect
// of the handles on the first point.
finalPath.flatten(options.brushSize / 5);
}
finalPath.smooth();
lastPoint = event.point;
secondLastPoint = event.lastPoint;
};
tool.onBroadMouseUp = function (event) {
// 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(lastPoint)) {
lastPoint = secondLastPoint;
}
// If the points are still equal, then there was no drag, so just draw a circle.
if (event.point.equals(lastPoint)) {
finalPath.remove();
finalPath = new paper.Path.Circle({
center: event.point,
radius: options.brushSize / 2
});
tool.stylePath(finalPath);
} else {
const step = (event.point.subtract(lastPoint)).normalize(options.brushSize / 2);
step.angle += 90;
const handleVec = step.clone();
handleVec.length = options.brushSize / 2;
const top = event.point.add(step);
const bottom = event.point.subtract(step);
finalPath.add(top);
finalPath.insert(0, bottom);
// Simplify before adding end cap so cap doesn't get warped
finalPath.simplify(1);
// Add end cap
step.angle -= 90;
finalPath.add(new paper.Segment(event.point.add(step), handleVec, -handleVec));
finalPath.closed = true;
}
// Resolve self-crossings
const newPath =
finalPath
.resolveCrossings()
.reorient(true /* nonZero */, true /* clockwise */)
.reduce({simplify: true});
newPath.copyAttributes(finalPath);
newPath.fillColor = finalPath.fillColor;
finalPath = newPath;
return finalPath;
};
};
module.exports = broadBrushHelper;

44
src/tools/eraser.js Normal file
View file

@ -0,0 +1,44 @@
// TODO share code with brush
pg.tools.registerTool({
id: 'eraser',
name: 'Eraser'
});
pg.tools.eraser = function() {
var blob = new pg.blob();
var options = {
brushWidth: 20
};
var components = {
brushWidth: {
type: 'float',
label: 'Eraser width',
min: 0
}
};
var activateTool = function() {
// get options from local storage if present
options = pg.tools.getLocalOptions(options);
var tool = new Tool();
blob.activateTool(true /* isEraser */, tool, options);
// setup floating tool options panel in the editor
pg.toolOptionPanel.setup(options, components, function() {});
tool.activate();
};
var deactivateTool = function() {
blob.deactivateTool();
};
return {
options: options,
activateTool : activateTool,
deactivateTool : deactivateTool
};
};