How is set the Pivot for Shapes in JavaFX after Rotate Transformation? - javafx

I'm trying to move the upper left corner on a rotated rectangle and keep the lower right corner fixed. But after the update of the pivots of the rectangle there is a jump of the rectangle. How do I have the corners set correctly so that the jump does not occur after update the pivots? The example illustrates the problem. The green and blue rectangle are different after clicking the button! Has everone an idea? May help the 'deltaTransform' procedure?
import java.util.logging.Level;
import java.util.logging.Logger;
import javafx.application.Application;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Point2D;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.paint.Color;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.scene.transform.NonInvertibleTransformException;
import javafx.scene.transform.Rotate;
import javafx.stage.Stage;
public class RotateTest extends Application {
Rectangle rect0,rect1, rect2;
#Override
public void start(Stage primaryStage) {
rect0 = new Rectangle(100, 100, 200, 100); //start rect to compare
rect0.setFill(Color.TRANSPARENT);
rect0.setStroke(Color.GREEN);
rect0.setStrokeWidth(1.5);
rect0.setRotate(20);
rect1 = new Rectangle(100, 100, 200, 100); // moved rect
rect1.setFill(Color.TRANSPARENT);
rect1.setStroke(Color.RED);
rect1.setStrokeWidth(1.5);
rect2 = new Rectangle(100, 100, 200, 100);// unmoved rect
rect2.setFill(Color.TRANSPARENT);
rect2.setStrokeWidth(1.5);
rect2.setStroke(Color.BLUE);
Rotate rotate = new Rotate(20, rect1.getX() + rect1.getWidth() / 2., rect1.getY() + rect1.getHeight() / 2.);
rect1.getTransforms().add(rotate);
rect2.getTransforms().add(rotate);
Button btn = new Button();
btn.setText("Move to target");
btn.setOnAction(new EventHandler<ActionEvent>() {
#Override
public void handle(ActionEvent event) {
try {
Point2D p1ns = new Point2D(150, 50); //target for upper left corner in transformed system
Point2D p1n = rotate.inverseTransform(p1ns);//target for upper left corner in nontransformed system
Point2D p2 = new Point2D(rect1.getX()+rect1.getWidth(), rect1.getY()+rect1.getHeight());
//bottom right corner in nontransformed system
Point2D p2s = rotate.transform(p2);//bottom right corner in transformed system
rect1.setX(p1n.getX());
rect1.setY(p1n.getY());
rect1.setWidth(p2.getX()-p1n.getX());
rect1.setHeight(p2.getY()-p1n.getY());
//this make the problem:
rotate.setPivotX(rect1.getX() + rect1.getWidth() / 2.);
rotate.setPivotY(rect1.getY() + rect1.getHeight() / 2.);
} catch (NonInvertibleTransformException ex) {
Logger.getLogger(RotateTest.class.getName()).log(Level.SEVERE, null, ex);
}
}
});
Group root = new Group();
for (int i = 0; i < 10; i++) {
Line l1 = new Line(i * 100, 0, i * 100, 400);
Line l2 = new Line(0, i * 100, 1000, i * 100);
root.getChildren().addAll(l1, l2);
}
root.getChildren().addAll(btn, rect0, rect1, rect2);
Scene scene = new Scene(root, 400, 300);
primaryStage.setTitle("Rotation");
primaryStage.setScene(scene);
primaryStage.show();
}
/**
* #param args the command line arguments
*/
public static void main(String[] args) {
launch(args);
}
}

Now I have found a solution. A secondary temporary Rotate-object helps! See the code now! The green and red rectangle have the same right bottom corner!
#Override
public void handle(ActionEvent event) {
try {
Point2D p1s = new Point2D(150, 50); //target for upper left corner in transformed system
Point2D p2s = rotate.transform(rect1.getX()+rect1.getWidth(), rect1.getY()+rect1.getHeight());//bottom right corner in transformed system
Rotate rotTemp=new Rotate(rotate.getAngle(), (p1s.getX() + p2s.getX() )/ 2., (p1s.getY() + p2s.getY() )/ 2.);
Point2D q1 = rotTemp.inverseTransform(p1s);
Point2D q2 = rotTemp.inverseTransform(p2s);
rect1.setX(q1.getX());
rect1.setY(q1.getY());
rect1.setWidth(q2.getX()-q1.getX());
rect1.setHeight(q2.getY()-q1.getY());
rotate.setPivotX(rotTemp.getPivotX());
rotate.setPivotY(rotTemp.getPivotY());
} catch (NonInvertibleTransformException ex) {
Logger.getLogger(RotateTest.class.getName()).log(Level.SEVERE, null, ex);
}
}

You can always fix the bottom right of a rectangle with a change of properties of the form:
x -> x + deltaX ;
y -> y + deltaY ;
width -> width - deltaX ;
height -> height - deltaY ;
So you can do
btn.setOnAction(new EventHandler<ActionEvent>() {
#Override
public void handle(ActionEvent event) {
try {
Point2D targetAbsolute = new Point2D(150, 50);
Point2D targetLocal = rotate.inverseTransform(targetAbsolute);
double newX = targetLocal.getX() ;
double newY = targetLocal.getY() ;
double deltaX = newX - rect1.getX();
double deltaY = newY - rect1.getY();
rect1.setX(newX);
rect1.setY(newY);
rect1.setWidth(rect1.getWidth() - deltaX);
rect1.setHeight(rect1.getHeight() - deltaY);
} catch (NonInvertibleTransformException ex) {
Logger.getLogger(RotateTest.class.getName()).log(Level.SEVERE, null, ex);
}
}
});

Related

Get Viewport of translated and scaled node

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();
}
}

keep rectangle rotation pivot on center when resized javafx

I recently start learn javafx. With help of several posts from here i created a draggable, resizable and rotable rectangle. My plan is use it as resizing control of custom nodes. I want rotation pívot keep on rectangle center but if is resized, center changes and when i set the new center the rectangle makes a bump. Why is happenning this and how i fix it? I am sorry if made any mistake english is not my native language. Thanks in advance
import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.stage.Stage;
import javafx.scene.Cursor;
import javafx.scene.Group;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.scene.transform.Rotate;
public class Main extends Application {
#Override
public void start(Stage primaryStage) {
final Delta dragDelta = new Delta();
Wrapper<Point2D> mouseLocation = new Wrapper<>();
Pane root = new Pane();
Rectangle rect = new Rectangle(100,100);
final Rotate rotate = new Rotate();
{
//set pivot on rectangle center
rotate.setPivotX((rect.getX() + rect.getWidth())/2);
rotate.setPivotY((rect.getY() + rect.getHeight())/2);
}
rect.setStyle(
"-fx-stroke: blue; " +
"-fx-stroke-width: 2px; " +
"-fx-stroke-dash-array: 12 2 4 2; " +
"-fx-stroke-dash-offset: 6; " +
"-fx-stroke-line-cap: butt; " +
"-fx-fill: rgba(255, 255, 255, .0);"
);
Group group = new Group();
Circle rotateCircle = new Circle(7);
Circle topLeft = new Circle(7);
topLeft.setOnMousePressed(e->{
mouseLocation.value = new Point2D(e.getSceneX(), e.getSceneY());
});
topLeft.setOnMouseDragged(e->{
// Get the mouse deltas
double dx = e.getSceneX() - mouseLocation.value.getX();
double dy = e.getSceneY() - mouseLocation.value.getY();
dragDelta.x = e.getSceneX();
dragDelta.x = e.getSceneY();
// Get the angle in radians
double tau = - Math.toRadians(rotate.getAngle());
double sinTau = Math.sin(tau);
double cosTau = Math.cos(tau);
// Perform a rotation on dx and dy to the object coordinate
double dx_ = dx * cosTau - dy * sinTau;
double dy_ = dy * cosTau + dx * sinTau;
rect.setWidth(w(rect) - dx_);
rect.setHeight(h(rect) - dy_);
// Set save the current mouse value
mouseLocation.value = new Point2D(e.getSceneX(), e.getSceneY());
// Move the control
group.setTranslateX(group.getTranslateX() + dx);
group.setTranslateY(group.getTranslateY() + dy);
});
topLeft.setOnMouseReleased(e->{
//This is the problem
rotate.setPivotX((rect.getX() + rect.getWidth())/2);
rotate.setPivotY((rect.getY() + rect.getHeight())/2);
});
group.getTransforms().add(rotate);
rotateCircle.centerXProperty().bind(rect.xProperty().add(rect.widthProperty()).divide(2));
rotateCircle.centerYProperty().bind(rect.yProperty().subtract(25d));
topLeft.setCenterX(rect.getX());
topLeft.setCenterY(rect.getY());
group.getChildren().addAll(rect, rotateCircle, topLeft);
group.setLayoutX(100);
group.setLayoutY(100);
root.getChildren().add(group);
Util.enableDrag(rect, rotate);
Util.makeRotable(rotateCircle, rotate);
Scene scene = new Scene(root,400,400);
scene.getStylesheets().add(getClass().getResource("application.css").toExternalForm());
primaryStage.setScene(scene);
primaryStage.show();
}
public double w(Rectangle rect) {
return Math.abs(rect.getWidth() - rect.getX());
}
public double h(Rectangle rect) {
return Math.abs(rect.getHeight() - rect.getY());
}
// Return the angle from 0 to 360
public static double deltaAngle (double x, double y, double px, double py) {
double dx = x - px;
double dy = y - py;
double angle = Math.abs(Math.toDegrees(Math.atan2(dy, dx)));
if(dy < 0) {
angle = 360 - angle;
}
return angle;
}
static class Util {
static Wrapper<Point2D> mouseLocation = new Wrapper<>();
// make a targetNode movable by dragging it around with the mouse.
static void enableDrag(Rectangle rectangle, Rotate rotate) {
final var dragDelta = new Delta();
Parent parent = rectangle.getParent();
rectangle.setOnMousePressed(mouseEvent -> {
mouseLocation.value = new Point2D(mouseEvent.getSceneX(), mouseEvent.getSceneY());
// record a delta distance for the drag and drop operation.
dragDelta.x = mouseEvent.getX() - mouseEvent.getX();
dragDelta.y = mouseEvent.getY() - mouseEvent.getY();
parent.getScene().setCursor(Cursor.MOVE);
});
rectangle.setOnMouseReleased(mouseEvent -> {
mouseLocation.value = null ;
parent.getScene().setCursor(Cursor.HAND);
parent.relocate(parent.getLayoutX() + parent.getTranslateX(),
parent.getLayoutY() + parent.getTranslateY());
parent.setTranslateX(0);
parent.setTranslateY(0);
rotate.setPivotX((rectangle.getX() + rectangle.getWidth())/2);
rotate.setPivotY((rectangle.getY() + rectangle.getHeight())/2);
});
rectangle.setOnMouseDragged(mouseEvent -> {
// Get the mouse deltas
double deltaX = mouseEvent.getSceneX() - mouseLocation.value.getX();
double deltaY = mouseEvent.getSceneY() - mouseLocation.value.getY();
parent.setTranslateX(parent.getTranslateX() + deltaX);
parent.setTranslateY(parent.getTranslateY() + deltaY);
mouseLocation.value = new Point2D(mouseEvent.getSceneX(), mouseEvent.getSceneY());
});
}
// make a targetNode movable by dragging it around with the mouse.
static void makeRotable(Circle circle, Rotate rotate) {
final var dragDelta = new Delta();
// Make it draggable
circle.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
dragDelta.x = event.getSceneX();
dragDelta.x = event.getSceneY();
});
// When it's dragged rotate the box
circle.addEventHandler(MouseEvent.MOUSE_DRAGGED, event -> {
var localToScene = circle.getParent().getLocalToSceneTransform();
double x1 = dragDelta.x;
double y1 = dragDelta.y;
var x2 = event.getSceneX();
var y2 = event.getSceneY();
var px = rotate.getPivotX() + localToScene.getTx();
var py = rotate.getPivotY() + localToScene.getTy();
// Work out the angle rotated
double th1 = deltaAngle(x1, y1, px, py);
double th2 = deltaAngle(x2, y2, px, py);
var angle = rotate.getAngle();
angle += th2 - th1;
// Rotate the rectangle
rotate.setAngle(angle);
dragDelta.x = event.getSceneX();
dragDelta.y = event.getSceneY();
});
}
}
// records relative x and y co-ordinates.
private static class Delta {
double x, y;
}
static class Wrapper<T> {
T value ;
}
public static void main(String[] args) {
launch(args);
}
}
You are scaling the rectangle by setting a new width and height:
rect.setWidth(w(rect) - dx_);
rect.setHeight(h(rect) - dy_);
This maintains the origin of the rectangle but changes its center.
Instead, consider scaling the rectangle around its center:
An easier approach anyway is to use a
Scale
transformation. With this you can specify the "pivot" point about
which the scale occurs.
as explained and demonstrated in this so answer.
By using Scale.setX and Scale.setY you can apply different width and height scales.
Edit:
When changing the pivot of the Rotate it also affects rotations that were performed before the change. That is the reason for the "jump"
you see.
To demonstrate it, run the following mre, where one buttons performs a rotate and the other only changes the pivot :
import javafx.application.*;
import javafx.scene.*;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.scene.shape.*;
import javafx.scene.transform.*;
import javafx.stage.Stage;
public class Main extends Application {
#Override
public void start(Stage primaryStage) {
Rectangle rect = new Rectangle(100,100);
rect.setStyle(
"-fx-stroke: blue; " +
"-fx-fill: rgba(255, 255, 255, .0);"
);
rect.setLayoutX(100);
rect.setLayoutY(100);
Rotate rotate = new Rotate();
rotate.setPivotX((rect.getX() + rect.getWidth())/2);
rotate.setPivotY((rect.getY() + rect.getHeight())/2);
rect.getTransforms().add(rotate);
Button rotateBtn = new Button("Rotate");
Button movePivotBtn = new Button("Move Rotate Pivot");
movePivotBtn.setDisable(true);
movePivotBtn.setLayoutX(60);
rotateBtn.setOnAction(e->{
rotate.setAngle(45);
rotateBtn.setDisable(true);
movePivotBtn.setDisable(false);
});
movePivotBtn.setOnAction(e->{
rotate.setPivotX(rotate.getPivotX()+15);
movePivotBtn.setDisable(true);
});
Pane root = new Pane(rotateBtn, movePivotBtn, rect);
Scene scene = new Scene(root,400,400);
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}

Highlighting rectangle when more than half overlaps

I have a JavaFX application with a pane that contains rectangles. These rectangles can be moved by dragging the mouse.
When I drag a rectangle over another rectangle, I would like the second (background) rectangle to be highlighted. This works, see code below
private boolean moveInProgress;
private Point2D prevPos;
public void onMousePressed(MouseEvent event) {
setMouseTransparent(true);
Point2D point = new Point2D(event.getSceneX(), event.getSceneY());
if (!moveInProgress) {
moveInProgress = true;
prevPos = point;
LOG.debug("Mouse move started on location " + prevPos);
}
event.consume();
}
public void onMouseDragged(MouseEvent event) {
if (moveInProgress) {
Point2D point = new Point2D(event.getSceneX(), event.getSceneY());
this.toFront();
double[] translationVector = new double[2];
translationVector[0] = point.getX() - prevPos.getX();
translationVector[1] = point.getY() - prevPos.getY();
setTranslateX(getTranslateX() + translationVector[0]);
setTranslateY(getTranslateY() + translationVector[1]);
prevPos = point;
}
event.consume();
}
public void onMouseReleased(MouseEvent event) {
setMouseTransparent(false);
if (moveInProgress) {
moveInProgress = false;
}
event.consume();
}
public void onDragDetected(MouseEvent event) {
startFullDrag();
event.consume();
}
public void onMouseDragEntered(MouseDragEvent event) {
getStyleClass().add("drag-target");
event.consume();
}
public void onMouseDragExited(MouseDragEvent event) {
if (getStyleClass().contains("drag-target")) {
getStyleClass().remove("drag-target");
}
event.consume();
}
I would like to highlight the underlying rectangle when more than half of my dragging rectangle overlaps. In this picture, I would like to highlight the red rectangle, since the grey rectangle overlaps more than half of it.
The problem is that the MouseDragEntered and MouseDragExited events are fired based on my mouse position. When my mouse position is for example the black dot in the picture, my mouse events will only be fired when my mouse enters the red rectangle.
Can anyone give me some pointers how to highlight the red rectangle when during a drag action of the grey rectangle, more than half of it overlaps?
One approach is to have each rectangle observe the bounds of the rectangle that is being dragged. Then it's reasonably easy to do a computation using Shape.intersect (or by other means) to see if the rectangle is 50% covered by the rectangle being dragged. The tricky part here is adding the listeners to the rectangle being dragged and removing them again when the rectangle stops being dragged.
Here's a quick example. I think I have things set up a little differently from the way you have them set up, but you should be able to adapt this to your use case easily enough.
import java.util.Random;
import javafx.application.Application;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.css.PseudoClass;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.stage.Stage;
public class DraggingHighlightRectangles extends Application {
private final Random rng = new Random();
private final ObjectProperty<Rectangle> draggingRectangle = new SimpleObjectProperty<>();
#Override
public void start(Stage primaryStage) {
Pane pane = new Pane();
pane.setMinSize(600, 600);
Button newRectButton = new Button("New Rectangle");
newRectButton.setOnAction(e -> pane.getChildren().add(createRectangle()));
BorderPane.setAlignment(newRectButton, Pos.CENTER);
BorderPane.setMargin(newRectButton, new Insets(5));
BorderPane root = new BorderPane(pane);
root.setBottom(newRectButton);
Scene scene = new Scene(root);
scene.getStylesheets().add("style.css");
primaryStage.setScene(scene);
primaryStage.show();
}
private Rectangle createRectangle() {
Rectangle rect = new Rectangle(rng.nextInt(400)+100, rng.nextInt(500)+50, 100, 50);
rect.setFill(randomColor());
rect.getStyleClass().add("rect");
ChangeListener<Bounds> boundsListener = (obs, oldBounds, newBounds) -> {
double myArea = rect.getWidth() * rect.getHeight() ;
Shape intersection = Shape.intersect(draggingRectangle.get(), rect);
Bounds intersectionBounds = intersection.getBoundsInLocal();
double intersectionArea = intersectionBounds.getWidth() * intersectionBounds.getHeight() ;
rect.pseudoClassStateChanged(PseudoClass.getPseudoClass("highlight"), intersectionArea >= 0.5 * myArea);
};
draggingRectangle.addListener((obs, oldRect, newRect) -> {
if (oldRect != null) {
oldRect.boundsInLocalProperty().removeListener(boundsListener);
}
if (newRect != null && newRect != rect) {
newRect.boundsInLocalProperty().addListener(boundsListener);
}
rect.pseudoClassStateChanged(PseudoClass.getPseudoClass("highlight"), false);
});
class MouseLocation { double x, y ; }
MouseLocation mouseLocation = new MouseLocation();
rect.setOnMousePressed(e -> {
draggingRectangle.set(rect);
rect.toFront();
mouseLocation.x = e.getX() ;
mouseLocation.y = e.getY() ;
});
rect.setOnMouseDragged(e -> {
rect.setX(rect.getX() + e.getX() - mouseLocation.x);
rect.setY(rect.getY() + e.getY() - mouseLocation.y);
mouseLocation.x = e.getX() ;
mouseLocation.y = e.getY() ;
});
rect.setOnMouseReleased(e -> draggingRectangle.set(null));
return rect ;
}
private Color randomColor() {
return Color.rgb(rng.nextInt(256), rng.nextInt(256), rng.nextInt(256));
}
public static void main(String[] args) {
launch(args);
}
}
My stylesheet, style.css, just contains
.rect:highlight {
-fx-fill: yellow ;
}

How can I make a ball move up or down in JavaFX?

I have javafx gui exercise to do and I have to make the ball move either up or down or right and left. Right now the ball goes randomly anywhere I suppose. Can you guys help me with the code? So that when I press the plus button, a ball will be added and goes up and down or right and left. And any other ball added has to shift either up or down? Any help would be awesome.Thanks.
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.stage.Stage;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ScrollBar;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.util.Duration;
public class MultipleBounceBall extends Application {
#Override // Override the start method in the Application class
public void start(Stage primaryStage) {
MultipleBallPane ballPane = new MultipleBallPane();
ballPane.setStyle("-fx-border-color: black");
Button btAdd = new Button("+");
Button btSubtract = new Button("-");
HBox hBox = new HBox(10);
hBox.getChildren().addAll(btAdd, btSubtract);
hBox.setAlignment(Pos.CENTER);
// Add or remove a ball
btAdd.setOnAction(e -> ballPane.add());
btSubtract.setOnAction(e -> ballPane.subtract());
// Pause and resume animation
ballPane.setOnMousePressed(e -> ballPane.pause());
ballPane.setOnMouseReleased(e -> ballPane.play());
// Use a scroll bar to control animation speed
ScrollBar sbSpeed = new ScrollBar();
sbSpeed.setMax(20);
sbSpeed.setValue(10);
ballPane.rateProperty().bind(sbSpeed.valueProperty());
BorderPane pane = new BorderPane();
pane.setCenter(ballPane);
pane.setTop(sbSpeed);
pane.setBottom(hBox);
// Create a scene and place the pane in the stage
Scene scene = new Scene(pane, 350, 450);
primaryStage.setTitle("Multiple Bounce Ball"); // Set the stage title
primaryStage.setScene(scene); // Place the scene in the stage
primaryStage.show(); // Display the stage
}
private class MultipleBallPane extends Pane {
private Timeline animation;
public MultipleBallPane() {
// Create an animation for moving the ball
animation = new Timeline(
new KeyFrame(Duration.millis(50), e -> moveBall()));
animation.setCycleCount(Timeline.INDEFINITE);
animation.play(); // Start animation
}
public void add() {
Color color = new Color(Math.random(),
Math.random(), Math.random(), 0.5);
getChildren().add(new Ball(30, 30, 20, color));
}
public void subtract() {
if (getChildren().size() > 0) {
getChildren().remove(getChildren().size() - 1);
}
}
public void play() {
animation.play();
}
public void pause() {
animation.pause();
}
public void increaseSpeed() {
animation.setRate(animation.getRate() + 0.1);
}
public void decreaseSpeed() {
animation.setRate(
animation.getRate() > 0 ? animation.getRate() - 0.1 : 0);
}
public DoubleProperty rateProperty() {
return animation.rateProperty();
}
protected void moveBall() {
for (Node node: this.getChildren()) {
Ball ball = (Ball)node;
// Check boundaries
if (ball.getCenterX() < ball.getRadius() ||
ball.getCenterX() > getWidth() - ball.getRadius()) {
ball.dx *= -1; // Change ball move direction
}
if (ball.getCenterY() < ball.getRadius() ||
ball.getCenterY() > getHeight() - ball.getRadius()) {
ball.dy *= -1; // Change ball move direction
}
// Adjust ball position
ball.setCenterX(ball.dx + ball.getCenterX());
ball.setCenterY(ball.dy + ball.getCenterY());
}
}
}
class Ball extends Circle {
private double dx = 1, dy = 1;
Ball(double x, double y, double radius, Color color) {
super(x, y, radius);
setFill(color); // Set ball color
}
}
/**
* The main method is only needed for the IDE with limited
* JavaFX support. Not needed for running from the command line.
*/
public static void main(String[] args) {
launch(args);
}
}
The problem is that the displacement elements of the ball movement (dx and dy) are both assigned to 1. Thus the ball moves to both x and y directions i.e. diagonally.
Change the declaration of these variables to:
private double dx = 1, dy = 0; // The ball will move right
private double dx = -1, dy = 0; // The ball will move left
private double dx = 0, dy = 1; // The ball will move down
private double dx = 0, dy = -1; // The ball will move up
If you would like the balls to move in random directions, you can do it for example by changing your Ball class:
class Ball extends Circle {
private double dx;
private double dy;
Ball(double x, double y, double radius, Color color) {
super(x, y, radius);
setFill(color); // Set ball color
switch (ThreadLocalRandom.current().nextInt(0, 4)) {
case 0:
dx = 0;
dy = 1;
break;
case 1:
dx = 0;
dy = -1;
break;
case 2:
dx = 1;
dy = 0;
break;
case 3:
dx = -1;
dy = 0;
break;
}
}
}
And if you would like them to start from random locations, you can do it by changing your MultipleBallPane.add() method:
public void add() {
Color color = new Color(Math.random(),
Math.random(), Math.random(), 0.5);
int radius = 20;
int x = ThreadLocalRandom.current().nextInt(radius, (int) getWidth() - radius);
int y = ThreadLocalRandom.current().nextInt(radius, (int) getHeight() - radius);
getChildren().add(new Ball(x, y, radius, color));
}

JavaFX line/curve with arrow head

I'm creating a graph in JavaFX which is supposed to be connected by directed edges. Best would be a bicubic curve. Does anyone know how to do add the arrow heads?
The arrow heads should of course be rotated depending on the end of the curve.
Here's a simple example without the arrows:
import javafx.application.Application;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import javafx.scene.shape.CubicCurve;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
public class BasicConnection extends Application {
public static void main(String[] args) {
launch(args);
}
#Override
public void start(Stage primaryStage) {
Group root = new Group();
// bending curve
Rectangle srcRect1 = new Rectangle(100,100,50,50);
Rectangle dstRect1 = new Rectangle(300,300,50,50);
CubicCurve curve1 = new CubicCurve( 125, 150, 125, 200, 325, 200, 325, 300);
curve1.setStroke(Color.BLACK);
curve1.setStrokeWidth(1);
curve1.setFill( null);
root.getChildren().addAll( srcRect1, dstRect1, curve1);
// steep curve
Rectangle srcRect2 = new Rectangle(100,400,50,50);
Rectangle dstRect2 = new Rectangle(200,500,50,50);
CubicCurve curve2 = new CubicCurve( 125, 450, 125, 450, 225, 500, 225, 500);
curve2.setStroke(Color.BLACK);
curve2.setStrokeWidth(1);
curve2.setFill( null);
root.getChildren().addAll( srcRect2, dstRect2, curve2);
primaryStage.setScene(new Scene(root, 800, 600));
primaryStage.show();
}
}
What's the best practice? Should I create a custom control or add 2 arrow controls per curve and rotate them (seems overkill to me)? Or is there a better solution?
Or does anyone know how to calculate the angle at which the cubic curve ends? I tried creating a simple small arrow and put it at the end of the curve, but it doesn't look nice if you don't rotate it slightly.
Thank you very much!
edit: Here's a solution in which I applied José's mechanism to jewelsea's cubic curve manipulator (CubicCurve JavaFX) in case someone nees it:
import java.util.ArrayList;
import java.util.List;
import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.event.EventHandler;
import javafx.geometry.Point2D;
import javafx.scene.Cursor;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.input.MouseEvent;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.CubicCurve;
import javafx.scene.shape.Line;
import javafx.scene.shape.Polygon;
import javafx.scene.shape.StrokeLineCap;
import javafx.scene.shape.StrokeType;
import javafx.scene.transform.Rotate;
import javafx.stage.Stage;
/**
* Example of how a cubic curve works, drag the anchors around to change the curve.
* Extended with arrows with the help of José Pereda: https://stackoverflow.com/questions/26702519/javafx-line-curve-with-arrow-head
* Original code by jewelsea: https://stackoverflow.com/questions/13056795/cubiccurve-javafx
*/
public class CubicCurveManipulatorWithArrows extends Application {
List<Arrow> arrows = new ArrayList<Arrow>();
public static class Arrow extends Polygon {
public double rotate;
public float t;
CubicCurve curve;
Rotate rz;
public Arrow( CubicCurve curve, float t) {
super();
this.curve = curve;
this.t = t;
init();
}
public Arrow( CubicCurve curve, float t, double... arg0) {
super(arg0);
this.curve = curve;
this.t = t;
init();
}
private void init() {
setFill(Color.web("#ff0900"));
rz = new Rotate();
{
rz.setAxis(Rotate.Z_AXIS);
}
getTransforms().addAll(rz);
update();
}
public void update() {
double size = Math.max(curve.getBoundsInLocal().getWidth(), curve.getBoundsInLocal().getHeight());
double scale = size / 4d;
Point2D ori = eval(curve, t);
Point2D tan = evalDt(curve, t).normalize().multiply(scale);
setTranslateX(ori.getX());
setTranslateY(ori.getY());
double angle = Math.atan2( tan.getY(), tan.getX());
angle = Math.toDegrees(angle);
// arrow origin is top => apply offset
double offset = -90;
if( t > 0.5)
offset = +90;
rz.setAngle(angle + offset);
}
/**
* Evaluate the cubic curve at a parameter 0<=t<=1, returns a Point2D
* #param c the CubicCurve
* #param t param between 0 and 1
* #return a Point2D
*/
private Point2D eval(CubicCurve c, float t){
Point2D p=new Point2D(Math.pow(1-t,3)*c.getStartX()+
3*t*Math.pow(1-t,2)*c.getControlX1()+
3*(1-t)*t*t*c.getControlX2()+
Math.pow(t, 3)*c.getEndX(),
Math.pow(1-t,3)*c.getStartY()+
3*t*Math.pow(1-t, 2)*c.getControlY1()+
3*(1-t)*t*t*c.getControlY2()+
Math.pow(t, 3)*c.getEndY());
return p;
}
/**
* Evaluate the tangent of the cubic curve at a parameter 0<=t<=1, returns a Point2D
* #param c the CubicCurve
* #param t param between 0 and 1
* #return a Point2D
*/
private Point2D evalDt(CubicCurve c, float t){
Point2D p=new Point2D(-3*Math.pow(1-t,2)*c.getStartX()+
3*(Math.pow(1-t, 2)-2*t*(1-t))*c.getControlX1()+
3*((1-t)*2*t-t*t)*c.getControlX2()+
3*Math.pow(t, 2)*c.getEndX(),
-3*Math.pow(1-t,2)*c.getStartY()+
3*(Math.pow(1-t, 2)-2*t*(1-t))*c.getControlY1()+
3*((1-t)*2*t-t*t)*c.getControlY2()+
3*Math.pow(t, 2)*c.getEndY());
return p;
}
}
public static void main(String[] args) throws Exception { launch(args); }
#Override public void start(final Stage stage) throws Exception {
CubicCurve curve = createStartingCurve();
Line controlLine1 = new BoundLine(curve.controlX1Property(), curve.controlY1Property(), curve.startXProperty(), curve.startYProperty());
Line controlLine2 = new BoundLine(curve.controlX2Property(), curve.controlY2Property(), curve.endXProperty(), curve.endYProperty());
Anchor start = new Anchor(Color.PALEGREEN, curve.startXProperty(), curve.startYProperty());
Anchor control1 = new Anchor(Color.GOLD, curve.controlX1Property(), curve.controlY1Property());
Anchor control2 = new Anchor(Color.GOLDENROD, curve.controlX2Property(), curve.controlY2Property());
Anchor end = new Anchor(Color.TOMATO, curve.endXProperty(), curve.endYProperty());
Group root = new Group();
root.getChildren().addAll( controlLine1, controlLine2, curve, start, control1, control2, end);
double[] arrowShape = new double[] { 0,0,10,20,-10,20 };
arrows.add( new Arrow( curve, 0f, arrowShape));
arrows.add( new Arrow( curve, 0.2f, arrowShape));
arrows.add( new Arrow( curve, 0.4f, arrowShape));
arrows.add( new Arrow( curve, 0.6f, arrowShape));
arrows.add( new Arrow( curve, 0.8f, arrowShape));
arrows.add( new Arrow( curve, 1f, arrowShape));
root.getChildren().addAll( arrows);
stage.setTitle("Cubic Curve Manipulation Sample");
stage.setScene(new Scene( root, 400, 400, Color.ALICEBLUE));
stage.show();
}
private CubicCurve createStartingCurve() {
CubicCurve curve = new CubicCurve();
curve.setStartX(100);
curve.setStartY(100);
curve.setControlX1(150);
curve.setControlY1(50);
curve.setControlX2(250);
curve.setControlY2(150);
curve.setEndX(300);
curve.setEndY(100);
curve.setStroke(Color.FORESTGREEN);
curve.setStrokeWidth(4);
curve.setStrokeLineCap(StrokeLineCap.ROUND);
curve.setFill(Color.CORNSILK.deriveColor(0, 1.2, 1, 0.6));
return curve;
}
class BoundLine extends Line {
BoundLine(DoubleProperty startX, DoubleProperty startY, DoubleProperty endX, DoubleProperty endY) {
startXProperty().bind(startX);
startYProperty().bind(startY);
endXProperty().bind(endX);
endYProperty().bind(endY);
setStrokeWidth(2);
setStroke(Color.GRAY.deriveColor(0, 1, 1, 0.5));
setStrokeLineCap(StrokeLineCap.BUTT);
getStrokeDashArray().setAll(10.0, 5.0);
}
}
// a draggable anchor displayed around a point.
class Anchor extends Circle {
Anchor(Color color, DoubleProperty x, DoubleProperty y) {
super(x.get(), y.get(), 10);
setFill(color.deriveColor(1, 1, 1, 0.5));
setStroke(color);
setStrokeWidth(2);
setStrokeType(StrokeType.OUTSIDE);
x.bind(centerXProperty());
y.bind(centerYProperty());
enableDrag();
}
// make a node movable by dragging it around with the mouse.
private void enableDrag() {
final Delta dragDelta = new Delta();
setOnMousePressed(new EventHandler<MouseEvent>() {
#Override public void handle(MouseEvent mouseEvent) {
// record a delta distance for the drag and drop operation.
dragDelta.x = getCenterX() - mouseEvent.getX();
dragDelta.y = getCenterY() - mouseEvent.getY();
getScene().setCursor(Cursor.MOVE);
}
});
setOnMouseReleased(new EventHandler<MouseEvent>() {
#Override public void handle(MouseEvent mouseEvent) {
getScene().setCursor(Cursor.HAND);
}
});
setOnMouseDragged(new EventHandler<MouseEvent>() {
#Override public void handle(MouseEvent mouseEvent) {
double newX = mouseEvent.getX() + dragDelta.x;
if (newX > 0 && newX < getScene().getWidth()) {
setCenterX(newX);
}
double newY = mouseEvent.getY() + dragDelta.y;
if (newY > 0 && newY < getScene().getHeight()) {
setCenterY(newY);
}
// update arrow positions
for( Arrow arrow: arrows) {
arrow.update();
}
}
});
setOnMouseEntered(new EventHandler<MouseEvent>() {
#Override public void handle(MouseEvent mouseEvent) {
if (!mouseEvent.isPrimaryButtonDown()) {
getScene().setCursor(Cursor.HAND);
}
}
});
setOnMouseExited(new EventHandler<MouseEvent>() {
#Override public void handle(MouseEvent mouseEvent) {
if (!mouseEvent.isPrimaryButtonDown()) {
getScene().setCursor(Cursor.DEFAULT);
}
}
});
}
// records relative x and y co-ordinates.
private class Delta { double x, y; }
}
}
Since you're already dealing with shapes (curves), the best approach for the arrows is just keep adding more shapes to the group, using Path.
Based on this answer, I've added two methods: one for getting any point of the curve at a given parameter between 0 (start) and 1 (end), one for getting the tangent to the curve at that point.
With these methods now you can draw an arrow tangent to the curve at any point. And we use them to create two at the start (0) and at the end (1):
#Override
public void start(Stage primaryStage) {
Group root = new Group();
// bending curve
Rectangle srcRect1 = new Rectangle(100,100,50,50);
Rectangle dstRect1 = new Rectangle(300,300,50,50);
CubicCurve curve1 = new CubicCurve( 125, 150, 125, 225, 325, 225, 325, 300);
curve1.setStroke(Color.BLACK);
curve1.setStrokeWidth(1);
curve1.setFill( null);
double size=Math.max(curve1.getBoundsInLocal().getWidth(),
curve1.getBoundsInLocal().getHeight());
double scale=size/4d;
Point2D ori=eval(curve1,0);
Point2D tan=evalDt(curve1,0).normalize().multiply(scale);
Path arrowIni=new Path();
arrowIni.getElements().add(new MoveTo(ori.getX()+0.2*tan.getX()-0.2*tan.getY(),
ori.getY()+0.2*tan.getY()+0.2*tan.getX()));
arrowIni.getElements().add(new LineTo(ori.getX(), ori.getY()));
arrowIni.getElements().add(new LineTo(ori.getX()+0.2*tan.getX()+0.2*tan.getY(),
ori.getY()+0.2*tan.getY()-0.2*tan.getX()));
ori=eval(curve1,1);
tan=evalDt(curve1,1).normalize().multiply(scale);
Path arrowEnd=new Path();
arrowEnd.getElements().add(new MoveTo(ori.getX()-0.2*tan.getX()-0.2*tan.getY(),
ori.getY()-0.2*tan.getY()+0.2*tan.getX()));
arrowEnd.getElements().add(new LineTo(ori.getX(), ori.getY()));
arrowEnd.getElements().add(new LineTo(ori.getX()-0.2*tan.getX()+0.2*tan.getY(),
ori.getY()-0.2*tan.getY()-0.2*tan.getX()));
root.getChildren().addAll(srcRect1, dstRect1, curve1, arrowIni, arrowEnd);
primaryStage.setScene(new Scene(root, 800, 600));
primaryStage.show();
}
/**
* Evaluate the cubic curve at a parameter 0<=t<=1, returns a Point2D
* #param c the CubicCurve
* #param t param between 0 and 1
* #return a Point2D
*/
private Point2D eval(CubicCurve c, float t){
Point2D p=new Point2D(Math.pow(1-t,3)*c.getStartX()+
3*t*Math.pow(1-t,2)*c.getControlX1()+
3*(1-t)*t*t*c.getControlX2()+
Math.pow(t, 3)*c.getEndX(),
Math.pow(1-t,3)*c.getStartY()+
3*t*Math.pow(1-t, 2)*c.getControlY1()+
3*(1-t)*t*t*c.getControlY2()+
Math.pow(t, 3)*c.getEndY());
return p;
}
/**
* Evaluate the tangent of the cubic curve at a parameter 0<=t<=1, returns a Point2D
* #param c the CubicCurve
* #param t param between 0 and 1
* #return a Point2D
*/
private Point2D evalDt(CubicCurve c, float t){
Point2D p=new Point2D(-3*Math.pow(1-t,2)*c.getStartX()+
3*(Math.pow(1-t, 2)-2*t*(1-t))*c.getControlX1()+
3*((1-t)*2*t-t*t)*c.getControlX2()+
3*Math.pow(t, 2)*c.getEndX(),
-3*Math.pow(1-t,2)*c.getStartY()+
3*(Math.pow(1-t, 2)-2*t*(1-t))*c.getControlY1()+
3*((1-t)*2*t-t*t)*c.getControlY2()+
3*Math.pow(t, 2)*c.getEndY());
return p;
}
And this is what it looks like:
If you move the control points, you'll see that the arrows are already well oriented:
CubicCurve curve1 = new CubicCurve( 125, 150, 55, 285, 375, 155, 325, 300);

Resources