Home Labs Galleries PetitOn

Using Sandy 3D Flash Library

Part 8, Using Lights and Filters

Earlier in this series we have seen how to create primitives, like the Box and the Sphere. We have played with transforms and interpolators, and we have dressed our 3D objects in different skins. This time we will investigate how we can use light and color filters, to tune the appearance of skins and textures. We'll start with the light.

You can download resources for this tutorial here.

The light of a Sandy world

To get a grasp on the properties and limitations of light in a Sandy world, you may want to read "Understanding lights in Sandy" by Kiroukou, the author of Sandy.

In short, there is only one light source in a Sandy world. It is placed at infinity, which means parallel light rays. The direction and intensity of the light may be varied programmatically. The light is always white and the 3D bodies cast no shadows, i.e. Sandy doesn't have ray tracing.

The World3D has a default light parallel to the z axis and with an intensity of 50.
This light is not on by default, but is activated on a per skin basis. For a skin to reflect the light, we must call its setLightingEnable() function passing a true value.

skin.setLightingEnable( true );

We have already used the default light, for example in Primitives section. Looking at the Pyramid example, we can clearly see that the light is directed along the positive x axis.

Tuning the light

The light source in Sandy can be installed by creating a new Light3D instance and passing it to the setLight() function of the World3D.

var direction = new Vector(0,0,1);
var intensity = 50;
var light:Light3D = new Light3D( direction, intensity );
world.setLight( light );

We can always access the current light using the getLight() function of the world.
To change the direction of the light we call its setDirection() function, passing a vector. The vector must be normalized, i.e. the length must be 1.
To change the intensity we call the setPower() function passing a value between 0 and MAX_POWER = 150.

Let's create a demo where the light intensity can be changed using a slider, and the light direction is set by clicking on the stage.

function createScene( bg:Group ):Void {
	var cube:Object3D = new Box( 50, 50, 50, 'quad' );
	skin = new MixedSkin( 0xF28F35, 100, 0, 0, 0 );
	// Enable the default light source on the skin
	skin.setLightingEnable( true );
	cube.setSkin( skin );
	bg.addChild( cube );

We have to enable the light for this skin, passing a true value to setLightingEnable().

In the init() function we give the camera an offset, to get a nice view of the cube, and we get the default light source by calling getLight() on the world. We don't have to create and set a new Light3D instance.

	cam = new Camera3D( 800, screen );
	cam.setPosition( 150, 200, -500 );
	cam.lookAt( 0, 0, 0 ); // Look at the origin
	world.addCamera( cam );
	light = world.getLight();

The light reference variable is global, so we can access it from event handlers.
In the setUpControls() function we set up the listeners and event handlers as usual.

function setUpControls(){
	// Light direction control
	onMouseDown = function() {
		if( _ymouse > 170 ) return; // mask out the control pad
		var mouseX = _xmouse - 100;
		var mouseY = _ymouse - 100;
		z = 50;
		var v = new Vector( mouseX, mouseY, z );
		var norm = VectorMath.getNorm( v );
 		light.setDirection(-mouseX/norm, -mouseY/norm, z/norm);

	// Light on/off button
	lightButton.onRelease = onOff;

	// Light power slider control
	lightText.text = lightSlider.value;
	var lightCh:Object = new Object();
	lightCh.onChange = setIntensity;

The onMouseDown handler catches the mouse position and creates a Vector, where the x and y values are the distance from the clicked point to the center of the screen, and the z value is fixed. The values we pass to the setDirection() function must be components of a normalized Vector, so we use the utility function VectorMath.getNorm() get the vector length. I we divide the x, y and z components with the norm, we have a normalized direction vector.

The strange cam.rotateY(0) is a work around to force the world to be recalculated, and the change to show up.

We also add a button to turn the light on and off for this skin. Here is the handler.

function onOff(){
	lightOn = ! lightOn;
	lightButton.setLabel ( lightOn ? "Off" : "On" );
	skin.setLightingEnable( lightOn );

The alphaSlider event handler is the setIntensity() function.

function setIntensity(){
	lightText.text = lightSlider.value;
	light.setPower( lightSlider.value );

We simply call the setPower() function with the slider value as argument. The slider, of course is set to give values within the allowed interval 0 - 150.

Now let's play ;)

For the textured version we create the skin from a MovieClip with an image in the library.
Everything else is the same.

imageholder._alpha = 000; // hide image on the Stage
var texture:BitmapData = new BitmapData( imageholder._width, imageholder._height);
texture.draw( this.imageholder );
skin = new TextureSkin( texture );

A transparent skin

Sometimes it is interesting to use transparent skins on our objects. You may want to create a glass box, or maybe a window on your bungalow. There are a few ways to accomplish this. The very simplest is to use the alpha value of the MixedSkin.

We have seen this skin earlier in this series, but without closer examination, so we better do that now. The MixedSkin draws an outline of each face and fills it with color.
Here is the constructor signature.

public function MixedSkin( cb:Number, ab:Number, cl:Number, al:Number, tl:Number )

The parameters are

cb	The fill color for the face
ab	The alpha value ( 0 - 100 ) for the fill color
cl	The line color for the edges
al	The alpha value for the edges
tl	The line thickness for the edges

If you have used the drawing functions of MovieClip, you'll probably recognize the parameters as those of the lineStyle( ) and beginFill( ) methods, which is exactly what the MixedSkin uses behind the scene.

To facilitate changes to these values, the MixedSkin class exposes corresponding properties, with setters and getters. Here we'll only use the alphaBkg property, which handles the fill color transparency. All the other properties can be changed dynamically as well, but I don't want to deprive you, my dear reader, the pleasure of discovery.

Click and drag to rotate

Here is the method that creates the scene graph

function createScene():Group {
	var bg:Group = new Group();
	// Create a Box
	var cube:Object3D = new Box(50, 50, 50, 'quad');
	skin = new MixedSkin(0x00FF00, alpha, 1, 10, 1);
	cube.enableBackFaceCulling = false;

	// Create a Pyramid inside the Box
	var pyramid:Object3D = new Pyramid( 60, 30, 30, 'quad');
	var skin2:Skin = new MixedSkin(0xFD6602, 100, 1, 10, 1);
	pyramide.setSkin( skin2 );

	// Transforms
	var trans:Transform3D = new Transform3D();
	trans.translate(0, -15, 0);
	tg2.setTransform( trans );
	tg2.addChild( pyramide );
	tg1.addChild( cube );
	tg1.addChild( tg2 );
	bg.addChild( tg1 );
	return bg;

The pyramid is created inside the cube, and translated slightly downwards to position it completely inside the cube. As we want to change the alpha value of the cube, we use a global variable 'alpha' for the skin. The tg1 and tg2 are globally defined TransformGroups.

Note: Normally the reference variable skin could be of type Skin, the super class of MixedSkin, as it is for skin2 used for the pyramid. If we want to access the properties of the skin, it must be declared as MixedSkin, as they are specific to this skin type.

Now we want the slider to change the alpha value within the allowed interval 0 - 100, so we need an event handler. We also want the user to be able to rotate the cube, by moving the mouse. Here are the event handlers.

function setUpControls(){
	// Mouse controls
	onMouseDown = function() {
		mousedown = true;
		mouseX = _xmouse;
		mouseY = _ymouse;
	onMouseUp = function() {
		mousedown = false;
		mouseX = _xmouse;
		mouseY = _ymouse;

	// Alpha slider control
	var alphaCh:Object = new Object();
	alphaText.text = alpha;
	alphaCh.onChange = function(evt:Object){
		alpha = evt.value;
		alphaText.text = alpha;
		skin.alphaBkg = alpha;

For the alphaSlider we create a listener object alphaCh, reacting on the onChange event. The onChange handler updates a text field with its value, and changes the alphaBkg property of the skin. This update is immediately visible.

We subscribe to the Mouse events onMouseDown and onMouseUp, and the event handlers updates the global variables mouseX and mouseY with the current mouse position. When the mouse is pressed, a Boolean mousedown is set to true, and when it is reseased it is set to false, indicating whether the cube should be rotated or not.

Nothing seems to happen here, but the actual rotation is taken care of by an onRenderEVENT handler. In the init() function we add a listener to the rendering engine, as we have done before.

world.addEventListener(World3D.onRenderEVENT, this, rotate);

The rotate() function will be called for each frame.

function rotate() {
   if ( mousedown && mouseY < 180 ) {
	x += ( _ymouse - mouseY )/10;
	y += ( mouseX -_xmouse )/10;
  rotation.rot( x, y, z );
  tg1.setTransform( rotation );

Here we see that if the mouse is pressed ( mousedown true ), the difference between the current mouse position values and the position where the mouse was pressed is added to current x and y values, with a scale factor. The values are used in a globally defined Transform3D, named rotation, and set as the transform of the tg1 TransformGroup.

The result is, that we get a rotation with a rotational axis defined by the mouse position, and a rotation speed proportional to the distance we drag the mouse.

Using the ColorTransform

Beautiful as it may be, the MixedSkin has its limitations, and oftentimes we want to use textures. If we want to affect colors or transparency of a TextureSkin, we cannot rely on color and alpha parameters. Instead we have to use a ColorTransform from the flash.geom package, or a ColorMatrix filter. Let's look at the ColorTransform first!

Flash BitmapData can be more or less transparent, using its alpha channel. This is the most reliable way to get transparent textures. Flash help gives this example:

myBitmapData.colorTransform( myBitmapData.rectangle, 
			new ColorTransform(1, 0, 0, 1, 255, 0, 0, 0));

The parameters are from left to right

redMultiplier, greenMultiplier, blueMultiplier, alphaMultiplier
redOffset, greenOffset, blueOffset, alphaOffset

When applied to a bitmap, the new color or alpha value is calculated for each pixel by multiplying the old value with the multiplier, and adding the offset value.

New color = colorMultiplier * old color + colorOffset

If any value after calculation is greater than 255, it is set to 255.

var colTrans = new ColorTransform(1,1,1,1,0,0,0,-155);
skin.texture.colorTransform( skin.texture.rectangle, colTrans );

We keep the click and drag interactivity, but add an event handler, that reacts to the up and down arrows on the keyboard, for moving the camera backwards and forwards.

Here is the new createScene() function.

function createScene(Void):Group{
	var g:Group = new Group();
	// Set transparent to hide the imageholder on the Stage
	imageholder._alpha = 000;

	texture = new BitmapData( imageholder._width, imageholder._height);
	texture.draw( this.imageholder );
	skin = new TextureSkin( texture );

	// Create the Box		
	box = new Box(80,80,80,'tri',2);
	box.setSkin( skin );
	box.setBackSkin( skin );
	// Transforms
	return g;

In the library we have a MovieClip with the id "image", containing a bitmap. We attach it to the Stage with the name "imageholder".

Now we have to convert the clip into a BitmapData object, so we create one with the same dimensions as the imageholder, and draw the imageholder into it. Finally we use this BitmapData as the texture of the the skin. The texture and skin variables are declared globally, because we need to reach them from other functions.

We create and skin the box, and add it to a globally defined TransformGroup tg. Now we are ready to run.

We want to change the color transform for the skin's texture, so we'll have to change the event handler for the alphaSlider. We may also add button, for toggling the back face culling on and off.

function setUpControls(){

	// Mouse controls as before here

	// Alpha slider control
	alphaText.text = alphaSlider.value;
	var alphaCh:Object = new Object();
	alphaCh.onChange = setAlpha;
	alphaSlider.addListener( alphaCh );

	// Button to switch back culling on and off
	cullingButton.onRelease = switchBackCulling;

When the user drags the slider head, the setAlpha() handler is invoked.

function setAlpha( evt ){
	var alphaDiff:Number = evt.value;
	alphaText.text = alphaDiff;

	var colTrans:ColorTransform = new ColorTransform( 1,1,1,1,0,0,0, alphaDiff );
	var textureClone:BitmapData = texture.clone();
	textureClone.colorTransform( texture.rectangle, colTrans );

	skin.texture = textureClone;		

We create a new ColorTransform where multiplier values equals 1, and all color offset values equals 0, except for the alphaOffset. When we apply this color transform to a bitmap it will keep all its color values, while the alpha value will change with the amount alphaDiff, which is the slider value.

It may seem strange at first, that we apply the transform to a clone of the texture, and not directly to the skin.texture itself. But what will happen if we do?

Well, the first time we apply a transform, we will get the expected effect, but after that we will get side effects. There are other properties of the bitmap, such as hue and saturation, which are effected when the transform is applied. The next time we apply a transform to the same bitmap, the already transformed bitmap is used as basis for new calculations. Changing the alpha values up and down, will change the contrast of the image, more for each transformation.

That's why we apply the ColorTransform to a fresh copy of the globally defined texture, and set it as the new skin texture.

If you want to play with the transparency of a MovieSkin or VideoSkin, it's a little more complicated, but fear not!

For the TextureSkin the texture is saved as a BitmapData only once, but for the moving skins, a snapshot must be taken for each frame of the MovieClip or video. Here the ColorMatrixFilter is comes to rescue.

Using the ColorMatrixFilter

The ColorMatrixFilter introduced with Flash Player 8, is a very flexible tool. It allows you to apply RBGA color and alpha transformations to every pixel in a bitmap or MovieClip.
For a beautiful example of what can be done with this filter, have a look at Quasimondos colormatrix demo.

The color transform for each color is described by a matrix, passed to the constructor of the ColorMatrixFilter class.

var matrix = new Array();

matrix = matrix.concat([1, 0, 0, 0, 0]); // red
matrix = matrix.concat([0, 1, 0, 0, 0]); // green
matrix = matrix.concat([0, 0, 1, 0, 0]); // blue
matrix = matrix.concat([0, 0, 0, a, 0]); // alpha

The 20 elements of the matrix are used to calculate new color and alpha values in the following manner:

redResult   = a[0]  * srcR + a[1]  * srcG + a[2]  * srcB + a[3]  * srcA + a[4]
greenResult = a[5]  * srcR + a[6]  * srcG + a[7]  * srcB + a[8]  * srcA + a[9]
blueResult  = a[10] * srcR + a[11] * srcG + a[12] * srcB + a[13] * srcA + a[14]
alphaResult = a[15] * srcR + a[16] * srcG + a[17] * srcB + a[18] * srcA + a[19]

If you only want to affect the alpha value, you can use the simplified matrix in the example above.

Before we use the filter on our skins, let's just take a brief look at how the filter works! When we set the filter on a MovieClip, the 'cache as bitmap' is automatically used.

When the filter is applied to a bitmap, a new bitmap is calculated.
The original is not changed.

Tuning colors and transparency

To use filters we have to import flash.filters.*.
To change RGBA values we use four different sliders. Then we set up the Stage.

function init(){

	// *** Create the world and camera here as usual ***

	filter = createFilter();
	rose.filters = new Array( filter );	

We attach the "rose" MovieClip from the library to the Stage ( or main time line, if you prefer ). Then we create the filter and set the filters property of the MovieClip. The filters property is an Array, which means it may take more than one filter. We also need to set up event handlers for the sliders.

Here is the code for the red slider event handler - all the others look the same.

function setUpControls(){
	var redCh:Object = new Object();
	redText.text = red;
	redCh.onChange = function(evt:Object){
		red = evt.value;
		redText.text = red;

	// *** The other handlers go here ***

Every onChange handler gets its value from the slider and calls the setFilter() function.

function setFilter(){
	rose.filters = new Array(createFilter());

The function createFilter() does the hard job.

function createFilter():BitmapFilter{
	var r:Number = red;
	var g:Number = green;
	var b:Number = blue;
	var a:Number = alpha;

	// Create the color matrix
	var matrix = new Array();
	matrix = matrix.concat([r, 0, 0, 0, 0]); // red channel
	matrix = matrix.concat([0, g, 0, 0, 0]); // green channel
	matrix = matrix.concat([0, 0, b, 0, 0]); // blue channel
	matrix = matrix.concat([0, 0, 0, a, 0]); // alpha channel

	// Create the color matrix filter
	var filter:BitmapFilter = new ColorMatrixFilter( matrix );
	return filter;

The red, green, blue and alpha variables are global, to keep their current values. For every change, a new matrix and a new filter is created.

My mothers Piece at Fridhem, Borgholm.



Here we are only changing the red component of the red channel, the green component of the green channel, and the blue component of the blue channel, affecting only the diagonal elements. Obviously much more can be done.

Color matrix on a TextureSkin

Filters are very versatile, and Kiroukou has cleverly introduced a "filters" property in BasicSkin, which is inherited by TextureSkin and other skins.

Now that we know how to use the ColorMatrixFilter, let's replicate the rotating transparent box, using the filter this time.

When we create the box, we set the TextureSkin as before

function createScene(Void):Group{
	var g:Group = new Group();
	skin = new TextureSkin(BitmapData.loadBitmap("monalisafit"));
	box = new Box(80,80,80,'tri');
	box.setSkin( skin );
	box.setBackSkin( skin );
	// Transforms
	var tg:TransformGroup = new TransformGroup();
	rotation = new Transform3D;
	return g;

We don't have to set any filter at this point, as this will be handled by the alpha slider event handler setAlpha().

function setAlpha( evt ){
	alpha = evt.value;
	alphaText.text = alpha;	// values between 0 and 1
	skin.filters = [createFilter()];		

Here we set the filters property of the skin. This property is an Array, which means we can set more than one filter on a skin. We use the same createFilter() function as for the beautiful rose above.

Color matrix on MovieSkin and VideoSkin

MovieSkin and VideoSkin inherits from TextureSkin, so it should be possible to use the ColorMatrixFilter on those skins as well.

Here is the createScene() function for the box with a MovieSkin.

function createScene(Void):Group{
	var g:Group = new Group();
	skin = new MovieSkin( runner );
	box = new Box(100,100,100,'tri');
	box.setSkin( skin );
	box.setBackSkin( skin );	
	return g;

No surprise there I'm sure. The 'runner' passed to the MovieSkin constructor, is a MovieClip on Stage, but out of sight.

Here is the corresponding createScene() function for the box with a VideoSkin.

function createScene(Void):Group{
	var g:Group = new Group();
	skin = new VideoSkin( video );
	box = new Box(100,100,100,'tri');
	box.setSkin( skin );
	box.setBackSkin( skin );	
	return g;

Of course, we have to get the video, which is an FLV file external to the SWF. As we did in the Faces and Skins tutorial, we use a NetStream for progressive download from the web server. Here is the getVideo() function.

function getVideo(){
	nc = new NetConnection();
	nc.connect(null);// Create a local streaming connection
	ns = new NetStream(nc);
	video.attachVideo(ns);//Attach NetStream video feed to Video object (on stage)
	video._alpha = 000;//to hide original (is this the best method?)


Blurred objects

Another BitmapFilter, which may be useful in a 3D scene, is the BlurFilter. It does just what the name suggests, it renders a movie clip or bitmap less sharp or blurred.

In a Sandy world this could be used to bring in a bit of fog or haze, to enhance the 3D feeling in larger outdoor scenes. In great landscapes far away objects are often perceived as blurred and with faint colors. This is due to pollution or natural water vapor in the air.

Another reason for less sharp objects, is the limitations of the eye or the camera lens.
Objects only appear to be sharp, if they are within the camera's depth of field. If they come too close or too far away, they become blurred. We can mimic this optical property using the BlurFilter.

The BlurFilter constructor takes three arguments, blurX, blurY and quality.
The parameters blurX and blurY determines the strength of the blur in the x and y directions respectively. The quality value is the number of times the blur operation should be applied. A value of 3 gives a near Gaussian blur.

Let's go to work!

We will create a scene, with some simple objects at different distances from the camera. Then we will apply blur to the objects, depending on how far they are from the camera.

As usual, we create the scenery in the createScene() function

First a ground plane for reference

function createScene( bg:Group ):Void {
	bg.addChild( plane = new Plane3D( 2000, 1500, 10, 'quad'));
	plane.setSkin( new MixedSkin( 0xF7FBAE, 80, 0, 100, 1 ));

Then some 3D objects

	var cube:Object3D;
	var skin:MixedSkin;
	var blurFilter:BlurFilter;
	var tg: TransformGroup;
	var transform:Transform3D;

	for( i = 0; i < NUM_OBJS; i++ ){ 
		var cube:Object3D = new Box( 50, 50, 50, 'quad' );

		// save global references to the objects
		skin = new MixedSkin( 0xF2B7EE, 100, 0, 100, 1 );
		cube.setSkin( skin );

		// Create a blur filter
		blurFilter = new BlurFilter(0,0,3);
		skin.filters = new Array(blurFilter);

		// distribute the 3D objects
		transform = new Transform3D();
		tg = new TransformGroup(transform);
		transform.translate( 40*i - 200, 50, 100*i );
		tg.addChild( cube );
		bg.addChild( tg );

Here we create number of cubes, and save references to them in an objects Array, so we can access them later.
We paint each cube with a MixedSkin. Notice that every cube has its own skin, because we want to be able to change them independently.

For each skin we create a near Gaussian blur filter with no blur, and set it as the first filter in the skin's filters array ( filters[0] ).

Finally we translate each cube to get them spread out in the landscape.

Now, let's apply the blur by setting the blurX and blurY values depending on the distance from the camera to the object.

Both the Camera3D and the Object3D has a getPosition() function, returning its position as a Vector. By subtracting the camera position from the object position, we get the Vector from the camera to the object. The length of that Vector, of course, is the distance. Here is the function for calculating and setting the blur for all objects.

function setBlur(  ){
	var cubePos:Vector;
	var skin:MixedSkin;
	var object:Object3D;
	var filterList:Array;
	var blur:Number;

	var camPos:Vector  = cam.getPosition();

	for ( i = 0; i < objects.length; i++ ){
		object = objects[i];

		// Calculate distance and blur
		cubePos = object.getPosition();
		distance = VectorMath.getNorm( VectorMath.sub( cubePos, camPos ));
		blur = Math.abs(distance - 600)/200;

		// get the filter list from the object
		skin = MixedSkin (object.getSkin());
		filterList = skin.filters;

		// Set new blur values
		filterList[0].blurX = filterList[0].blurY = blur;
		skin.filters = filterList;

First we get the camera position, and then for each Object3D in the objects Array, we get its position. We then calculate the distance using VectorMath utility functions. The sub() function subtracts the second Vector from the first, and the norm() function gives us the length of the Vector.

From the distance value we calculate a blur value, such that we have no blur when the distance from the camera is 600. Some testing to get a nice amount of blur, gave a factor 1/200 for the blur values. As you will see in a moment, using the absolute value of distance - 600, leads to blur not only for distant objects, but also for objects very near the camera.

We can get a reference to the skin directly form the object, but the getskin() function return a reference of type Skin, so we have to Typecast it to MixedSkin, to access its filters property.

We get a copy of the filters Array, and we set new blur values on the BlurFilter, which is the first filter in the array. Then we set the filters property of the skin again.

Note that we cannot change the properties directly on the skins filters property, so we have to get a copy first, change that copy and set it with the new values.

Note: In Sandy 1.1, there is a bug in the Object3D getPosition() function. To make this example work, you should replace the getPosition() function in the Object3D.as with the one found in the Sandy 1.2 version of Object3D.as. You'll find Object3D.as in the core directory of the Sandy library.

For your convenience, here is the bug fixed function.

// Pasted from sandy.core.Object3D version 1.2 
public function getPosition( Void ):Vector {
    var v:Vertex = aPoints[0];
    return new Vector( v.tx - v.x, v.ty - v.y, v.tz - v.z );

Move the camera using the arrow keys on your keyboard.

To move the camera around, we check the arrow keys on the keyboard for each frame. The event handler for the onRenderEvent this time is cameraMove().

function cameraMove(){
	if (Key.isDown (Key.UP))	{cam.moveForward(20); setBlur(  );}
	if (Key.isDown (Key.DOWN))	{cam.moveForward(-20); setBlur(  );}
	if (Key.isDown (Key.LEFT))	{cam.pan(-3); setBlur(  );}
	if (Key.isDown (Key.RIGHT))	{cam.pan(3); setBlur(  );}

	// Set the initial blur values
	if (firstFrame){firstFrame=false; setBlur();}	

If one of the arrow keys is pressed, the camera is moved forwards or backwards, or rotated around its local y axis. Then the setBlur() function is called, to calculate and set the blur values for the new camera position.

Note: To set the initial blur values, one might expect, that we could just call the setBlur() function, as soon as the scene is created. Surprisingly we'll get no blur at all, so all objects will look sharp, until we move the camera. A trace of the object positions, reveals that they are all at (0, 0, 0) until the first rendering operation is done.

To solve the problem, we have to call the setBlur() function once for the first frame.
A Boolean firstFrame set to true, flags that we are on the first frame.
At the first onRenderEVENT, when the cameraMove() is called, it calls the setBlur() function and and sets firstFrame to false.

That's all for now. Of course you can do a lot more with filters. There are other interesting bitmap filters, for example the GlowFilter. And maybe you can try to combine filters to create your stunning special effects.

I wish you much pleasure, as you play with filter properties!

Stay tuned for more on Using Sandy!