Perspective Transform in Javascript
Copy link to clipboard
Copied
Hej everyone,
I'm working on a script, which should do an automatic perspective correction. Unfortunately the only way I found out until now is some code, generated with the ScriptListener Plugin - and I am having a hard time figuring out what the values / maths are doing. For better understanding what I'm trying to achieve, here is an image:
The yellow colored area is the original image. Green is the image I'm trying to get. I need to do an perspective correction on the top anchor points. Doing it manually it works fine. However, when I look at the script, the scriptListener is generating, I don't have any clue what the values in there are meaning. Perhaps there is some kind of math I don't get. I can't get any match regarding the values by doing it manually, and the ones the listener writes.
Anyone an idea? Is there anywhere an angle or distance of how the anchor points are being moved?
var idTrnf = charIDToTypeID( "Trnf" );
var desc48 = new ActionDescriptor();
var idnull = charIDToTypeID( "null" );
var ref21 = new ActionReference();
var idLyr = charIDToTypeID( "Lyr " );
var idOrdn = charIDToTypeID( "Ordn" );
var idTrgt = charIDToTypeID( "Trgt" );
ref21.putEnumerated( idLyr, idOrdn, idTrgt );
desc48.putReference( idnull, ref21 );
var idFTcs = charIDToTypeID( "FTcs" );
var idQCSt = charIDToTypeID( "QCSt" );
var idQcsa = charIDToTypeID( "Qcsa" );
desc48.putEnumerated( idFTcs, idQCSt, idQcsa );
var idOfst = charIDToTypeID( "Ofst" );
var desc49 = new ActionDescriptor();
var idHrzn = charIDToTypeID( "Hrzn" );
var idPxl = charIDToTypeID( "#Pxl" );
desc49.putUnitDouble( idHrzn, idPxl, -16.066570 );
var idVrtc = charIDToTypeID( "Vrtc" );
var idPxl = charIDToTypeID( "#Pxl" );
desc49.putUnitDouble( idVrtc, idPxl, 16.381048 );
var idOfst = charIDToTypeID( "Ofst" );
desc48.putObject( idOfst, idOfst, desc49 );
var idWdth = charIDToTypeID( "Wdth" );
var idPrc = charIDToTypeID( "#Prc" );
desc48.putUnitDouble( idWdth, idPrc, 134.475806 );
var idSkew = charIDToTypeID( "Skew" );
var desc50 = new ActionDescriptor();
var idHrzn = charIDToTypeID( "Hrzn" );
var idAng = charIDToTypeID( "#Ang" );
desc50.putUnitDouble( idHrzn, idAng, 19.127500 );
var idVrtc = charIDToTypeID( "Vrtc" );
var idAng = charIDToTypeID( "#Ang" );
desc50.putUnitDouble( idVrtc, idAng, 0.000000 );
var idPnt = charIDToTypeID( "Pnt " );
desc48.putObject( idSkew, idPnt, desc50 );
var idUsng = charIDToTypeID( "Usng" );
var desc51 = new ActionDescriptor();
var idHrzn = charIDToTypeID( "Hrzn" );
var idPrc = charIDToTypeID( "#Prc" );
desc51.putUnitDouble( idHrzn, idPrc, -0.000000 );
var idVrtc = charIDToTypeID( "Vrtc" );
var idPrc = charIDToTypeID( "#Prc" );
desc51.putUnitDouble( idVrtc, idPrc, 0.655242 );
var idPnt = charIDToTypeID( "Pnt " );
desc48.putObject( idUsng, idPnt, desc51 );
var idIntr = charIDToTypeID( "Intr" );
var idIntp = charIDToTypeID( "Intp" );
var idbicubicAutomatic = stringIDToTypeID( "bicubicAutomatic" );
desc48.putEnumerated( idIntr, idIntp, idbicubicAutomatic );
executeAction( idTrnf, desc48, DialogModes.NO );
Explore related tutorials & articles
Copy link to clipboard
Copied
Seems a Skew and the Offset of a point are registered.
Some time ago I used elements from stack support.jsx to achieve something similar.
Copy link to clipboard
Copied
I know of this thread. but it didn't gave me any insight.
To make it a little bit easier:
Yellow: Box with 100x100 px dimensions. Doing a perspective transform (manually and listening with scriptListener) of 45°.Top Right Handle. The only values that have changed are die width (from 100% to 300%) and the angle (45°).
Now, this is what the listener gets:
var idTrnf = charIDToTypeID( "Trnf" );
var desc6 = new ActionDescriptor();
var idnull = charIDToTypeID( "null" );
var ref4 = new ActionReference();
var idLyr = charIDToTypeID( "Lyr " );
var idOrdn = charIDToTypeID( "Ordn" );
var idTrgt = charIDToTypeID( "Trgt" );
ref4.putEnumerated( idLyr, idOrdn, idTrgt );
desc6.putReference( idnull, ref4 );
var idFTcs = charIDToTypeID( "FTcs" );
var idQCSt = charIDToTypeID( "QCSt" );
var idQcsa = charIDToTypeID( "Qcsa" );
desc6.putEnumerated( idFTcs, idQCSt, idQcsa );
var idOfst = charIDToTypeID( "Ofst" );
var desc7 = new ActionDescriptor();
var idHrzn = charIDToTypeID( "Hrzn" );
var idPxl = charIDToTypeID( "#Pxl" );
desc7.putUnitDouble( idHrzn, idPxl, 0.000000 );
var idVrtc = charIDToTypeID( "Vrtc" );
var idPxl = charIDToTypeID( "#Pxl" );
desc7.putUnitDouble( idVrtc, idPxl, 25.000000 );
var idOfst = charIDToTypeID( "Ofst" );
desc6.putObject( idOfst, idOfst, desc7 );
var idWdth = charIDToTypeID( "Wdth" );
var idPrc = charIDToTypeID( "#Prc" );
desc6.putUnitDouble( idWdth, idPrc, 150.000000 );
var idUsng = charIDToTypeID( "Usng" );
var desc8 = new ActionDescriptor();
var idHrzn = charIDToTypeID( "Hrzn" );
var idPrc = charIDToTypeID( "#Prc" );
desc8.putUnitDouble( idHrzn, idPrc, -0.000000 );
var idVrtc = charIDToTypeID( "Vrtc" );
var idPrc = charIDToTypeID( "#Prc" );
desc8.putUnitDouble( idVrtc, idPrc, 1.000000 );
var idPnt = charIDToTypeID( "Pnt " );
desc6.putObject( idUsng, idPnt, desc8 );
var idIntr = charIDToTypeID( "Intr" );
var idIntp = charIDToTypeID( "Intp" );
var idbicubicAutomatic = stringIDToTypeID( "bicubicAutomatic" );
desc6.putEnumerated( idIntr, idIntp, idbicubicAutomatic );
executeAction( idTrnf, desc6, DialogModes.NO );
Where are the values, that I've used manually? And how can I use this Script to do something like 20°, for instance?
Copy link to clipboard
Copied
Why do you want to use this particular code when the intended result can be achieved with different code (and more easily it seems to me)?
As far as I can figure it out
• The descriptor with idOfst refers to the center (the intersection of the diagonals). It it is 0 the centre is fixed.
• The descriptor with idWdth to the width at the horizontal intersection at the height of the center. If it is 100 the width at the centre stays fixed.
• The descriptor with idUsng is a bit difficult to interpret for me … starting with a square changing the UnitDouble idVrtc
– -0,025 results in 1,333 times the width and 1,066 times the height
– -0,05 results in 2 times the width and 1,333 the height
– -0,075 in 4 times the original width and 2,2855 times the height
In this example the results for -0,025, -0,05, -0,075 and -0,09 are overlaid:
Now that is a long way off from being useful, but I’m not good at Math anymore and the functions and the trigonometry that would be necessary here seem fairly fancy to me …
Copy link to clipboard
Copied
Thank you for the replay again.
I think I got a little more insight about what is happening. Besides that: I don't want or need to use the code from the action listener. It is plain the only way I came up with so far.
If you got an easier way to do do this, I would be more than happy to hear about.
Copy link to clipboard
Copied
The code I had posted in the other thread uses a four-point Path as the basis of the transformation, but one could just as well define the Array of four points some other way.

Copy link to clipboard
Copied
share the link of your ”four-point Path ” code?
Copy link to clipboard
Copied
That code is pretty old, I think the issue could be resolved more easily now.
// this script attempts to fit the active layer into an area defined by a 4-point-path’s points;
// cs5 on mac;
// 2011, use it at your own risk;
#target photoshop
// from adobe’s terminology.jsx;
const classChannel = app.charIDToTypeID('Chnl');
const classRectangle = app.charIDToTypeID('Rctn');
const enumNone = app.charIDToTypeID('None');
const eventSet = app.charIDToTypeID('setd');
const eventTransform = app.charIDToTypeID('Trnf');
const keySelection = app.charIDToTypeID('fsel');
const krectangleStr = app.stringIDToTypeID("rectangle");
const kquadrilateralStr = app.stringIDToTypeID("quadrilateral");
const keyBottom = app.charIDToTypeID('Btom');
const keyLeft = app.charIDToTypeID('Left');
const keyNull = app.charIDToTypeID('null');
const keyRight = app.charIDToTypeID('Rght');
const keyTo = app.charIDToTypeID('T ');
const keyTop = app.charIDToTypeID('Top ');
const typeOrdinal = app.charIDToTypeID('Ordn');
const unitPixels = app.charIDToTypeID('#Pxl');
// from adobe’s geometry.jsx;
//
// =================================== TPoint ===================================
//
function TPoint( x, y )
{
this.fX = x;
this.fY = y;
}
// TPoint Constants
const kTPointOrigion = new TPoint( 0, 0 );
TPoint.kOrigin = kTPointOrigion;
const kTPointInfinite = new TPoint( Infinity, Infinity );
TPoint.kInfinite = kTPointInfinite;
const kTPointClassname = "TPoint";
TPoint.prototype.className = kTPointClassname;
// Overloaded math operators
TPoint.prototype["=="] = function( Src )
{
return (this.fX == Src.fX) && (this.fY == Src.fY);
}
TPoint.prototype["+"] = function( b )
{
return new TPoint( this.fX + b.fX, this.fY + b.fY );
}
TPoint.prototype["-"] = function( b, reversed )
{
if (typeof(b) == "undefined")
// unary minus
return new TPoint( -this.fX, -this.fY )
else
{
if (reversed)
return new TPoint( b.fX - this.fX, by.fY - this.fY );
else
return new TPoint( this.fX - b.fX, this.fY - b.fY);
}
}
//
// Multiply and divide work with scalars as well as points
//
TPoint.prototype["*"] = function( b )
{
if (typeof(b) == 'number')
return new TPoint( this.fX * b, this.fY * b );
else
return new TPoint( this.fX * b.fX, this.fY * b.fY );
}
TPoint.prototype["/"] = function( b, reversed )
{
if (reversed)
{
if (typeof(b) == "number")
debugger;
// Can't divide a number by a point
else
return new TPoint( b.fX / this.fX, b.fY / this.fY );
}
else
{
if (typeof(b) == 'number')
return new TPoint( this.fX / b, this.fY / b );
else
return new TPoint( this.fX / b.fX, this.fY / b.fY );
}
}
TPoint.prototype.toString = function()
{
return "[" + this.fX.toString() + "," + this.fY.toString() + "]";
}
TPoint.prototype.vectorLength = function()
{
return Math.sqrt( this.fX * this.fX + this.fY * this.fY );
}
////////////////////////////////////
var myDocument = app.activeDocument;
// deselect;
try {
myDocument.selection.deselect();
// =======================================================
var idDslc = charIDToTypeID( "Dslc" );
var desc4 = new ActionDescriptor();
var idnull = charIDToTypeID( "null" );
var ref2 = new ActionReference();
var idPath = charIDToTypeID( "Path" );
ref2.putClass( idPath );
desc4.putReference( idnull, ref2 );
executeAction( idDslc, desc4, DialogModes.NO );
}
catch (e) {};
// switch units to pixels;
app.preferences.rulerUnits = Units.PIXELS;
// verify document has paths;
var thePathList = new Array;
// create list of possible paths;
for (var g = 0; g < myDocument.pathItems.length; g++) {
var checkPath = myDocument.pathItems
if (checkPath.subPathItems.length == 1 && checkPath.subPathItems[0].pathPoints.length == 4) {
thePathList.push(checkPath);
}
};
// get path;
switch (thePathList.length) {
case 0:
break;
case 1:
var aPath = thePathList[0];
break;
default:
var aPath = selectAPath (thePathList);
break;
};
//////////// transformation ////////////
if (aPath) {
try {
// paste the image into the document;
var theScreenImage = myDocument.activeLayer;
//////////// corners ////////////
// get the horicontal and vertical coordinates in pixels;
var hor1 = Number(aPath.subPathItems[0].pathPoints[0].anchor[0]);
var hor2 = Number(aPath.subPathItems[0].pathPoints[1].anchor[0]);
var hor3 = Number(aPath.subPathItems[0].pathPoints[2].anchor[0]);
var hor4 = Number(aPath.subPathItems[0].pathPoints[3].anchor[0]);
var ver1 = Number(aPath.subPathItems[0].pathPoints[0].anchor[1]);
var ver2 = Number(aPath.subPathItems[0].pathPoints[1].anchor[1]);
var ver3 = Number(aPath.subPathItems[0].pathPoints[2].anchor[1]);
var ver4 = Number(aPath.subPathItems[0].pathPoints[3].anchor[1]);
// order the horicontal and vertical coordinates;
var horList = [hor1, hor2, hor3, hor4];
var verList = [ver1, ver2, ver3, ver4];
horList.sort(sortNumber);
verList.sort(sortNumber);
// check the horicontal value;
var leftPoints = new Array;
var rightPoints = new Array;
for (var k=0; k<aPath.subPathItems[0].pathPoints.length; k++) {
if (aPath.subPathItems[0].pathPoints
|| aPath.subPathItems[0].pathPoints
leftPoints = leftPoints.concat(aPath.subPathItems[0].pathPoints
}
else {
rightPoints = rightPoints.concat(aPath.subPathItems[0].pathPoints
}
};
// define the four cornerpoints;
if (leftPoints[1] <= leftPoints[3]) {
var aTopLeft = [leftPoints[0], leftPoints[1]]
var aBottomLeft = [leftPoints[2], leftPoints[3]];
}
else {
var aTopLeft = [leftPoints[2], leftPoints[3]]
var aBottomLeft = [leftPoints[0], leftPoints[1]];
};
if (rightPoints[1] <= rightPoints[3]) {
var aTopRight = [rightPoints[0], rightPoints[1]]
var aBottomRight = [rightPoints[2], rightPoints[3]];
}
else {
var aTopRight = [rightPoints[2], rightPoints[3]]
var aBottomRight = [rightPoints[0], rightPoints[1]];
}
//////////// transform to the new corners ////////////
transformActiveLayer( [new TPoint(aTopLeft[0], aTopLeft[1]), new TPoint(aTopRight[0], aTopRight[1]), new TPoint(aBottomRight[0], aBottomRight[1]), new TPoint(aBottomLeft[0], aBottomLeft[1])]);
// resets the preferences units;
app.preferences.rulerUnits = originalUnits;
}
catch (e) {}
};
////////////////////////////////////
////////////////////////////////////
////////////////////////////////////
//////////// the functions ////////////
// the dialog for multiple possible paths;
function selectAPath (thePathList) {
var dlg = new Window('dialog', "Select a path to use for the perspective", [500,300,800,400])
dlg.pathSel = dlg.add('dropdownlist', [12,13,288,35]);
for (var m = 0; m < thePathList.length; m++) {
dlg.pathSel.add("item", thePathList
};
dlg.pathSel.selection = dlg.pathSel[0];
dlg.pathSel.active = true;
dlg.buildBtn = dlg.add('button', [13,42,145,62], 'OK', {name:'ok'});
dlg.cancelBtn = dlg.add('button', [155,42,288,62], 'Cancel', {name:'cancel'});
var myReturn = dlg.show ();
if (myReturn == true) {
var aPath = app.activeDocument.pathItems.getByName(dlg.pathSel.selection);
return aPath
}
};
// sort numbers, found at www.w3schools.com;
function sortNumber(a,b) {
return a - b;
};
// from adobe’s stacksupport.jsx;
// Apply a perspective transform to the current layer, with the
// corner TPoints given in newCorners (starts at top left, in clockwise order)
// Potential DOM fix
function transformActiveLayer( newCorners )
{
function pxToNumber( px )
{
return px.as("px");
}
var saveUnits = app.preferences.rulerUnits;
app.preferences.rulerUnits = Units.PIXELS;
var i;
var setArgs = new ActionDescriptor();
var chanArg = new ActionReference();
chanArg.putProperty( classChannel, keySelection );
// setArgs.putReference( keyNull, chanArg );
var boundsDesc = new ActionDescriptor();
var layerBounds = app.activeDocument.activeLayer.bounds;
boundsDesc.putUnitDouble( keyTop, unitPixels, pxToNumber( layerBounds[1] ) );
boundsDesc.putUnitDouble( keyLeft, unitPixels, pxToNumber( layerBounds[0] ) );
boundsDesc.putUnitDouble( keyRight, unitPixels, pxToNumber( layerBounds[2] ) );
boundsDesc.putUnitDouble( keyBottom, unitPixels, pxToNumber( layerBounds[3] ) );
// setArgs.putObject( keyTo, classRectangle, boundsDesc );
// executeAction( eventSet, setArgs );
var result = new ActionDescriptor();
var args = new ActionDescriptor();
var quadRect = new ActionList();
quadRect.putUnitDouble( unitPixels, pxToNumber( layerBounds[0] ) );
// ActionList put is different from ActionDescriptor put
quadRect.putUnitDouble( unitPixels, pxToNumber( layerBounds[1] ) );
quadRect.putUnitDouble( unitPixels, pxToNumber( layerBounds[2] ) );
quadRect.putUnitDouble( unitPixels, pxToNumber( layerBounds[3] ) );
var quadCorners = new ActionList();
for (i = 0; i < 4; ++i)
{
quadCorners.putUnitDouble( unitPixels, newCorners.fX );
quadCorners.putUnitDouble( unitPixels, newCorners.fY );
}
args.putList( krectangleStr, quadRect );
args.putList( kquadrilateralStr, quadCorners );
executeAction( eventTransform, args );
// Deselect
deselArgs = new ActionDescriptor();
deselRef = new ActionReference();
deselRef.putProperty( classChannel, keySelection );
deselArgs.putReference( keyNull, deselRef );
deselArgs.putEnumerated( keyTo, typeOrdinal, enumNone );
executeAction( eventSet, deselArgs );
app.preferences.rulerUnits = saveUnits;
}

Copy link to clipboard
Copied
- thanks,I will study your code first , if there is a more simple way, please also tell us,(Code is not necessarily required)actually the ideas,steps。。。。。。,thanks again
Copy link to clipboard
Copied
If you have follow-up questions may want to start a new thread.
Copy link to clipboard
Copied
I'm late to this party, but I wanted to share that I figured out the solution to the numbers for the "Using" command.
The units of the numbers don't appear to matter at all. The number is used for a Z-perspective transform, relative to whatever point is your anchor. If you pass in numbers H and V, this is the formula for how points will be transformed:
z = 1/3 + xH/width + yV/height
x' = x / 3z
y' = y / 3z
Basically, the focal plane is at z = 1/3. Or you can go with a simpler formula:
z = 1 + 3(xH/width + yV/height)
x' = x / z
y' = y / z
Take for instance a square. Anchor it to Qcs6 (bottom middle). Now apply the Using command with H=0 and V=-1/3. You'll have a trapezoid that's just as wide at the bottom as it ever was, but it will be half as high and the top will be half as wide.
The top right corner of the square starts at x=width/2 and y=-height relative to the origin. It transforms like so:
z = 1 + 3(x/width)(0) + 3(y/height)(-1/3) = 1 + 3(1/2)(0) + 3(-1)(-1/3) = 2
x' = x / 2 = width/4
y' = y / 2 = -height/2
I hope that's helpful, if not to the OP anymore then maybe to others. I racked my brain trying to figure this out. Happy scripting!
Copy link to clipboard
Copied
I wasn't able to make much sense of any of the answers in this thread, but after a day of taking measurements and finding formulas that fit them, I eventually came up with a function that is able to do a centered, one-axis transform that just changes one side of a shape by the desired amount. Posting it here in case it helps anyone else.
var initialLayer = app.activeDocument.activeLayer;
var selectedLayerBounds = initialLayer.boundsNoEffects;
var layerW = selectedLayerBounds[2].as("px") - selectedLayerBounds[0].as("px");
var layerH = selectedLayerBounds[3].as("px") - selectedLayerBounds[1].as("px");
function selectionFromLayerOpacity(){
var ref1 = new ActionReference();
ref1.putProperty( charIDToTypeID( "Chnl" ), charIDToTypeID( "fsel" ) );
var ref2 = new ActionReference();
ref2.putEnumerated( charIDToTypeID( "Chnl" ), charIDToTypeID( "Chnl" ), charIDToTypeID( "Trsp" ) );
//ref2.putName( charIDToTypeID( "Lyr " ), "Layer 1" );//omitting this causes it to act upon whatever layer is already selected instead
var desc3 = new ActionDescriptor();
desc3.putReference( charIDToTypeID( "null" ), ref1 );
desc3.putReference( charIDToTypeID( "T " ), ref2 );
executeAction( charIDToTypeID( "setd" ), desc3, DialogModes.NO );
}
function doPerspective(frac){
var A = -100*(1/(frac+1)-0.5)*(layerW/100);
var B = 100 + A*(2/(layerW/100));
var C = -4*(1/(frac+1)-0.5)*(100/layerW);
var desc1 = new ActionDescriptor();
var ref = new ActionReference();
ref.putEnumerated( charIDToTypeID( "Lyr " ), charIDToTypeID( "Ordn" ), charIDToTypeID( "Trgt" ) );
desc1.putReference( charIDToTypeID( "null" ), ref );
desc1.putEnumerated( charIDToTypeID( "FTcs" ), charIDToTypeID( "QCSt" ), charIDToTypeID( "Qcsa" ) );
var desc2 = new ActionDescriptor();
desc2.putUnitDouble( charIDToTypeID( "Hrzn" ), charIDToTypeID( "#Pxl" ), A );
desc2.putUnitDouble( charIDToTypeID( "Vrtc" ), charIDToTypeID( "#Pxl" ), 0 );
desc1.putObject( charIDToTypeID( "Ofst" ), charIDToTypeID( "Ofst" ), desc2 );
desc1.putUnitDouble( charIDToTypeID( "Hght" ), charIDToTypeID( "#Prc" ), B );
var desc3 = new ActionDescriptor();
desc3.putUnitDouble( charIDToTypeID( "Hrzn" ), charIDToTypeID( "#Prc" ), C );
desc3.putUnitDouble( charIDToTypeID( "Vrtc" ), charIDToTypeID( "#Prc" ), 0 );
desc1.putObject( charIDToTypeID( "Usng" ), charIDToTypeID( "Pnt " ), desc3 );
desc1.putBoolean( charIDToTypeID( "Lnkd" ), true );
desc1.putEnumerated( charIDToTypeID( "Intr" ), charIDToTypeID( "Intp" ), charIDToTypeID( "Bcbc" ) );
executeAction( charIDToTypeID( "Trnf" ), desc1, DialogModes.NO );
}
selectionFromLayerOpacity();
doPerspective(0.5);
Copy link to clipboard
Copied
Could you explain the actual meaning of the frac argument in doPerspective function?
Copy link to clipboard
Copied
How large the left side will end up relative to its initial size.
If it's performed on a square that's 200px per side, a frac of 0.5 will make the left side end up 100px tall.
Copy link to clipboard
Copied
Yes, it really works. )
Checked on SmartObject.
doPerspective (0.1);
doPerspective (10);
selectionFromLayerOpacity() - no need in this case.
Copy link to clipboard
Copied
Yeah, the selection stuff was just to make it simpler as a standalone test case—if you have a selection already then you can just use doPerspective. (Although you'll still need to get the selection's width from somewhere for the layerW term, but that should be pretty easy to just adapt to whatever situation you're using it in.)
Copy link to clipboard
Copied
It is very cool. How do you get this formula?
Well, very cool!
Copy link to clipboard
Copied
I took lots and lots of measurements Take a 200x200 square, shrink one side by 10 pixels, look at the values found in the ScriptListener output... then try again shrinking by 20 pixels, by 30 pixels... try again with a 200x100 rectangle, with a 100x200 rectangle, and so on... Put all the data points on a graph, and then just try and guess a formula that would fit the data... I got close with a parabola, but then I remembered it's a perspective transform, so something involving 1/x would make more sense... and I just kept nudging the terms until the line went through the points, then rounded the numbers and it fit even better... then just cancelled out as many terms as I could.... (although I just realised, there's still some redundant numbers in the formula for A; it could just be A = -(1/(frac+1)-0.5)*layerW couldn't it!) It was kind of exhausting and it really did take me all day, but it was fun, I love a good puzzle

