/* Copyright (c) 2014-2016 Jesper Öqvist <jesper@llbit.se> * * This file is part of Chunky. * * Chunky is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Chunky 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 Chunky. If not, see <http://www.gnu.org/licenses/>. */ package se.llbit.chunky.ui; import javafx.beans.value.ChangeListener; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.fxml.Initializable; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.control.Button; import javafx.scene.control.MenuButton; import javafx.scene.control.MenuItem; import javafx.scene.control.TextInputDialog; import javafx.scene.control.Tooltip; import javafx.scene.image.Image; import javafx.scene.image.PixelFormat; import javafx.scene.image.WritableImage; import javafx.scene.image.WritablePixelFormat; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import se.llbit.chunky.renderer.scene.Sky; import se.llbit.chunky.ui.render.SkyTab; import se.llbit.json.JsonParser; import se.llbit.math.ColorUtil; import se.llbit.math.Constants; import se.llbit.math.Vector4; import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.URL; import java.nio.IntBuffer; import java.util.List; import java.util.Optional; import java.util.ResourceBundle; /** * A control for editing the sky color gradient. * The edited gradient does not use linear blending. */ public class GradientEditor extends VBox implements Initializable { private static final WritablePixelFormat<IntBuffer> PIXEL_FORMAT = PixelFormat.getIntArgbInstance(); private final SkyTab sky; private String[][] presets = { {"Clear", "[{\"rgb\":\"0BABC7\",\"pos\":0.0},{\"rgb\":\"75AAFF\",\"pos\":1.0}]"}, {"Desert", "[{\"rgb\":\"FF9966\",\"pos\":0.0},{\"rgb\":\"FFB77D\",\"pos\":0.19811320754716982},{\"rgb\":\"FFFFB3\",\"pos\":0.3867924528301887},{\"rgb\":\"D5ECEE\",\"pos\":0.7358490566037735},{\"rgb\":\"EBFCFD\",\"pos\":1.0}]"}, {"The End", "[{\"rgb\":\"2F1234\",\"pos\":0.0},{\"rgb\":\"321237\",\"pos\":0.42924528301886794},{\"rgb\":\"110713\",\"pos\":0.6320754716981132},{\"rgb\":\"000000\",\"pos\":1.0}]"}, {"Mountain", "[{\"rgb\":\"718A83\",\"pos\":0.0},{\"rgb\":\"E7E8E8\",\"pos\":0.41745283018867924},{\"rgb\":\"BBF1F4\",\"pos\":0.5801886792452831},{\"rgb\":\"72F0F7\",\"pos\":0.7735849056603774},{\"rgb\":\"58F0F9\",\"pos\":1.0}]"}, {"The Nether", "[{\"rgb\":\"000000\",\"pos\":0.0},{\"rgb\":\"000000\",\"pos\":0.20047169811320756},{\"rgb\":\"B31A1A\",\"pos\":0.7240566037735849},{\"rgb\":\"B3281A\",\"pos\":0.8655660377358491},{\"rgb\":\"B3341A\",\"pos\":1.0}]"}, {"Overcast", "[{\"rgb\":\"A5BECA\",\"pos\":0.0},{\"rgb\":\"BED6DD\",\"pos\":0.5259433962264151},{\"rgb\":\"D2E9EB\",\"pos\":0.7358490566037735},{\"rgb\":\"E3F3F4\",\"pos\":1.0}]"}, }; @FXML private MenuButton loadPreset; @FXML private Button prevBtn; @FXML private Button nextBtn; @FXML private Button removeBtn; @FXML private Button addBtn; @FXML private Button importBtn; @FXML private Button exportBtn; @FXML private Canvas canvas; @FXML private SimpleColorPicker colorPicker; private List<Vector4> gradient; int selected = 0; private ChangeListener<? super Color> colorListener = (observable, oldValue, newValue) -> { Vector4 stop = gradient.get(selected); stop.x = newValue.getRed(); stop.y = newValue.getGreen(); stop.z = newValue.getBlue(); gradientChanged(); }; public GradientEditor(SkyTab sky) throws IOException { this.sky = sky; FXMLLoader loader = new FXMLLoader(getClass().getResource("GradientEditor.fxml")); loader.setRoot(this); loader.setController(this); loader.load(); } @Override public void initialize(URL location, ResourceBundle resources) { for (String[] preset : presets) { MenuItem menuItem = new MenuItem(preset[0]); menuItem.setOnAction(e -> importGradient(preset[1])); loadPreset.getItems().add(menuItem); } prevBtn.setTooltip(new Tooltip("Select the previous stop.")); prevBtn.setOnAction(e -> { selectStop(Math.max(selected - 1, 0)); draw(); }); nextBtn.setTooltip(new Tooltip("Select the next stop.")); nextBtn.setOnAction(e -> { selectStop(Math.min(selected + 1, gradient.size() - 1)); draw(); }); addBtn.setTooltip(new Tooltip("Add a new stop after the selected stop.")); addBtn.setOnAction(e -> { selectStop(addStopAfter(selected)); gradientChanged(); }); removeBtn.setTooltip(new Tooltip("Delete the selected stop.")); removeBtn.setDisable(true); removeBtn.setOnAction(e -> { if (removeStop(selected)) { selectStop(Math.min(selected, gradient.size() - 1)); nextBtn.setDisable(selected == gradient.size() - 1); gradientChanged(); } }); importBtn.setOnAction(e -> { TextInputDialog dialog = new TextInputDialog(); dialog.setTitle("Import Gradient"); dialog.setHeaderText("Gradient Import"); dialog.setContentText("Graident JSON:"); Optional<String> result = dialog.showAndWait(); if (result.isPresent()) { importGradient(result.get()); } }); exportBtn.setOnAction(e -> { TextInputDialog dialog = new TextInputDialog(Sky.gradientJson(gradient).toCompactString()); dialog.setTitle("Gradient Export"); dialog.setHeaderText("Gradient Export"); dialog.setContentText("Gradient JSON:"); dialog.showAndWait(); }); canvas.setOnMouseDragged(e -> { double pos = e.getX() / canvas.getWidth(); if (selected > 0 && selected + 1 < gradient.size()) { pos = Math.max(gradient.get(selected - 1).w, pos); pos = Math.min(gradient.get(selected + 1).w, pos); gradient.get(selected).w = pos; gradientChanged(); } }); canvas.setOnMousePressed(e -> { // Select closest stop. double pos = e.getX() / canvas.getWidth(); double closest = Double.MAX_VALUE; int stop = 0; int index = 0; for (Vector4 m : gradient) { double distance = Math.abs(m.w - pos); if (distance < closest) { stop = index; closest = distance; } index += 1; } selectStop(stop); draw(); }); canvas.setOnMouseClicked(e -> { if (e.getClickCount() == 2) { // Add new stop after the last stop before click position. double pos = e.getX() / canvas.getWidth(); int stop = 0; int index = 0; for (Vector4 m : gradient) { if (pos >= m.w) { stop = index; } else { break; } index += 1; } int added = addStopAfter(stop, pos); gradient.get(added).w = pos; selectStop(added); gradientChanged(); } }); colorPicker.colorProperty().addListener(colorListener); } private void importGradient(String data) { JsonParser parser = new JsonParser(new ByteArrayInputStream(data.getBytes())); try { List<Vector4> newGradient = Sky.gradientFromJson(parser.parse().array()); if (newGradient != null) { setGradientNoUpdate(newGradient); } } catch (IOException | JsonParser.SyntaxError ignored) { // Ignored. } } private Color toFxColor(Vector4 color) { return new Color(color.x, color.y, color.z, 1); } private void gradientChanged() { removeBtn.setDisable(gradient.size() == 2); draw(); sky.gradientChanged(gradient); } /** * @return the index of the added stop */ private int addStopAfter(int stop) { int i0; int i1; if (stop == gradient.size() - 1) { i0 = stop - 1; i1 = stop; } else { i0 = stop; i1 = stop + 1; } gradient.add(i1, blend(gradient.get(i0), gradient.get(i1), 0.5)); nextBtn.setDisable(selected == gradient.size() - 1); return i1; } /** * @return the index of the added stop */ private int addStopAfter(int stop, double pos) { int i0; int i1; if (stop == gradient.size() - 1) { i0 = stop - 1; i1 = stop; } else { i0 = stop; i1 = stop + 1; } Vector4 s0 = gradient.get(i0); Vector4 s1 = gradient.get(i1); gradient.add(i1, blend(s0, s1, (pos - s0.w) / (s1.w - s0.w))); nextBtn.setDisable(selected == gradient.size() - 1); return i1; } /** * @return {@code true} if a stop was deleted. */ public boolean removeStop(int stop) { if (gradient.size() > 2) { gradient.remove(stop); if (stop == 0) { gradient.get(0).w = 0; } else if (stop == gradient.size()) { gradient.get(stop - 1).w = 1; } return true; } return false; } private Vector4 blend(Vector4 s0, Vector4 s1, double d) { double xx = 0.5 * (Math.sin(Math.PI * d - Constants.HALF_PI) + 1); double a = 1 - xx; double b = xx; return new Vector4(a * s0.x + b * s1.x, a * s0.y + b * s1.y, a * s0.z + b * s1.z, a * s0.w + b * s1.w); } private synchronized void selectStop(int stop) { if (stop != selected) { selected = stop; updateSelectedColor(); } } private void updateSelectedColor() { colorPicker.colorProperty().removeListener(colorListener); colorPicker.setColor(toFxColor(gradient.get(selected))); colorPicker.colorProperty().addListener(colorListener); prevBtn.setDisable(selected == 0); nextBtn.setDisable(selected == gradient.size() - 1); } private void draw() { int width = (int) canvas.getWidth(); int height = (int) canvas.getHeight(); Image image = drawGradient(width, height, gradient); GraphicsContext gc = canvas.getGraphicsContext2D(); gc.drawImage(image, 0, 0); gc.setFill(Color.WHITE); gc.fillRect(0, 0, width, 15); int min = 0, max = width; if (selected > 0) { min = (int) (gradient.get(selected - 1).w * width); } if (selected < gradient.size() - 1) { max = (int) (gradient.get(selected + 1).w * width); } gc.setFill(Color.LIGHTGRAY); gc.fillRect(min, 0, max - min, 15); int index = 0; for (Vector4 stop : gradient) { if (index == selected) { index += 1; continue; } gc.setFill(Color.WHITE); int x = Math.min(width - 1, (int) (stop.w * width)); double[] xPoints = {x - 5, x, x + 5}; double[] yPoints = {0, 15, 0}; gc.fillPolygon(xPoints, yPoints, 3); boolean isEndpoint = index == 0 || index == gradient.size() - 1; if (isEndpoint) { gc.setStroke(Color.GRAY); } else { gc.setStroke(Color.BLACK); } gc.strokeLine(x - 5, 0, x, 15); gc.strokeLine(x, 15, x + 5, 0); gc.strokeLine(x - 5, 0, x + 5, 0); index += 1; } gc.setLineWidth(2); Vector4 stop = gradient.get(selected); boolean isEndpoint = selected == 0 || selected == gradient.size() - 1; if (isEndpoint) { gc.setFill(Color.GRAY); } else { gc.setFill(Color.BLACK); } int x = Math.min(width - 1, (int) (stop.w * width)); double[] xPoints = {x - 5, x, x + 5}; double[] yPoints = {0, 15, 0}; gc.fillPolygon(xPoints, yPoints, 3); gc.setStroke(Color.WHITE); gc.strokeLine(x - 5, 0, x, 15); gc.strokeLine(x, 15, x + 5, 0); gc.strokeLine(x - 5, 0, x + 5, 0); } protected static Image drawGradient(int width, int height, List<Vector4> gradient) { int[] pixels = new int[width * height]; if (width <= 0 || height <= 0 || gradient.size() < 2) { throw new IllegalArgumentException(); } int x = 0; // Fill the first row. for (int i = 0; i < width; ++i) { double weight = i / (double) width; Vector4 c0 = gradient.get(x); Vector4 c1 = gradient.get(x + 1); double xx = (weight - c0.w) / (c1.w - c0.w); while (x + 2 < gradient.size() && xx > 1) { x += 1; c0 = gradient.get(x); c1 = gradient.get(x + 1); xx = (weight - c0.w) / (c1.w - c0.w); } xx = 0.5 * (Math.sin(Math.PI * xx - Constants.HALF_PI) + 1); double a = 1 - xx; double b = xx; int argb = ColorUtil .getArgb(a * c0.x + b * c1.x, a * c0.y + b * c1.y, a * c0.z + b * c1.z, 1); pixels[i] = argb; } // Copy top row to the rest of the image. for (int j = 1; j < height; ++j) { System.arraycopy(pixels, 0, pixels, j * width, width); } WritableImage image = new WritableImage(width, height); image.getPixelWriter().setPixels(0, 0, width, height, PIXEL_FORMAT, pixels, 0, width); return image; } private void setGradientNoUpdate(List<Vector4> gradient) { this.gradient = gradient; selected = Math.min(selected, gradient.size() - 1); updateSelectedColor(); gradientChanged(); } public void setGradient(List<Vector4> gradient) { this.gradient = gradient; selected = Math.min(selected, gradient.size() - 1); updateSelectedColor(); draw(); } }