In this tutorial we will build a simple pseudo-spring-mass simulation using mass balls and simulated "rubber bands". The balls will start at different locations and have random mass values. When balls get close to one another they turn red; balls cannot collide (they'll just pass through one another). The rubber bands will connect random balls together, will have random strength values, and will have randomly-chosen "lax" lengths. The rubber bands will follow Hook's law, which says that the force of a rubber band is proportional to the length of the band minus the lax length, all times the strength.
This tutorial teaches:
The rubber band class is the easiest to write. Make a directory called sim/app/tutorial5. In it, create a file called Band.java. In this file, write:
package sim.app.tutorial5; public class Band implements java.io.Serializable { public double laxDistance; public double strength; public Band(double laxDistance, double strength) { this.laxDistance = laxDistance; this.strength = strength; } // Bean Properties for our Inspector public void setStrength(double val) { if (val > 0) strength = val; } public double getStrength() { return strength; } public void setLaxDistance(double val) { if (val >= 0) laxDistance = val; } public double getLaxDistance() { return laxDistance; } }
Straightforward enough.
We'll write the model class (Tutorial5) before writing the Ball class -- it's easier to explain things that way. Create a file called Tutorial5.java. In it add:
package sim.app.tutorial5; import sim.engine.*; import sim.field.continuous.*; import sim.field.network.*; import sim.util.*; import ec.util.*; public class Tutorial5 extends SimState { public Continuous2D balls; public NetworkField bands; public int numBalls = 50; public int numBands = 60; public final static double maxMass = 10.0; public final static double minMass = 1.0; public final static double minLaxBandDistance = 10.0; public final static double maxLaxBandDistance = 50.0; public final static double minBandStrength = 5.0; public final static double maxBandStrength = 10.0; public final static double collisionDistance = 5.0; public int getNumBalls() { return numBalls; } public void setNumBalls(int val) { if (val >= 2 ) numBalls = val; } public int getNumBands() { return numBands; } public void setNumBands(int val) { if (val >= 0 ) numBands = val; } public Tutorial5(long seed) { super(new MersenneTwisterFast(seed), new Schedule(2)); balls = new Continuous2D(collisionDistance,100,100); bands = new NetworkField(); }
So objects can belong in more than one field?
Yes. There is only one exception: Edges relating objects in a NetworkField can only be associated with a given NetworkField. To relate the same two objects in two different NetworkFields, you must use two different Edges. Of course any Edge's info object can be reused in different Fields. |
Why pass collisionDistance to the Continuous2D constructor?
Continuous2D stores objects not only with a Double2D location, but also puts them in a discretized grid associated with an Int2D. This is to allow you to efficiently get all objects located within some distance of an object in a Continuous2D. The choice of discretization depends on several factors, but if your objects are just point objects (like our Balls will be) then usually you want a discretization about the same size as the typical lookup-within-distance you'll be asking the Continuous2D for. In our case, that's collisionDistance. For more information, look up the documentation on Continuous2D. |
A Sequence is a Steppable which contains an array of Steppables. When it's stepped, it steps all its Steppables in turn. Plain Sequences step their Steppables in the order of the array, but a RandomSequence will randomize the array first, and a ParallelSequence will step each one in a separate thread (be careful with that one -- only use it if you know what you're doing threadwise). There are other kinds of special Steppables available: a WeakStep contains a single Steppable and steps it but only holds onto it as a java.lang.ref.WeakReference (it can be garbage-collected). A MultiStep contains a single steppable, and either steps it N times when the MultiStep is stepped, or steps it once every N times that the MultiStep is stepped.
So there's no way to have two different "step()" methods for an Agent registered in a Schedule?
Nope. An agent can only "do one thing". But it's not an issue. Just make a different agent which does the job -- often just a little anonymous Steppable which calls the right function like we're doing here. |
public void start() { super.start(); balls = new Continuous2D(collisionDistance,100,100); bands = new NetworkField(); Steppable[] s = new Steppable[numBalls]; // make the balls for(int i=0; i<numBalls;i++) { // must be final to be used in the anonymous class below final Ball ball = new Ball(0,0,random.nextDouble() * (maxMass-minMass) + minMass); balls.setObjectLocation(ball, new Double2D(random.nextDouble() * 100, random.nextDouble() * 100)); bands.addNode(ball); schedule.scheduleRepeating(ball); // schedule the balls to compute their force after everyone's moved s[i] = new Steppable() { public void step(SimState state) { ball.computeForce(state); } // see Tutorial 3 for why this is helpful static final long serialVersionUID = -4269174171145445918L; }; } // add the sequence schedule.scheduleRepeating(Schedule.EPOCH,1,new Sequence(s),1);
Now we make the Bands. All the Balls have been dumped into the NetworkField already -- all we need to do is build Edges connecting two balls, labelling the Edges with a given Band. We'll add random rubber bands. To keep it simple, it's possible that two edges could connect the same two Balls.
// make the bands Bag ballObjs = balls.getAllObjects(); for(int i=0;i<numBands;i++) { Band band = new Band(random.nextDouble() * (maxLaxBandDistance - minLaxBandDistance) + minLaxBandDistance, random.nextDouble() * (maxBandStrength - minBandStrength) + minBandStrength); Ball from; from = (Ball)(ballObjs.objs[random.nextInt(ballObjs.numObjs)]); Ball to = from; while(to == from) to = (Ball)(ballObjs.objs[random.nextInt(ballObjs.numObjs)]); bands.addEdge(from,to,band); } }
Finish up with a boilerplate main() which we won't use anyway -- this Tutorial is for visualization. Plus some serial UID stuff.
public static void main(String[] args) { Tutorial5 tutorial5 = null; for(int x=0;x<args.length-1;x++) if (args[x].equals("-checkpoint")) { SimState state = SimState.readFromCheckpoint(new java.io.File(args[x+1])); if (state == null) System.exit(1); else if (!(state instanceof Tutorial5)) { System.out.println("Checkpoint contains some other simulation: " + state); System.exit(1); } else tutorial5 = (Tutorial5)state; } if (tutorial5==null) { tutorial5 = new Tutorial5(System.currentTimeMillis()); tutorial5.start(); } long time; while((time = tutorial5.schedule.time()) < 10000) { if (time % 10 == 0) System.out.println(time); if (!tutorial5.schedule.step(tutorial5)) break; if (time%500==0 && time!=0) { String s = "tutorial5." + time + ".checkpoint"; System.out.println("Checkpointing to file: " + s); tutorial5.writeToCheckpoint(new java.io.File(s)); } } tutorial5.finish(); } // see Tutorial 3 for why this is helpful static final long serialVersionUID = -7164072518609011190L; }
This is where most of the physics lies. Create a file called Ball.java. In it, add:
package sim.app.tutorial5; import sim.engine.*; import sim.portrayal.*; import sim.util.*; import sim.field.network.*; import sim.field.continuous.*; import java.awt.*; import java.awt.geom.*; public class Ball implements Steppable { // force on the Ball public double forcex; public double forcey; // Ball mass public double mass; // Current Ball velocity public double velocityx; public double velocityy; // did the Ball collide? public boolean collision; // for drawing: always sqrt of mass public double diameter; public double getVelocityX() { return velocityx; } public void setVelocityX(double val) { velocityx = val; } public double getVelocityY() { return velocityy; } public void setVelocityY(double val) { velocityy = val; } public double getMass() { return mass; } public void setMass(double val) { if (val > 0) { mass = val; diameter = Math.sqrt(val); } } public Ball(double vx, double vy, double m) { velocityx=vx; velocityy=vy; mass = m; diameter = Math.sqrt(m); }
Our balls have a mass, a velocity in x and y, a current force in x and y, a diameter to display (determined by the mass), and a flag indicating whether or not they've collided on this pass. When a Ball is stepped, it will apply its current forces, adjusting its velocity and moving one step accordingly. It will also determine if it's collided with other objects.
We'll start with the collision computation. What we'll do is grab all the balls within the "collision distance" from the Continuous2D field using getObjectsWithinDistance(...). This method is liberal and may return objects that are well outside the desired distance (it's just using the discretized buckets to return objects). We then examine the objects one by one to see if they're within the distance, and if so, set collision flags in them and in us. You get the location of a given object in the field, as expected, with getObjectLocation(...).
public void computeCollision(Tutorial5 tut) { collision = false; Double2D me = tut.balls.getObjectLocation(this); Bag b = tut.balls.getObjectsWithinDistance(me,Tutorial5.collisionDistance); for(int x=0;x<b.numObjs;x++) if( this != b.objs[x] ) { Double2D loc = tut.balls.getObjectLocation(b.objs[x]); if ((loc.x-me.x)*(loc.x-me.x) + (loc.y-me.y)*(loc.y-me.y) <= Tutorial5.collisionDistance * Tutorial5.collisionDistance) { collision = true; ((Ball)(b.objs[x])).collision = true; } } }
Now let's take the step. Once we compute the velocity, we get the current position using getObjectLocation, then set the new position with setObjectLocation. Notice the similarity to SparseGrid2D. This is because both SparseGrid2D and Continuous2D are subclasses of SparseField.
public void step(SimState state) { Tutorial5 tut = (Tutorial5) state; // acceleration = force / mass final double ax = forcex / mass; final double ay = forcey / mass; // velocity = velocity + acceleration velocityx += ax; velocityy += ay; // position = position + velocity Double2D pos = tut.balls.getObjectLocation(this); Double2D newpos = new Double2D(pos.x+velocityx, pos.y + velocityy); tut.balls.setObjectLocation(this,newpos); // compute collisions computeCollision(tut); }
Next we need to implement the computeForce(...) funtion used by the force-computer in the Tutorial5 class. We begin with a function that adds the force from a given rubber band connecting us with another ball.
public void addForce(Double2D otherBallLoc, Double2D myLoc, Band band) { // compute difference final double dx = otherBallLoc.x - myLoc.x; final double dy = otherBallLoc.y - myLoc.y; final double len = Math.sqrt(dx*dx + dy*dy); final double l = band.laxDistance; //Hook's law final double k = band.strength/512.0; // cut down reasonably final double forcemagnitude = (len - l) * k; // add rubber band force if (len - l > 0) { forcex += (dx * forcemagnitude) / len; forcey += (dy * forcemagnitude) / len; } }
Now to implement the computeForce(...) function, we go to the NetworkField, extract all the edges going into or coming out of us, getting the rubber bands and the "other Ball"s out of those edges, then calling addForce with each of them. You extract edges from the NetworkField with getEdgesIn(obj) and getEdgesOut(obj). The other ball will be in the edge's from() field or to() field respectively, and the Band will be in the object's info field. Note that from and to are called with functions -- they can't be changed -- but info can be accessed simply by accessing (or modifying at any time) the slot directly.
public void computeForce(SimState state) { Tutorial5 tut = (Tutorial5) state; NetworkField bands = tut.bands; Continuous2D balls = tut.balls; Double2D me = balls.getObjectLocation(this); forcex = 0; forcey = 0; // rubber bands exert a force both ways -- // so our graph is undirected. We need to get edges // both in and out, as they could be located either place Bag in = bands.getEdgesIn(this); Bag out = bands.getEdgesOut(this); if (in!=null) for(int x=0;x// from him to me Double2D him = balls.getObjectLocation(other); addForce(him,me,b); } if (out!=null) for(int x=0;x // from me to him Double2D him = balls.getObjectLocation(other); addForce(him,me,b); } } }
package sim.app.tutorial5; import sim.portrayal.network.*; import sim.portrayal.continuous.*; import sim.engine.*; import sim.display.*; import javax.swing.*; import java.awt.Color; public class Tutorial5WithUI extends GUIState { public Display2D display; public JFrame displayFrame; Network2DPortrayal edgePortrayal = new Network2DPortrayal(); Continuous2DPortrayal nodePortrayal = new Continuous2DPortrayal(); public static void main(String[] args) { Tutorial5WithUI vid = new Tutorial5WithUI(); Console c = new Console(vid); c.setVisible(true); } public Tutorial5WithUI() { super(new Tutorial5( System.currentTimeMillis())); } public Tutorial5WithUI(SimState state) { super(state); } public String getName() { return "Tutorial 5"; } public String getInfo() { return "<H2>Tutorial 5</H2> Balls and Rubber Bands!"; } public Object getSimulationInspectedObject() { return state; } public void start() { super.start(); setupPortrayals(); } public void load(SimState state) { super.load(state); setupPortrayals(); }
Now we need to set up the portrayals. The Continuous2D is portrayed straightforwardly enough. But the NetworkField needs some explanation. NetworkField2DPortrayal only draws edges! Nodes must be stored in another field, either a SparseGrid2D or a Continuous2D. This field must be passed to a container object called SpatialNetwork2D, which is in turn handed to the NetworkField2DPortrayal. The locations of the nodes in the SparseGrid2D or Continuous2D determine where the edges are drawn. But you'll need to draw the nodes yourself by using a SparseGrid2DPortrayal or Continuous2DPortrayal respectively. I suggest you draw the edges first, then the nodes on top of them. Here we go:
public void setupPortrayals() { Tutorial5 tut = (Tutorial5) state; // tell the portrayals what to portray and how to portray them edgePortrayal.setField( new SpatialNetwork2D( tut.balls, tut.bands ) ); edgePortrayal.setPortrayalForAll(new BandPortrayal2D()); nodePortrayal.setField( tut.balls ); // reschedule the displayer display.reset(); display.setBackdrop(Color.white); // redraw the display display.repaint(); }
Notice that we specified a special SimplePortrayal to draw our Edges. This is a subclass of EdgePortrayal2D which we will write, called BandPortrayal2D.java. More on that later.
Notice also that no portrayal was specified for the Balls. This is because we'll let the Balls portray themselves. More on that later as well. We'll finish up with typical methods:
public void init(Controller c) { super.init(c); // make the displayer display = new Display2D(600,600,this,1); displayFrame = display.createFrame(); displayFrame.setTitle("Tutorial 5 Display"); c.registerFrame(displayFrame); // register the frame so it appears in the "Display" list displayFrame.setVisible(true); display.attach( edgePortrayal, "Bands" ); display.attach( nodePortrayal, "Balls" ); } public void quit() { super.quit(); if (displayFrame!=null) displayFrame.dispose(); displayFrame = null; display = null; } }
FROM... |
public class Ball implements Steppable |
CHANGE TO |
public class Ball extends SimplePortrayal2D implements Steppable |
Couldn't we just use sim.portrayal.simple.OvalPortrayal2D?
Yes, but we'll roll our own for tutorial purposes. |
Why not use Graphics2D Affine Transforms?
They are very very very slow. Also, font sizes are transformed, so if you draw text labels, then scale it up, the text gets bigger -- usually that's not what you want. Similarly, lines get thicker, etc. Instead we give you the scale information and you an do what you like with it. |
We'll draw Balls using the diameter parameter we defined early on. Add to Ball.java the function:
public void draw(Object object, Graphics2D graphics, DrawInfo2D info) { final double width = info.draw.width * diameter; final double height = info.draw.height * diameter; if (collision) graphics.setColor(Color.red); else graphics.setColor(Color.blue); final int x = (int)(info.draw.x - width / 2.0); final int y = (int)(info.draw.y - height / 2.0); final int w = (int)(width); final int h = (int)(height); // draw centered on the origin graphics.fillOval(x,y,w,h); }
Why do we draw with fillOval instead of using an Ellipse2D?
Ellipse2D, like most of Java2D, is much slower than fillOval (or other java.awt.Graphics primitives). |
public boolean hitObject(Object object, DrawInfo2D range) { final double SLOP = 1.0; // need a little extra diameter to hit circles final double width = range.draw.width * diameter; final double height = range.draw.height * diameter; Ellipse2D.Double ellipse = new Ellipse2D.Double( range.draw.x-width/2-SLOP, range.draw.y-height/2-SLOP, width+SLOP*2, height+SLOP*2 ); return ( ellipse.intersects( range.clip.x, range.clip.y, range.clip.width, range.clip.height ) ); }
Now we need to write an SimpleEdgePortrayal2D subclass to draw our Rubber Bands. The default SimpleEdgePortrayal2D class draws edges directed (red on one end, black on the other), with info.toString() as a label in gray in the middle. That's not quite what we want. We'd like it to be black everywhere, with a small strength value in the center, drawn in blue. So we'll make our own version.
Create a file called BandPortrayal2D.java. In this file, add:
package sim.app.tutorial5; import sim.field.network.*; import sim.portrayal.network.*; import sim.portrayal.*; import java.awt.*; public class BandPortrayal2D extends SimpleEdgePortrayal2D { // how our strength should look java.text.DecimalFormat strengthFormat = new java.text.DecimalFormat("#0.##"); public void draw(Object object, Graphics2D graphics, DrawInfo2D info) {
So far, looks like an ordinary draw method. But Edges expect that their DrawInfo2D be actually an EdgeDrawInfo2D object, which adds a second drawing point (the first one is at the (x,y) origin) to define the line. Add:
// this better be an EdgeDrawInfo2D! :-) EdgeDrawInfo2D ei = (EdgeDrawInfo2D) info; // likewise, this better be an Edge! Edge e = (Edge) object; // our start (x,y), ending (x,y), and midpoint (for drawing the label) final int startX = (int)ei.draw.x; final int startY = (int)ei.draw.y; final int endX = (int)ei.secondPoint.x; final int endY = (int)ei.secondPoint.y; final int midX = (int)((ei.draw.x+ei.secondPoint.x) / 2); final int midY = (int)((ei.draw.y+ei.secondPoint.y) / 2);
Now we can draw the line.
// draw line Band b = (Band)(e.info); graphics.setColor(Color.black); graphics.drawLine (startX, startY, endX, endY); // draw label in blue graphics.setColor(Color.blue); graphics.setFont(labelFont); // default font for Edge labels String information = strengthFormat.format(((Band)(e.info)).strength); int width = graphics.getFontMetrics().stringWidth(information); graphics.drawString( information, midX - width / 2, midY ); } // use the default hitObject -- don't bother writing that one, it works fine }
Save and compile all files, then run java sim.app.tutorial5.Tutorial5WithUI. Notice that the balls bounce well outside of the clip region (try scaling out). We'd like to be able to scale out and be able to see more of the region, even though it's "out of bounds" so to speak.
We can do this by telling Display2D to turn off its clip. In the Tutorial5WithUI.java file, change:
FROM... |
display = new Display2D(600,600,this,1); |
CHANGE TO |
display = new Display2D(600,600,this,1); // turn off clipping display.setClipping(false); |
Recompile the Tutorial5WithUI.java file, and re-run the application. Scale out and enjoy.