Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Drawing transform independent layout bounds in JavaFX

For a simple vector drawing app I am looking to implement a "Selection Box", that is a graphic representation of the layoutBounds of a Node.

Example:

example

Thanks to jewelsea and his BoundsExample, I have a good understanding now on how to get the data for the box. The part I am struggling with is actually drawing the box on the scene, in a way that correctly respects the transformations on the nodes.

Correctly in this case means the bounds logical size get scaled with a node, but the stroke of the selection box stays constant. Meaning a selection box scales with its corresponding node, but the stroke stays unscaled.

I can think of two general strategies to implement such a selection box.

  1. As a property of my custom node The selection box could be an internal details of my custom node with its visibility bound to the nodes selected state. In that case I would need to find a way to have nodes IGNORE the parents transformations, if that is possible.

  2. Drawing selection boxes on top of the scaled nodes on a transparent pane In that case I would bind the selection boxes to the layout bounds of the scaled nodes, after applying the transformations of a node to its bounds. This does not seem to happen in JFX (even for 'boundsInParent') as you can quickly test in the example by applying some scaling to 'group' in line ~122.

Modified example with scaling:

package application;

import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.scene.Cursor;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.ListView;
import javafx.scene.control.RadioButton;
import javafx.scene.control.ToggleGroup;
import javafx.scene.effect.DropShadow;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.scene.shape.StrokeLineCap;
import javafx.scene.shape.StrokeType;
import javafx.scene.web.WebView;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.stage.WindowEvent;

/** Demo for understanding JavaFX Layout Bounds */
public class BoundsPlayground extends Application
{
  final ObservableList<Shape>     shapes             = FXCollections.observableArrayList();
  final ObservableList<ShapePair> intersections      = FXCollections.observableArrayList();
  ObjectProperty<BoundsType>      selectedBoundsType = new SimpleObjectProperty<BoundsType>(
                                                         BoundsType.LAYOUT_BOUNDS );

  public static void main( final String[] args )
  {
    launch( args );
  }
  @Override
  public void start( final Stage stage )
  {
    stage.setTitle( "Bounds Playground" );
    // define some objects to manipulate on the scene.
    final Circle greenCircle = new Circle( 100, 100, 50, Color.FORESTGREEN );
    greenCircle.setId( "Green Circle" );
    final Circle redCircle = new Circle( 300, 200, 50, Color.FIREBRICK );
    redCircle.setId( "Red Circle" );

    final Line line = new Line( 25, 300, 375, 200 );
    line.setId( "Line" );
    line.setStrokeLineCap( StrokeLineCap.ROUND );
    line.setStroke( Color.MIDNIGHTBLUE );
    line.setStrokeWidth( 5 );

    final Anchor anchor1 = new Anchor( "Anchor 1", line.startXProperty(), line.startYProperty() );
    final Anchor anchor2 = new Anchor( "Anchor 2", line.endXProperty(), line.endYProperty() );

    final Group group = new Group( greenCircle, redCircle, line, anchor1, anchor2 );

    // monitor intersections of shapes in the scene.
    for ( final Node node : group.getChildrenUnmodifiable() )
    {
      if ( node instanceof Shape )
      {
        shapes.add( (Shape) node );
      }
    }
    testIntersections();

    // enable dragging for the scene objects.
    final Circle[] circles = { greenCircle, redCircle, anchor1, anchor2 };
    for ( final Circle circle : circles )
    {
      enableDrag( circle );
      circle.centerXProperty().addListener( new ChangeListener<Number>()
      {
        @Override
        public void changed( final ObservableValue<? extends Number> observableValue, final Number oldValue,
                             final Number newValue )
        {
          testIntersections();
        }
      } );
      circle.centerYProperty().addListener( new ChangeListener<Number>()
      {
        @Override
        public void changed( final ObservableValue<? extends Number> observableValue, final Number oldValue,
                             final Number newValue )
        {
          testIntersections();
        }
      } );
    }

    // define an overlay to show the layout bounds of the scene's shapes.
    final Group layoutBoundsOverlay = new Group();
    layoutBoundsOverlay.setMouseTransparent( true );
    for ( final Shape shape : shapes )
    {
      if ( !(shape instanceof Anchor) )
      {
        layoutBoundsOverlay.getChildren().add( new BoundsDisplay( shape ) );
      }
    }
    // layout the scene.
    final StackPane background = new StackPane();
    background.setStyle( "-fx-background-color: cornsilk;" );
    final Scene scene = new Scene( new Group( background, group, layoutBoundsOverlay ), 600, 500 );

    group.setScaleX( 5 );
    group.setScaleY( 5 );


    background.prefHeightProperty().bind( scene.heightProperty() );
    background.prefWidthProperty().bind( scene.widthProperty() );
    stage.setScene( scene );
    stage.show();

    createUtilityWindow( stage, layoutBoundsOverlay, new Shape[]{ greenCircle, redCircle } );
  }

  // update the list of intersections.
  private void testIntersections()
  {
    intersections.clear();
    // for each shape test it's intersection with all other shapes.
    for ( final Shape src : shapes )
    {
      for ( final Shape dest : shapes )
      {
        final ShapePair pair = new ShapePair( src, dest );
        if ( !(pair.a instanceof Anchor) && !(pair.b instanceof Anchor) && !intersections.contains( pair )
            && pair.intersects( selectedBoundsType.get() ) )
        {
          intersections.add( pair );
        }
      }
    }
  }

  // make a node movable by dragging it around with the mouse.
  private void enableDrag( final Circle circle )
  {
    final Delta dragDelta = new Delta();
    circle.setOnMousePressed( new EventHandler<MouseEvent>()
    {
      @Override
      public void handle( final MouseEvent mouseEvent )
      {
        // record a delta distance for the drag and drop operation.
        dragDelta.x = circle.getCenterX() - mouseEvent.getX();
        dragDelta.y = circle.getCenterY() - mouseEvent.getY();
        circle.getScene().setCursor( Cursor.MOVE );
      }
    } );
    circle.setOnMouseReleased( new EventHandler<MouseEvent>()
    {
      @Override
      public void handle( final MouseEvent mouseEvent )
      {
        circle.getScene().setCursor( Cursor.HAND );
      }
    } );
    circle.setOnMouseDragged( new EventHandler<MouseEvent>()
    {
      @Override
      public void handle( final MouseEvent mouseEvent )
      {
        circle.setCenterX( mouseEvent.getX() + dragDelta.x );
        circle.setCenterY( mouseEvent.getY() + dragDelta.y );
      }
    } );
    circle.setOnMouseEntered( new EventHandler<MouseEvent>()
    {
      @Override
      public void handle( final MouseEvent mouseEvent )
      {
        if ( !mouseEvent.isPrimaryButtonDown() )
        {
          circle.getScene().setCursor( Cursor.HAND );
        }
      }
    } );
    circle.setOnMouseExited( new EventHandler<MouseEvent>()
    {
      @Override
      public void handle( final MouseEvent mouseEvent )
      {
        if ( !mouseEvent.isPrimaryButtonDown() )
        {
          circle.getScene().setCursor( Cursor.DEFAULT );
        }
      }
    } );
  }

  // a helper enumeration of the various types of bounds we can work with.
  enum BoundsType
  {
    LAYOUT_BOUNDS, BOUNDS_IN_LOCAL, BOUNDS_IN_PARENT
  }

  // a translucent overlay display rectangle to show the bounds of a Shape.
  class BoundsDisplay extends Rectangle
  {
    // the shape to which the bounds display has been type.
    final Shape                    monitoredShape;
    private ChangeListener<Bounds> boundsChangeListener;

    BoundsDisplay( final Shape shape )
    {
      setFill( Color.LIGHTGRAY.deriveColor( 1, 1, 1, 0.35 ) );
      setStroke( Color.LIGHTGRAY.deriveColor( 1, 1, 1, 0.5 ) );
      setStrokeType( StrokeType.INSIDE );
      setStrokeWidth( 3 );
      monitoredShape = shape;
      monitorBounds( BoundsType.LAYOUT_BOUNDS );
    }

    // set the type of the shape's bounds to monitor for the bounds display.
    void monitorBounds( final BoundsType boundsType )
    {
      // remove the shape's previous boundsType.
      if ( boundsChangeListener != null )
      {
        final ReadOnlyObjectProperty<Bounds> oldBounds;
        switch ( selectedBoundsType.get() )
        {
          case LAYOUT_BOUNDS:
            oldBounds = monitoredShape.layoutBoundsProperty();
            break;
          case BOUNDS_IN_LOCAL:
            oldBounds = monitoredShape.boundsInLocalProperty();
            break;
          case BOUNDS_IN_PARENT:
            oldBounds = monitoredShape.boundsInParentProperty();
            break;
          default :
            oldBounds = null;
        }
        if ( oldBounds != null )
        {
          oldBounds.removeListener( boundsChangeListener );
        }
      }

      // determine the shape's bounds for the given boundsType.
      final ReadOnlyObjectProperty<Bounds> bounds;
      switch ( boundsType )
      {
        case LAYOUT_BOUNDS:
          bounds = monitoredShape.layoutBoundsProperty();
          break;
        case BOUNDS_IN_LOCAL:
          bounds = monitoredShape.boundsInLocalProperty();
          break;
        case BOUNDS_IN_PARENT:
          bounds = monitoredShape.boundsInParentProperty();
          break;
        default :
          bounds = null;
      }

      // set the visual bounds display based upon the new bounds and keep it in sync.
      if ( bounds != null )
      {
        updateBoundsDisplay( bounds.get() );

        // keep the visual bounds display based upon the new bounds and keep it in sync.
        boundsChangeListener = new ChangeListener<Bounds>()
        {
          @Override
          public void changed( final ObservableValue<? extends Bounds> observableValue,
                               final Bounds oldBounds, final Bounds newBounds )
          {
            updateBoundsDisplay( newBounds );
          }
        };
        bounds.addListener( boundsChangeListener );
      }
    }

    // update this bounds display to match a new set of bounds.
    private void updateBoundsDisplay( final Bounds newBounds )
    {
      setX( newBounds.getMinX() );
      setY( newBounds.getMinY() );
      setWidth( newBounds.getWidth() );
      setHeight( newBounds.getHeight() );
    }
  }
  // an anchor displayed around a point.
  class Anchor extends Circle
  {
    Anchor( final String id, final DoubleProperty x, final DoubleProperty y )
    {
      super( x.get(), y.get(), 10 );
      setId( id );
      setFill( Color.GOLD.deriveColor( 1, 1, 1, 0.5 ) );
      setStroke( Color.GOLD );
      setStrokeWidth( 2 );
      setStrokeType( StrokeType.OUTSIDE );
      x.bind( centerXProperty() );
      y.bind( centerYProperty() );
    }
  }
  // records relative x and y co-ordinates.
  class Delta
  {
    double x, y;
  }
  // records a pair of (possibly) intersecting shapes.
  class ShapePair
  {
    private final Shape a, b;
    public ShapePair( final Shape src, final Shape dest )
    {
      a = src;
      b = dest;
    }

    public boolean intersects( final BoundsType boundsType )
    {
      if ( a == b )
      {
        return false;
      }
      a.intersects( b.getBoundsInLocal() );
      switch ( boundsType )
      {
        case LAYOUT_BOUNDS:
          return a.getLayoutBounds().intersects( b.getLayoutBounds() );
        case BOUNDS_IN_LOCAL:
          return a.getBoundsInLocal().intersects( b.getBoundsInLocal() );
        case BOUNDS_IN_PARENT:
          return a.getBoundsInParent().intersects( b.getBoundsInParent() );
        default :
          return false;
      }
    }

    @Override
    public String toString()
    {
      return a.getId() + " : " + b.getId();
    }

    @Override
    public boolean equals( final Object other )
    {
      final ShapePair o = (ShapePair) other;
      return o != null && (a == o.a && b == o.b || a == o.b && b == o.a);
    }

    @Override
    public int hashCode()
    {
      int result = a != null ? a.hashCode() : 0;
      result = 31 * result + (b != null ? b.hashCode() : 0);
      return result;
    }
  }

  // define a utility stage for reporting intersections.
  private void createUtilityWindow( final Stage stage, final Group boundsOverlay,
                                    final Shape[] transformableShapes )
  {
    final Stage reportingStage = new Stage();
    reportingStage.setTitle( "Control Panel" );
    reportingStage.initStyle( StageStyle.UTILITY );
    reportingStage.setX( stage.getX() + stage.getWidth() );
    reportingStage.setY( stage.getY() );

    // define content for the intersections utility panel.
    final ListView<ShapePair> intersectionView = new ListView<ShapePair>( intersections );
    final Label instructions = new Label( "Click on any circle in the scene to the left to drag it around." );
    instructions.setMinSize( Control.USE_PREF_SIZE, Control.USE_PREF_SIZE );
    instructions.setStyle( "-fx-font-weight: bold; -fx-text-fill: darkgreen;" );

    final Label intersectionInstructions =
        new Label( "Any intersecting bounds in the scene will be reported below." );
    instructions.setMinSize( Control.USE_PREF_SIZE, Control.USE_PREF_SIZE );

    // add the ability to set a translate value for the circles.
    final CheckBox translateNodes = new CheckBox( "Translate circles" );
    translateNodes.selectedProperty().addListener( new ChangeListener<Boolean>()
    {
      @Override
      public void changed( final ObservableValue<? extends Boolean> observableValue, final Boolean oldValue,
                           final Boolean doTranslate )
      {
        if ( doTranslate )
        {
          for ( final Shape shape : transformableShapes )
          {
            shape.setTranslateY( 100 );
            testIntersections();
          }
        }
        else
        {
          for ( final Shape shape : transformableShapes )
          {
            shape.setTranslateY( 0 );
            testIntersections();
          }
        }
      }
    } );
    translateNodes.selectedProperty().set( false );

    // add the ability to add an effect to the circles.
    final Label modifyInstructions = new Label( "Modify visual display aspects." );
    modifyInstructions.setStyle( "-fx-font-weight: bold;" );
    modifyInstructions.setMinSize( Control.USE_PREF_SIZE, Control.USE_PREF_SIZE );
    final CheckBox effectNodes = new CheckBox( "Add an effect to circles" );
    effectNodes.selectedProperty().addListener( new ChangeListener<Boolean>()
    {
      @Override
      public void changed( final ObservableValue<? extends Boolean> observableValue, final Boolean oldValue,
                           final Boolean doTranslate )
      {
        if ( doTranslate )
        {
          for ( final Shape shape : transformableShapes )
          {
            shape.setEffect( new DropShadow() );
            testIntersections();
          }
        }
        else
        {
          for ( final Shape shape : transformableShapes )
          {
            shape.setEffect( null );
            testIntersections();
          }
        }
      }
    } );
    effectNodes.selectedProperty().set( true );

    // add the ability to add a stroke to the circles.
    final CheckBox strokeNodes = new CheckBox( "Add outside strokes to circles" );
    strokeNodes.selectedProperty().addListener( new ChangeListener<Boolean>()
    {
      @Override
      public void changed( final ObservableValue<? extends Boolean> observableValue, final Boolean oldValue,
                           final Boolean doTranslate )
      {
        if ( doTranslate )
        {
          for ( final Shape shape : transformableShapes )
          {
            shape.setStroke( Color.LIGHTSEAGREEN );
            shape.setStrokeWidth( 10 );
            testIntersections();
          }
        }
        else
        {
          for ( final Shape shape : transformableShapes )
          {
            shape.setStrokeWidth( 0 );
            testIntersections();
          }
        }
      }
    } );
    strokeNodes.selectedProperty().set( true );
    // add the ability to show or hide the layout bounds overlay.
    final Label showBoundsInstructions = new Label( "The gray squares represent layout bounds." );
    showBoundsInstructions.setStyle( "-fx-font-weight: bold;" );
    showBoundsInstructions.setMinSize( Control.USE_PREF_SIZE, Control.USE_PREF_SIZE );
    final CheckBox showBounds = new CheckBox( "Show Bounds" );
    boundsOverlay.visibleProperty().bind( showBounds.selectedProperty() );
    showBounds.selectedProperty().set( true );

    // create a container for the display control checkboxes.
    final VBox displayChecks = new VBox( 10 );
    displayChecks.getChildren().addAll( modifyInstructions, translateNodes, effectNodes, strokeNodes,
        showBoundsInstructions, showBounds );

    // create a toggle group for the bounds type to use.
    final ToggleGroup boundsToggleGroup = new ToggleGroup();
    final RadioButton useLayoutBounds = new RadioButton( "Use Layout Bounds" );
    final RadioButton useBoundsInLocal = new RadioButton( "Use Bounds in Local" );
    final RadioButton useBoundsInParent = new RadioButton( "Use Bounds in Parent" );
    useLayoutBounds.setToggleGroup( boundsToggleGroup );
    useBoundsInLocal.setToggleGroup( boundsToggleGroup );
    useBoundsInParent.setToggleGroup( boundsToggleGroup );
    final VBox boundsToggles = new VBox( 10 );
    boundsToggles.getChildren().addAll( useLayoutBounds, useBoundsInLocal, useBoundsInParent );

    // change the layout bounds display depending on which bounds type has been selected.
    useLayoutBounds.selectedProperty().addListener( new ChangeListener<Boolean>()
    {
      @Override
      public void changed( final ObservableValue<? extends Boolean> observableValue, final Boolean aBoolean,
                           final Boolean isSelected )
      {
        if ( isSelected )
        {
          for ( final Node overlay : boundsOverlay.getChildren() )
          {
            ((BoundsDisplay) overlay).monitorBounds( BoundsType.LAYOUT_BOUNDS );
          }
          selectedBoundsType.set( BoundsType.LAYOUT_BOUNDS );
          testIntersections();
        }
      }
    } );
    useBoundsInLocal.selectedProperty().addListener( new ChangeListener<Boolean>()
    {
      @Override
      public void changed( final ObservableValue<? extends Boolean> observableValue, final Boolean aBoolean,
                           final Boolean isSelected )
      {
        if ( isSelected )
        {
          for ( final Node overlay : boundsOverlay.getChildren() )
          {
            ((BoundsDisplay) overlay).monitorBounds( BoundsType.BOUNDS_IN_LOCAL );
          }
          selectedBoundsType.set( BoundsType.BOUNDS_IN_LOCAL );
          testIntersections();
        }
      }
    } );
    useBoundsInParent.selectedProperty().addListener( new ChangeListener<Boolean>()
    {
      @Override
      public void changed( final ObservableValue<? extends Boolean> observableValue, final Boolean aBoolean,
                           final Boolean isSelected )
      {
        if ( isSelected )
        {
          for ( final Node overlay : boundsOverlay.getChildren() )
          {
            ((BoundsDisplay) overlay).monitorBounds( BoundsType.BOUNDS_IN_PARENT );
          }
          selectedBoundsType.set( BoundsType.BOUNDS_IN_PARENT );
          testIntersections();
        }
      }
    } );
    useLayoutBounds.selectedProperty().set( true );

    final WebView boundsExplanation = new WebView();
    boundsExplanation
        .getEngine()
        .loadContent(
            "<html><body bgcolor='darkseagreen' fgcolor='lightgrey' style='font-size:12px'><dl>"
                + "<dt><b>Layout Bounds</b></dt><dd>The boundary of the shape.</dd><br/>"
                + "<dt><b>Bounds in Local</b></dt><dd>The boundary of the shape and effect.</dd><br/>"
                + "<dt><b>Bounds in Parent</b></dt><dd>The boundary of the shape, effect and transforms.<br/>The co-ordinates of what you see.</dd>"
                + "</dl></body></html>" );
    boundsExplanation.setPrefWidth( 100 );
    boundsExplanation.setMinHeight( 130 );
    boundsExplanation.setMaxHeight( 130 );
    boundsExplanation.setStyle( "-fx-background-color: transparent" );

    // layout the utility pane.
    final VBox utilityLayout = new VBox( 10 );
    utilityLayout
        .setStyle( "-fx-padding:10; -fx-background-color: linear-gradient(to bottom, lightblue, derive(lightblue, 20%));" );
    utilityLayout.getChildren().addAll( instructions, intersectionInstructions, intersectionView,
        displayChecks, boundsToggles, boundsExplanation );
    utilityLayout.setPrefHeight( 530 );
    reportingStage.setScene( new Scene( utilityLayout ) );
    reportingStage.show();

    // ensure the utility window closes when the main app window closes.
    stage.setOnCloseRequest( new EventHandler<WindowEvent>()
    {
      @Override
      public void handle( final WindowEvent windowEvent )
      {
        reportingStage.close();
      }
    } );
  }
}

Since I am fairly new to JFX I wanted to ask for advice. Hope you find this problem interesting :)

Best regards, Oliver.

like image 870
Oliver Jan Krylow Avatar asked Nov 10 '22 12:11

Oliver Jan Krylow


1 Answers

As it turns out, the SceneBuilder itself is the largest freely available JavaFX project that I know of, that has that excact problem already solved.

By studying the com.oracle.javafx.scenebuilder.kit.editor package of the SceneBuilder source code I was reassured, that my second proposed startegy is the way to go.

like image 90
Oliver Jan Krylow Avatar answered Dec 10 '22 14:12

Oliver Jan Krylow