blob: da79c8359efc50fa52dab3ca9fcd39a1fd101d0b [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.sis.gui.map;
import java.util.Locale;
import java.nio.IntBuffer;
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.GraphicsEnvironment;
import java.awt.GraphicsConfiguration;
import java.awt.Font;
import java.awt.font.GlyphVector;
import java.awt.image.DataBufferInt;
import java.awt.image.RenderedImage;
import java.awt.image.BufferedImage;
import java.awt.image.VolatileImage;
import javafx.application.Platform;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.image.ImageView;
import javafx.scene.image.PixelBuffer;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.util.Callback;
import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
import org.apache.sis.internal.system.Configuration;
/**
* A canvas for maps to be rendered using Java2D from Abstract Window Toolkit.
* The map is rendered using Java2D in a background thread, then copied in a JavaFX image.
* Java2D is used for rendering the map because it may contain too many elements for a scene graph.
* After the map has been rendered, other JavaFX nodes can be put on top of the map, typically for
* controls by the user.
*
* @author Martin Desruisseaux (Geomatys)
* @version 1.4
* @since 1.1
*/
public abstract class MapCanvasAWT extends MapCanvas {
/**
* Whether to try to get native acceleration in the {@link VolatileImage} used for painting the map.
* Native acceleration is of limited interested here because even if painting occurs in video card
* memory, it is copied to Java heap before to be transferred to JavaFX image, which may itself copy
* back to video card memory. I'm not aware of a way to perform direct transfer from AWT to JavaFX.
* Consequently before to enable this acceleration, we should benchmark to see if it is worth.
*/
@Configuration
private static final boolean NATIVE_ACCELERATION = false;
/**
* Size (in pixels) of the warning symbol to show if rendering fail.
*/
private static final int WARNING_SIZE = 200;
/**
* The symbols to show on top of the image as a warning when rendering failed.
*/
private static final String WARNING_TEXT = "\u26A0";
/**
* Default value of {@link #imageMargin}.
*/
@Configuration
private static final Insets DEFAULT_MARGIN = new Insets(SCROLL_EVENT_SIZE);
/**
* Number of additional pixels to paint on each sides of the image, outside the viewing area.
* Computing a larger image reduces the black borders that user sees during translations or
* during zoom out before the new image is repainted.
*/
public final ObjectProperty<Insets> imageMargin;
/**
* A buffer where to draw the content of the map for the region to be displayed.
* This buffer uses ARGB color model, contrarily to the {@link RenderedImage} of
* {@link org.apache.sis.coverage.grid.GridCoverage} which may have any color model.
* This buffered image will contain only the visible region of the map;
* it may be a zoom over a small region.
*
* <p>This buffered image contains the same data than the {@linkplain #image} of this canvas.
* Those two images will share the same data array (no copy) and the same coordinate system.</p>
*
* <h4>Restriction</h4>
* Type is restricted to {@link BufferedImage#TYPE_INT_ARGB_PRE} or {@link BufferedImage#TYPE_4BYTE_ABGR_PRE}
* because JavaFX {@link PixelBuffer} (stored in {@link #bufferWrapper}) accepts only those types.
* We arbitrarily choose {@code TYPE_INT_ARGB_PRE}.
*/
private BufferedImage buffer;
/**
* A temporary buffer where to draw the {@link RenderedImage} in a background thread.
* We use this double-buffering when the {@link #buffer} is already wrapped by JavaFX.
* After creating the image in background, its content is copied to {@link #buffer} in
* JavaFX thread.
*/
private VolatileImage doubleBuffer;
/**
* The graphic configuration at the time {@link #buffer} has been rendered.
* Used for creating compatible {@link #doubleBuffer} before updating image content.
* This configuration determines whether native acceleration will be enabled or not.
*
* @see #NATIVE_ACCELERATION
*/
private GraphicsConfiguration bufferConfiguration;
/**
* Wraps {@link #buffer} data array for use by JavaFX images. This is the mechanism used
* by JavaFX 13+ for allowing {@link #image} to share the same data than {@link #buffer}.
* The same wrapper can be used for many {@link WritableImage} instances (e.g. thumbnails).
*
* <h4>Invariants</h4>
* <ul>
* <li>Shall be non-null if and only If {@link #buffer} is non-null.</li>
* </ul>
*/
private PixelBuffer<IntBuffer> bufferWrapper;
/**
* The node where the rendered map will be shown. Its content is prepared in a background thread
* by {@link Renderer}. Subclasses should not set the image content directly.
*/
protected final ImageView image;
/**
* Whether {@link #WARNING_TEXT} has been drawn on top of the image.
* When warning is shown, navigation should be disabled.
*/
private boolean isWarningShown;
/**
* Creates a new canvas for JavaFX application.
*
* @param locale the locale to use for labels and some messages, or {@code null} for default.
*/
public MapCanvasAWT(final Locale locale) {
super(locale);
imageMargin = new SimpleObjectProperty<>(this, "imageMargin", DEFAULT_MARGIN);
image = new ImageView();
image.setPreserveRatio(true);
floatingPane.getChildren().add(image);
}
/**
* Returns the image bounds. This is used for determining if a
* repaint is necessary after {@link MapCanvas} size changed.
*/
@Override
final Bounds getBoundsInParent() {
return image.getBoundsInParent();
}
/**
* Clears {@link #buffer} and all support fields.
*/
private void clearBuffer() {
buffer = null;
doubleBuffer = null;
bufferWrapper = null;
bufferConfiguration = null;
}
/**
* Invoked in JavaFX thread for creating a renderer to be executed in a background thread.
* Subclasses should copy in this method all {@code MapCanvas} properties that the background thread
* will need for performing the rendering process.
*
* @return rendering process to be executed in background thread,
* or {@code null} if there is nothing to paint.
*/
@Override
protected abstract Renderer createRenderer();
/**
* A snapshot of {@link MapCanvasAWT} state to paint as an image.
* The snapshot is created in JavaFX thread by the {@link MapCanvasAWT#createRenderer()} method,
* then the rendering process is executed in a background thread.
* Methods are invoked in the following order:
*
* <table class="sis">
* <caption>Methods invoked during a map rendering process</caption>
* <tr><th>Method</th> <th>Thread</th> <th>Remarks</th></tr>
* <tr><td>{@link #createRenderer()}</td> <td>JavaFX thread</td> <td>Collects all needed information.</td></tr>
* <tr><td>{@link #render()}</td> <td>Background thread</td> <td>Computes what can be done in advance.</td></tr>
* <tr><td>{@link #paint(Graphics2D)}</td> <td>Background thread</td> <td>Holds a {@link Graphics2D}.</td></tr>
* <tr><td>{@link #commit(MapCanvas)}</td> <td>JavaFX thread</td> <td>Saves data to cache for reuse.</td></tr>
* </table>
*
* This class should not access any {@link MapCanvasAWT} property from a method invoked in background thread
* ({@link #render()} and {@link #paint(Graphics2D)}). It may access {@link MapCanvasAWT} properties from the
* {@link #commit(MapCanvas)} method.
*
* @author Martin Desruisseaux (Geomatys)
* @version 1.1
* @since 1.1
*/
protected abstract static class Renderer extends MapCanvas.Renderer {
/**
* Values of the {@link MapCanvasAWT#imageMargin} property at construction time.
* Those values are initialized by {@link #isValid(Insets, BufferedImage)}.
*/
private int left, top;
/**
* Image width and height, taking in account the margins.
* Those values are initialized by {@link #isValid(Insets, BufferedImage)}.
*/
private int width, height;
/**
* Creates a new renderer. The {@linkplain #getWidth() width} and {@linkplain #getHeight() height}
* are initially zero; they will get a non-zero values before {@link #paint(Graphics2D)} is invoked.
*/
protected Renderer() {
}
/**
* Rounds and clamp the given value. The upper limit is arbitrary.
*/
private static int clamp(final double value) {
return (int) Math.max(0, Math.min(Short.MAX_VALUE, Math.round(value)));
}
/**
* Returns whether the given buffer is non-null and has the expected size.
* This verification shall be done only after {@link #initialize(Pane)} has been invoked.
*
* @param margin value of {@link #imageMargin}.
* @param buffer value of {@link #buffer}.
*/
private boolean isValid(final Insets margin, final BufferedImage buffer) {
width = getWidth();
height = getHeight();
if (margin != null) {
final int right, bottom;
top = clamp(margin.getTop());
right = clamp(margin.getRight());
bottom = clamp(margin.getBottom());
left = clamp(margin.getLeft());
width = Math.addExact(width, left + right);
height = Math.addExact(height, top + bottom);
}
return (buffer != null)
&& buffer.getWidth() == width
&& buffer.getHeight() == height;
}
/**
* Applies translation on the given graphics before {@link #paint(Graphics2D)}.
*/
private void translate(final Graphics2D gr) {
gr.translate(left, top);
}
/**
* Compensates the translation applied by {@link #translate(Graphics2D)}.
* This method is invoked only if the image painting has been successful,
* otherwise we assume that old content is still present and require the
* old translations.
*/
private void translate(final ImageView image) {
image.setTranslateX(-left);
image.setTranslateY(-top);
}
/**
* Invoked in a background thread before {@link #paint(Graphics2D)}. Subclasses can override
* this method if some rendering steps do not need {@link Graphics2D} handler. Doing work in
* advance allow to hold the {@link Graphics2D} handler for a shorter time.
*
* <p>The default implementation does nothing.</p>
*
* @throws Exception if an error occurred while preparing data.
*/
@Override
protected void render() throws Exception {
}
/**
* Invoked after {@link #render()} for doing the actual map painting.
* This method is invoked in a background thread, potentially many times if {@link VolatileImage} content
* is invalidated in the middle of rendering process. This method should not access any {@link MapCanvas}
* property; if some canvas properties are needed, they should have been copied at construction time.
*
* @param gr the Java2D handler to use for rendering the map.
*/
protected abstract void paint(Graphics2D gr);
/**
* Invoked in JavaFX thread after successful {@link #paint(Graphics2D)} completion. This method can update the
* {@link #floatingPane} children with the nodes (images, shaped, <i>etc.</i>) created by {@link #render()}.
* If this method detects that data has changed during the time {@code Renderer} was working in background,
* then this method can return {@code true} for requesting a new repaint. In such case that repaint will use
* a new {@link Renderer} instance; the current instance will not be reused.
*
* <p>The default implementation does nothing and returns {@code true}.</p>
*
* @param canvas the canvas where drawing has been done. It will be a {@link MapCanvasAWT} instance.
* @return {@code true} on success, or {@code false} if the rendering should be redone
* (for example because a change has been detected in the data).
*/
@Override
protected boolean commit(MapCanvas canvas) {
return true;
}
}
/**
* Invoked when the map content needs to be rendered again into the {@link #image}.
* It may be because the map has new content, or because the viewed region moved or
* has been zoomed.
*
* <p>There is two possible situations:</p>
* <ul class="verbose">
* <li>If the current buffers are not suitable, then we clear everything related to Java2D buffered images.
* Those resources will be recreated from scratch in background thread. There is no need for double-buffering
* in such case because the new {@link BufferedImage} will not be shared with JavaFX image before the end
* of this task.</li>
* <li>Otherwise (current buffer it still valid), we should not update {@link BufferedImage} in a background
* thread because the internal array of that image is shared with JavaFX image. That image can be updated
* only in JavaFX thread through the {@code PixelBuffer.update(…)} method. A {@link VolatileImage} is used
* as a temporary buffer.</li>
* </ul>
*
* In all cases we need to be careful to not use directly any {@link MapCanvas} field from the {@code call()}
* methods. Information needed by {@code call()} must be copied first.
*
* <h4>Preconditions</h4>
* It is important that no other worker is in progress at the time this method is invoked
* ({@code assert renderingInProgress == null}), otherwise conflicts may happen when workers
* will update the {@code MapCanvasAWT} fields after they completed their task.
*
* @see #requestRepaint()
*/
@Override
final RenderingTask<?> createWorker(final MapCanvas.Renderer mc) {
assert Platform.isFxApplicationThread();
final Renderer context = (Renderer) mc;
if (!context.isValid(imageMargin.get(), buffer)) {
clearBuffer();
return new Creator(context);
} else {
return new Updater(context);
}
}
/**
* Background tasks for creating a new {@link BufferedImage}. This task is invoked when there are no
* previous resources that we can recycle, either because they have never been created yet or because
* they are not suitable anymore (for example because the image size changed).
*/
private final class Creator extends RenderingTask<WritableImage> {
/**
* The user-provided object which will perform the actual rendering.
* Its {@link Renderer#paint(Graphics2D)} method will be invoked in background thread.
*/
private final Renderer renderer;
/**
* The Java2D image where to do the rendering. This image will be created in a background thread
* and assigned to the {@link MapCanvasAWT#buffer} field in JavaFX thread if rendering succeed.
*/
private BufferedImage drawTo;
/**
* Wrapper around {@link #buffer} internal array for interoperability between Java2D and JavaFX.
* Created only if {@link #drawTo} have been successfully painted.
*/
private PixelBuffer<IntBuffer> wrapper;
/**
* The graphic configuration at the time {@link #drawTo} has been rendered.
* This will be used for creating {@link VolatileImage} when updating the image.
*/
private GraphicsConfiguration configuration;
/**
* Creates a new task for painting without resource recycling.
*/
Creator(final Renderer context) {
renderer = context;
}
/**
* Invoked in background thread for creating and rendering the image (may be slow).
* Any {@link MapCanvas} property needed by this method shall be copied before the
* background thread is executed; no direct reference to {@link MapCanvas} here.
*/
@Override
protected WritableImage call() throws Exception {
renderer.render();
final int width = renderer.width;
final int height = renderer.height;
drawTo = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB_PRE);
final Graphics2D gr = drawTo.createGraphics();
try {
configuration = gr.getDeviceConfiguration();
renderer.translate(gr);
renderer.paint(gr);
} finally {
gr.dispose();
}
if (NATIVE_ACCELERATION) {
if (!configuration.getImageCapabilities().isAccelerated()) {
configuration = GraphicsEnvironment.getLocalGraphicsEnvironment()
.getDefaultScreenDevice().getDefaultConfiguration();
}
}
/*
* The call to `array.getData()` below should be after we finished drawing in the new
* BufferedImage, because this direct access to data array disables GPU accelerations.
*/
final DataBufferInt array = (DataBufferInt) drawTo.getRaster().getDataBuffer();
IntBuffer ib = IntBuffer.wrap(array.getData(), array.getOffset(), array.getSize());
wrapper = new PixelBuffer<>(width, height, ib, PixelFormat.getIntArgbPreInstance());
return new WritableImage(wrapper);
}
/**
* Invoked in JavaFX thread on success. The JavaFX image is set to the result, then intermediate
* buffers created by this task are saved in {@link MapCanvas} fields for reuse next time that
* an image of the same size will be rendered again.
*/
@Override
protected void succeeded() {
image.setImage(getValue());
renderer.translate(image);
buffer = drawTo;
bufferWrapper = wrapper;
bufferConfiguration = configuration;
final boolean done = renderer.commit(MapCanvasAWT.this);
clearWarning();
renderingCompleted(this);
if (!done || contentsChanged()) {
repaint();
}
}
@Override protected void failed() {renderingCompleted(this);}
@Override protected void cancelled() {renderingCompleted(this);}
}
/**
* Background tasks for painting in an existing {@link BufferedImage}. This task is invoked
* when previous resources (JavaFX image and Java2D volatile/buffered image) can be reused.
* The Java2D volatile image will be rendered in background thread, then its content will be
* transferred to JavaFX image (through {@link BufferedImage} shared array) in JavaFX thread.
*/
private final class Updater extends RenderingTask<VolatileImage> implements Callback<PixelBuffer<IntBuffer>, Rectangle2D> {
/**
* The user-provided object which will perform the actual rendering.
* Its {@link Renderer#paint(Graphics2D)} method will be invoked in background thread.
*/
private final Renderer renderer;
/**
* The buffer during last paint operation. This buffer will be reused if possible,
* but may become invalid and in need to be recreated. May be {@code null}.
*/
private VolatileImage previousBuffer;
/**
* The configuration to use for creating a new {@link VolatileImage}
* if {@link #previousBuffer} is invalid.
*/
private final GraphicsConfiguration configuration;
/**
* Whether {@link VolatileImage} content became invalid and needs to be recreated.
*/
private boolean contentsLost;
/**
* Creates a new task for painting with resource recycling.
*/
Updater(final Renderer context) {
renderer = context;
previousBuffer = doubleBuffer;
configuration = bufferConfiguration;
}
/**
* Invoked in background thread for rendering the image (may be slow).
* Any {@link MapCanvas} field needed by this method shall be copied before the
* background thread is executed; no direct reference to {@link MapCanvas} here.
*/
@Override
protected VolatileImage call() throws Exception {
renderer.render();
final int width = renderer.width;
final int height = renderer.height;
VolatileImage drawTo = previousBuffer;
previousBuffer = null; // For letting GC do its work.
if (drawTo == null) {
drawTo = configuration.createCompatibleVolatileImage(width, height, VolatileImage.TRANSLUCENT);
}
boolean invalid = true;
try {
do {
if (drawTo.validate(configuration) == VolatileImage.IMAGE_INCOMPATIBLE) {
drawTo = configuration.createCompatibleVolatileImage(width, height, VolatileImage.TRANSLUCENT);
}
final Graphics2D gr = drawTo.createGraphics();
try {
gr.setBackground(ColorModelFactory.TRANSPARENT);
gr.clearRect(0, 0, drawTo.getWidth(), drawTo.getHeight());
renderer.translate(gr);
renderer.paint(gr);
} finally {
gr.dispose();
}
invalid = drawTo.contentsLost();
} while (invalid && !isCancelled());
} finally {
if (invalid) {
drawTo.flush(); // Release native resources on cancellation or exception thrown.
}
}
return drawTo;
}
/**
* Invoked by {@link PixelBuffer#updateBuffer(Callback)} for updating the {@link #buffer} content.
* This method must be invoked in JavaFX thread. It copies the {@link VolatileImage} content to the
* {@link BufferedImage} shared with JavaFX in a single {@code Graphics2D.drawImage(…)} operation.
* The whole destination surface shall be written by {@code drawImage(…)}, so there is no need to invoke
* {@link Graphics2D#clearRect} first. It is important because the small delay between {@code clearRect(…)}
* and {@code drawImage(…)} can cause twinkle.
*/
@Override
public Rectangle2D call(final PixelBuffer<IntBuffer> wrapper) {
final VolatileImage drawTo = doubleBuffer;
final Graphics2D gr = buffer.createGraphics();
try {
gr.setComposite(AlphaComposite.Src); // Copy source (previous destination is discarded).
gr.drawImage(drawTo, 0, 0, null);
contentsLost = drawTo.contentsLost();
} finally {
gr.dispose();
}
return null; // Indicate that the entire buffer was dirty.
}
/**
* Invoked in JavaFX thread on success. The actual transfer from Java2D image to JavaFX image
* will happen just before the next pulse for making sure that the affine transform and the
* image are updated together before rendering. Doing that way avoid flickering effects.
*/
@Override
protected void succeeded() {
final Scene scene = fixedPane.getScene();
if (scene != null) {
final Runnable pulseAction = new Runnable() {
@Override public void run() {
scene.removePreLayoutPulseListener(this);
transferImage();
}
};
scene.addPreLayoutPulseListener(pulseAction);
Platform.requestNextPulse();
} else {
transferImage();
}
}
/**
* Invoked in JavaFX thread on success. The JavaFX image is set to the result, then the double buffer
* created by this task is saved in {@link MapCanvas} fields for reuse next time that an image of the
* same size will be rendered again.
*/
private void transferImage() {
final VolatileImage drawTo = getValue();
doubleBuffer = drawTo;
try {
bufferWrapper.updateBuffer(this); // This will invoke the `call(PixelBuffer)` method above.
} finally {
drawTo.flush(); // Release native resources.
}
renderer.translate(image);
final boolean done = renderer.commit(MapCanvasAWT.this);
clearWarning();
renderingCompleted(this);
if (!done || contentsLost || contentsChanged()) {
repaint();
}
}
/** Clears the image in the same way than failure. Defined for safety but should not happen. */
@Override protected void cancelled() {failed();}
/**
* Invoked in JavaFX thread on failure. No result is available. The JavaFX image is set to an empty image.
* {@link VolatileImage#flush()} has already been invoked by the finally block in {@link #call()}.
*/
@Override
protected void failed() {
if (!isWarningShown) {
final int rw = renderer.getWidth();
final int rh = renderer.getHeight();
final int x = Math.max((rw - WARNING_SIZE) / 2, 0);
final int y = Math.max((rh - WARNING_SIZE) / 2, 0);
final int width = rw - 2*x;
final int height = rh - 2*y;
final Font font = new Font(Font.SERIF, Font.PLAIN, height);
bufferWrapper.updateBuffer((final PixelBuffer<IntBuffer> wrapper) -> {
final Graphics2D gr = buffer.createGraphics();
try {
renderer.translate(gr);
gr.setColor(new Color(0xA0804040, true));
gr.fillRoundRect(x, y, width, height, width/5, height/5);
gr.setColor(Color.RED);
final GlyphVector glyphs = font.createGlyphVector(gr.getFontRenderContext(), WARNING_TEXT);
final java.awt.geom.Rectangle2D vb = glyphs.getVisualBounds();
gr.drawGlyphVector(glyphs, (float) (x + 0.5*width - vb.getCenterX()),
(float) (y + 0.5*height - vb.getCenterY()));
} finally {
gr.dispose();
}
return null;
});
isWarningShown = true;
setNavigationDisabled(true);
}
renderingCompleted(this);
}
}
/**
* If warning symbol was drawn on top of the image, clears it and restores navigation.
*/
private void clearWarning() {
if (isWarningShown) {
isWarningShown = false;
setNavigationDisabled(false);
}
}
/**
* Clears the image and all intermediate buffer.
* Invoking this method may help to release memory when the map is no longer shown.
*
* <h4>Usage</h4>
* Overriding methods in subclasses should invoke {@code super.clear()}.
* Other methods should generally not invoke this method directly,
* and use the following code instead:
*
* {@snippet lang="java" :
* runAfterRendering(this::clear);
* }
*
* @see #runAfterRendering(Runnable)
*/
@Override
protected void clear() {
image.setImage(null);
clearBuffer();
super.clear();
}
}