package physics; import java.util.HashMap; import java.util.Map; import java.util.Set; import javafx.animation.AnimationTimer; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.collections.MapChangeListener; import javafx.collections.SetChangeListener; import javafx.geometry.Point2D; import javafx.scene.Node; import layout.PhysLayout; import org.jbox2d.collision.shapes.MassData; import org.jbox2d.collision.shapes.Shape; import org.jbox2d.common.Vec2; import org.jbox2d.dynamics.Body; import org.jbox2d.dynamics.BodyDef; import org.jbox2d.dynamics.BodyType; import org.jbox2d.dynamics.World; import physics.shapes.NodeShapeBuilder; /** * Manage a JBox2D simulation of multiple JavaFX nodes. Nodes have no collision, * and are moved by applying forces (from mechanical springs and forcefields). * * @author Christoph Burschka <christoph@burschka.de> */ public class Box2DSpringSimulation { private final PhysLayout layout; private final Map<Node, Body> bodies; private final World world; private double friction = 0.5; private AnimationTimer animation; private long timeStep = (long) 1e6, timeStamp = 0; private static final int ITER_VELOCITY = 6, ITER_POS = 3; private static final double DRAG_SPEED = 1.5; private final ReadOnlyBooleanWrapper running = new ReadOnlyBooleanWrapper(false); /** * Create a new simulation for a particular layout. * * @param layout */ public Box2DSpringSimulation(PhysLayout layout) { this.layout = layout; bodies = new HashMap<>(); // New zero-gravity world: world = new World(new Vec2(0, 0)); layout.getNodes().stream().forEach((node) -> { createBody(node); }); layout.getNodes().addListener((SetChangeListener.Change<? extends Node> change) -> { if (change.wasAdded()) { createBody(change.getElementAdded()); } if (change.wasRemoved()) { world.destroyBody(bodies.get(change.getElementRemoved())); bodies.remove(change.getElementRemoved()); } }); layout.getMasses().addListener((MapChangeListener.Change<? extends Node, ? extends Double> change) -> { Node node = change.getKey(); Body body = bodies.get(node); if (body != null) { if (change.wasAdded() && change.getValueAdded().isInfinite()) { body.setType(BodyType.STATIC); } else if (change.wasRemoved() && change.getValueRemoved().isInfinite()) { body.setType(BodyType.DYNAMIC); } body.destroyFixture(body.getFixtureList()); createBodyFixture(node, body); } }); this.createAnimation(); } /** * Create a new simulation while also setting custom values for the time * step and friction. * * @param layout * @param dt * @param friction */ public Box2DSpringSimulation(PhysLayout layout, double dt, double friction) { this(layout); setTimeStep(dt); setFriction(friction); } private void createBody(Node node) { BodyDef def = new BodyDef(); def.position.set((float) node.getLayoutX(), (float) node.getLayoutY()); // Infinite-mass bodies are immovable. def.type = layout.getMass(node) == Double.POSITIVE_INFINITY ? BodyType.STATIC : BodyType.DYNAMIC; Body body = world.createBody(def); createBodyFixture(node, body); bodies.put(node, body); } private void createBodyFixture(Node node, Body body) { Shape s = NodeShapeBuilder.createShape(node); MassData m = new MassData(); s.computeMass(m, 1); body.createFixture(s, (float) layout.getMass(node) / m.mass); } /** * Get the current friction value. * * @return the friction, as the proportion of velocity and opposing force. */ public double getFriction() { return this.friction; } /** * Set the friction value. * * @param friction the friction, as the proportion of velocity and opposing * force. */ public final void setFriction(double friction) { this.friction = friction; } /** * Execute one simulated time step, according to the current time step * length. */ public void step() { // Box2D physics work by applying a fixed force on every timestep. applyAllForces(); // 6 iterations of u' and 3 iterations of u (recommended value). world.step((float) (timeStep * 1e-9), ITER_VELOCITY, ITER_POS); } /** * Update object positions based on their JavaFX nodes. * * For elements that have been moved and released by the mouse, their * movement during the last simulated timestep becomes their new momentum, * if the simulation is running. * * This allows "throwing" an element with the mouse. * * @param timeInterval nanoseconds since the last timestep. if set to 0, all * displaced elements will lose their momentum. */ public void updateModel(long timeInterval) { bodies.entrySet().stream().forEach((e) -> { Node node = e.getKey(); Body body = e.getValue(); Vec2 relative = new Vec2((float) node.getLayoutX(), (float) node.getLayoutY()); relative.addLocal(new Vec2((float) node.getTranslateX(), (float) node.getTranslateY())); relative.subLocal(body.getPosition()); // If the node has been moved externally or pressed, update. if (relative.length() > 1e-3) { Vec2 p = body.getTransform().p; body.setTransform(p.add(relative), body.getAngle()); // Use last timestep to set momentum. if (isRunning() && timeInterval > 0 && !node.isPressed()) { body.setLinearVelocity(relative.mul((float) (DRAG_SPEED * 1e9 / timeInterval))); } else { body.setLinearVelocity(new Vec2()); } } // Elements must not move while they are held with the mouse. else if (node.isPressed()) { body.setLinearVelocity(new Vec2()); } body.setActive(!node.isPressed()); }); } /** * Update object positions based on their JavaFX nodes. * * The momentum of displaced objects is set to 0. This method should be used * to update the simulation model while the simulation is not running. */ public void updateModel() { updateModel(0); } /** * Relocate the JavaFX nodes according to their simulated movement. */ public void updateView() { bodies.entrySet().stream().forEach((e) -> { Vec2 p = e.getValue().getPosition() .sub(new Vec2((float) e.getKey().getLayoutX(), (float) e.getKey().getLayoutY())); e.getKey().setTranslateX(p.x); e.getKey().setTranslateY(p.y); }); } private void createAnimation() { animation = new AnimationTimer() { @Override public void handle(long now) { long nextTimeStamp = timeStamp + timeStep; // Simulate in dt-sized steps until caught up. updateModel(now - timeStamp); while (nextTimeStamp < now) { step(); timeStamp = nextTimeStamp; nextTimeStamp = timeStamp + timeStep; } updateView(); } }; } public void destroy() { stopSimulation(); animation = null; } /** * Start simulating. */ public void startSimulation() { if (animation == null) { return; } updateModel(); running.set(true); timeStamp = System.nanoTime(); animation.start(); } /** * Stop the simulation in progress. */ public void stopSimulation() { running.set(false); if (animation != null) { animation.stop(); } } /** * Check whether the simulation is currently running. * * @return true if the simulation is running. */ public boolean isRunning() { return running.get(); } /** * Observable boolean value that allows modules to respond to the simulation * being started or stopped. * * @return a read-only observable boolean value that is true when the * simulation is running. */ public ReadOnlyBooleanProperty getRunning() { return running.getReadOnlyProperty(); } /** * Set the simulated time step. * * This is distinct from the frame rate, and will always be fixed. * * @param dt time step in seconds. */ public final void setTimeStep(double dt) { timeStep = (long) (dt * 1e9); } /** * Get the simulated time step. * * @return the current timestep in seconds. */ public double getTimeStep() { return timeStep * 1e-9; } /** * Applies a spring force to one endpoint of spring set. * * @param a the target of the force * @param b the other endpoint of the spring set. * @param springs the springs between the two bodies. */ private void applySprings(Body a, Body b, Set<Spring> springs) { Point2D pA = point(a.getPosition()); Point2D pB = point(b.getPosition()); Point2D force = springs.stream().map((s) -> { return s.getForce(pA, pB); }).reduce(new Point2D(0, 0), (x, y) -> { return x.add(y); }); a.applyForceToCenter(vec(force)); } /** * Applies a spring force to a tethered node. * * @param body the target of the force * @param tethers the tethers connected to the node */ private void applyTethers(Body body, Set<Tether> tethers) { Point2D p = point(body.getPosition()); Point2D force = tethers.stream().map((s) -> { return s.getForce(p); }).reduce(new Point2D(0, 0), (x, y) -> { return x.add(y); }); body.applyForceToCenter(vec(force)); } /** * Apply an opposing force in proportion to the velocity of a body. * * @param a */ private void applyFriction(Body a) { Vec2 v = a.getLinearVelocity(); a.applyForceToCenter(v.mul((float) -friction)); } private void applyAllForces() { layout.getAllConnections().stream().forEach((e) -> { Node a = e.getKey().getKey(); Node b = e.getKey().getValue(); Set<Spring> s = e.getValue(); applySprings(bodies.get(a), bodies.get(b), s); }); layout.getAllTethers().stream().forEach((e) -> { Node node = e.getKey(); Set<Tether> t = e.getValue(); applyTethers(bodies.get(node), t); }); bodies.values().stream().forEach((a) -> { applyFriction(a); layout.getFields().stream().forEach((field) -> { a.applyForceToCenter(vec(field.force(point(a.getPosition())))); }); }); } private static Point2D point(Vec2 v) { return new Point2D(v.x, v.y); } private static Vec2 vec(Point2D v) { return new Vec2((float) v.getX(), (float) v.getY()); } }