Scripting Sample: Convex Hull

On my AE expression blog, I broke down an expression that creates a dynamic minimum wrapping shape around a set of ‘point’ layers.

I mention in that post the use of a script to populate an array (or rather, the expression string that defines an array), and auto-generate a layer with a shape that has the final expression on a path shape property.

The creation of this script is a surprisingly basic task:

  • Loop through the selected layers.
  • For each layer, create a “point” variable string using the current layer’s name and index of the loop– and stick it to the end of all of the “point” strings of the layers that came before.
  • At the end of the loop, append the beginning and end of the convex hull expression (stored as escaped strings) before and after the ‘points’ array list string, respectively.
  • Create a Shape Layer, add a path, set the path’s expression to the compiled convex hull expression string.
// to use: Select a bunch of layers and then run the script.
{
  var preString = "\/\/ This is a messy way to calculate a minimum enclosing shape given a set of 2D points. Just replace the \"points[0]=.... ;\" section with your own selection of points.\r\n\r\n\/\/ The expression uses a Graham scan to find the points on the outer edge of the shape-- utilizing a cross product to find the point with the greatest \"left-turn\" for the next vertex on the hull.\r\n\r\n\/\/ It\'s meant to go on a path shape-- either a mask path or a Shape Layer path.\r\nfunction indexOfMin(arr) { \/\/ find index of point that has the lowest x-position.\r\n\tif (arr.length === 0) {\r\n\t\treturn -1;\r\n\t}\r\n\tvar curMin = arr[0][0];\r\n\tvar minIndex = 0;\r\n\r\n\tfor (var i = 1; i < arr.length; i++) {\r\n\t\tif (arr[i][0] < curMin) {\r\n\t\t\tminIndex = i;\r\n\t\t\tcurMin = arr[i][0];\r\n\t\t}\r\n\t}\r\n\treturn minIndex;\r\n}\r\n\r\n\/\/ create the necessary things\r\npoints = [];\r\nt = [];\r\nhull = [];\r\npInd = [];\r\nhullInd = 0;\r\n\n";

  var postString = "\n\/\/ simple modulo to make sure our indices stay in-range of the length of the points array\r\n\r\nfunction cInd(_i) {\r\n\treturn _i % points.length;\r\n}\r\n\r\n\/\/ find left-most point\r\n\r\ni1 = indexOfMin(points);\r\n\r\n\r\n\/\/ add leftMost to hullArray\r\naddToHull(i1);\r\n\r\n\/\/ tentative winner\r\ncwInd = cInd(i1 + 1);\r\n\r\n\/\/ loop through every point to find the actual winner\r\nfor (var j = 1; j < points.length; j++) {\r\n\r\n\tcheckInd = cInd(pInd[hullInd - 1] + j); \/\/ the contender\r\n\r\n\tv1 = sub(hull[hullInd - 1], points[cwInd]); \/\/ vector from the last point on the hull to the tentative winner\r\n\tv2 = sub(hull[hullInd - 1], points[checkInd]); \/\/ vector from the last point on the hull to the contender\r\n\tif (cross(v1, v2)[2] < 0) { \/\/ if v2 is counter-clockwise to v1, v2 is the new winner\r\n\t\tcwInd = checkInd;\r\n\t}\r\n\r\n\t\/\/ when we reach the end of the loop, add the winner to the hull\r\n\r\n\tif (j == points.length - 1) {\r\n\t\treached = addToHull(cwInd);\r\n\t\tcwInd = cInd(cwInd + 1);\r\n\r\n\r\n\t\t\/\/ reset loop until the winning hull point is the left-most point again\r\n\t\tif (!reached) {\r\n\t\t\tj = 1;\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunction addToHull(_pInd) {\r\n\ttemp = hullInd;\r\n\tendReached = false;\r\n\r\n\tif (pInd[0] == null || pInd[0] !== _pInd) {\r\n\t\thull[hullInd] = points[_pInd]; \/\/ add winning point to hull\r\n\t\tpInd[hullInd] = _pInd; \/\/ store index of this point\'s index \r\n\t\tt[hullInd] = [0, 0]; \/\/ add point tangents\r\n\t\thullInd++;\r\n\t} else {\r\n\t\tendReached = true;\r\n\t}\r\n\treturn endReached;\r\n}\r\n\r\ncreatePath(hull, t, t, true);";

  var comp = app.project.activeItem;
  var l = comp.selectedLayers;
  var pointsString = "";

  for (var i = 0; i < l.length; i++){
    pointsString += "points[" + i.toString() + "] = fromCompToSurface(thisComp.layer(\"" + l[i].name + "\").toComp(thisComp.layer(\"" + l[i].name + "\").anchorPoint));\r\n";
  }


  var newShape = comp.layers.addShape();
  newShape.name = "fastShape_" + l[0].name;
  shapeGroup = newShape.property("ADBE Root Vectors Group");
  shapePathGroup = shapeGroup.addProperty("ADBE Vector Shape - Group"); // add a path
  shapePath = shapePathGroup.property("ADBE Vector Shape");
  shapeStroke = shapeGroup.addProperty("ADBE Vector Graphic - Stroke");
  shapePath.expression = preString + pointsString + postString;
  shapePath.expressionEnabled = true;
  
}

Setup

For the uninitiated, the script may look crazier than it is because of how the beginning and end of the convex hull expression are stored. The variables preString and postString are simply containers for the beginning and end of the working convex hull expression– the text of the expression having been copy/pasted into a Javascript string escaper. This ‘escaping’ step is necessary because, without it, our script would think we’re trying to execute the convex hull expression. We need the expression to exist as text/string information for it to be applied correctly to a property in our timeline.

var preString = "\/\/ This is a messy way to calculate a minimum enclosing shape given a set of 2D points. Just replace the \"points[0]=.... ;\" section with your own selection of points.\r\n\r\n\/\/ The expression uses a Graham scan to find the points on the outer edge of the shape-- utilizing a cross product to find the point with the greatest \"left-turn\" for the next vertex on the hull.\r\n\r\n\/\/ It\'s meant to go on a path shape-- either a mask path or a Shape Layer path.\r\nfunction indexOfMin(arr) { \/\/ find index of point that has the lowest x-position.\r\n\tif (arr.length === 0) {\r\n\t\treturn -1;\r\n\t}\r\n\tvar curMin = arr[0][0];\r\n\tvar minIndex = 0;\r\n\r\n\tfor (var i = 1; i < arr.length; i++) {\r\n\t\tif (arr[i][0] < curMin) {\r\n\t\t\tminIndex = i;\r\n\t\t\tcurMin = arr[i][0];\r\n\t\t}\r\n\t}\r\n\treturn minIndex;\r\n}\r\n\r\n\/\/ create the necessary things\r\npoints = [];\r\nt = [];\r\nhull = [];\r\npInd = [];\r\nhullInd = 0;\r\n\n";

  var postString = "\n\/\/ simple modulo to make sure our indices stay in-range of the length of the points array\r\n\r\nfunction cInd(_i) {\r\n\treturn _i % points.length;\r\n}\r\n\r\n\/\/ find left-most point\r\n\r\ni1 = indexOfMin(points);\r\n\r\n\r\n\/\/ add leftMost to hullArray\r\naddToHull(i1);\r\n\r\n\/\/ tentative winner\r\ncwInd = cInd(i1 + 1);\r\n\r\n\/\/ loop through every point to find the actual winner\r\nfor (var j = 1; j < points.length; j++) {\r\n\r\n\tcheckInd = cInd(pInd[hullInd - 1] + j); \/\/ the contender\r\n\r\n\tv1 = sub(hull[hullInd - 1], points[cwInd]); \/\/ vector from the last point on the hull to the tentative winner\r\n\tv2 = sub(hull[hullInd - 1], points[checkInd]); \/\/ vector from the last point on the hull to the contender\r\n\tif (cross(v1, v2)[2] < 0) { \/\/ if v2 is counter-clockwise to v1, v2 is the new winner\r\n\t\tcwInd = checkInd;\r\n\t}\r\n\r\n\t\/\/ when we reach the end of the loop, add the winner to the hull\r\n\r\n\tif (j == points.length - 1) {\r\n\t\treached = addToHull(cwInd);\r\n\t\tcwInd = cInd(cwInd + 1);\r\n\r\n\r\n\t\t\/\/ reset loop until the winning hull point is the left-most point again\r\n\t\tif (!reached) {\r\n\t\t\tj = 1;\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunction addToHull(_pInd) {\r\n\ttemp = hullInd;\r\n\tendReached = false;\r\n\r\n\tif (pInd[0] == null || pInd[0] !== _pInd) {\r\n\t\thull[hullInd] = points[_pInd]; \/\/ add winning point to hull\r\n\t\tpInd[hullInd] = _pInd; \/\/ store index of this point\'s index \r\n\t\tt[hullInd] = [0, 0]; \/\/ add point tangents\r\n\t\thullInd++;\r\n\t} else {\r\n\t\tendReached = true;\r\n\t}\r\n\treturn endReached;\r\n}\r\n\r\ncreatePath(hull, t, t, true);";

When the script has been executed correctly, those strings will be translated into working expression code with the same line breaks, indentations, and comments as my original expression…. as if I typed it into the expression editor myself.

The Layer Loop

This step is kind of a prerequisite when it comes to writing scripts with layer automation as the goal. Basically, we look at the current open timeline/composition (app.project.activeItem), and use a for loop to access all of the selected layers (using the app.project.activeItem.selectedLayers object).

A basic selectedLayers loop looks something like this:

{
var sL = app.project.activeItem.selectedLayers;

for (var i = 0; i < sL.length; i++){
// Do something useful
sL[i].selected = true; // not useful
}
}

Yes. You read that right. This is a truly useless script. It literally accomplishes nothing. (For each selected layer, select the layer.) It’s valid and complete, though. It will run.

In the convex hull script, our selected layer loop populates a string using the index of the loop (the current value of i) and the current layer’s name. (Yes… your layers will all need unique names for the expression to run without unexpected results.)

for (var i = 0; i < l.length; i++){
    pointsString += "points[" + i.toString() + "] = fromCompToSurface(thisComp.layer(\"" + l[i].name + "\").toComp(thisComp.layer(\"" + l[i].name + "\").anchorPoint));\r\n";
  }

This loop only executes one line of code, but it’s accomplishing a lot. Not only are we generating the string for the current “points” array variable, we’re also appending it to the cumulative result of the strings generated by all of the previous layers in the loop. (String concatenation, if you want the technical term.)

i.toString() and l[i].name are dynamic and changing over the course of the script execution. They’re inserted into what is effectively a template string. They’re the only parts of those particular lines of expression code that vary.

Let’s look at an example. If we have three selected layers– let’s call them NullA, NullB, and NullC (selected in that order)– the resulting (escaped) string that’s generated at the beginning of the loop (when i is equal to 0) (… and, by extension, what pointsString is equal to) is this:

"points[0] = fromCompToSurface(thisComp.layer(\"NullA\").toComp(thisComp.layer(\"NullA\").anchorPoint));\r\n"

The next step in the loop (when i is equal to 1), pointsString will be equal to this:

"points[0] = fromCompToSurface(thisComp.layer(\"NullA\").toComp(thisComp.layer(\"NullA\").anchorPoint));\r\npoints[1] = fromCompToSurface(thisComp.layer(\"NullB\").toComp(thisComp.layer(\"NullB\").anchorPoint));\r\n"

and when i is equal to 2:

"points[0] = fromCompToSurface(thisComp.layer(\"NullA\").toComp(thisComp.layer(\"NullA\").anchorPoint));\r\npoints[1] = fromCompToSurface(thisComp.layer(\"NullB\").toComp(thisComp.layer(\"NullB\").anchorPoint));\r\npoints[2] = fromCompToSurface(thisComp.layer(\"NullC\").toComp(thisComp.layer(\"NullC\").anchorPoint));\r\n"

It’s difficult to see through the formatting in that string because of the escaped quotes and the line-break/carriage return characters, but when the expression gets applied to the path property, this is how that string will read in the expression editor:

points[0] = fromCompToSurface(thisComp.layer("NullA").toComp(thisComp.layer("NullA").anchorPoint));
points[1] = fromCompToSurface(thisComp.layer("NullB").toComp(thisComp.layer("NullB").anchorPoint));
points[2] = fromCompToSurface(thisComp.layer("NullC").toComp(thisComp.layer("NullC").anchorPoint));

Shape Generation

I won’t go into too much detail about this step, but I have a very basic “add new Shape Layer with a path and a stroke” operation near the end of my script, so there’s a path property to dump my expression into.

var newShape = comp.layers.addShape();
newShape.name = "fastShape_" + l[0].name;
shapeGroup = newShape.property("ADBE Root Vectors Group");
shapePathGroup = shapeGroup.addProperty("ADBE Vector Shape - Group"); // add a path
shapePath = shapePathGroup.property("ADBE Vector Shape");
shapeStroke = shapeGroup.addProperty("ADBE Vector Graphic - Stroke");

Once we have that new Shape Layer, we can stick the beginning, middle, and end expression strings together and apply the result to the path shape property as an expression:

shapePath.expression = preString + pointsString + postString;
shapePath.expressionEnabled = true;

As a side note: For my use case, generating a new layer and path made the most sense for my particular workflow, but you could potentially write the resulting expression string to a text file and copy/paste the expression from there. You could also save a step and put the string in the clipboard automatically.

Wrapping up

I hope this example serves as a simple entry into scripting for expressions. Managing long strings (or long lists of variables based on layers, in this case) can be a struggle without the help of a robust text editor with multiple cursors or snippets that have cursor placement built in. Scripting can lighten the load in a big way because of how easily/intuitively JavaScript can manipulate text strings.

When Adobe added the createPath expression, it opened up a new avenue for expressions that have (potentially) lots of layer references. Looking at lots of layers and doing something in relation to those layers is what makes scripting, I think, a great addition to any After Effects artist’s tool belt. (Another side note: I plan to explore some cleaner, more streamlined alternatives to Adobe’s built-in Points Follow Nulls button within the Create Nulls from Paths window… eventually.)

Published by thatsmadden

I'm an animation artist with an interest in code-driven motion.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Create your website with WordPress.com
Get started
%d bloggers like this: