playing around with fsm generation

This commit is contained in:
hneemann 2018-11-21 18:47:20 +01:00
parent 03e048ee5e
commit a0c134b01d
15 changed files with 945 additions and 0 deletions

View File

@ -153,6 +153,16 @@ public class Vector implements VectorInterface {
return new Vector(x * a, y * a);
}
/**
* Creates a new vector which has the value this*a
*
* @param a a
* @return this*a
*/
public VectorFloat mul(float a) {
return new VectorFloat(x * a, y * a);
}
@Override
public Vector div(int d) {
return new Vector(x / d, y / d);
@ -236,4 +246,10 @@ public class Vector implements VectorInterface {
public Vector round() {
return this;
}
@Override
public float len() {
return (float) Math.sqrt(x * x + y * y);
}
}

View File

@ -126,4 +126,9 @@ public class VectorFloat implements VectorInterface {
public Vector round() {
return new Vector(getX(), getY());
}
@Override
public float len() {
return (float) Math.sqrt(x * x + y * y);
}
}

View File

@ -73,4 +73,9 @@ public interface VectorInterface {
* @return a int vector
*/
Vector round();
/**
* @return the length of the vector
*/
float len();
}

View File

@ -0,0 +1,168 @@
/*
* Copyright (c) 2018 Helmut Neemann.
* Use of this source code is governed by the GPL v3 license
* that can be found in the LICENSE file.
*/
package de.neemann.digital.fsm;
import de.neemann.digital.analyse.expression.Expression;
import de.neemann.digital.draw.graphics.Graphic;
import de.neemann.digital.draw.graphics.VectorFloat;
import java.util.ArrayList;
/**
* A simple finite state machine
*/
public class FSM {
private ArrayList<State> states;
private ArrayList<Transition> transitions;
/**
* Creates a new FSM containing the given states
*
* @param state the states
*/
public FSM(State... state) {
states = new ArrayList<>();
transitions = new ArrayList<>();
for (State s : state)
add(s);
}
/**
* Adds a state to the FSM
*
* @param state the state to add
* @return this for chained calls
*/
public FSM add(State state) {
state.setNumber(states.size());
states.add(state);
return this;
}
/**
* Adds a transition to the FSM
*
* @param transition the transition to add
* @return this for chained calls
*/
public FSM add(Transition transition) {
transitions.add(transition);
return this;
}
/**
* Adds a transition to the FSM
*
* @param from the from state
* @param to the to state
* @param condition the condition
* @return this for chained calls
* @throws FinitStateMachineException FinitStateMachineException
*/
public FSM transition(String from, String to, Expression condition) throws FinitStateMachineException {
return transition(findState(from), findState(to), condition);
}
/**
* Adds a transition to the FSM
*
* @param from the from state
* @param to the to state
* @param condition the condition
* @return this for chained calls
* @throws FinitStateMachineException FinitStateMachineException
*/
public FSM transition(int from, int to, Expression condition) throws FinitStateMachineException {
return transition(findState(from), findState(to), condition);
}
/**
* Adds a transition to the FSM
*
* @param from the from state
* @param to the to state
* @param condition the condition
* @return this for chained calls
* @throws FinitStateMachineException FinitStateMachineException
*/
public FSM transition(State from, State to, Expression condition) {
return add(new Transition(from, to, condition));
}
private State findState(String name) throws FinitStateMachineException {
for (State s : states)
if (s.getName().equals(name))
return s;
throw new FinitStateMachineException("State " + name + " not found!");
}
private State findState(int number) throws FinitStateMachineException {
for (State s : states)
if (s.getNumber() == number)
return s;
throw new FinitStateMachineException("State " + number + " not found!");
}
/**
* Calculates all forces to move the elements
*
* @return this for chained calls
*/
public FSM calculateForces() {
for (State s : states)
s.calcExpansionForce(states);
for (Transition t : transitions)
t.calcForce(states, transitions);
return this;
}
/**
* Draws the FSM
*
* @param gr the Graphic instance to draw to
*/
public void drawTo(Graphic gr) {
for (State s : states)
s.drawTo(gr);
for (Transition t : transitions)
t.drawTo(gr);
}
/**
* Moved the elements
*
* @param dt the time step
*/
public void move(int dt) {
for (State s : states)
s.move(dt);
for (Transition t : transitions)
t.move(dt);
}
/**
* Orders all states in a big circle
*/
public void circle() {
double delta = 2 * Math.PI / states.size();
double rad = 0;
for (State s : states)
if (s.getRadius() > rad)
rad = s.getRadius();
rad *= 4;
double phi = 0;
for (State s : states) {
s.setPosition(new VectorFloat((float) (Math.sin(phi) * rad), (float) (-Math.cos(phi) * rad)));
phi += delta;
}
for (Transition t : transitions)
t.initPos();
}
}

View File

@ -0,0 +1,20 @@
/*
* Copyright (c) 2018 Helmut Neemann.
* Use of this source code is governed by the GPL v3 license
* that can be found in the LICENSE file.
*/
package de.neemann.digital.fsm;
/**
* Esxeption thrown if there is a problem delaing with the FSM
*/
public class FinitStateMachineException extends Exception {
/**
* Creates a new exception
*
* @param message ther message
*/
public FinitStateMachineException(String message) {
super(message);
}
}

View File

@ -0,0 +1,125 @@
/*
* Copyright (c) 2018 Helmut Neemann.
* Use of this source code is governed by the GPL v3 license
* that can be found in the LICENSE file.
*/
package de.neemann.digital.fsm;
import de.neemann.digital.draw.graphics.VectorFloat;
/**
* A movable element.
*/
public class Movable {
private VectorFloat position;
private VectorFloat speed;
private VectorFloat force;
/**
* Creates a new instance
*/
public Movable() {
force = new VectorFloat(0, 0);
speed = new VectorFloat(0, 0);
position = new VectorFloat((float) Math.random() - 0.5f, (float) Math.random() - 0.5f).mul(100);
}
/**
* Sets the position
*
* @param position the position
*/
public void setPos(VectorFloat position) {
this.position = position;
}
/**
* Adds the given value to the force
*
* @param df the force to add
*/
public void addToForce(VectorFloat df) {
force = force.add(df);
}
/**
* Applies a repulsive force which decreases with the square of the distance.
*
* @param pos the position of the causer of the force
* @param reach the reach of the force
*/
public void addRepulsive(VectorFloat pos, float reach) {
final VectorFloat dif = position.sub(pos);
float dist = dif.len();
if (dist == 0) {
addToForce(new VectorFloat((float) Math.random() - 0.5f, (float) Math.random() - 0.5f).mul(2));
} else {
float f = reach * reach / (dist * dist) / 2;
if (f > 100)
f = 100;
addToForce(dif.norm().mul(f));
}
}
/**
* Applies a repulsive force which decreases linear with the the distance.
*
* @param pos the position of the causer of the force
* @param reach the reach of the force
*/
public void addRepulsiveInv(VectorFloat pos, float reach) {
final VectorFloat dif = position.sub(pos);
float dist = dif.len();
if (dist == 0) {
addToForce(new VectorFloat((float) Math.random() - 0.5f, (float) Math.random() - 0.5f).mul(2));
} else {
float f = reach / dist / 2;
if (f > 100)
f = 100;
addToForce(dif.norm().mul(f));
}
}
/**
* Adds an attractive force
*
* @param target the targe
* @param scale a scaling factor
*/
public void addAttractiveTo(VectorFloat target, float scale) {
addToForce(target.sub(position).mul(scale));
}
/**
* @return the force
*/
public VectorFloat getForce() {
return force;
}
/**
* Sets the force to zero
*/
public void resetForce() {
this.force = new VectorFloat(0, 0);
}
/**
* @return the position
*/
public VectorFloat getPos() {
return position;
}
/**
* Moves the element
*
* @param dt the time step
*/
public void move(int dt) {
speed = speed.add(force.mul(0.2f));
position = position.add(speed);
speed = speed.mul(0.7f);
}
}

View File

@ -0,0 +1,127 @@
/*
* Copyright (c) 2018 Helmut Neemann.
* Use of this source code is governed by the GPL v3 license
* that can be found in the LICENSE file.
*/
package de.neemann.digital.fsm;
import de.neemann.digital.draw.graphics.*;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* Represents a state
*/
public class State extends Movable {
private static final int RAD = 70;
private static final float REACH = 500;
private int number;
private String name;
private int radius;
private TreeMap<String, Long> values;
/**
* Creates a new state
*
* @param name the name of the state
*/
public State(String name) {
super();
this.name = name;
this.radius = RAD;
values = new TreeMap<>();
}
/**
* Adds a outputvalue to the state
*
* @param name the name
* @param val the value
* @return this for chained calls
*/
public State val(String name, long val) {
values.put(name, val);
return this;
}
/**
* @return the name of the state
*/
public String getName() {
return name;
}
/**
* Sets the position
*
* @param position the position
* @return this for chained calls
*/
public State setPosition(VectorFloat position) {
setPos(position);
return this;
}
/**
* Calculates the repolsive forces
*
* @param states the states to take into account
*/
public void calcExpansionForce(List<State> states) {
resetForce();
for (State s : states)
if (s != this)
addRepulsive(s.getPos(), REACH);
}
/**
* Draws the state
*
* @param gr the Graphic instance to draw to
*/
public void drawTo(Graphic gr) {
VectorInterface rad = new Vector(RAD, RAD);
gr.drawCircle(getPos().sub(rad), getPos().add(rad), Style.NORMAL);
Vector delta = new Vector(0, Style.NORMAL.getFontSize());
VectorFloat pos = getPos().add(delta.mul(-1));
gr.drawText(pos, pos.add(new Vector(1, 0)), Integer.toString(number), Orientation.CENTERCENTER, Style.NORMAL);
pos = pos.add(delta);
gr.drawText(pos, pos.add(new Vector(1, 0)), name, Orientation.CENTERCENTER, Style.NORMAL);
for (Map.Entry<String, Long> v : values.entrySet()) {
pos = pos.add(delta);
String str = v.getKey() + "->" + v.getValue();
gr.drawText(pos, pos.add(new Vector(1, 0)), str, Orientation.CENTERCENTER, Style.NORMAL);
}
}
/**
* @return the radius of the state
*/
public float getRadius() {
return radius;
}
/**
* Sets the number of the state
*
* @param number the number
*/
public void setNumber(int number) {
this.number = number;
}
/**
* @return the number of the state
*/
public int getNumber() {
return number;
}
}

View File

@ -0,0 +1,124 @@
/*
* Copyright (c) 2018 Helmut Neemann.
* Use of this source code is governed by the GPL v3 license
* that can be found in the LICENSE file.
*/
package de.neemann.digital.fsm;
import de.neemann.digital.analyse.expression.Expression;
import de.neemann.digital.analyse.expression.format.FormatToExpression;
import de.neemann.digital.analyse.expression.format.FormatterException;
import de.neemann.digital.draw.graphics.*;
import java.util.List;
/**
* Represents a transition
*/
public class Transition extends Movable {
private static final FormatToExpression FORMAT = FormatToExpression.FORMATTER_UNICODE;
private static final float EXPANSION_TRANS = 0.001f;
private final State fromState;
private final State toState;
private final Expression condition;
/**
* Creates a new transition
*
* @param fromState the state to leave
* @param toState the state to enter
* @param condition the condition
*/
public Transition(State fromState, State toState, Expression condition) {
super();
this.fromState = fromState;
this.toState = toState;
this.condition = condition;
initPos();
}
/**
* Calculates the repulsive forces
*
* @param states the states
* @param transitions the transitions
*/
public void calcForce(List<State> states, List<Transition> transitions) {
float preferredLen = Math.max(fromState.getRadius(), toState.getRadius()) * 5;
calcForce(preferredLen, states, transitions);
}
/**
* Calculates the repulsive forces
*
* @param preferredDist the preferred distance
* @param states the states
* @param transitions the transitions
*/
public void calcForce(float preferredDist, List<State> states, List<Transition> transitions) {
VectorFloat dir = fromState.getPos().sub(toState.getPos());
float len = dir.len();
float d = len - preferredDist;
dir = dir.mul(EXPANSION_TRANS * d);
toState.addToForce(dir);
fromState.addToForce(dir.mul(-1));
resetForce();
VectorFloat center = fromState.getPos().add(toState.getPos()).mul(0.5f);
addAttractiveTo(center, 1);
for (State s : states)
addRepulsive(s.getPos(), 2000);
for (Transition t : transitions)
if (t != this)
addRepulsiveInv(t.getPos(), 1000);
}
/**
* Draws the transition
*
* @param gr the Graphic instance to draw to
*/
public void drawTo(Graphic gr) {
VectorFloat difFrom = getPos().sub(fromState.getPos()).norm().mul(fromState.getRadius());
VectorFloat difTo = getPos().sub(toState.getPos()).norm().mul(toState.getRadius());
final VectorFloat start = fromState.getPos().add(difFrom);
final VectorFloat end = toState.getPos().add(difTo);
Polygon p = new Polygon(false)
.add(start)
.add(getPos(), getPos(), end);
final Style arrowStyle = Style.SHAPE_PIN;
gr.drawPolygon(p, arrowStyle);
// gr.drawLine(start, getPos(), Style.THIN);
// gr.drawLine(getPos(), end, Style.THIN);
// arrow
VectorFloat lot = new VectorFloat(difTo.getYFloat(), -difTo.getXFloat()).mul(0.5f);
gr.drawLine(end, end.add(difTo.add(lot).mul(0.2f)), arrowStyle);
gr.drawLine(end, end.add(difTo.sub(lot).mul(0.2f)), arrowStyle);
if (condition != null) {
String format;
try {
format = FORMAT.format(condition);
} catch (FormatterException e) {
format = "error";
}
gr.drawText(getPos(), getPos().add(new Vector(1, 0)), format, Orientation.CENTERCENTER, Style.NORMAL);
}
}
/**
* Initializes the position of the transition
*/
public void initPos() {
setPos(fromState.getPos().add(toState.getPos()).mul(0.5f)
.add(new VectorFloat((float) Math.random() - 0.5f, (float) Math.random() - 0.5f).mul(30)));
}
}

View File

@ -0,0 +1,122 @@
/*
* Copyright (c) 2018 Helmut Neemann.
* Use of this source code is governed by the GPL v3 license
* that can be found in the LICENSE file.
*/
package de.neemann.digital.fsm.gui;
import de.neemann.digital.draw.graphics.GraphicMinMax;
import de.neemann.digital.draw.graphics.GraphicSwing;
import de.neemann.digital.draw.graphics.Style;
import de.neemann.digital.draw.graphics.Vector;
import de.neemann.digital.fsm.FSM;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.MouseEvent;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
import java.awt.geom.Point2D;
/**
* The component to show the fsm
*/
public class FSMComponent extends JComponent {
private boolean isManualScale;
private AffineTransform transform = new AffineTransform();
private FSM fsm;
/**
* Creates a new component
*
* @param fsm the fsm to visualize
*/
public FSMComponent(FSM fsm) {
this.fsm = fsm;
fsm.circle();
addMouseWheelListener(e -> {
Vector pos = getPosVector(e);
double f = Math.pow(0.9, e.getWheelRotation());
transform.translate(pos.x, pos.y);
transform.scale(f, f);
transform.translate(-pos.x, -pos.y);
isManualScale = true;
repaint();
});
addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent componentEvent) {
if (!isManualScale)
fitFSM();
}
});
setPreferredSize(new Dimension(600, 600));
}
private Vector getPosVector(MouseEvent e) {
return getPosVector(e.getX(), e.getY());
}
private Vector getPosVector(int x, int y) {
try {
Point2D.Double p = new Point2D.Double();
transform.inverseTransform(new Point(x, y), p);
return new Vector((int) Math.round(p.getX()), (int) Math.round(p.getY()));
} catch (NoninvertibleTransformException e1) {
throw new RuntimeException(e1);
}
}
/**
* Fits the FSM to the window
*/
public void fitFSM() {
GraphicMinMax gr = new GraphicMinMax();
fsm.drawTo(gr);
AffineTransform newTrans = new AffineTransform();
if (gr.getMin() != null && getWidth() != 0 && getHeight() != 0) {
Vector delta = gr.getMax().sub(gr.getMin());
double sx = ((double) getWidth()) / (delta.x + Style.NORMAL.getThickness() * 2);
double sy = ((double) getHeight()) / (delta.y + Style.NORMAL.getThickness() * 2);
double s = Math.min(sx, sy);
newTrans.setToScale(s, s); // set Scaling
Vector center = gr.getMin().add(gr.getMax()).div(2);
newTrans.translate(-center.x, -center.y); // move drawing center to (0,0)
Vector dif = new Vector(getWidth(), getHeight()).div(2);
newTrans.translate(dif.x / s, dif.y / s); // move drawing center to frame center
isManualScale = false;
} else {
isManualScale = true;
}
if (!newTrans.equals(transform)) {
transform = newTrans;
repaint();
}
}
@Override
protected void paintComponent(Graphics graphics) {
super.paintComponent(graphics);
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, getWidth(), getHeight());
Graphics2D gr2 = (Graphics2D) graphics;
gr2.transform(transform);
GraphicSwing gr = new GraphicSwing(gr2, 1);
fsm.drawTo(gr);
}
}

View File

@ -0,0 +1,124 @@
/*
* Copyright (c) 2018 Helmut Neemann.
* Use of this source code is governed by the GPL v3 license
* that can be found in the LICENSE file.
*/
package de.neemann.digital.fsm.gui;
import de.neemann.digital.analyse.expression.Expression;
import de.neemann.digital.analyse.parser.ParseException;
import de.neemann.digital.analyse.parser.Parser;
import de.neemann.digital.fsm.FSM;
import de.neemann.digital.fsm.State;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.IOException;
/**
* The dialog to show the FSM
*/
public class FSMDialog extends JDialog {
private final FSM fsm;
private final FSMComponent fsmComponent;
private final Timer timer;
/**
* Creates a new instance
*
* @param frame the parents frame
* @param fsm the fsm to visualize
*/
public FSMDialog(Frame frame, FSM fsm) {
super(frame, "FSM");
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
this.fsm = fsm;
fsmComponent = new FSMComponent(fsm);
getContentPane().add(fsmComponent);
pack();
setLocationRelativeTo(frame);
fsmComponent.fitFSM();
timer = new Timer(100, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent actionEvent) {
fsm.calculateForces();
fsm.move(100);
repaint();
}
});
timer.start();
addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent windowEvent) {
timer.stop();
}
});
}
/**
* A simple test method
*
* @param args the programs arguments
* @throws Exception Exception
*/
public static void main(String[] args) throws Exception {
/*
FSM fsm = new FSM()
.add(new State("initial").val("Y", 0))
.add(new State("1 match").val("Y", 0))
.add(new State("2 matches").val("Y", 0))
.add(new State("found").val("Y", 1))
.transition(0, 1, new Parser("!E").parse().get(0))
.transition(1, 2, new Parser("!E").parse().get(0))
.transition(2, 3, new Parser("E").parse().get(0))
.transition(1, 0, new Parser("E").parse().get(0))
.transition(3, 0, new Parser("E").parse().get(0))
.transition(3, 1, new Parser("!E").parse().get(0));*/
State top = new State("top");
State topSet = new State("topSet").val("Y", 1);
State leftA = new State("leftA");
State leftB = new State("leftB");
State bottom = new State("bottom");
State bottomSet = new State("bottomSet").val("Y", 1);
State rightA = new State("rightA");
State rightB = new State("rightB");
FSM fsm = new FSM(top, topSet, leftA, leftB, bottom, bottomSet, rightA, rightB)
.transition(top, leftA, e("A & !B"))
.transition(top, rightA, e("!A & B"))
.transition(topSet, top, null)
.transition(rightA, top, e("!A & !B"))
.transition(rightB, topSet, e("!A & !B"))
.transition(leftA, top, e("!A & !B"))
.transition(leftB, topSet, e("!A & !B"))
.transition(bottom, leftB, e("A & !B"))
.transition(bottom, rightB, e("!A & B"))
.transition(bottomSet, bottom, null)
.transition(rightB, bottom, e("A & B"))
.transition(rightA, bottomSet, e("A & B"))
.transition(leftB, bottom, e("A & B"))
.transition(leftA, bottomSet, e("A & B"));
new FSMDialog(null, fsm).setVisible(true);
}
private static Expression e(String s) throws IOException, ParseException {
return new Parser(s).parse().get(0);
}
}

View File

@ -0,0 +1,9 @@
/*
* Copyright (c) 2018 Helmut Neemann.
* Use of this source code is governed by the GPL v3 license
* that can be found in the LICENSE file.
*/
/**
* Classes to visualize the FSM
*/
package de.neemann.digital.fsm.gui;

View File

@ -0,0 +1,10 @@
/*
* Copyright (c) 2018 Helmut Neemann.
* Use of this source code is governed by the GPL v3 license
* that can be found in the LICENSE file.
*/
/**
* The classes needed to describe a finite state machine
*/
package de.neemann.digital.fsm;

View File

@ -0,0 +1,22 @@
package de.neemann.digital.fsm;
import de.neemann.digital.draw.graphics.VectorFloat;
import junit.framework.TestCase;
public class FSMTest extends TestCase {
public void testSimple() throws FinitStateMachineException {
FSM fsm = new FSM()
.add(new State("0").setPosition(new VectorFloat(-1,0)))
.add(new State("1").setPosition(new VectorFloat(0,1)))
.add(new State("2").setPosition(new VectorFloat(1,0)))
.add(new State("3").setPosition(new VectorFloat(0,-1)))
.transition("0", "1", null)
.transition("1", "2", null)
.transition("2", "3", null)
.transition("3", "0", null);
fsm.calculateForces();
}
}

View File

@ -0,0 +1,26 @@
package de.neemann.digital.fsm;
import de.neemann.digital.draw.graphics.VectorFloat;
import junit.framework.TestCase;
import java.util.Arrays;
public class StateTest extends TestCase {
public void testCalcExpansionForce() {
State a = new State("a").setPosition(new VectorFloat(0, 0));
State b = new State("b").setPosition(new VectorFloat(100, 0));
a.calcExpansionForce(Arrays.asList(a, b));
assertEquals(0, a.getForce().getYFloat(), 1e-5);
final float near = a.getForce().getXFloat();
assertTrue(near <= 0);
b.setPosition(new VectorFloat(200, 0));
a.calcExpansionForce(Arrays.<State>asList(a, b));
final float far = a.getForce().getXFloat();
assertTrue(far <= 0);
assertTrue(Math.abs(far) < Math.abs(near));
}
}

View File

@ -0,0 +1,42 @@
package de.neemann.digital.fsm;
import de.neemann.digital.draw.graphics.VectorFloat;
import junit.framework.TestCase;
import java.util.Arrays;
public class TransitionTest extends TestCase {
public void testCalcForceIsPreferred() {
State a = new State("a").setPosition(new VectorFloat(0, 0));
State b = new State("b").setPosition(new VectorFloat(10, 0));
Transition t = new Transition(a, b, null);
t.calcForce(10, Arrays.asList(a, b), Arrays.asList(t));
assertEquals(0, a.getForce().getXFloat(), 1e-5);
assertEquals(0, a.getForce().getYFloat(), 1e-5);
assertEquals(0, b.getForce().getXFloat(), 1e-5);
assertEquals(0, b.getForce().getYFloat(), 1e-5);
}
public void testCalcForceToClose() {
State a = new State("a").setPosition(new VectorFloat(0, 0));
State b = new State("b").setPosition(new VectorFloat(10, 0));
Transition t = new Transition(a, b, null);
t.calcForce(20, Arrays.asList(a, b), Arrays.asList(t));
assertTrue(a.getForce().getXFloat() < 0);
assertEquals(0, a.getForce().getYFloat(), 1e-5);
assertTrue(b.getForce().getXFloat() > 0);
assertEquals(0, b.getForce().getYFloat(), 1e-5);
}
public void testCalcForceToFar() {
State a = new State("a").setPosition(new VectorFloat(0, 0));
State b = new State("b").setPosition(new VectorFloat(10, 0));
Transition t = new Transition(a, b, null);
t.calcForce(5, Arrays.asList(a, b), Arrays.asList(t));
assertTrue(a.getForce().getXFloat() > 0);
assertEquals(0, a.getForce().getYFloat(), 1e-5);
assertTrue(b.getForce().getXFloat() < 0);
assertEquals(0, b.getForce().getYFloat(), 1e-5);
}
}