/* Copyright (c) 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.application.Platform;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.control.TextField;
import javafx.scene.control.TextFormatter;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import se.llbit.chunky.PersistentSettings;
import se.llbit.chunky.renderer.OutputMode;
import se.llbit.chunky.renderer.RenderController;
import se.llbit.chunky.renderer.RenderMode;
import se.llbit.chunky.renderer.RenderStatusListener;
import se.llbit.chunky.renderer.Renderer;
import se.llbit.chunky.renderer.ResetReason;
import se.llbit.chunky.renderer.SnapshotControl;
import se.llbit.chunky.renderer.scene.AsynchronousSceneManager;
import se.llbit.chunky.renderer.scene.RenderResetHandler;
import se.llbit.chunky.renderer.scene.Scene;
import se.llbit.chunky.ui.render.AdvancedTab;
import se.llbit.chunky.ui.render.CameraTab;
import se.llbit.chunky.ui.render.EntitiesTab;
import se.llbit.chunky.ui.render.GeneralTab;
import se.llbit.chunky.ui.render.HelpTab;
import se.llbit.chunky.ui.render.LightingTab;
import se.llbit.chunky.ui.render.PostprocessingTab;
import se.llbit.chunky.ui.render.RenderControlsTab;
import se.llbit.chunky.ui.render.SkyTab;
import se.llbit.chunky.ui.render.WaterTab;
import se.llbit.chunky.world.Icon;
import se.llbit.log.Log;
import se.llbit.util.ProgressListener;
import se.llbit.util.TaskTracker;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
/**
* Controller for the Render Controls dialog.
*/
public class RenderControlsFxController implements Initializable, RenderResetHandler {
private AsynchronousSceneManager asyncSceneManager;
public ChunkyFxController getChunkyController() {
return controller;
}
public RenderController getRenderController() {
return controller.getChunky().getRenderController();
}
static class GUIRenderListener implements RenderStatusListener {
private final RenderControlsFxController gui;
private int spp;
private int sps;
public GUIRenderListener(RenderControlsFxController renderControls) {
this.gui = renderControls;
}
@Override public void setRenderTime(long time) {
Platform.runLater(() -> {
int seconds = (int) ((time / 1000) % 60);
int minutes = (int) ((time / 60000) % 60);
int hours = (int) (time / 3600000);
gui.renderTimeLbl.setText(String
.format("Render time: %d hours, %d minutes, %d seconds", hours, minutes, seconds));
});
}
@Override public void setSamplesPerSecond(int sps) {
this.sps = sps;
updateSppStats();
}
@Override public void setSpp(int spp) {
this.spp = spp;
updateSppStats();
}
private void updateSppStats() {
Platform.runLater(() -> gui.sppLbl.setText(String
.format("%s SPP, %s SPS", gui.decimalFormat.format(spp),
gui.decimalFormat.format(sps))));
}
@Override public void renderStateChanged(RenderMode state) {
Platform.runLater(() -> {
switch (state) {
case RENDERING:
gui.start.setSelected(true);
break;
case PAUSED:
gui.pause.setSelected(true);
break;
case PREVIEW:
gui.reset.setSelected(true);
break;
}
});
}
}
public final DecimalFormat decimalFormat = new DecimalFormat();
/**
* The number of milliseconds spent on rendering a scene until
* the reset confirmation must be shown when trying to edit
* the scene state.
*/
private static final long SCENE_EDIT_GRACE_PERIOD = 30000;
private Scene scene;
private File saveFrameDirectory = new File(System.getProperty("user.dir"));
private Stage stage;
private RenderCanvasFx canvas;
private Renderer renderer;
/** Used to ensure only one render reset confirm dialog is displayed at a time. */
protected AtomicBoolean resetConfirmMutex = new AtomicBoolean(false);
private final ProgressListener progressListener = new ProgressListener() {
@Override public void setProgress(String task, int done, int start, int target) {
Platform.runLater(() -> {
progressBar.setProgress((double) done / (target - start));
progressLbl.setText(String.format("%s: %s of %s", task, decimalFormat.format(done),
decimalFormat.format(target)));
etaLbl.setText("ETA: N/A");
});
}
@Override public void setProgress(String task, int done, int start, int target, String eta) {
Platform.runLater(() -> {
progressBar.setProgress((double) done / (target - start));
progressLbl.setText(String.format("%s: %s of %s", task, decimalFormat.format(done),
decimalFormat.format(target)));
etaLbl.setText("ETA: " + eta);
});
}
};
private RenderStatusListener renderTracker = RenderStatusListener.NONE;
private TaskTracker taskTracker = new TaskTracker(ProgressListener.NONE);
private Collection<RenderControlsTab> tabs = new ArrayList<>();
/** Maps JavaFX tabs to tab controllers. */
private Map<Tab, RenderControlsTab> tabControllers = Collections.emptyMap();
@FXML private TextField sceneNameField;
@FXML private Button saveBtn;
@FXML private ToggleButton start;
@FXML private ToggleButton pause;
@FXML private ToggleButton reset;
@FXML private Button saveFrameBtn;
@FXML private Button togglePreviewBtn;
@FXML private ProgressBar progressBar;
@FXML private Label progressLbl;
@FXML private Label etaLbl;
@FXML private Label renderTimeLbl;
@FXML private Label sppLbl;
@FXML private IntegerAdjuster targetSpp;
@FXML private Button saveDefaultSpp;
@FXML private TabPane tabPane;
private ChunkyFxController controller;
public RenderControlsFxController() {
decimalFormat.setGroupingSize(3);
decimalFormat.setGroupingUsed(true);
}
@Override public void initialize(URL location, ResourceBundle resources) {
saveBtn.setTooltip(new Tooltip("Save the current scene."));
saveBtn.setGraphic(new ImageView(Icon.disk.fxImage()));
saveBtn.setOnAction(e -> asyncSceneManager.saveScene());
saveFrameBtn.setOnAction(this::saveCurrentFrame);
togglePreviewBtn.setOnAction(e -> {
if (canvas == null || !canvas.isShowing()) {
openPreview();
} else {
canvas.hide();
}
});
start.setGraphic(new ImageView(Icon.play.fxImage()));
start.setTooltip(new Tooltip("Start rendering."));
start.setOnAction(e -> scene.startRender());
pause.setGraphic(new ImageView(Icon.pause.fxImage()));
pause.setTooltip(new Tooltip("Pause the render."));
pause.setOnAction(e -> scene.pauseRender());
reset.setGraphic(new ImageView(Icon.stop.fxImage()));
reset.setTooltip(new Tooltip("Resets the current render. Discards render progress."));
reset.setOnAction(e -> scene.haltRender());
sppLbl.setTooltip(new Tooltip("SPP = Samples Per Pixel, SPS = Samples Per Second"));
targetSpp.setName("Target SPP");
targetSpp.setTooltip("Rendering is stopped after reaching the target Samples Per Pixel (SPP).");
targetSpp.setRange(100, 100000);
targetSpp.makeLogarithmic();
saveDefaultSpp.setTooltip(new Tooltip("Make the current SPP target the default."));
saveDefaultSpp.setOnAction(e ->
PersistentSettings.setSppTargetDefault(scene.getTargetSpp()));
}
private void saveCurrentFrame(Event event) {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save Current Frame");
if (saveFrameDirectory != null && saveFrameDirectory.isDirectory()) {
fileChooser.setInitialDirectory(saveFrameDirectory);
}
OutputMode outputMode = scene.getOutputMode();
String extension = ".png";
switch (outputMode) {
case PNG:
fileChooser.setSelectedExtensionFilter(
new FileChooser.ExtensionFilter("PNG files", "*.png"));
break;
case TIFF_32:
extension = ".tiff";
fileChooser.setSelectedExtensionFilter(
new FileChooser.ExtensionFilter("PNG files", "*.png"));
break;
}
fileChooser.setInitialFileName(String.format("%s-%d%s",
scene.name(), renderer.getRenderStatus().getSpp(), extension));
File target = fileChooser.showSaveDialog(stage);
if (target != null) {
saveFrameDirectory = target.getParentFile();
try {
if (!target.getName().endsWith(extension)) {
target = new File(target.getPath() + extension);
}
scene.saveFrame(target, taskTracker);
} catch (IOException e1) {
Log.error("Failed to save current frame", e1);
}
}
}
public void setStage(Stage stage) {
this.stage = stage;
stage.setOnHiding(e -> {
controller.getMap().cameraPositionUpdated(); // Clear the camera view visualization.
scene.setRenderMode(RenderMode.PAUSED);
scene.forceReset();
if (canvas != null && canvas.isShowing()) {
canvas.close();
}
});
stage.setOnShown(e -> {
openPreview();
controller.getMap().cameraPositionUpdated(); // Trigger redraw of camera view visualization.
});
}
private void buildTabs() {
try {
// Create the default tabs:
tabs.add(new GeneralTab());
tabs.add(new LightingTab());
tabs.add(new SkyTab());
tabs.add(new WaterTab());
tabs.add(new CameraTab());
tabs.add(new EntitiesTab());
tabs.add(new PostprocessingTab());
tabs.add(new AdvancedTab());
tabs.add(new HelpTab());
// Transform tabs (allows plugin hooks to modify the tabs):
tabs = controller.getChunky().getRenderControlsTabTransformer().apply(tabs);
if (tabs.contains(null)) {
Log.error("Null tabs inserted in tab collection (possible plugin error).");
tabs = tabs.stream().filter(tab -> tab != null).collect(Collectors.toList());
}
Collection<Tab> javaFxTabs = new ArrayList<>();
tabControllers = new HashMap<>();
for (RenderControlsTab tab : tabs) {
Tab javaFxTab = tab.getTab();
tabControllers.put(javaFxTab, tab);
javaFxTabs.add(javaFxTab);
}
tabPane.getTabs().addAll(javaFxTabs);
} catch (IOException e) {
Log.error("Failed to build render controls tabs.", e);
}
tabPane.getSelectionModel().selectedItemProperty()
.addListener((observable, oldValue, newValue) -> updateTab(newValue));
}
private void refreshSettings() {
targetSpp.set(scene.getTargetSpp());
updateTab(tabPane.getSelectionModel().getSelectedItem());
}
private void updateTab(Tab tab) {
RenderControlsTab controller = tabControllers.get(tab);
if (controller != null) {
tabControllers.get(tab).update(scene);
} else {
Log.error("Missing tab controller!");
}
}
public void openPreview() {
if (canvas == null) {
canvas = new RenderCanvasFx(scene, renderer);
EventHandler<WindowEvent> onHiding = canvas.getOnHiding();
EventHandler<WindowEvent> onShowing = canvas.getOnShowing();
canvas.setOnHiding(e -> {
togglePreviewBtn.setText("Show preview window");
onHiding.handle(e);
});
canvas.setOnShowing(e -> {
togglePreviewBtn.setText("Hide preview window");
onShowing.handle(e);
});
canvas.initOwner(stage);
canvas.show();
canvas.setRenderListener(renderTracker);
} else {
canvas.show();
canvas.toFront();
}
int x = (int) (stage.getX() + stage.getWidth());
int y = (int) stage.getY();
canvas.setX(x);
canvas.setY(y);
canvas.repaint();
}
public RenderCanvasFx getCanvas() {
return canvas;
}
public void setController(ChunkyFxController controller) {
this.controller = controller;
RenderController renderController = controller.getChunky().getRenderController();
this.renderTracker = new GUIRenderListener(this);
this.taskTracker = new TaskTracker(progressListener);
buildTabs();
renderer = renderController.getRenderer();
scene = renderController.getSceneManager().getScene();
sceneNameField.setText(scene.name);
sceneNameField.setTextFormatter(new TextFormatter<TextFormatter.Change>(change -> {
if (change.isReplaced()) {
if (change.getText().isEmpty()) {
// Disallow clearing the scene name.
change.setText(change.getControlText().substring(change.getRangeStart(),
change.getRangeEnd()));
}
}
if (change.isAdded()) {
if (!AsynchronousSceneManager.sceneNameIsValid(change.getText())) {
// Stop a change adding illegal characters to the scene name.
change.setText("");
}
}
return change;
}));
sceneNameField.textProperty().addListener((observable, oldValue, newValue) -> {
scene.setName(newValue);
renderController.getSceneProvider().withSceneProtected(scene1 -> scene1.setName(newValue));
updateTitle();
});
sceneNameField.setOnAction(event -> asyncSceneManager.saveScene());
targetSpp.set(scene.getTargetSpp());
targetSpp.onValueChange(value -> scene.setTargetSpp(value));
// TODO: remove the cast.
asyncSceneManager =
(AsynchronousSceneManager) renderController.getSceneManager();
asyncSceneManager.setResetHandler(this);
asyncSceneManager.setTaskTracker(taskTracker);
asyncSceneManager.setOnSceneLoaded(() -> {
CountDownLatch guiUpdateLatch = new CountDownLatch(1);
Platform.runLater(() -> {
synchronized (scene) {
sceneNameField.setText(scene.name());
canvas.setCanvasSize(scene.width, scene.height);
}
updateTitle();
refreshSettings();
guiUpdateLatch.countDown();
});
new Thread(() -> {
try {
guiUpdateLatch.await();
canvas.forceRepaint();
} catch (InterruptedException ignored) {
// Ignored.
}
}).start();
});
asyncSceneManager.setOnChunksLoaded(() -> Platform.runLater(() -> {
openPreview();
tabs.forEach(RenderControlsTab::onChunksLoaded);
}));
asyncSceneManager.setTaskTracker(taskTracker);
renderer.setSnapshotControl(SnapshotControl.DEFAULT);
renderer.setOnFrameCompleted((scene1, spp) -> {
if (SnapshotControl.DEFAULT.saveSnapshot(scene1, spp)) {
// Save the current frame.
scene1.saveSnapshot(renderController.getContext().getSceneDirectory(), taskTracker);
}
if (SnapshotControl.DEFAULT.saveRenderDump(scene1, spp)) {
// Save the scene description and current render dump.
asyncSceneManager.saveScene();
}
});
renderer.addRenderListener(renderTracker);
renderer.setRenderTask(taskTracker.backgroundTask());
tabs.forEach(tab -> tab.setController(this));
updateTab(tabPane.getSelectionModel().getSelectedItem());
}
private void updateTitle() {
stage.setTitle("Render Controls - " + scene.name());
}
@Override public boolean allowSceneRefresh() {
if (scene.getResetReason() == ResetReason.SCENE_LOADED
|| renderer.getRenderStatus().getRenderTime() < SCENE_EDIT_GRACE_PERIOD) {
return true;
} else {
requestRenderReset();
}
return false;
}
private void requestRenderReset() {
if (resetConfirmMutex.compareAndSet(false, true)) {
Platform.runLater(() -> {
try {
ConfirmResetPopup popup = new ConfirmResetPopup(
() -> {
// On accept.
asyncSceneManager.applySceneChanges();
resetConfirmMutex.set(false);
},
() -> {
// On reject.
asyncSceneManager.discardSceneChanges();
refreshSettings();
resetConfirmMutex.set(false);
});
popup.show(stage);
} catch (IOException e) {
Log.warn("Could not open reset confirmation dialog.", e);
}
});
}
}
}