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) { if (this.tool) { this.tool.options = options; this.tool.resizeCursorIfNeeded(); } } activateTool (isEraser, tool, options) { this.tool = tool; tool.cursorPreviewLastPoint = new paper.Point(-10000, -10000); tool.resizeCursorIfNeeded = function (point) { if (typeof point === 'undefined') { point = this.cursorPreviewLastPoint; } else { this.cursorPreviewLastPoint = point; } if (this.brushSize === this.options.brushSize) { return; } const newPreview = new paper.Path.Circle({ center: point, radius: this.options.brushSize / 2 }); if (this.cursorPreview) { this.cursorPreview.segments = newPreview.segments; newPreview.remove(); } else { this.cursorPreview = newPreview; } this.brushSize = this.options.brushSize; }; this.setOptions(options); tool.stylePath = function (path) { if (isEraser) { path.fillColor = 'white'; if (path === this.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 === this.cursorPreview) { path.strokeColor = 'cornflowerblue'; path.strokeWidth = 1; } } }; tool.stylePath(this.tool.cursorPreview); tool.fixedDistance = 1; broadBrushHelper(tool); // TODO add //pg.segmentbrushhelper(tool, options); tool.onMouseMove = function (event) { tool.resizeCursorIfNeeded(event.point); tool.stylePath(this.cursorPreview); this.cursorPreview.bringToFront(); this.cursorPreview.position = event.point; }; tool.onMouseDown = function (event) { tool.resizeCursorIfNeeded(event.point); if (event.event.button > 0) return; // only first mouse button if (this.options.brushSize < BlobTool.THRESHOLD) { this.brush = BlobTool.BROAD; this.onBroadMouseDown(event); } else { this.brush = BlobTool.SEGMENT; this.onSegmentMouseDown(event); } this.cursorPreview.bringToFront(); this.cursorPreview.position = event.point; paper.view.draw(); }; tool.onMouseDrag = function (event) { tool.resizeCursorIfNeeded(event.point); 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}`); } this.cursorPreview.bringToFront(); this.cursorPreview.position = event.point; paper.view.draw(); }; tool.onMouseUp = function (event) { tool.resizeCursorIfNeeded(event.point); 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); } this.cursorPreview.bringToFront(); this.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 = []; // 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 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 !== this.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 () { if (this.tool) { this.tool.cursorPreview.remove(); this.tool.remove(); } } } module.exports = BlobTool;