Related
The ask: How do I get the viewing rectangle in the coordinates of a transformed and scaled node?
The code is attached below, it is based upon the code from this answer: JavaFX 8 Dynamic Node scaling
The details:
I have a simple pane, BigGridPane that contains a collection of squares, all 50x50.
I have it within this PanAndZoomPane construct that was lifted from the answer referenced above. I can not honestly say I fully understand the PanAndZoomPane implementation. For example, it's not clear to me why it needs a ScrollPane at all, but I have not delved in to trying without it.
The PanAndZoomPane lets me pan and zoom my BigGridPane. This works just dandy.
There are 4 Panes involved in this total construct, in this heirarchy: ScrollPane contains PanAndZoomPane which contains Group which contains BigGridPane.
ScrollPane
PanAndZoomPane
Group
BigGridPane
I have put listeners on the boundsInLocalProperty and boundsInParentProperty of all of these, and the only one of these that changes while panning and zooming, is the boundsInParentProperty of the PanAndZoomPane. (For some reason I've seen it trigger on the scroll pane, but all of the values are the same, so I don't include that here).
Along with the boundsInParentProperty changes, the translateX, translateY, and myScale properties of the PanAndZoomPane change as things move around. This is expected, of course. myScale is bound to the scaleX and scaleY properties of the PanAndZoomPane.
This is what it looks like at startup.
If I pan the grid as shown, putting 2-2 in the upper left:
We can see the properties of the PanAndZoomPane.
panAndZoom in parent: BoundingBox [minX:-99.5, minY:-99.5, minZ:0.0,
width:501.5, height:501.5, depth:0.0,
maxX:402.0, maxY:402.0, maxZ:0.0]
paz scale = 1.0 - tx: -99.0 - ty: -99.0
Scale is 1 (no zoom), and we've translated ~100x100. That is, the origin of the BigGridPane is at -100,-100. This all makes complete sense. Similarly, the bounding box shows the same thing. The origin is at -100,-100.
In this scenario, I would like to derive a rectangle that shows me what I'm seeing in the window, in the coordinates of the BigGridPane. That would mean a rectangle of
x:100 y:100 width:250 height:250
Normally, I think, this would be the viewport of the ScrollPane, but since this code isn't actually using the ScrollPane for scrolling (again, I'm not quite exactly what it's role is here), the ScrollPane viewport never changes.
I should note that there are shenanigans happening right now because of the retina display on my mac. If you look at the rectangles, showing 5x5, they're 50x50 rectangles, so we should be seeing 10x10, but because of the retina display on my iMac, everything is doubled. What we're seeing in BigGridPane coordinates is a 250x250 block of 5 squares, offset by 100x100. The fact that this is being showing in a window of 500x500 is a detail (but unlikely one we can ignore).
But to reiterate what my question is, that's what I'm trying to get: that 250x250 square at 100x100.
It's odd that it's offset by 100x100 even though the frame is twice as big (500 vs 250). If I pan to where 1-1 is the upper left, the offset is -50,-50, like it should be.
Now, let's add zooming, and pan again to 2-2.
1 click of the scroll wheel and the scale jumps to 1.5.
panAndZoom in parent: BoundingBox [minX:-149.375, minY:-150.375, minZ:0.0,
width:752.25, height:752.25, depth:0.0,
maxX:602.875, maxY:601.875, maxZ:0.0]
paz scale = 1.5 - tx: -23.375 - ty: -24.375
What I want, again, in this case, is a rectangle in BigGridPane coordinates. Roughly:
x:100 y:100 w:150 h:150
We see we're offset by 2x2 boxes (100x100) and we see 3+ boxes (150x150).
So. Back to the bounding box. MinX and minY = -150,-150. This is good. 100 x 1.5 = 150. Similarly the width and height are 750. 500 x 1.5 = 750. So, that is good.
The translates are where we go off the rails. -23.375, -24.375. I have no idea where these numbers come from. I can't seem to correlate them to anything in regards to 100, 150, 1.5 zoom, etc.
Worse, if we pan (while still at 1.5 scale) to "0,0", before, at scale=1, tx and ty were both 0. That's good.
panAndZoom in parent: BoundingBox [minX:0.625, minY:0.625, minZ:0.0,
width:752.25, height:752.25, depth:0.0,
maxX:752.875, maxY:752.875, maxZ:0.0]
paz scale = 1.5 - tx: 126.625 - ty: 126.625
Now, they're 126.625 (probably should be rounded to 125). I have no idea where those numbers come from.
I've tried all sorts of runs on the numbers to see where these numbers come from.
JavaFX knows what the numbers are! (even if the whole retina thing is kind of messing with my head, I'm going to ignore it for the moment).
And I don't see anything in the transforms of any of the panes.
So, my coordinate systems are all over the map, and I'd like to know what part of my BigGridPane is being shown in my panned and scaled view.
Code:
package pkg;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.Stage;
import javafx.util.Duration;
public class PanZoomTest extends Application {
private ScrollPane scrollPane = new ScrollPane();
private final DoubleProperty zoomProperty = new SimpleDoubleProperty(1.0d);
private final DoubleProperty deltaY = new SimpleDoubleProperty(0.0d);
private final Group group = new Group();
PanAndZoomPane panAndZoomPane = null;
BigGridPane1 bigGridPane = new BigGridPane1(10, 10, 50);
#Override
public void start(Stage primaryStage) throws Exception {
scrollPane.setPannable(true);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
group.getChildren().add(bigGridPane);
panAndZoomPane = new PanAndZoomPane();
zoomProperty.bind(panAndZoomPane.myScale);
deltaY.bind(panAndZoomPane.deltaY);
panAndZoomPane.getChildren().add(group);
SceneGestures sceneGestures = new SceneGestures(panAndZoomPane);
scrollPane.setContent(panAndZoomPane);
panAndZoomPane.toBack();
addListeners("panAndZoom", panAndZoomPane);
scrollPane.addEventFilter(MouseEvent.MOUSE_PRESSED, sceneGestures.getOnMousePressedEventHandler());
scrollPane.addEventFilter(MouseEvent.MOUSE_DRAGGED, sceneGestures.getOnMouseDraggedEventHandler());
scrollPane.addEventFilter(ScrollEvent.ANY, sceneGestures.getOnScrollEventHandler());
AnchorPane anchorPane = new AnchorPane();
anchorPane.getChildren().add(scrollPane);
anchorPane.setTopAnchor(scrollPane, 1.0d);
anchorPane.setRightAnchor(scrollPane, 1.0d);
anchorPane.setBottomAnchor(scrollPane, 1.0d);
anchorPane.setLeftAnchor(scrollPane, 1.0d);
BorderPane root = new BorderPane(anchorPane);
Label label = new Label("Pan and Zoom Test");
root.setTop(label);
Scene scene = new Scene(root, 250, 250);
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
private void addListeners(String label, Node node) {
node.boundsInLocalProperty().addListener((o) -> {
System.out.println(label + " in local: " + node.getBoundsInLocal());
});
node.boundsInParentProperty().addListener((o) -> {
System.out.println(label + " in parent: " + node.getBoundsInParent());
System.out.println("paz scale = " + panAndZoomPane.getScale() + " - "
+ panAndZoomPane.getTranslateX() + " - "
+ panAndZoomPane.getTranslateY());
System.out.println(group.getTransforms());
});
}
class BigGridPane extends Region {
int rows;
int cols;
int size;
Font numFont = Font.font("sans-serif", 8);
FontMetrics numMetrics = new FontMetrics(numFont);
public BigGridPane(int cols, int rows, int size) {
this.rows = rows;
this.cols = cols;
this.size = size;
int sizeX = cols * size;
int sizeY = rows * size;
setMinSize(sizeX, sizeY);
setMaxSize(sizeX, sizeY);
setPrefSize(sizeX, sizeY);
populate();
}
#Override
protected void layoutChildren() {
System.out.println("grid layout");
super.layoutChildren();
}
private void populate() {
ObservableList<Node> children = getChildren();
children.clear();
for (int i = 0; i < cols; i++) {
for (int j = 0; j < rows; j++) {
Rectangle r = new Rectangle(i * size, j * size, size, size);
r.setFill(null);
r.setStroke(Color.BLACK);
String label = i + "-" + j;
Point2D p = new Point2D(r.getBoundsInLocal().getCenterX(), r.getBoundsInLocal().getCenterY());
Text t = new Text(label);
t.setX(p.getX() - numMetrics.computeStringWidth(label) / 2);
t.setY(p.getY() + numMetrics.getLineHeight() / 2);
t.setFont(numFont);
children.add(r);
children.add(t);
}
}
}
}
class PanAndZoomPane extends Pane {
public static final double DEFAULT_DELTA = 1.5d; //1.3d
DoubleProperty myScale = new SimpleDoubleProperty(1.0);
public DoubleProperty deltaY = new SimpleDoubleProperty(0.0);
private Timeline timeline;
public PanAndZoomPane() {
this.timeline = new Timeline(30);//60
// add scale transform
scaleXProperty().bind(myScale);
scaleYProperty().bind(myScale);
}
public double getScale() {
return myScale.get();
}
public void setScale(double scale) {
myScale.set(scale);
}
public void setPivot(double x, double y, double scale) {
// note: pivot value must be untransformed, i. e. without scaling
// timeline that scales and moves the node
timeline.getKeyFrames().clear();
timeline.getKeyFrames().addAll(
new KeyFrame(Duration.millis(200), new KeyValue(translateXProperty(), getTranslateX() - x)), //200
new KeyFrame(Duration.millis(200), new KeyValue(translateYProperty(), getTranslateY() - y)), //200
new KeyFrame(Duration.millis(200), new KeyValue(myScale, scale)) //200
);
timeline.play();
}
public double getDeltaY() {
return deltaY.get();
}
public void setDeltaY(double dY) {
deltaY.set(dY);
}
}
/**
* Mouse drag context used for scene and nodes.
*/
class DragContext {
double mouseAnchorX;
double mouseAnchorY;
double translateAnchorX;
double translateAnchorY;
}
/**
* Listeners for making the scene's canvas draggable and zoomable
*/
public class SceneGestures {
private DragContext sceneDragContext = new DragContext();
PanAndZoomPane panAndZoomPane;
public SceneGestures(PanAndZoomPane canvas) {
this.panAndZoomPane = canvas;
}
public EventHandler<MouseEvent> getOnMousePressedEventHandler() {
return onMousePressedEventHandler;
}
public EventHandler<MouseEvent> getOnMouseDraggedEventHandler() {
return onMouseDraggedEventHandler;
}
public EventHandler<ScrollEvent> getOnScrollEventHandler() {
return onScrollEventHandler;
}
private EventHandler<MouseEvent> onMousePressedEventHandler = new EventHandler<MouseEvent>() {
public void handle(MouseEvent event) {
sceneDragContext.mouseAnchorX = event.getX();
sceneDragContext.mouseAnchorY = event.getY();
sceneDragContext.translateAnchorX = panAndZoomPane.getTranslateX();
sceneDragContext.translateAnchorY = panAndZoomPane.getTranslateY();
}
};
private EventHandler<MouseEvent> onMouseDraggedEventHandler = new EventHandler<MouseEvent>() {
public void handle(MouseEvent event) {
panAndZoomPane.setTranslateX(sceneDragContext.translateAnchorX + event.getX() - sceneDragContext.mouseAnchorX);
panAndZoomPane.setTranslateY(sceneDragContext.translateAnchorY + event.getY() - sceneDragContext.mouseAnchorY);
event.consume();
}
};
/**
* Mouse wheel handler: zoom to pivot point
*/
private EventHandler<ScrollEvent> onScrollEventHandler = new EventHandler<ScrollEvent>() {
#Override
public void handle(ScrollEvent event) {
double delta = PanAndZoomPane.DEFAULT_DELTA;
double scale = panAndZoomPane.getScale(); // currently we only use Y, same value is used for X
double oldScale = scale;
panAndZoomPane.setDeltaY(event.getDeltaY());
if (panAndZoomPane.deltaY.get() < 0) {
scale /= delta;
} else {
scale *= delta;
}
double f = (scale / oldScale) - 1;
double dx = (event.getX() - (panAndZoomPane.getBoundsInParent().getWidth() / 2 + panAndZoomPane.getBoundsInParent().getMinX()));
double dy = (event.getY() - (panAndZoomPane.getBoundsInParent().getHeight() / 2 + panAndZoomPane.getBoundsInParent().getMinY()));
panAndZoomPane.setPivot(f * dx, f * dy, scale);
event.consume();
}
};
}
class FontMetrics {
final private Text internal;
public float lineHeight;
public FontMetrics(Font fnt) {
internal = new Text();
internal.setFont(fnt);
Bounds b = internal.getLayoutBounds();
lineHeight = (float) b.getHeight();
}
public float computeStringWidth(String txt) {
internal.setText(txt);
return (float) internal.getLayoutBounds().getWidth();
}
public float getLineHeight() {
return lineHeight;
}
}
}
Generally, you can get the bounds of node1 in the coordinate system of node2 if both are in the same scene using
node2.sceneToLocal(node1.localToScene(node1.getBoundsInLocal()));
I don't understand all the code you posted; I don't really know why you are using a scroll pane when you seem to be implementing all the panning and zooming yourself. Here is a simpler version of a PanZoomPane and then a test which shows how to use the idea above to get the bounds of the viewport in the coordinate system of the panning/zooming content. The "viewport" is just the bounds of the panning/zooming pane in the coordinate system of the content.
If you need the additional functionality in your version of panning and zooming, you should be able to adapt this idea to that; but it would take me too long to understand everything you are doing there.
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.layout.Region;
import javafx.scene.shape.Rectangle;
import javafx.scene.transform.Affine;
import javafx.scene.transform.Transform;
public class PanZoomPane extends Region {
private final Node content ;
private final Rectangle clip ;
private Affine transform ;
private Point2D mouseDown ;
private static final double SCALE = 1.01 ; // zoom factor per pixel scrolled
public PanZoomPane(Node content) {
this.content = content ;
getChildren().add(content);
clip = new Rectangle();
setClip(clip);
transform = Affine.affine(1, 0, 0, 1, 0, 0);
content.getTransforms().setAll(transform);
content.setOnMousePressed(event -> mouseDown = new Point2D(event.getX(), event.getY()));
content.setOnMouseDragged(event -> {
double deltaX = event.getX() - mouseDown.getX();
double deltaY = event.getY() - mouseDown.getY();
translate(deltaX, deltaY);
});
content.setOnScroll(event -> {
double pivotX = event.getX();
double pivotY = event.getY();
double scale = Math.pow(SCALE, event.getDeltaY());
scale(pivotX, pivotY, scale);
});
}
public Node getContent() {
return content ;
}
#Override
protected void layoutChildren() {
clip.setWidth(getWidth());
clip.setHeight(getHeight());
}
public void scale(double pivotX, double pivotY, double scale) {
transform.append(Transform.scale(scale, scale, pivotX, pivotY));
}
public void translate(double x, double y) {
transform.append(Transform.translate(x, y));
}
public void reset() {
transform.setToIdentity();
}
}
import javafx.application.Application;
import javafx.beans.binding.Binding;
import javafx.beans.binding.ObjectBinding;
import javafx.geometry.Bounds;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.geometry.VPos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.RowConstraints;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
public class PanZoomTest extends Application {
private Binding<Bounds> viewport ;
#Override
public void start(Stage stage) {
Node content = createContent(50, 50, 50) ;
PanZoomPane pane = new PanZoomPane(content);
viewport = new ObjectBinding<>() {
{
bind(
pane.localToSceneTransformProperty(),
pane.boundsInLocalProperty(),
content.localToSceneTransformProperty()
);
}
#Override
protected Bounds computeValue() {
return content.sceneToLocal(pane.localToScene(pane.getBoundsInLocal()));
}
};
viewport.addListener((obs, oldViewport, newViewport) -> System.out.println(newViewport));
BorderPane root = new BorderPane(pane);
Button reset = new Button("Reset");
reset.setOnAction(event -> pane.reset());
HBox buttons = new HBox(reset);
buttons.setAlignment(Pos.CENTER);
buttons.setPadding(new Insets(10));
root.setTop(buttons);
Scene scene = new Scene(root, 800, 800);
stage.setScene(scene);
stage.show();
}
private Node createContent(int columns, int rows, double cellSize) {
GridPane grid = new GridPane() ;
ColumnConstraints cc = new ColumnConstraints();
cc.setMinWidth(cellSize);
cc.setPrefWidth(cellSize);
cc.setMaxWidth(cellSize);
cc.setFillWidth(true);
cc.setHalignment(HPos.CENTER);
for (int column = 0 ; column < columns ; column++) {
grid.getColumnConstraints().add(cc);
}
RowConstraints rc = new RowConstraints();
rc.setMinHeight(cellSize);
rc.setPrefHeight(cellSize);
rc.setMaxHeight(cellSize);
rc.setFillHeight(true);
rc.setValignment(VPos.CENTER);
for (int row = 0 ; row < rows ; row++) {
grid.getRowConstraints().add(rc);
}
for (int x = 0 ; x < columns ; x++) {
for (int y = 0 ; y < rows ; y++) {
Label label = new Label(String.format("[%d, %d]", x, y));
label.setBackground(new Background(
new BackgroundFill(Color.BLACK, CornerRadii.EMPTY, Insets.EMPTY),
new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, new Insets(1,1,0,0))
));
label.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE);
grid.add(label, x, y);
}
}
return grid ;
}
public static void main(String[] args) {
launch();
}
}
I have inherited a simulation program to extend with new features. The original was written as an Applet using the AWT library for graphics. Before adding the new features I want to adapt the program to the desktop and use JavaFX instead of AWT.
The simulation paints hundreds or thousands of objects dozens of times per second, then erases them and repaints them at new locations, effectively animating them. I am using a Canvas object for that part of the UI. Erasing is done by repainting the object with the background color. What I am seeing though is that erasing objects is incomplete. A kind of "halo" gets left behind though.
The following program illustrates the problem. Clicking the "Draw" button causes it to draw a few hundred circles on the Canvas using the foreground color. After drawing, clicking the button again will erase the circles by re-drawing them in the background color. Multiple cycles of draw/erase will build up a visible background of "ghost" images.
package com.clartaq.antialiasingghosts;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
import java.util.Random;
public class Main extends Application {
static final int NUM_CIRCLES = 500;
static final int CIRCLE_DIAMETER = 10;
static final double PANEL_WIDTH = 75.0;
static final double PANEL_HEIGHT = 40.0;
static final Color FG_COLOR = Color.rgb(10, 0, 200);
static final Color BG_COLOR = Color.rgb(255, 255, 255);
static final double BUTTON_WIDTH = 50.0;
GraphicsContext gc;
Random rand = new Random();
double[] px = new double[NUM_CIRCLES];
double[] py = new double[NUM_CIRCLES];
void randomizeParticlePositions() {
for (int i = 0; i < NUM_CIRCLES; i++) {
px[i] = rand.nextDouble() * PANEL_WIDTH;
py[i] = rand.nextDouble() * PANEL_HEIGHT;
}
}
void drawCircles(Color color) {
gc.setFill(color);
for (int i = 0; i < NUM_CIRCLES; i++) {
var screenX = px[i] * CIRCLE_DIAMETER;
var screenY = py[i] * CIRCLE_DIAMETER;
gc.fillOval(screenX, screenY, CIRCLE_DIAMETER, CIRCLE_DIAMETER);
}
}
#Override
public void start(Stage stage) {
String javaVersion = System.getProperty("java.version");
String javafxVersion = System.getProperty("javafx.version");
stage.setTitle("AntiAliasingGhosts -- erasing objects leaves ghosts in JavaFX");
Label versionLabel = new Label("JavaFX " + javafxVersion
+ ", running on Java " + javaVersion + ".");
double canvasWidth = (PANEL_WIDTH * CIRCLE_DIAMETER);
double canvasHeight = (PANEL_HEIGHT * CIRCLE_DIAMETER);
Canvas canvasRef = new Canvas(canvasWidth, canvasHeight);
gc = canvasRef.getGraphicsContext2D();
Button deBtn = new Button("Draw");
deBtn.setPrefWidth(BUTTON_WIDTH);
deBtn.setOnAction(e -> {
String txt = deBtn.getText();
switch (txt) {
case "Draw" -> {
randomizeParticlePositions();
drawCircles(FG_COLOR);
deBtn.setText("Erase");
}
case "Erase" -> {
drawCircles(BG_COLOR);
deBtn.setText("Draw");
}
default -> Platform.exit();
}
});
Button exBtn = new Button("Exit");
exBtn.setPrefWidth(BUTTON_WIDTH);
exBtn.setOnAction(e -> Platform.exit());
TilePane tp = new TilePane();
tp.setAlignment(Pos.CENTER);
tp.setHgap(10);
tp.getChildren().addAll(deBtn, exBtn);
VBox root = new VBox();
root.setPadding(new Insets(7));
root.setSpacing(10);
root.setAlignment(Pos.CENTER);
root.getChildren().addAll(versionLabel, canvasRef, tp);
StackPane sp = new StackPane(root);
BackgroundFill bf = new BackgroundFill(BG_COLOR, CornerRadii.EMPTY, Insets.EMPTY);
Background bg = new Background(bf);
sp.setBackground(bg);
Scene scene = new Scene(sp, 640.0, 480.0);
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch();
}
}
I can get good erasure by expanding the diameter of the circles by 2 pixels when erasing. Of course, that can affect nearby shapes too.
Also, using the fillRect method to erase the entire Canvas seems reasonable, but that means everything has to be re-drawn if anything has to be re-drawn. I suppose it is possible to optimize the re-draw by erasing and re-drawing a smaller section of the Canvas but I don't want to do that if it isn't necessary.
Magnifying sections of the program display shows that it is really an antialiasing effect. Constructing the Scene with the SceneAntialiasing.DISABLED parameter does not seem to have any effect.
Attempting to turn off image smoothing as suggested in this question does not help.
Is possible to erase a single shape drawn on a Canvas by re-drawing it in the background color?
I am using Java 17.0.1, JavaFX 17.0.1, and a 5K Mac display if that is relevant.
For expedience, note the difference between fillOval and strokeOval() in the GraphicsContext. You can conditionally erase the outline in drawCircles() as a function of a suitable boolean value:
if (stroke) {
gc.setStroke(BG_COLOR);
gc.strokeOval(screenX, screenY, CIRCLE_DIAMETER, CIRCLE_DIAMETER);
}
Try a few representative shapes, e.g. fillRect, to verify the desired result.
A better alternative, IMO, is to pursue the erase -> render strategy. Complete examples seen here and here may help you establish whether the approach is scalable to your use-case. See also this related examination of resampling artifact.
Expedient approach, as tested:
import javafx.application.Application;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
import java.util.Random;
public class Main extends Application {
static final int NUM_CIRCLES = 500;
static final int CIRCLE_DIAMETER = 10;
static final double PANEL_WIDTH = 75.0;
static final double PANEL_HEIGHT = 40.0;
static final Color FG_COLOR = Color.rgb(10, 0, 200);
static final Color BG_COLOR = Color.rgb(255, 255, 255);
static final double BUTTON_WIDTH = 50.0;
GraphicsContext gc;
Random rand = new Random();
private boolean stroke;
double[] px = new double[NUM_CIRCLES];
double[] py = new double[NUM_CIRCLES];
void randomizeParticlePositions() {
for (int i = 0; i < NUM_CIRCLES; i++) {
px[i] = rand.nextDouble() * PANEL_WIDTH;
py[i] = rand.nextDouble() * PANEL_HEIGHT;
}
}
void drawCircles(Color color) {
gc.setFill(color);
for (int i = 0; i < NUM_CIRCLES; i++) {
var screenX = px[i] * CIRCLE_DIAMETER;
var screenY = py[i] * CIRCLE_DIAMETER;
gc.fillOval(screenX, screenY, CIRCLE_DIAMETER, CIRCLE_DIAMETER);
if (stroke) {
gc.setStroke(BG_COLOR);
gc.strokeOval(screenX, screenY, CIRCLE_DIAMETER, CIRCLE_DIAMETER);
}
}
}
#Override
public void start(Stage stage) {
String javaVersion = System.getProperty("java.version");
String javafxVersion = System.getProperty("javafx.version");
stage.setTitle("AntiAliasingGhosts -- erasing objects leaves ghosts in JavaFX");
Label versionLabel = new Label("JavaFX " + javafxVersion
+ ", running on Java " + javaVersion + ".");
double canvasWidth = (PANEL_WIDTH * CIRCLE_DIAMETER);
double canvasHeight = (PANEL_HEIGHT * CIRCLE_DIAMETER);
Canvas canvasRef = new Canvas(canvasWidth, canvasHeight);
gc = canvasRef.getGraphicsContext2D();
Button deBtn = new Button("Draw");
deBtn.setPrefWidth(BUTTON_WIDTH);
deBtn.setOnAction(e -> {
String txt = deBtn.getText();
switch (txt) {
case "Draw" -> {
randomizeParticlePositions();
drawCircles(FG_COLOR);
deBtn.setText("Erase");
stroke = true;
}
case "Erase" -> {
drawCircles(BG_COLOR);
deBtn.setText("Draw");
stroke = false;
}
default ->
Platform.exit();
}
});
Button exBtn = new Button("Exit");
exBtn.setPrefWidth(BUTTON_WIDTH);
exBtn.setOnAction(e -> Platform.exit());
TilePane tp = new TilePane();
tp.setAlignment(Pos.CENTER);
tp.setHgap(10);
tp.getChildren().addAll(deBtn, exBtn);
VBox root = new VBox();
root.setPadding(new Insets(7));
root.setSpacing(10);
root.setAlignment(Pos.CENTER);
root.getChildren().addAll(versionLabel, canvasRef, tp);
StackPane sp = new StackPane(root);
BackgroundFill bf = new BackgroundFill(BG_COLOR, CornerRadii.EMPTY, Insets.EMPTY);
Background bg = new Background(bf);
sp.setBackground(bg);
Scene scene = new Scene(sp, 640.0, 480.0);
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
launch();
}
}
Is there any way to stop this blurring when scaling a Canvas?
I presume it's related to GPU interpolation. But what I need is a pixel-perfect "pixelated" zoom here. Just use the color of the nearest "real" neighboring pixel.
I've seen the solutions here but of the two suggested that work (#1 / #4), #4 is definitely CPU scaling and #1 I guess is too.
This scaling needs to be FAST - I'd like to be able to support up to maybe 20-25 layers (probably Canvases in a StackPane but I'm open to other ideas as long as they don't melt the CPU). I'm having doubts this can be done without GPU support which JFX offers, but maybe not with a flexible enough API. Strategies like #4 in the linked answer which rely on CPU rescaling probably aren't going to work.
If you zoom into the highest zoom level with this code the blurring is obvious.
Do we need an update to the JFX API to support this or something?
This should be possible to do.
import javafx.application.Application;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Paint;
import javafx.stage.Stage;
public class ScaleTest extends Application {
private static final int width = 1200;
private static final int height = 800;
private static final int topMargin = 32;
private StackPane stackPane;
private IntegerProperty zoomLevel = new SimpleIntegerProperty(100);
#Override
public void start(Stage stage) {
stage.setWidth(width);
stage.setMinHeight(height);
stackPane = new StackPane();
stackPane.setLayoutY(topMargin);
Canvas canvas = new Canvas(width, height - topMargin);
Label label = new Label();
label.setLayoutY(2);
label.setLayoutX(2);
label.setStyle("-fx-text-fill: #FFFFFF");
label.textProperty().bind(zoomLevel.asString());
Button zoomInBtn = new Button("Zoom In");
zoomInBtn.setLayoutY(2);
zoomInBtn.setLayoutX(50);
zoomInBtn.onActionProperty().set((e) -> {
if (zoomLevel.get() < 3200) {
zoomLevel.set(zoomLevel.get() * 2);
stackPane.setScaleX(zoomLevel.get() / 100.0);
stackPane.setScaleY(zoomLevel.get() / 100.0);
}
});
Button zoomOutBtn = new Button("Zoom Out");
zoomOutBtn.setLayoutY(2);
zoomOutBtn.setLayoutX(140);
zoomOutBtn.onActionProperty().set((e) -> {
if (zoomLevel.get() > 25) {
zoomLevel.set(zoomLevel.get() / 2);
stackPane.setScaleX(zoomLevel.get() / 100.0);
stackPane.setScaleY(zoomLevel.get() / 100.0);
}
});
Pane mainPane = new Pane(stackPane, label, zoomInBtn, zoomOutBtn);
mainPane.setStyle("-fx-background-color: #000000");
Scene scene = new Scene(mainPane);
stage.setScene(scene);
drawGrid(canvas, 0, 0, width, height - topMargin, 16);
stackPane.getChildren().add(canvas);
stage.show();
}
private void drawGrid(Canvas canvas, int xPos, int yPos, int width, int height, int gridSize) {
boolean darkX = true;
String darkCol = "#111111";
String lightCol = "#222266";
for (int x = xPos; x < canvas.getWidth(); x += gridSize) {
boolean dark = darkX;
darkX = !darkX;
if (x > width) {
break;
}
for (int y = yPos; y < canvas.getHeight(); y += gridSize) {
if (y > height) {
break;
}
dark = !dark;
String color;
if (dark) {
color = darkCol;
} else {
color = lightCol;
}
canvas.getGraphicsContext2D().setFill(Paint.valueOf(color));
canvas.getGraphicsContext2D().fillRect(x, y, gridSize, gridSize);
}
}
}
}
Your example adjusts the node's scale properties to resample a fixed-size image, which inevitably results in such artifact. Only a vector representation can be scaled with arbitrary precision. You may need to decide how your application will support vectors and/or bitmaps. For example, if your application were really about scaling rectangles, you would invoke fillRect() with scaled coordinates, rather than scaling a picture of a smaller rectangle.
You've cited a good summary or resizing, so I'll focus on vector opportunities:
Concrete subclasses of Shape are, in effect, vector representations of geometric elements that can be rendered at any scale; resize the example shown here or here to see the effect; scroll to zoom and click to drag the circle here, noting that its border remains sharp at all scales.
An SVGPath is a Shape that can be scaled as shown here.
Internally, a Font stores the vector representation of individual glyphs. When instantiated, the glyph is rasterized at a certain point size.
If a suitable vector representation is known, drawing can be tied to the size of the Canvas as shown here.
This seems to do the trick:
package com.analogideas.scratch;
import javafx.application.Application;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.WritableImage;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Paint;
import javafx.stage.Stage;
public class ImagePixelator extends Application {
private static final int width = 1200;
private static final int height = 800;
private static final int topMargin = 32;
private IntegerProperty zoomLevel = new SimpleIntegerProperty(1);
public static void main(String[] args) {
launch(args);
}
#Override
public void start(Stage stage) {
stage.setWidth(width);
stage.setMinHeight(height);
Canvas canvasOut = new Canvas(width, height - topMargin);
Canvas canvas = new Canvas(width, height - topMargin);
drawGrid(canvas, 0, 0, width, height - topMargin, 16);
WritableImage img = canvas.snapshot(null, null);
drawImage(canvasOut, img, 0, 0, 1);
StackPane pane = new StackPane(canvasOut);
Label label = new Label();
label.setLayoutY(2);
label.setLayoutX(2);
label.setStyle("-fx-text-fill: #FFFFFF");
label.textProperty().bind(zoomLevel.asString());
Button zoomInBtn = new Button("Zoom In");
zoomInBtn.setLayoutY(2);
zoomInBtn.setLayoutX(50);
zoomInBtn.onActionProperty().set((e) -> {
if (zoomLevel.get() < 3200) {
zoomLevel.set(zoomLevel.get() * 2);
int z = zoomLevel.get();
drawImage(canvasOut, img, 0, 0, z);
}
});
Button zoomOutBtn = new Button("Zoom Out");
zoomOutBtn.setLayoutY(2);
zoomOutBtn.setLayoutX(140);
zoomOutBtn.onActionProperty().set((e) -> {
if (zoomLevel.get() > 1) {
zoomLevel.set(zoomLevel.get() /2);
int z = zoomLevel.get();
drawImage(canvasOut, img, 0, 0, z);
}
});
Pane mainPane = new Pane(pane, label, zoomInBtn, zoomOutBtn);
mainPane.setStyle("-fx-background-color: #000000");
Scene scene = new Scene(mainPane);
stage.setScene(scene);
stage.show();
}
private void drawImage(Canvas canvas, Image image, int x, int y, float zoom) {
GraphicsContext g = canvas.getGraphicsContext2D();
g.setImageSmoothing(false);
int w = (int) (image.getWidth() * zoom);
int h = (int) (image.getHeight() * zoom);
g.drawImage(image, x, y, w, h);
}
private void drawGrid(Canvas canvas, int xPos, int yPos, int width, int height, int gridSize) {
Paint darkPaint = Paint.valueOf("#111111");
Paint lightPaint = Paint.valueOf("#222266");
GraphicsContext g = canvas.getGraphicsContext2D();
g.setImageSmoothing(false);
int xLimit = width / gridSize;
int yLimit = height / gridSize;
for (int x = 0; x <= xLimit; x++) {
for (int y = 0; y <= yLimit; y++) {
boolean dark = (((x ^ y) & 1) == 0);
g.setFill(dark ? darkPaint : lightPaint);
g.fillRect(xPos + x * gridSize, yPos + y * gridSize, gridSize, gridSize);
}
}
}
}
I'm working on Eclipse e4 RCP application where I have one SWT part. Using JavaFX interoperability with SWT I have added javafx.embed.swt.FXCanvas. After I create a javafx.scene.Scene with 50000 javafx.scene.control.Button and hook it to the canvas, application memory jumps to 1.5gb.
Problem starts when I close that part, application does not release a memory. When I open that part again, memory jumps to 2.something gb.
Part class:
import javax.annotation.PostConstruct;
import org.eclipse.jface.layout.GridDataFactory;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.DisposeEvent;
import org.eclipse.swt.events.DisposeListener;
import org.eclipse.swt.layout.GridLayout;
import org.eclipse.swt.widgets.Composite;
import javafx.embed.swt.FXCanvas;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.Pane;
public class StressTestJavaFXPart
{
private Scene scene;
private FXCanvas canvas;
private final int columns = 100;
private final int rows = 500;
#PostConstruct
public void createComposite(final Composite parent)
{
parent.setLayout(new GridLayout(1, false));
canvas = new FXCanvas(parent, SWT.NONE);
GridDataFactory.fillDefaults().grab(true, true).span(0, 0).applyTo(canvas);
final Pane pane = new Pane();
for (int i = 0; i < columns ; i++)
{
for (int j = 0; j < rows ; j++)
{
final Button button = new Button(String.format("%s : %s", i, j));
pane.getChildren().add(button);
button.setLayoutX(100 * i);
button.setLayoutY(100 * j);
}
}
final ScrollPane sp = new ScrollPane(pane);
scene = new Scene(sp);
canvas.setScene(scene);
parent.addDisposeListener(new DisposeListener()
{
#Override
public void widgetDisposed(final DisposeEvent e)
{
canvas.dispose();
System.gc();
}
});
}
}
Any suggestions are very welcome! Thanks
I solved it using reflection:
parent.addDisposeListener(disposeEvent -> {
//release Pixel Buffer via Reflection -> prevents memory leak
try {
Field pixelBuffer = FXCanvas.class.getDeclaredField("pixelsBuf");
pixelBuffer.setAccessible(true);
pixelBuffer.set(this, null);
} catch (NoSuchFieldException | IllegalAccessException e) {
}
});
Worked well for me.
Consider a rectangle traversing a long, linear path. It would be useful to figure out where the shape had gone earlier in the animation. Displaying the entire path before the shape moves is not what I want. That is easily done by adding the path to the pane.
I want a trailing line behind the shape representing the path that the shape has traversed through so far. Does anyone know how to do this in Javafx? I am using Path and PathTransition to animate my object along a path.
There are various solutions. Depending on which one you choose decides your outcome.
You could use a Canvas and paint lines on it while a Node moves along the Path.
import javafx.animation.Animation;
import javafx.animation.PathTransition;
import javafx.application.Application;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.CubicCurveTo;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javafx.stage.Stage;
import javafx.util.Duration;
public class PathVisualization extends Application {
private static double SCENE_WIDTH = 400;
private static double SCENE_HEIGHT = 260;
Canvas canvas;
#Override
public void start(Stage primaryStage) throws Exception {
Pane root = new Pane();
Path path = createPath();
canvas = new Canvas(SCENE_WIDTH,SCENE_HEIGHT);
root.getChildren().addAll(path, canvas);
primaryStage.setScene(new Scene(root, SCENE_WIDTH, SCENE_HEIGHT));
primaryStage.show();
Animation animation = createPathAnimation(path, Duration.seconds(5));
animation.play();
}
private Path createPath() {
Path path = new Path();
path.setStroke(Color.RED);
path.setStrokeWidth(10);
path.getElements().addAll(new MoveTo(20, 20), new CubicCurveTo(380, 0, 380, 120, 200, 120), new CubicCurveTo(0, 120, 0, 240, 380, 240), new LineTo(20,20));
return path;
}
private Animation createPathAnimation(Path path, Duration duration) {
GraphicsContext gc = canvas.getGraphicsContext2D();
// move a node along a path. we want its position
Circle pen = new Circle(0, 0, 4);
// create path transition
PathTransition pathTransition = new PathTransition( duration, path, pen);
pathTransition.currentTimeProperty().addListener( new ChangeListener<Duration>() {
Location oldLocation = null;
/**
* Draw a line from the old location to the new location
*/
#Override
public void changed(ObservableValue<? extends Duration> observable, Duration oldValue, Duration newValue) {
// skip starting at 0/0
if( oldValue == Duration.ZERO)
return;
// get current location
double x = pen.getTranslateX();
double y = pen.getTranslateY();
// initialize the location
if( oldLocation == null) {
oldLocation = new Location();
oldLocation.x = x;
oldLocation.y = y;
return;
}
// draw line
gc.setStroke(Color.BLUE);
gc.setFill(Color.YELLOW);
gc.setLineWidth(4);
gc.strokeLine(oldLocation.x, oldLocation.y, x, y);
// update old location with current one
oldLocation.x = x;
oldLocation.y = y;
}
});
return pathTransition;
}
public static class Location {
double x;
double y;
}
public static void main(String[] args) {
launch(args);
}
}
Here's a screenshot how it looks like. Red is the actual path, Blue is the path that is drawn on the Canvas:
Other solutions use e. g. a clip. However, if you choose the same Duration as I did above (i. e. 5 seconds) with that technique, you'll get gaps like this:
The solution with the line drawing has its drawbacks as well. If you choose 1 second, you'll see the line segments. A possibiliy to circumvent this would be to smooth the path yourself. But for that you'd have to get into splitting the path into segments and that's a bit math-y.
Slightly offtopic, but how to paint along the mouse coordinates may also be interesing for you to give you ideas.
Michael Bostock does a path animation by manipulating the stroke dash array and interpolating the stroke dash offset. He provides an example (of course) which you can view here.
The same approach can be taken in JavaFX. Here is a DrawPathTransition
(Kotlin) class I created which uses this technique:
class DrawPathTransition(val path: Path) : Transition() {
private val DEFAULT_DURATION = Duration.millis(400.0)
private val length = path.totalLength
var duration: Duration
get() = durationProperty.get()
set(value) {
durationProperty.set(value)
}
val durationProperty = SimpleObjectProperty(DEFAULT_DURATION)
init {
durationProperty.addListener({ _ -> cycleDuration = duration })
statusProperty().addListener({ _, _, status ->
when(status) {
Status.RUNNING -> path.strokeDashArray.addAll(length, length)
Status.STOPPED -> path.strokeDashArray.clear()
}
})
}
override fun interpolate(frac: Double) {
path.strokeDashOffset = length - length * frac
}
}
The tricky part here is getting the path's total length. See my answer to this question for how that can be accomplished.
You can then combine a PathTransition with the above DrawPathTransition of the same duration in a ParallelTransition to get what you desire.
Since this approach modifies strokeDashArray and strokeDashOffset it only works with solid lines, but what if we want to support dashed lines as well? Nadieh Bremer has an excellent article about this which can be reviewed here.
The DrawPathTransition (Kotlin) class provided below implements this technique. Note that this can create a rather large strokeDashArray during the transition.
class DrawPathTransition(val path: Path) : Transition() {
private val length = path.totalLength
private val stroked = path.strokeDashArray.isNotEmpty()
private val dashArray: List<Double> = if(stroked) ArrayList(path.strokeDashArray) else emptyList()
private val dashLength = dashArray.sum()
private val dashOffset = path.strokeDashOffset
var duration: Duration
get() = durationProperty.get()
set(value) {
durationProperty.set(value)
}
val durationProperty = SimpleObjectProperty(DEFAULT_DURATION)
init {
durationProperty.addListener({ _ -> cycleDuration = duration })
if(stroked) {
val n = (length / dashLength).toInt()
path.strokeDashArray.clear()
(1..n).forEach { path.strokeDashArray.addAll(dashArray) }
path.strokeDashArray.addAll(0.0, length)
statusProperty().addListener({ _, _, status ->
if(status == Animation.Status.STOPPED) {
path.strokeDashOffset = dashOffset
path.strokeDashArray.setAll(dashArray)
}
})
}
}
override fun interpolate(frac: Double) {
path.strokeDashOffset = length - length * frac
}
}
I wasn't completely happy with this approach though, as the stroke appears to "march" along the path as the path is drawn, which doesn't look great particularly with short durations. Rather I wanted it to appear as if the stroke was being "revealed" over time (so no stroke movement). The DrawPathTransition (Kotlin) class below implements my solution:
class DrawPathTransition(val path: Path) : Transition() {
private val length = path.totalLength
private val stroked = path.strokeDashArray.isNotEmpty()
private val dashArray: List<Double> = if(stroked) ArrayList(path.strokeDashArray) else emptyList()
private val dashSum = dashArray.sum()
private val dashOffset = path.strokeDashOffset
var duration: Duration
get() = durationProperty.get()
set(value) {
durationProperty.set(value)
}
val durationProperty = SimpleObjectProperty(DEFAULT_DURATION)
init {
durationProperty.addListener({ _ -> cycleDuration = duration })
if(stroked) {
statusProperty().addListener({ _, _, status ->
if(status == Animation.Status.STOPPED) {
path.strokeDashOffset = dashOffset
path.strokeDashArray.setAll(dashArray)
}
})
}
}
override fun interpolate(frac: Double) {
val l = length * frac
if(stroked) {
path.strokeDashOffset = l
val n = ceil(l / dashSum).toInt()
path.strokeDashArray.clear()
path.strokeDashArray.addAll(0.0, l)
(1..n).forEach { path.strokeDashArray.addAll(dashArray) }
path.strokeDashArray.addAll(0.0, length - l)
}
else path.strokeDashOffset = length - l
}
}