/* * Copyright 2017 Laszlo Balazs-Csiki * * This file is part of Pixelitor. Pixelitor is free software: you * can redistribute it and/or modify it under the terms of the GNU * General Public License, version 3 as published by the Free * Software Foundation. * * Pixelitor is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Pixelitor. If not, see <http://www.gnu.org/licenses/>. */ package pixelitor.tools; import pixelitor.Composition; import pixelitor.colors.FillType; import pixelitor.filters.gui.RangeParam; import pixelitor.gui.ImageComponent; import pixelitor.gui.utils.SliderSpinner; import pixelitor.layers.Drawable; import pixelitor.utils.ImageUtils; import pixelitor.utils.debug.DebugNode; import javax.swing.*; import java.awt.AlphaComposite; import java.awt.Color; import java.awt.Cursor; import java.awt.Graphics2D; import java.awt.Point; import java.awt.Rectangle; import java.awt.event.MouseEvent; import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.util.ArrayDeque; import java.util.Deque; import static pixelitor.Composition.ImageChangeActions.FULL; import static pixelitor.colors.FillType.BACKGROUND; import static pixelitor.colors.FillType.FOREGROUND; import static pixelitor.colors.FillType.TRANSPARENT; import static pixelitor.gui.utils.SliderSpinner.TextPosition.WEST; /** * A paint bucket tool. */ public class PaintBucketTool extends Tool { private final RangeParam toleranceParam = new RangeParam("Tolerance", 0, 20, 255); private JComboBox<FillType> fillComboBox; public PaintBucketTool() { super('p', "Paint Bucket", "paint_bucket_tool_icon.png", "click to fill with the selected color", Cursor.getDefaultCursor(), true, true, false, ClipStrategy.IMAGE_ONLY); } @Override public void initSettingsPanel() { settingsPanel.add(new SliderSpinner(toleranceParam, WEST, false)); fillComboBox = new JComboBox<>(new FillType[]{FOREGROUND, BACKGROUND, TRANSPARENT}); settingsPanel.addWithLabel("Fill With:", fillComboBox); } private Color getFillColor() { FillType fillType = getFillType(); return fillType.getColor(); } private FillType getFillType() { return (FillType) fillComboBox.getSelectedItem(); } @Override public void mousePressed(MouseEvent e, ImageComponent ic) { // do nothing } @Override public void mouseDragged(MouseEvent e, ImageComponent ic) { // do nothing } @Override public void mouseReleased(MouseEvent e, ImageComponent ic) { double x = userDrag.getEndX(); double y = userDrag.getEndY(); Composition comp = ic.getComp(); Drawable dr = comp.getActiveDrawable(); int tx = dr.getTX(); int ty = dr.getTY(); x -= tx; y -= ty; AffineTransform translationTransform = null; if (tx != 0 || ty != 0) { translationTransform = AffineTransform.getTranslateInstance(-tx, -ty); } BufferedImage image = dr.getImage(); BufferedImage original = ImageUtils.copyImage(image); BufferedImage workingCopy = ImageUtils.copyImage(image); Color newColor = getFillColor(); Rectangle replacedArea = scanlineFloodFill(workingCopy, (int) x, (int) y, newColor, toleranceParam.getValue()); if (replacedArea != null) { // something was replaced ToolAffectedArea affectedArea = new ToolAffectedArea(dr, replacedArea, true); saveSubImageForUndo(original, affectedArea); Graphics2D g = image.createGraphics(); comp.applySelectionClipping(g, translationTransform); g.setComposite(AlphaComposite.Src); g.drawImage(workingCopy, 0, 0, null); g.dispose(); comp.imageChanged(FULL); dr.updateIconImage(); } workingCopy.flush(); } /** * Uses the "Scanline fill" algorithm described at * http://en.wikipedia.org/wiki/Flood_fill */ private static Rectangle scanlineFloodFill(BufferedImage img, int x, int y, Color newColor, int tolerance) { int minX = x; int maxX = x; int minY = y; int maxY = y; int imgHeight = img.getHeight(); int imgWidth = img.getWidth(); if (x < 0 || x >= imgWidth || y < 0 || y >= imgHeight) { return null; } int rgbToBeReplaced = img.getRGB(x, y); int newRGB = newColor.getRGB(); if (rgbToBeReplaced == newRGB) { return null; } int[] pixels = ImageUtils.getPixelsAsArray(img); // Needed because the tolerance: we cannot assume that // if the pixel is within the target range, it has been processed boolean[] checkedPixels = new boolean[pixels.length]; for (int i = 0; i < checkedPixels.length; i++) { checkedPixels[i] = false; } Deque<Point> stack = new ArrayDeque<>(); // the double-ended queue is used as a simple LIFO stack stack.push(new Point(x, y)); while (!stack.isEmpty()) { Point p = stack.pop(); x = p.x; y = p.y; // find the last replaceable point to the left int scanlineMinX = x - 1; int offset = y * imgWidth; while ((scanlineMinX >= 0) && similarColor(pixels[scanlineMinX + offset], rgbToBeReplaced, tolerance)) { scanlineMinX--; } scanlineMinX++; // find the last replaceable point to the right int scanlineMaxX = x + 1; while ((scanlineMaxX < img.getWidth()) && similarColor(pixels[scanlineMaxX + offset], rgbToBeReplaced, tolerance)) { scanlineMaxX++; } scanlineMaxX--; // set the minX, maxX, minY, maxY variables that will be used to calculate the affected area if (scanlineMinX < minX) { minX = scanlineMinX; } if (scanlineMaxX > maxX) { maxX = scanlineMaxX; } if (y > maxY) { maxY = y; } else if (y < minY) { minY = y; } // draw a line between (scanlineMinX, y) and (scanlineMaxX, y) for (int i = scanlineMinX; i <= scanlineMaxX; i++) { int index = i + offset; pixels[index] = newRGB; checkedPixels[index] = true; } // look upwards for new points to be inspected later if (y > 0) { // if there are multiple pixels to be replaced that are horizontal neighbours, // only one of them has to be inspected later boolean pointsInLine = false; int upOffset = (y - 1) * imgWidth; for (int i = scanlineMinX; i <= scanlineMaxX; i++) { int upIndex = i + upOffset; boolean shouldBeReplaced = !checkedPixels[upIndex] && similarColor(pixels[upIndex], rgbToBeReplaced, tolerance); if (!pointsInLine && shouldBeReplaced) { Point inspectLater = new Point(i, y - 1); stack.push(inspectLater); pointsInLine = true; } else if (pointsInLine && !shouldBeReplaced) { pointsInLine = false; } } } // look downwards for new points to be inspected later if (y < imgHeight - 1) { boolean pointsInLine = false; int downOffset = (y + 1) * imgWidth; for (int i = scanlineMinX; i <= scanlineMaxX; i++) { int downIndex = i + downOffset; boolean shouldBeReplaced = !checkedPixels[downIndex] && similarColor(pixels[downIndex], rgbToBeReplaced, tolerance); if (!pointsInLine && shouldBeReplaced) { Point inspectLater = new Point(i, y + 1); stack.push(inspectLater); pointsInLine = true; } else if (pointsInLine && !shouldBeReplaced) { pointsInLine = false; } } } } // return the affected area return new Rectangle(minX, minY, maxX - minX + 1, maxY - minY + 1); } private static boolean similarColor(int color1, int color2, int tolerance) { if (color1 == color2) { return true; } int r1 = (color1 >>> 16) & 0xFF; int g1 = (color1 >>> 8) & 0xFF; int b1 = color1 & 0xFF; int r2 = (color2 >>> 16) & 0xFF; int g2 = (color2 >>> 8) & 0xFF; int b2 = color2 & 0xFF; return (r2 <= r1 + tolerance) && (r2 >= r1 - tolerance) && (g2 <= g1 + tolerance) && (g2 >= g1 - tolerance) && (b2 <= b1 + tolerance) && (b2 >= b1 - tolerance); } @Override public DebugNode getDebugNode() { DebugNode node = super.getDebugNode(); node.addIntChild("Tolerance", toleranceParam.getValue()); node.addQuotedStringChild("Fill With", getFillType().toString()); return node; } }