/*
    Infinity Panel 2.0 by CJXander
    Smooth, looping motion paths for any layer in After Effects.

    Paths
      Infinity   a figure-eight that settles at both tips
      Circle     round and round, with 1 to 12 eased stops per loop
      Triangle   settles into every corner
      Square     settles into every corner
      Polygon    pentagon, hexagon, heptagon or octagon
      Star       3 to 12 points, with adjustable depth

    How to use
      1. Select the layers to move. With nothing selected, a new null is made for you.
      2. Pick a path and set Size, Loop Time and Ease.
      3. Click Apply Motion. One undo (Ctrl+Z, or Cmd+Z on a Mac) takes it all back.

    The path is centered on the layer's own position, so keyframed and parented layers keep
    working. Its settings are added to the layer as Expression Controls (Path Size, Path Loop
    Time, Path Ease and so on), so you can change or keyframe them later in Effect Controls.
    Cameras and lights can't hold effects, so their settings are written into the expression.

    Install as a dockable panel
      Windows: C:\Program Files\Adobe\Adobe After Effects <version>\Support Files\Scripts\ScriptUI Panels
      macOS:   /Applications/Adobe After Effects <version>/Scripts/ScriptUI Panels
      Copy this file there, restart After Effects and open it from the Window menu, then dock it
      wherever you like. (File > Scripts > Run Script File opens it as a floating window instead.)
*/

(function InfinityPanel(thisObj) {
    var NAME = "Infinity Panel";
    var VERSION = "2.0";
    var AUTHOR = "CJXander";
    var SETTINGS_SECTION = "Infinity Panel 2";
    var SIGNATURE = "Infinity Panel";                      // every expression this panel writes starts with it
    var OLD_SIGNATURE = "1 + Math.sin(t) * Math.sin(t)";   // part of the 1.x expression, so Remove clears that too
    var NULL_NAME = "Infinity Null";
    var LEM_STEPS = 100;                                   // samples used to measure the figure-eight
    var STEPS = "1.  Select a layer, or nothing for a new null\n2.  Pick a path and set the motion\n3.  Click Apply Motion";
    var LABEL_WIDTH = 62;

    var SHAPES = [
        { id: "infinity", label: "Infinity", tip: "Infinity: a figure-eight that settles at both tips" },
        { id: "circle", label: "Circle", tip: "Circle: round and round, with eased stops" },
        { id: "triangle", label: "Triangle", tip: "Triangle: settles into every corner" },
        { id: "square", label: "Square", tip: "Square: settles into every corner" },
        { id: "polygon", label: "Polygon", tip: "Polygon: pentagon, hexagon, heptagon or octagon" },
        { id: "star", label: "Star", tip: "Star: pick how many points it has and how deep it is" }
    ];
    var POLYGON_NAMES = ["", "", "", "Triangle", "Square", "Pentagon", "Hexagon", "Heptagon", "Octagon"];

    // Expression Controls added to the layer. The expression reads them by name and index,
    // so it works in every language version of After Effects.
    var SLIDER = "ADBE Slider Control";
    var ANGLE = "ADBE Angle Control";
    var CHECKBOX = "ADBE Checkbox Control";
    var CONTROLS = {
        size: { name: "Path Size", type: SLIDER },
        loop: { name: "Path Loop Time (sec)", type: SLIDER },
        ease: { name: "Path Ease (%)", type: SLIDER },
        rotation: { name: "Path Rotation", type: ANGLE },
        offset: { name: "Path Offset (%)", type: SLIDER },
        reverse: { name: "Path Reverse", type: CHECKBOX },
        sides: { name: "Path Sides", type: SLIDER },
        stops: { name: "Path Stops", type: SLIDER },
        points: { name: "Star Points", type: SLIDER },
        depth: { name: "Star Depth (%)", type: SLIDER }
    };
    var CONTROL_ORDER = ["size", "loop", "ease", "rotation", "offset", "reverse", "sides", "stops", "points", "depth"];

    var DEFAULTS = {
        shape: "infinity", sides: 5, points: 5, depth: 40, stops: 1,
        size: 200, loopTime: 3, ease: 50, rotation: 0, offset: 0,
        reverse: false, spread: false
    };

    var state = null;

    // ------------------------------------------------------------------ helpers

    // A problem the user can fix, shown in the status line instead of an alert.
    function userError(message) {
        return { isUser: true, message: message };
    }

    function trimString(s) {
        return String(s).replace(/^\s+|\s+$/g, "");
    }

    function readNumber(text) {
        var s = trimString(text).replace(",", ".");
        if (s === "") return null;
        var v = parseFloat(s);
        return isNaN(v) ? null : v;
    }

    function clamp(v, lo, hi) {
        return Math.min(hi, Math.max(lo, v));
    }

    function roundTo(v, decimals) {
        var f = Math.pow(10, decimals);
        return Math.round(v * f) / f;
    }

    function formatNumber(v, decimals) {
        return String(roundTo(v, decimals));
    }

    function contains(list, item) {
        for (var i = 0; i < list.length; i++) {
            if (list[i] === item) return true;
        }
        return false;
    }

    function copyState(st) {
        var out = {};
        for (var key in st) {
            if (st.hasOwnProperty(key)) out[key] = st[key];
        }
        return out;
    }

    function quoted(name) {
        return "\"" + name + "\"";
    }

    // "A", "A and B", "A, B, C and 2 more"
    function nameList(names) {
        var q = [];
        for (var i = 0; i < names.length && i < 3; i++) q.push(quoted(names[i]));
        if (names.length > 3) return q.join(", ") + " and " + (names.length - 3) + " more";
        if (q.length < 2) return q.join("");
        return q.slice(0, q.length - 1).join(", ") + " and " + q[q.length - 1];
    }

    // ------------------------------------------------------------------ settings

    function loadSetting(key, fallback) {
        try {
            if (app.settings.haveSetting(SETTINGS_SECTION, key)) return app.settings.getSetting(SETTINGS_SECTION, key);
        } catch (e) { }
        return fallback;
    }

    function saveSetting(key, value) {
        try { app.settings.saveSetting(SETTINGS_SECTION, key, String(value)); } catch (e) { }
    }

    function numberSetting(key, lo, hi, whole) {
        var v = readNumber(loadSetting(key, ""));
        if (v === null) return DEFAULTS[key];
        v = clamp(v, lo, hi);
        return whole ? Math.round(v) : v;
    }

    function loadState() {
        var st = copyState(DEFAULTS);
        var shape = loadSetting("shape", DEFAULTS.shape);
        for (var i = 0; i < SHAPES.length; i++) {
            if (SHAPES[i].id === shape) st.shape = shape;
        }
        st.sides = numberSetting("sides", 5, 8, true);
        st.points = numberSetting("points", 3, 12, true);
        st.depth = numberSetting("depth", 5, 95, true);
        st.stops = numberSetting("stops", 1, 12, true);
        st.size = numberSetting("size", 0, 100000, false);
        st.loopTime = numberSetting("loopTime", 0, 3600, false);
        st.ease = numberSetting("ease", 0, 100, false);
        st.rotation = numberSetting("rotation", -3600, 3600, false);
        st.offset = numberSetting("offset", -10000, 10000, false);
        st.reverse = loadSetting("reverse", "0") === "1";
        st.spread = loadSetting("spread", "0") === "1";
        return st;
    }

    function saveState() {
        for (var key in state) {
            if (!state.hasOwnProperty(key)) continue;
            var v = state[key];
            if (v === true) v = "1";
            else if (v === false) v = "0";
            saveSetting(key, v);
        }
    }

    // ------------------------------------------------------------------ paths

    function isPolygon(shape) {
        return shape === "triangle" || shape === "square" || shape === "polygon";
    }

    // The number that goes with each path: corners, eased stops or star points.
    function shapeCount(st) {
        switch (st.shape) {
            case "circle": return st.stops;
            case "triangle": return 3;
            case "square": return 4;
            case "polygon": return st.sides;
            case "star": return st.points;
        }
        return 2;
    }

    function shapeTitle(st) {
        if (st.shape === "infinity") return "Infinity";
        if (st.shape === "circle") return "Circle";
        if (st.shape === "star") return st.points + "-Point Star";
        return POLYGON_NAMES[shapeCount(st)] || (shapeCount(st) + "-Sided Polygon");
    }

    function familyTitle(shape) {
        if (shape === "infinity") return "Infinity";
        if (shape === "circle") return "Circle";
        if (shape === "star") return "Star";
        return "Polygon";
    }

    // One loop is cut into this many pieces; the motion eases in and out of each one.
    function segmentCount(shape, count) {
        if (shape === "infinity") return 2;
        if (shape === "star") return count * 2;
        return count;
    }

    // u runs 0 to 1 through one piece. strength: 0 = constant speed,
    // 0.5 = a smooth stop at both ends, 1 = a strong, snappy ease.
    function easeCurve(u, strength) {
        var s = clamp(strength, 0, 1);
        var a = Math.min(1, s * 2);
        u = clamp(u - a * Math.sin(2 * Math.PI * u) / (2 * Math.PI), 0, 1);
        var k = 1 + Math.max(0, s * 2 - 1) * 2;
        var x = Math.pow(u, k);
        return x / (x + Math.pow(1 - u, k));
    }

    // Lemniscate of Bernoulli, the same figure-eight as version 1. Tips at x = -1 and 1.
    function lemniscate(t) {
        var a = 2 * Math.PI * t;
        var d = 1 + Math.sin(a) * Math.sin(a);
        return [Math.cos(a) / d, Math.sin(a) * Math.cos(a) / d];
    }

    var lemLengths = null;

    // Point on the figure-eight, measured along its length so equal steps are equal distances.
    function infinityPoint(along) {
        if (lemLengths === null) {
            lemLengths = [0];
            var prev = lemniscate(0);
            for (var j = 1; j <= LEM_STEPS; j++) {
                var cur = lemniscate(j / LEM_STEPS);
                lemLengths.push(lemLengths[j - 1] + Math.sqrt(Math.pow(cur[0] - prev[0], 2) + Math.pow(cur[1] - prev[1], 2)));
                prev = cur;
            }
        }
        var target = along * lemLengths[LEM_STEPS];
        var k = 0;
        while (k < LEM_STEPS - 1 && lemLengths[k + 1] < target) k++;
        var f = (target - lemLengths[k]) / (lemLengths[k + 1] - lemLengths[k]);
        return lemniscate((k + f) / LEM_STEPS);
    }

    // Corner j of a polygon or star at size 1. Odd polygons point up, even ones sit flat.
    function cornerPoint(shape, count, depth, j) {
        if (shape === "star") {
            var a = -Math.PI / 2 + j * Math.PI / count;
            var r = (j % 2 === 0) ? 1 : depth;
            return [r * Math.cos(a), r * Math.sin(a)];
        }
        var gap = 2 * Math.PI / count;
        var b = -Math.PI / 2 + (count % 2 === 0 ? gap / 2 : 0) + j * gap;
        return [Math.cos(b), Math.sin(b)];
    }

    // A point on the path at size 1, centered on 0,0. along runs 0 to 1 around the path.
    function pathPoint(shape, count, depth, along) {
        if (shape === "infinity") return infinityPoint(along);
        if (shape === "circle") {
            var a = -Math.PI / 2 + along * 2 * Math.PI;
            return [Math.cos(a), Math.sin(a)];
        }
        var corners = (shape === "star") ? count * 2 : count;
        var e = along * corners;
        var j = Math.floor(e);
        var p0 = cornerPoint(shape, count, depth, j);
        var p1 = cornerPoint(shape, count, depth, j + 1);
        return [p0[0] + (p1[0] - p0[0]) * (e - j), p0[1] + (p1[1] - p0[1]) * (e - j)];
    }

    // How far around the path the layer is after a fraction of a loop, with easing.
    function motionAlong(st, phase) {
        var p = phase - Math.floor(phase);
        if (st.reverse) p = 1 - p;
        var n = segmentCount(st.shape, shapeCount(st));
        var s = p * n;
        var i = Math.floor(s);
        return (i + easeCurve(s - i, st.ease / 100)) / n;
    }

    function rotatePoint(pt, degrees) {
        var r = degrees * Math.PI / 180;
        var c = Math.cos(r);
        var s = Math.sin(r);
        return [pt[0] * c - pt[1] * s, pt[0] * s + pt[1] * c];
    }

    // ------------------------------------------------------------------ expression

    function literal(v) {
        return String(roundTo(v, 4));
    }

    function controlRef(key) {
        return "effect(\"" + CONTROLS[key].name + "\")(1)";
    }

    function controlsFor(shape) {
        var keys = ["size", "loop", "ease", "rotation", "offset", "reverse"];
        if (shape === "circle") keys.push("stops");
        if (isPolygon(shape)) keys.push("sides");
        if (shape === "star") {
            keys.push("points");
            keys.push("depth");
        }
        return keys;
    }

    // The Position expression. It does the same math as motionAlong and pathPoint above.
    // mode: "xy" for Position, "x" or "y" when the layer's dimensions are separated.
    function buildExpression(st, useControls, mode) {
        var lines = [];
        function add(text) { lines.push(text); }
        function param(name, key, value, tail) {
            add("var " + name + " = " + (useControls ? controlRef(key) : literal(value)) + (tail || "") + ";");
        }

        add("// " + NAME + " " + VERSION + " by " + AUTHOR + ": " + familyTitle(st.shape) + " path");
        add(useControls ? "// Change or keyframe the Path controls in Effect Controls." :
            "// This layer can't hold effects, so the settings are written in here.");
        add("");
        param("ipSize", "size", st.size);
        param("ipLoop", "loop", st.loopTime);
        param("ipEase", "ease", st.ease, " / 100");
        param("ipRotation", "rotation", st.rotation);
        param("ipOffset", "offset", st.offset, " / 100");
        param("ipReverse", "reverse", st.reverse ? 1 : 0);
        if (st.shape === "circle") param("ipCount", "stops", st.stops);
        if (isPolygon(st.shape)) param("ipCount", "sides", shapeCount(st));
        if (st.shape === "star") {
            param("ipCount", "points", st.points);
            param("ipDepth", "depth", st.depth, " / 100");
        }
        add("");
        add("// 0 = constant speed, 0.5 = smooth stop at every corner, 1 = strong ease");
        add("function ipEaseCurve(u, s) {");
        add("    s = Math.min(1, Math.max(0, s));");
        add("    var a = Math.min(1, s * 2);");
        add("    u = Math.min(1, Math.max(0, u - a * Math.sin(2 * Math.PI * u) / (2 * Math.PI)));");
        add("    var k = 1 + Math.max(0, s * 2 - 1) * 2;");
        add("    var x = Math.pow(u, k);");
        add("    return x / (x + Math.pow(1 - u, k));");
        add("}");
        add("");
        add("// How far through the loop it is, from 0 to 1. Loop Time 0 holds it at Offset.");
        add("var ipPhase = (ipLoop > 0 ? (time - thisLayer.startTime) / ipLoop : 0) + ipOffset;");
        add("ipPhase -= Math.floor(ipPhase);");
        add("if (ipReverse > 0.5) ipPhase = 1 - ipPhase;");
        add("");
        if (st.shape === "infinity") {
            add("var ipSegments = 2;  // settles at both tips");
        } else if (st.shape === "circle") {
            add("var ipSegments = Math.max(1, Math.round(ipCount));  // eased stops per loop");
        } else if (st.shape === "star") {
            add("var ipPoints = Math.max(2, Math.round(ipCount));");
            add("var ipSegments = ipPoints * 2;  // settles into every corner");
        } else {
            add("var ipSides = Math.max(3, Math.round(ipCount));");
            add("var ipSegments = ipSides;  // settles into every corner");
        }
        add("var ipStep = ipPhase * ipSegments;");
        add("var ipIndex = Math.floor(ipStep);");
        add("var ipAlong = (ipIndex + ipEaseCurve(ipStep - ipIndex, ipEase)) / ipSegments;");
        add("");
        if (st.shape === "infinity") {
            add("// Figure-eight, measured along its length so the speed is even before easing");
            add("function ipLemniscate(t) {");
            add("    var a = 2 * Math.PI * t;");
            add("    var d = 1 + Math.sin(a) * Math.sin(a);");
            add("    return [Math.cos(a) / d, Math.sin(a) * Math.cos(a) / d];");
            add("}");
            add("var ipLengths = [0];");
            add("var ipPrev = ipLemniscate(0);");
            add("for (var j = 1; j <= " + LEM_STEPS + "; j++) {");
            add("    var ipCur = ipLemniscate(j / " + LEM_STEPS + ");");
            add("    ipLengths.push(ipLengths[j - 1] + Math.sqrt(Math.pow(ipCur[0] - ipPrev[0], 2) + Math.pow(ipCur[1] - ipPrev[1], 2)));");
            add("    ipPrev = ipCur;");
            add("}");
            add("var ipTarget = ipAlong * ipLengths[" + LEM_STEPS + "];");
            add("var ipK = 0;");
            add("while (ipK < " + (LEM_STEPS - 1) + " && ipLengths[ipK + 1] < ipTarget) ipK++;");
            add("var ipF = (ipTarget - ipLengths[ipK]) / (ipLengths[ipK + 1] - ipLengths[ipK]);");
            add("var ipPoint = ipLemniscate((ipK + ipF) / " + LEM_STEPS + ");");
        } else if (st.shape === "circle") {
            add("var ipTurn = -Math.PI / 2 + ipAlong * 2 * Math.PI;");
            add("var ipPoint = [Math.cos(ipTurn), Math.sin(ipTurn)];");
        } else {
            if (st.shape === "star") {
                add("function ipCorner(j) {");
                add("    var a = -Math.PI / 2 + j * Math.PI / ipPoints;");
                add("    var r = (j % 2 == 0) ? 1 : ipDepth;");
                add("    return [r * Math.cos(a), r * Math.sin(a)];");
                add("}");
            } else {
                add("function ipCorner(j) {");
                add("    var gap = 2 * Math.PI / ipSides;");
                add("    var a = -Math.PI / 2 + (ipSides % 2 == 0 ? gap / 2 : 0) + j * gap;");
                add("    return [Math.cos(a), Math.sin(a)];");
                add("}");
            }
            add("var ipEdge = ipAlong * ipSegments;");
            add("var ipJ = Math.floor(ipEdge);");
            add("var ipA = ipCorner(ipJ);");
            add("var ipB = ipCorner(ipJ + 1);");
            add("var ipPoint = [ipA[0] + (ipB[0] - ipA[0]) * (ipEdge - ipJ), ipA[1] + (ipB[1] - ipA[1]) * (ipEdge - ipJ)];");
        }
        add("");
        add("// Size and rotation, centered on the layer's own position");
        add("var ipTilt = ipRotation * Math.PI / 180;");
        add("var ipX = ipSize * (ipPoint[0] * Math.cos(ipTilt) - ipPoint[1] * Math.sin(ipTilt));");
        add("var ipY = ipSize * (ipPoint[0] * Math.sin(ipTilt) + ipPoint[1] * Math.cos(ipTilt));");
        if (mode === "x") {
            add("value + ipX;");
        } else if (mode === "y") {
            add("value + ipY;");
        } else {
            add("var ipPos = value;");
            add("ipPos.length > 2 ? [ipPos[0] + ipX, ipPos[1] + ipY, ipPos[2]] : [ipPos[0] + ipX, ipPos[1] + ipY];");
        }
        return lines.join("\n");
    }

    // ------------------------------------------------------------------ layers

    function activeComp() {
        var comp = app.project.activeItem;
        if (!(comp instanceof CompItem)) throw userError("Open a composition first, then select the layers to move.");
        return comp;
    }

    function selectedLayersInOrder(comp) {
        var list = [];
        var selection = comp.selectedLayers;
        for (var i = 0; i < selection.length; i++) list.push(selection[i]);
        list.sort(function (a, b) { return a.index - b.index; });
        return list;
    }

    // The properties to put the expression on, with the mode for each, or null if the layer can't move.
    function positionProperties(layer) {
        var transform = null;
        try { transform = layer.property("ADBE Transform Group"); } catch (e) { transform = null; }
        if (transform === null) return null;
        var position = transform.property("ADBE Position");
        if (position === null) return null;
        var separated = false;
        try { separated = position.dimensionsSeparated; } catch (e2) { separated = false; }
        if (separated) {
            var px = transform.property("ADBE Position_0");
            var py = transform.property("ADBE Position_1");
            if (px === null || py === null || !px.canSetExpression || !py.canSetExpression) return null;
            return [{ prop: px, mode: "x" }, { prop: py, mode: "y" }];
        }
        return position.canSetExpression ? [{ prop: position, mode: "xy" }] : null;
    }

    function effectsOf(layer) {
        try { return layer.property("ADBE Effect Parade"); } catch (e) { return null; }
    }

    function canHoldControls(layer) {
        var effects = effectsOf(layer);
        if (effects === null) return false;
        try { return effects.canAddProperty(SLIDER); } catch (e) { return false; }
    }

    function findControl(layer, key) {
        var effects = effectsOf(layer);
        if (effects === null) return null;
        var def = CONTROLS[key];
        for (var i = 1; i <= effects.numProperties; i++) {
            var effect = effects.property(i);
            if (effect.name === def.name && effect.matchName === def.type) return effect;
        }
        return null;
    }

    // Adds the controls this path needs and removes the ones it doesn't.
    // Controls that are keyframed or have an expression are left as they are.
    function setControls(layer, st, kept) {
        var needed = controlsFor(st.shape);
        var values = {
            size: st.size, loop: st.loopTime, ease: st.ease, rotation: st.rotation, offset: st.offset,
            reverse: st.reverse ? 1 : 0, sides: shapeCount(st), stops: st.stops, points: st.points, depth: st.depth
        };
        var i, key, effect;
        for (i = 0; i < CONTROL_ORDER.length; i++) {
            key = CONTROL_ORDER[i];
            if (contains(needed, key)) continue;
            effect = findControl(layer, key);
            if (effect !== null) effect.remove();
        }
        for (i = 0; i < needed.length; i++) {
            key = needed[i];
            effect = findControl(layer, key);
            if (effect === null) {
                effect = effectsOf(layer).addProperty(CONTROLS[key].type);
                effect.name = CONTROLS[key].name;
                effect.property(1).setValue(values[key]);
            } else {
                var prop = effect.property(1);
                if (prop.numKeys > 0 || (prop.expressionEnabled && trimString(prop.expression) !== "")) {
                    if (!contains(kept, CONTROLS[key].name)) kept.push(CONTROLS[key].name);
                } else {
                    prop.setValue(values[key]);
                }
            }
        }
        orderControls(layer, needed);
    }

    // Keeps the controls together in a tidy order. Only moves them if they're out of order.
    function orderControls(layer, needed) {
        try {
            var last = 0;
            var inOrder = true;
            var i, effect;
            for (i = 0; i < needed.length; i++) {
                effect = findControl(layer, needed[i]);
                if (effect === null) continue;
                if (effect.propertyIndex < last) inOrder = false;
                last = effect.propertyIndex;
            }
            if (inOrder) return;
            for (i = 0; i < needed.length; i++) {
                effect = findControl(layer, needed[i]);
                if (effect !== null) effect.moveTo(effectsOf(layer).numProperties);
            }
        } catch (e) { }
    }

    function isOurExpression(text) {
        var s = String(text);
        return s.indexOf(SIGNATURE) >= 0 || s.indexOf(OLD_SIGNATURE) >= 0;
    }

    // Runs the new expression once so a mistake shows up in the panel, not just in the comp.
    function expressionProblem(prop, comp) {
        try { prop.valueAtTime(comp.time, false); } catch (e) { }
        var message = "";
        try { message = prop.expressionError; } catch (e2) { message = ""; }
        return message ? String(message) : "";
    }

    function applyMotion(st) {
        var comp = activeComp();
        var layers = selectedLayersInOrder(comp);
        var made = null;
        if (layers.length === 0) {
            made = comp.layers.addNull();
            made.name = NULL_NAME;
            try { made.property("ADBE Transform Group").property("ADBE Position").setValue([comp.width / 2, comp.height / 2]); } catch (e) { }
            layers = [made];
        }

        var targets = [];
        var skipped = [];
        var i;
        for (i = 0; i < layers.length; i++) {
            if (layers[i].locked) {
                skipped.push(quoted(layers[i].name) + " (locked)");
            } else if (positionProperties(layers[i]) === null) {
                skipped.push(quoted(layers[i].name) + " (no Position to move)");
            } else {
                targets.push(layers[i]);
            }
        }
        if (targets.length === 0) {
            throw userError("Nothing to move: " + skipped.join(", ") + ". Select a layer with a Position.");
        }

        var kept = [];
        var baked = [];
        var replaced = [];
        var broken = [];
        for (i = 0; i < targets.length; i++) {
            var layer = targets[i];
            var layerState = copyState(st);
            if (st.spread && targets.length > 1) layerState.offset = roundTo(st.offset + 100 * i / targets.length, 4);
            var withControls = canHoldControls(layer);
            if (withControls) setControls(layer, layerState, kept);
            else baked.push(layer.name);
            var props = positionProperties(layer);
            for (var m = 0; m < props.length; m++) {
                var prop = props[m].prop;
                var old = "";
                try { old = prop.expression; } catch (e3) { old = ""; }
                if (trimString(old) !== "" && !isOurExpression(old) && !contains(replaced, layer.name)) replaced.push(layer.name);
                prop.expression = buildExpression(layerState, withControls, props[m].mode);
                var problem = expressionProblem(prop, comp);
                if (problem !== "") broken.push(quoted(layer.name) + ": " + problem);
            }
        }
        if (made !== null) {
            try { made.selected = true; } catch (e4) { }
        }

        var title = shapeTitle(st);
        var text;
        if (made !== null) text = "Added " + quoted(NULL_NAME) + " on the " + title + " path.";
        else if (targets.length === 1) text = quoted(targets[0].name) + " now follows the " + title + " path.";
        else text = targets.length + " layers now follow the " + title + " path" + (st.spread ? ", spread evenly." : ".");
        if (baked.length < targets.length) text += " Fine-tune it in Effect Controls.";

        var notes = [];
        if (baked.length > 0) notes.push(nameList(baked) + (baked.length > 1 ? " have" : " has") +
            " no Effect Controls, so the settings are written into the expression.");
        if (kept.length > 0) notes.push("Left the keyframes on " + kept.join(", ") + " as they were.");
        if (replaced.length > 0) notes.push("Replaced the Position expression on " + nameList(replaced) + ".");
        if (skipped.length > 0) notes.push("Skipped " + skipped.join(", ") + ".");
        if (broken.length > 0) notes.push("Expression error on " + broken.join("; "));
        return {
            text: text + (notes.length > 0 ? " " + notes.join(" ") : ""),
            warning: skipped.length > 0 || broken.length > 0 || replaced.length > 0
        };
    }

    function removeMotion() {
        var comp = activeComp();
        var layers = selectedLayersInOrder(comp);
        if (layers.length === 0) throw userError("Select the layers to take the motion off.");
        var cleared = [];
        var locked = [];
        var names = ["ADBE Position", "ADBE Position_0", "ADBE Position_1", "ADBE Position_2"];
        for (var i = 0; i < layers.length; i++) {
            var layer = layers[i];
            if (layer.locked) {
                locked.push(layer.name);
                continue;
            }
            var touched = false;
            var transform = null;
            try { transform = layer.property("ADBE Transform Group"); } catch (e) { transform = null; }
            if (transform !== null) {
                for (var n = 0; n < names.length; n++) {
                    var prop = null;
                    try { prop = transform.property(names[n]); } catch (e2) { prop = null; }
                    if (prop !== null && isOurExpression(prop.expression)) {
                        prop.expression = "";
                        touched = true;
                    }
                }
            }
            for (var c = 0; c < CONTROL_ORDER.length; c++) {
                var effect = findControl(layer, CONTROL_ORDER[c]);
                if (effect !== null) {
                    effect.remove();
                    touched = true;
                }
            }
            if (touched) cleared.push(layer.name);
        }
        var lockedNote = (locked.length > 0) ? " Skipped " + nameList(locked) + " (locked)." : "";
        if (cleared.length === 0) {
            return { text: "None of the selected layers had motion from this panel." + lockedNote, warning: true };
        }
        return { text: "Removed the motion from " + nameList(cleared) + "." + lockedNote, warning: locked.length > 0 };
    }

    // ------------------------------------------------------------------ look

    // Areas the panel paints itself carry their own colors, so they read the same
    // at any After Effects brightness. Plain labels keep the host's text color.
    var C = {
        accentA: [0.86, 0.24, 0.78],
        accentB: [0.47, 0.31, 0.96],
        ink: [1, 1, 1],
        header: [0.09, 0.08, 0.11],
        title: [0.97, 0.96, 0.98],
        subtitle: [0.64, 0.61, 0.69],
        tile: [0.20, 0.19, 0.23],
        tileHover: [0.27, 0.25, 0.31],
        tileIcon: [0.80, 0.78, 0.84],
        canvas: [0.10, 0.09, 0.12],
        canvasEdge: [0.22, 0.21, 0.26],
        guide: [0.27, 0.26, 0.32],
        path: [0.36, 0.34, 0.42],
        dotDim: [0.30, 0.18, 0.42],
        canvasText: [0.93, 0.91, 0.96],
        canvasNote: [0.58, 0.56, 0.63],
        secondary: [0.24, 0.23, 0.27],
        secondaryHover: [0.30, 0.29, 0.34],
        secondaryPressed: [0.18, 0.17, 0.21],
        secondaryInk: [0.91, 0.90, 0.93],
        divider: [0.29, 0.28, 0.32],
        idle: [0.58, 0.56, 0.62],
        ok: [0.36, 0.84, 0.52],
        warn: [0.98, 0.74, 0.30],
        error: [0.95, 0.40, 0.38]
    };
    var RADIUS = 4;
    var PRIMARY = { primary: true };
    var SECONDARY = { base: C.secondary, hover: C.secondaryHover, pressed: C.secondaryPressed, ink: C.secondaryInk };
    var fontName = "dialog";   // replaced by the font After Effects uses for panels
    var fonts = {};

    // Fonts are made once and reused.
    function makeFont(bold, size) {
        var key = (bold ? "b" : "r") + size;
        if (!fonts.hasOwnProperty(key)) {
            try {
                fonts[key] = ScriptUI.newFont(fontName, bold ? ScriptUI.FontStyle.BOLD : ScriptUI.FontStyle.REGULAR, size);
            } catch (e) {
                fonts[key] = null;
            }
        }
        return fonts[key];
    }

    function setFont(control, bold, size) {
        var font = makeFont(bold, size);
        if (font === null) return;
        try { control.graphics.font = font; } catch (e) { }
    }

    function setTextColor(control, rgb) {
        try {
            var g = control.graphics;
            g.foregroundColor = g.newPen(g.PenType.SOLID_COLOR, rgb, 1);
        } catch (e) { }
    }

    function setBackground(control, rgb) {
        try {
            var g = control.graphics;
            g.backgroundColor = g.newBrush(g.BrushType.SOLID_COLOR, rgb);
        } catch (e) { }
    }

    function addText(parent, text, bold, size, rgb) {
        var label = parent.add("statictext", undefined, text);
        setFont(label, bold, size);
        if (rgb) setTextColor(label, rgb);
        return label;
    }

    // Wrapped text. ScriptUI needs a height for it: ask how tall the wrapped text is,
    // and keep an estimate from the width in case the answer comes back as one line.
    function addParagraph(parent, text, width) {
        var perLine = Math.max(10, Math.floor(width / 6.6));
        var lines = 0;
        var paragraphs = text.split("\n");
        for (var i = 0; i < paragraphs.length; i++) {
            lines += Math.max(1, Math.ceil(paragraphs[i].length / perLine));
        }
        var label = parent.add("statictext", undefined, text, { multiline: true });
        var height = lines * 16 + 4;
        try {
            var measured = label.graphics.measureString(text, label.graphics.font, width);
            height = Math.max(height, Math.ceil(measured[1]) + 4);
        } catch (e) { }
        label.preferredSize = [width, height];
        return label;
    }

    function mix(a, b, t) {
        return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
    }

    function solidBrush(g, rgb) {
        return g.newBrush(g.BrushType.SOLID_COLOR, rgb);
    }

    function solidPen(g, rgb, width) {
        return g.newPen(g.PenType.SOLID_COLOR, rgb, width);
    }

    // Starts a new path around a rectangle with rounded corners. Fill or stroke it after.
    function roundedPath(g, x, y, w, h, r) {
        r = Math.max(0, Math.min(r, w / 2, h / 2));
        var centers = [[x + w - r, y + r], [x + w - r, y + h - r], [x + r, y + h - r], [x + r, y + r]];
        var first = null;
        g.newPath();
        for (var c = 0; c < 4; c++) {
            for (var s = 0; s <= 4; s++) {
                var a = (c * 90 - 90 + s * 22.5) * Math.PI / 180;
                var px = centers[c][0] + r * Math.cos(a);
                var py = centers[c][1] + r * Math.sin(a);
                if (first === null) {
                    first = [px, py];
                    g.moveTo(px, py);
                } else {
                    g.lineTo(px, py);
                }
            }
        }
        g.lineTo(first[0], first[1]);
    }

    // How far the rounded corners cut into the top and bottom edges at px from the left.
    function cornerInset(px, w, r) {
        var d = 0;
        if (px < r) d = r - px;
        else if (px > w - r) d = px - (w - r);
        return (d > 0) ? r - Math.sqrt(Math.max(0, r * r - d * d)) : 0;
    }

    // Fills a rounded rectangle in one color, or blended left to right from one color to another.
    // A blend is painted as thin strips, finer around the corners so they stay round.
    function fillRounded(g, x, y, w, h, r, from, to) {
        r = Math.max(0, Math.min(r, w / 2, h / 2));
        if (!to) {
            roundedPath(g, x, y, w, h, r);
            g.fillPath(solidBrush(g, from));
            return;
        }
        var edges = [];
        var i;
        var fine = Math.ceil(r);
        var middle = w - 2 * r;
        var coarse = Math.max(1, Math.ceil(middle / 3));
        for (i = 0; i < fine; i++) edges.push(i * r / fine);
        for (i = 0; i < coarse; i++) edges.push(r + i * middle / coarse);
        for (i = 0; i < fine; i++) edges.push(w - r + i * r / fine);
        edges.push(w);
        for (i = 0; i < edges.length - 1; i++) {
            var x0 = edges[i];
            var x1 = edges[i + 1];
            if (x1 <= x0) continue;
            var t0 = cornerInset(x0, w, r);
            var t1 = cornerInset(x1, w, r);
            var overlap = (i < edges.length - 2) ? 0.5 : 0;   // hides seams between strips
            g.newPath();
            g.moveTo(x + x0, y + t0);
            g.lineTo(x + x1 + overlap, y + t1);
            g.lineTo(x + x1 + overlap, y + h - t1);
            g.lineTo(x + x0, y + h - t0);
            g.lineTo(x + x0, y + t0);
            g.fillPath(solidBrush(g, mix(from, to, (x0 + x1) / 2 / w)));
        }
    }

    // The accent blend, lighter on hover and darker while pressed.
    function accentFill(g, w, h, ds, r) {
        var a = C.accentA;
        var b = C.accentB;
        if (ds && ds.leftButtonPressed) {
            a = mix(a, [0, 0, 0], 0.2);
            b = mix(b, [0, 0, 0], 0.2);
        } else if (ds && ds.mouseOver) {
            a = mix(a, [1, 1, 1], 0.14);
            b = mix(b, [1, 1, 1], 0.14);
        }
        fillRounded(g, 0, 0, w, h, r, a, b);
    }

    function fillDot(g, x, y, r, rgb) {
        g.newPath();
        g.ellipsePath(x - r, y - r, 2 * r, 2 * r);
        g.fillPath(solidBrush(g, rgb));
    }

    function strokeLine(g, pts, pen) {
        if (pts.length < 2) return;
        g.newPath();
        g.moveTo(pts[0][0], pts[0][1]);
        for (var i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]);
        g.strokePath(pen);
    }

    function fillShape(g, pts, rgb) {
        g.newPath();
        g.moveTo(pts[0][0], pts[0][1]);
        for (var i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]);
        g.lineTo(pts[0][0], pts[0][1]);
        g.fillPath(solidBrush(g, rgb));
    }

    function textSize(g, text, font) {
        return g.measureString(text, font !== null ? font : g.font);
    }

    function drawText(g, text, font, rgb, x, y) {
        g.drawString(text, solidPen(g, rgb, 1), x, y, font !== null ? font : g.font);
    }

    function drawCentered(g, text, font, rgb, w, h) {
        var m = textSize(g, text, font);
        drawText(g, text, font, rgb, Math.round((w - m[0]) / 2), Math.round((h - m[1]) / 2));
    }

    // If custom drawing ever fails, the control falls back to its standard look.
    function drawFailed(control, err) {
        try { control.onDraw = undefined; } catch (e) { }
    }

    // ScriptUI has no redraw call: changing the text (buttons) or hiding and showing (groups) repaints.
    function repaint(control) {
        if (!control) return;
        try {
            if (control.type === "group") {
                control.visible = false;
                control.visible = true;
            } else {
                var text = control.text;
                control.text = text + " ";
                control.text = text;
            }
        } catch (e) { }
    }

    // Scales points into a box, keeping their proportions.
    function fitPoints(pts, x, y, w, h) {
        var minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
        var i;
        for (i = 0; i < pts.length; i++) {
            minX = Math.min(minX, pts[i][0]);
            maxX = Math.max(maxX, pts[i][0]);
            minY = Math.min(minY, pts[i][1]);
            maxY = Math.max(maxY, pts[i][1]);
        }
        var bw = Math.max(maxX - minX, 1e-6);
        var bh = Math.max(maxY - minY, 1e-6);
        var s = Math.min(w / bw, h / bh);
        var ox = x + (w - bw * s) / 2 - minX * s;
        var oy = y + (h - bh * s) / 2 - minY * s;
        var out = [];
        for (i = 0; i < pts.length; i++) out.push([ox + pts[i][0] * s, oy + pts[i][1] * s]);
        return { points: out, scale: s, ox: ox, oy: oy };
    }

    // Outline of a path at size 1, for the shape buttons.
    function iconPoints(id) {
        var pts = [];
        var i;
        if (id === "infinity" || id === "circle") {
            for (i = 0; i <= 48; i++) pts.push(id === "infinity" ? lemniscate(i / 48) : pathPoint("circle", 1, 1, i / 48));
            return pts;
        }
        // Written as if/else on purpose: ExtendScript misreads chained a ? b : c ? d : e.
        var count = state.points;
        if (id === "triangle") count = 3;
        else if (id === "square") count = 4;
        else if (id === "polygon") count = state.sides;
        var kind = (id === "star") ? "star" : "polygon";
        var corners = (id === "star") ? count * 2 : count;
        for (i = 0; i <= corners; i++) pts.push(cornerPoint(kind, count, state.depth / 100, i));
        return pts;
    }

    // ------------------------------------------------------------------ widgets

    // Flat button with rounded corners. PRIMARY is always in the accent blend. With isOn, it's
    // a toggle that lights up while isOn() is true. glyph "minus" or "plus" draws that sign.
    function flatButton(parent, text, scheme, size, fontSize, isOn, glyph) {
        var button = parent.add("button", undefined, text);
        button.preferredSize = size;
        var font = makeFont(true, fontSize);
        button.onDraw = function (ds) {
            try {
                var g = this.graphics;
                var w = this.size[0];
                var h = this.size[1];
                var on = scheme.primary || (isOn ? isOn() : false);
                if (on) {
                    accentFill(g, w, h, ds, RADIUS);
                } else {
                    var fill = scheme.base;
                    if (ds && ds.leftButtonPressed) fill = scheme.pressed;
                    else if (ds && ds.mouseOver) fill = scheme.hover;
                    fillRounded(g, 0, 0, w, h, RADIUS, fill);
                }
                var ink = on ? C.ink : scheme.ink;
                if (glyph) {
                    var cx = Math.round(w / 2);
                    var cy = Math.round(h / 2);
                    var pen = solidPen(g, ink, 2);
                    strokeLine(g, [[cx - 4, cy], [cx + 4, cy]], pen);
                    if (glyph === "plus") strokeLine(g, [[cx, cy - 4], [cx, cy + 4]], pen);
                } else {
                    drawCentered(g, this.text, font, ink, w, h);
                }
            } catch (e) {
                drawFailed(this, e);
            }
        };
        return button;
    }

    // Round badge with the infinity sign.
    function addLogo(parent, size) {
        var logo = parent.add("group");
        logo.preferredSize = [size, size];
        logo.onDraw = function () {
            try {
                var g = this.graphics;
                fillDot(g, size / 2, size / 2, size / 2, C.accentA);
                var pts = [];
                for (var i = 0; i <= 64; i++) {
                    var p = lemniscate(i / 64);
                    pts.push([size / 2 + p[0] * size * 0.33, size / 2 + p[1] * size * 0.5]);
                }
                strokeLine(g, pts, solidPen(g, C.ink, 2.5));
            } catch (e) {
                drawFailed(this, e);
            }
        };
        return logo;
    }

    function addGradientLine(parent, height) {
        var line = parent.add("group");
        line.alignment = ["fill", "top"];
        line.preferredSize = [10, height];
        setBackground(line, C.accentA);
        line.onDraw = function () {
            try {
                fillRounded(this.graphics, 0, 0, this.size[0], this.size[1], 0, C.accentA, C.accentB);
            } catch (e) {
                drawFailed(this, e);
            }
        };
        return line;
    }

    function addDivider(parent) {
        var line = parent.add("group");
        line.alignment = ["fill", "top"];
        line.preferredSize = [10, 1];
        setBackground(line, C.divider);
        return line;
    }

    // Small round light for the status line.
    function statusLight(parent, getColor) {
        var light = parent.add("group");
        light.preferredSize = [10, 16];
        light.onDraw = function () {
            try {
                fillDot(this.graphics, 5, 8, 4, getColor());
            } catch (e) {
                drawFailed(this, e);
            }
        };
        return light;
    }

    function shapeTile(parent, shape, onPick) {
        var tile = parent.add("button", undefined, shape.label);
        tile.preferredSize = [36, 32];
        tile.helpTip = shape.tip;
        tile.onDraw = function (ds) {
            try {
                var g = this.graphics;
                var w = this.size[0];
                var h = this.size[1];
                var on = (state.shape === shape.id);
                if (on) accentFill(g, w, h, ds, RADIUS);
                else fillRounded(g, 0, 0, w, h, RADIUS, (ds && ds.mouseOver) ? C.tileHover : C.tile);
                var fit = fitPoints(iconPoints(shape.id), 6, 6, w - 12, h - 12);
                strokeLine(g, fit.points, solidPen(g, on ? C.ink : C.tileIcon, 1.75));
            } catch (e) {
                drawFailed(this, e);
            }
        };
        tile.onClick = function () { onPick(shape.id); };
        return tile;
    }

    function labeledRow(parent, label, tip) {
        var row = parent.add("group");
        row.orientation = "row";
        row.alignment = ["fill", "top"];
        row.alignChildren = ["left", "center"];
        row.spacing = 6;
        var text = row.add("statictext", undefined, label);
        text.preferredSize = [LABEL_WIDTH, 18];
        if (tip) text.helpTip = tip;
        return row;
    }

    // Slider with a number field. The slider covers the usual range; the field takes any value.
    // def: key, label, unit, min, max, scale (slider steps per unit), decimals, lo, hi, live, tip
    function paramRow(parent, def, onChange) {
        var row = labeledRow(parent, def.label, def.tip);
        var slider = row.add("slider", undefined, clamp(state[def.key], def.min, def.max) * def.scale,
            def.min * def.scale, def.max * def.scale);
        slider.alignment = ["fill", "center"];
        slider.preferredSize = [60, 18];
        slider.helpTip = def.tip;
        var field = row.add("edittext", undefined, formatNumber(state[def.key], def.decimals));
        field.characters = 5;
        field.helpTip = def.tip;
        var unit = row.add("statictext", undefined, def.unit);
        unit.preferredSize = [24, 18];
        var syncing = false;

        slider.onChanging = function () {
            if (syncing) return;
            var v = roundTo(this.value / def.scale, def.decimals);
            state[def.key] = v;
            field.text = formatNumber(v, def.decimals);
            if (def.live) onChange(false);
        };
        slider.onChange = function () {
            if (syncing) return;
            this.onChanging();
            onChange(true);
        };
        field.onChange = function () {
            var v = readNumber(this.text);
            if (v === null) v = state[def.key];
            if (def.lo !== null) v = Math.max(def.lo, v);
            if (def.hi !== null) v = Math.min(def.hi, v);
            v = roundTo(v, def.decimals);
            state[def.key] = v;
            this.text = formatNumber(v, def.decimals);
            syncing = true;
            try { slider.value = clamp(v, def.min, def.max) * def.scale; } catch (e) { }
            syncing = false;
            onChange(true);
        };
        return row;
    }

    // [-] number [+] for whole numbers.
    function stepper(parent, label, key, lo, hi, tip, onChange) {
        var row = labeledRow(parent, label, tip);
        row.spacing = 4;
        var minus = flatButton(row, "-", SECONDARY, [24, 22], 13, null, "minus");
        var field = row.add("edittext", undefined, String(state[key]));
        field.characters = 3;
        field.helpTip = tip;
        var plus = flatButton(row, "+", SECONDARY, [24, 22], 13, null, "plus");
        function setCount(v) {
            v = Math.round(clamp(v, lo, hi));
            state[key] = v;
            field.text = String(v);
            onChange();
        }
        minus.onClick = function () { setCount(state[key] - 1); };
        plus.onClick = function () { setCount(state[key] + 1); };
        field.onChange = function () {
            var v = readNumber(this.text);
            setCount(v === null ? state[key] : v);
        };
        return row;
    }

    // A row of toggle buttons where one choice is lit.
    function segmented(parent, choices, size, getValue, setValue) {
        var buttons = [];
        function refresh() {
            for (var j = 0; j < buttons.length; j++) repaint(buttons[j]);
        }
        for (var i = 0; i < choices.length; i++) {
            buttons.push(choiceButton(parent, choices[i], size, getValue, setValue, refresh));
        }
        return buttons;
    }

    function choiceButton(parent, choice, size, getValue, setValue, refresh) {
        var button = flatButton(parent, choice.label, SECONDARY, size, 11, function () { return getValue() === choice.value; });
        if (choice.tip) button.helpTip = choice.tip;
        button.onClick = function () {
            setValue(choice.value);
            refresh();
        };
        return button;
    }

    // ------------------------------------------------------------------ preview

    // The preview card: the path, 40 dots at equal steps in time (they bunch up where the
    // motion eases), the start point with an arrow for the direction, and a small cross
    // where the layer's own position is.
    function drawPreview(canvas) {
        var g = canvas.graphics;
        var w = canvas.size[0];
        var h = canvas.size[1];
        roundedPath(g, 0.5, 0.5, w - 1, h - 1, RADIUS + 2);
        g.fillPath(solidBrush(g, C.canvas));
        roundedPath(g, 0.5, 0.5, w - 1, h - 1, RADIUS + 2);
        g.strokePath(solidPen(g, C.canvasEdge, 1));

        // Title on the left and timing on the right, in a band above the drawing.
        // The timing gets shorter when there isn't room for all of it.
        var titleFont = makeFont(true, 11);
        var title = shapeTitle(state).toUpperCase();
        drawText(g, title, titleFont, C.canvasText, 10, 7);
        var titleEnd = 10 + textSize(g, title, titleFont)[0];
        var loopText = (state.loopTime > 0) ? formatNumber(state.loopTime, 2) + " s" : "Still";
        var easeText = formatNumber(state.ease, 0) + "%";
        var infos = [
            ((state.loopTime > 0) ? loopText + " loop" : "Holding still") + "  \u00B7  " + easeText + " ease",
            loopText + "  \u00B7  " + easeText + " ease",
            loopText + " \u00B7 " + easeText
        ];
        var noteFont = makeFont(false, 10);
        for (var k = 0; k < infos.length; k++) {
            var infoX = Math.round(w - 10 - textSize(g, infos[k], noteFont)[0]);
            if (infoX >= titleEnd + 10) {
                drawText(g, infos[k], noteFont, C.canvasNote, infoX, 8);
                break;
            }
        }

        var count = shapeCount(state);
        var depth = state.depth / 100;
        var i;
        var outline = [];
        for (i = 0; i <= 160; i++) outline.push(rotatePoint(pathPoint(state.shape, count, depth, i / 160), state.rotation));
        var DOTS = 40;
        var dots = [];
        for (i = 0; i < DOTS; i++) {
            var along = motionAlong(state, i / DOTS + state.offset / 100);
            dots.push(rotatePoint(pathPoint(state.shape, count, depth, along), state.rotation));
        }
        var start = motionAlong(state, state.offset / 100);
        var ahead = start + (state.reverse ? -0.03 : 0.03);
        ahead = rotatePoint(pathPoint(state.shape, count, depth, ahead - Math.floor(ahead)), state.rotation);

        var fit = fitPoints(outline, 16, 30, w - 32, h - 42);
        function toCanvas(p) { return [fit.ox + p[0] * fit.scale, fit.oy + p[1] * fit.scale]; }

        var guide = solidPen(g, C.guide, 1);
        var c = toCanvas([0, 0]);
        strokeLine(g, [[c[0] - 5, c[1]], [c[0] + 5, c[1]]], guide);
        strokeLine(g, [[c[0], c[1] - 5], [c[0], c[1] + 5]], guide);

        strokeLine(g, fit.points, solidPen(g, C.path, 2));
        for (i = 1; i < DOTS; i++) {
            var d = toCanvas(dots[i]);
            fillDot(g, d[0], d[1], 2.3, mix(C.dotDim, C.accentA, i / (DOTS - 1)));
        }

        var s0 = toCanvas(dots[0]);
        var s1 = toCanvas(ahead);
        var dx = s1[0] - s0[0];
        var dy = s1[1] - s0[1];
        var len = Math.sqrt(dx * dx + dy * dy);
        if (len > 0.5) {
            dx /= len;
            dy /= len;
            var tip = [s0[0] + dx * 16, s0[1] + dy * 16];
            var back = [s0[0] + dx * 8, s0[1] + dy * 8];
            fillShape(g, [tip, [back[0] - dy * 4.5, back[1] + dx * 4.5], [back[0] + dy * 4.5, back[1] - dx * 4.5]], C.ink);
        }
        fillDot(g, s0[0], s0[1], 5.5, C.ink);
        fillDot(g, s0[0], s0[1], 3.5, C.accentA);
    }

    // ------------------------------------------------------------------ help

    function showHelp() {
        var d = new Window("dialog", NAME + " Help");
        d.orientation = "column";
        d.alignChildren = ["fill", "top"];
        d.margins = 18;
        d.spacing = 8;

        var head = d.add("group");
        head.orientation = "row";
        head.alignChildren = ["left", "center"];
        head.spacing = 10;
        addLogo(head, 34);
        var headText = head.add("group");
        headText.orientation = "column";
        headText.alignChildren = ["left", "top"];
        headText.spacing = 0;
        addText(headText, NAME + " " + VERSION, true, 15);
        addText(headText, "by " + AUTHOR, false, 11);

        addText(d, "How to use", true, 12);
        addParagraph(d, "1. Select the layers to move. With nothing selected, a new null is made for you.\n" +
            "2. Pick a path and set Size, Loop Time and Ease.\n" +
            "3. Click Apply Motion. One undo (Ctrl+Z, or Cmd+Z on a Mac) takes it all back.", 400);

        addText(d, "Ease", true, 12);
        addParagraph(d, "0% moves at a constant speed. 50% comes to a smooth stop at every corner, " +
            "and at both tips of the infinity. 100% is a strong, snappy ease. In the preview, " +
            "each dot is an equal step in time, so dots bunch up where the motion slows down.", 400);

        addText(d, "After applying", true, 12);
        addParagraph(d, "The path is centered on the layer's own position. Its settings are under Effect " +
            "Controls (Path Size, Path Loop Time, Path Ease and so on), where you can change or keyframe them. " +
            "To switch to another path, select the layer, pick the path and click Apply Motion again. " +
            "Remove takes the motion and the controls off the selected layers.", 400);

        addText(d, "Tips", true, 12);
        addParagraph(d, "For a seamless render, make the comp a whole number of loops long.\n" +
            "Loop Time 0 holds the layer still, so you can keyframe Path Offset to move it along the path yourself.\n" +
            "To speed up or slow down over time, keyframe Path Offset rather than Loop Time.\n" +
            "Spread places several selected layers evenly around the path.", 400);

        var ok = d.add("button", undefined, "OK", { name: "ok" });
        ok.alignment = ["right", "top"];
        d.show();
    }

    // ------------------------------------------------------------------ panel

    function buildUI(thisObj) {
        state = loadState();
        var win = (thisObj instanceof Panel) ? thisObj :
            new Window("palette", NAME + " " + VERSION, undefined, { resizeable: true });
        win.orientation = "column";
        win.alignChildren = ["fill", "top"];
        win.spacing = 0;
        win.margins = 0;
        try { fontName = win.graphics.font.name || fontName; } catch (e) { }

        var tiles = [];
        var preview = null;
        var status = null;
        var light = null;
        var statusKind = "idle";

        function setStatus(kind, text) {
            statusKind = kind;
            repaint(light);
            status.text = text;
            status.helpTip = text;
        }

        function changed(save) {
            repaint(preview);
            if (save) saveState();
        }

        // Header: logo, name and version on a dark bar, with the accent gradient under it.
        var top = win.add("group");
        top.orientation = "column";
        top.alignChildren = ["fill", "top"];
        top.spacing = 0;
        top.margins = 0;
        var header = top.add("group");
        header.orientation = "row";
        header.alignChildren = ["left", "center"];
        header.margins = [12, 10, 12, 10];
        header.spacing = 10;
        setBackground(header, C.header);
        addLogo(header, 34);
        var titles = header.add("group");
        titles.orientation = "column";
        titles.alignChildren = ["left", "top"];
        titles.alignment = ["fill", "center"];
        titles.spacing = 1;
        addText(titles, "INFINITY PANEL", true, 16, C.title);
        addText(titles, "by " + AUTHOR, false, 10, C.subtitle);
        var version = addText(header, "v" + VERSION, false, 10, C.subtitle);
        version.alignment = ["right", "top"];
        addGradientLine(top, 3);

        var body = win.add("group");
        body.orientation = "column";
        body.alignChildren = ["fill", "top"];
        body.margins = [12, 12, 12, 12];
        body.spacing = 8;

        // Live preview of the path and its timing.
        preview = body.add("group");
        preview.alignment = ["fill", "top"];
        preview.preferredSize = [236, 132];
        preview.helpTip = "Each dot is an equal step in time: they bunch up where the motion eases. " +
            "The arrow shows where it starts and which way it goes.";
        preview.onDraw = function () {
            try {
                drawPreview(this);
            } catch (e) {
                drawFailed(this, e);
            }
        };

        // Path picker.
        var tileRow = body.add("group");
        tileRow.orientation = "row";
        tileRow.alignment = ["center", "top"];
        tileRow.spacing = 4;
        var optionGroups = {};
        function showOptions() {
            for (var id in optionGroups) {
                if (optionGroups.hasOwnProperty(id)) optionGroups[id].visible = (id === state.shape);
            }
        }
        function pickShape(id) {
            state.shape = id;
            for (var i = 0; i < tiles.length; i++) repaint(tiles[i]);
            showOptions();
            changed(true);
        }
        for (var s = 0; s < SHAPES.length; s++) tiles.push(shapeTile(tileRow, SHAPES[s], pickShape));

        // Options for the chosen path, stacked so the panel doesn't jump around.
        var stack = body.add("group");
        stack.orientation = "stack";
        stack.alignment = ["fill", "top"];
        stack.alignChildren = ["fill", "top"];
        function optionGroup(id) {
            var group = stack.add("group");
            group.orientation = "column";
            group.alignChildren = ["fill", "top"];
            group.spacing = 6;
            optionGroups[id] = group;
            return group;
        }
        function note(group, text) {
            var label = addParagraph(group, text, 220);
            label.alignment = ["fill", "top"];
            return label;
        }

        note(optionGroup("infinity"), "A figure-eight that settles at both tips. Size is half its width.");

        var circleGroup = optionGroup("circle");
        stepper(circleGroup, "Stops", "stops", 1, 12, "How many times per loop it eases to a stop.",
            function () { changed(true); });
        note(circleGroup, "Eased stops per loop. 1 is a single smooth pause.");

        note(optionGroup("triangle"), "Settles into all three corners. Size is the distance to a corner.");
        note(optionGroup("square"), "Settles into all four corners. Size is the distance to a corner.");

        var polygonGroup = optionGroup("polygon");
        var sidesRow = labeledRow(polygonGroup, "Sides", "How many sides the polygon has.");
        sidesRow.spacing = 4;
        segmented(sidesRow, [
            { label: "5", value: 5, tip: "Pentagon" },
            { label: "6", value: 6, tip: "Hexagon" },
            { label: "7", value: 7, tip: "Heptagon" },
            { label: "8", value: 8, tip: "Octagon" }
        ], [30, 22], function () { return state.sides; }, function (v) {
            state.sides = v;
            repaint(tiles[4]);
            changed(true);
        });
        note(polygonGroup, "Pentagon, hexagon, heptagon or octagon. Settles into every corner.");

        var starGroup = optionGroup("star");
        stepper(starGroup, "Points", "points", 3, 12, "How many points the star has.", function () {
            repaint(tiles[5]);
            changed(true);
        });
        paramRow(starGroup, {
            key: "depth", label: "Depth", unit: "%", min: 5, max: 95, scale: 1, decimals: 0, lo: 5, hi: 95, live: true,
            tip: "How far in the star's inner corners sit, as a percentage of Size. Lower is spikier."
        }, function (save) {
            repaint(tiles[5]);
            changed(save);
        });
        showOptions();

        addDivider(body);

        // Motion settings.
        var params = [
            { key: "size", label: "Size", unit: "px", min: 0, max: 1000, scale: 1, decimals: 0, lo: 0, hi: null, live: false,
                tip: "How far the layer travels from its own position: the distance from the center to the corners." },
            { key: "loopTime", label: "Loop Time", unit: "sec", min: 0, max: 20, scale: 10, decimals: 2, lo: 0, hi: null, live: false,
                tip: "Seconds for one trip around the path. 0 holds it still, so you can keyframe Path Offset." },
            { key: "ease", label: "Ease", unit: "%", min: 0, max: 100, scale: 1, decimals: 0, lo: 0, hi: 100, live: true,
                tip: "0% = constant speed. 50% = a smooth stop at every corner. 100% = a strong, snappy ease." },
            { key: "rotation", label: "Rotation", unit: "\u00B0", min: -180, max: 180, scale: 1, decimals: 1, lo: null, hi: null, live: true,
                tip: "Turns the whole path around the layer's position." },
            { key: "offset", label: "Offset", unit: "%", min: 0, max: 100, scale: 1, decimals: 1, lo: null, hi: null, live: true,
                tip: "Where along the path the layer starts." }
        ];
        for (var p = 0; p < params.length; p++) paramRow(body, params[p], changed);

        var directionRow = labeledRow(body, "Direction", "Which way it travels around the path.");
        directionRow.spacing = 4;
        segmented(directionRow, [
            { label: "Forward", value: false, tip: "Clockwise around circles and polygons" },
            { label: "Reverse", value: true, tip: "The other way around" }
        ], [76, 22], function () { return state.reverse; }, function (v) {
            state.reverse = v;
            changed(true);
        });

        var spreadCheck = body.add("checkbox", undefined, "Spread layers around the path");
        spreadCheck.value = state.spread;
        spreadCheck.helpTip = "With more than one layer selected, each starts at an even step around the path.";
        spreadCheck.onClick = function () {
            state.spread = this.value;
            saveState();
        };

        var applyButton = flatButton(body, "Apply Motion", PRIMARY, [100, 36], 13);
        applyButton.alignment = ["fill", "top"];
        applyButton.helpTip = "Moves the selected layers along the path. With nothing selected, a new null is made.";

        var tools = body.add("group");
        tools.orientation = "row";
        tools.alignment = ["fill", "top"];
        tools.spacing = 6;
        var removeButton = flatButton(tools, "Remove", SECONDARY, [86, 24], 11);
        removeButton.alignment = ["fill", "center"];
        removeButton.helpTip = "Takes the motion and its controls off the selected layers.";
        var helpButton = flatButton(tools, "Help", SECONDARY, [60, 24], 11);
        helpButton.alignment = ["fill", "center"];
        helpButton.helpTip = "How the panel works, with tips.";

        addDivider(body);

        var statusRow = body.add("group");
        statusRow.orientation = "row";
        statusRow.alignChildren = ["left", "top"];
        statusRow.spacing = 6;
        light = statusLight(statusRow, function () { return C[statusKind]; });
        status = statusRow.add("statictext", undefined, STEPS, { multiline: true });
        status.alignment = ["fill", "top"];
        status.preferredSize = [200, 64];
        setStatus("idle", STEPS);

        function run(action, undoName) {
            app.beginUndoGroup(undoName);
            try {
                var result = action();
                setStatus(result.warning ? "warn" : "ok", result.text);
            } catch (err) {
                if (err && err.isUser) {
                    setStatus("error", err.message);
                } else {
                    setStatus("error", "Something went wrong. Press Ctrl+Z (Cmd+Z on a Mac) to undo anything it changed.");
                    alert("Something went wrong: " + String(err) + (err && err.line ? " (line " + err.line + ")" : ""), NAME);
                }
            } finally {
                app.endUndoGroup();
            }
        }

        applyButton.onClick = function () {
            saveState();
            run(function () { return applyMotion(state); }, NAME + ": " + shapeTitle(state));
        };
        removeButton.onClick = function () {
            run(removeMotion, NAME + ": Remove");
        };
        helpButton.onClick = function () {
            showHelp();
        };

        win.onResizing = win.onResize = function () { this.layout.resize(); };
        if (win instanceof Window) {
            win.center();
            win.show();
        } else {
            win.layout.layout(true);
            win.layout.resize();
        }
        return win;
    }

    buildUI(thisObj);
})(this);
