javascriptmathcanvasfabricjsskeletal-animation

How can I create a 2D skeleton using forward kinematics in Fabric.js/JavaScript?


The default Fabric.js stickman example is very limited in the things it can achieve. Using forward kinematics, how can I rewrite the stickman to add parent-children relationships between the nodes and rotate all children if a parent node is dragged?


Solution

  • I didn't have prior experience implementing forward kinematics, but I thought I'd give it a go anyways (so apologies in advance if my code doesn't match other tutorials 😅).

    In my opinion, the best approach is to have a class (Joint) that represents the tree of joints, and another class (Skeleton) that wraps over the joint tree and maps each joint to a circle on the canvas. It also helps to make the tree bi-directional, so that child joints can easily access their parent joints. With this design, it was fairly easy to design Joint to hold only state relevant to the relative positioning of joints, whereas only Skeleton dealt with rendering and updating the joint positions.

    And after defining a few helper functions and doing some high-school-level math, it wasn't too bad to piece it all together. Does this match your expected behavior?

    /** @typedef {{distance: number, angle: number}} JointPlacement */
    /** @typedef {{x: number, y: number}} Posn A position*/
    /** @typedef {0} ForwardDirection */
    /** @typedef {1} BackwardDirection */
    /** @typedef {ForwardDirection | BackwardDirection} BijectionDirection */
    
    var canvas = new fabric.Canvas("c", { selection: false });
    fabric.Object.prototype.originX = fabric.Object.prototype.originY = "center";
    
    /**
     * Represents a Bijection (two-way map)
     * @template T
     * @template U
     */
    class Bijection {
        /** @type {ForwardDirection} */
        static FORWARD = 0;
        /** @type {BackwardDirection} */
        static BACKWARD = 1;
    
        /** @type {[Map<T,U>, Map<U,T>]} */
        #maps = [new Map(), new Map()];
    
        /**
         * @template {BijectionDirection} Direction
         * @param {Direction} direction
         * @param {Direction extends ForwardDirection ? T : U} key
         * @returns {(Direction extends ForwardDirection ? U : T) | undefined}
         */
        get(direction, key) {
            return this.#maps[direction].get(key);
        }
    
        /**
         * @param {T} forwardKey
         * @param {U} backwardKey
         */
        set(forwardKey, backwardKey) {
            // ensure order is consistent
            const frwdMappedVal = this.#maps[0].get(forwardKey);
            this.#maps[0].delete(forwardKey);
            this.#maps[1].delete(frwdMappedVal);
    
            const backMappedVal = this.#maps[1].get(backwardKey);
            this.#maps[0].delete(backwardKey);
            this.#maps[1].delete(backMappedVal);
    
            // populate maps with new values
            this.#maps[0].set(forwardKey, backwardKey);
            this.#maps[1].set(backwardKey, forwardKey);
        }
    }
    
    class Joint {
        /** @type {Map<Joint, JointPlacement>} */
        #children = new Map();
        /** @type {Joint?} */
        #parent = null;
        /** @type {Joint?} */
        #root = null;
    
        constructor(name) {
            this.name = name; // for debugging convenience
        }
    
        getChildren() {
            return [...this.#children];
        }
    
        getChildPlacement(node) {
            return this.#children.get(node);
        }
    
        getParent() {
            return this.#parent;
        }
    
        addChild(node, distance, globalAngle) {
            if (node.#root !== null || node === this) {
                throw new Error(`Detected a cycle: "${node.name}" is already in the tree.`);
            }
    
            this.#children.set(node, { distance, angle: globalAngle });
            node.#parent = this;
            node.#root = this.#root ?? this;
            return this;
        }
    
        /**
         * Recursively updates child joints with angle change.
         * @param {number} dTheta
         */
        transmitPlacementChange(dTheta) {
            for (const [childJoint, { distance, angle }] of this.#children) {
                this.#children.set(childJoint, { distance, angle: angle + dTheta });
                childJoint.transmitPlacementChange(dTheta);
            }
        }
    }
    
    class Skeleton {
        static #SCALE_FACTOR = 25;
    
        /** @type {Bijection<Joint, fabric.Circle>} */
        #jointCircleBijection = new Bijection();
        /** @type {Joint, fabric.Line>} */
        #jointLineMap = new Map();
        /** @type {Joint} */
        #rootJoint;
        /** @type {Posn} */
        #posn;
    
        /**
         * @param {Joint} rootJoint
         * @param {Posn} param1
         */
        constructor(rootJoint, posn) {
            this.#rootJoint = rootJoint;
            this.#posn = posn;
            this.#draw();
        }
    
        getRootJoint() {
            return this.#rootJoint;
        }
    
        /**
         * Tries to move the joint to the given coordinates as close as possible.
         * Affects child joints but not parents (no inverse kinematics)
         * @param {fabric.Circle} circle
         * @param {Posn} coords
         */
        moveJoint(circle, coords) {
            const joint = this.#jointCircleBijection.get(Bijection.BACKWARD, circle);
            if (!joint) return;
    
            if (joint === this.#rootJoint) {
                this.#drawSubtree(this.#rootJoint, coords);
                return;
            }
    
            /** @type {Joint} */
            const parent = joint.getParent();
            const parentCircle = this.#jointCircleBijection.get(Bijection.FORWARD, parent);
            const px = parentCircle.left;
            const py = parentCircle.top;
    
            const { x, y } = coords;
            const rawAngle = toDegrees(Math.atan2(py - y, x - px));
            const newAngle = rawAngle < 0 ? rawAngle + 360 : rawAngle;
    
            const jointPlacement = parent.getChildPlacement(joint);
            if (jointPlacement) {
                const { angle: oldAngle } = jointPlacement;
                jointPlacement.angle = newAngle;
                const dTheta = newAngle - oldAngle;
                joint.transmitPlacementChange(dTheta);
    
                this.#drawSubtree(parent, { x: px, y: py });
            }
        }
    
        /** Draws the skeleton starting from the root joint. */
        #draw() {
            this.#drawSubtree(this.#rootJoint, this.#posn);
            this.#jointCircleBijection.get(Bijection.FORWARD, this.#rootJoint)?.set({ fill: "red" });
        }
    
        /**
         * Recursively draws the skeleton's joints.
         * @param {Joint} root
         * @param {Posn} param1
         */
        #drawSubtree(root, { x, y }) {
            const SCALE_FACTOR = Skeleton.#SCALE_FACTOR;
    
            for (const [joint, { distance, angle }] of root.getChildren()) {
                const childX = x + Math.cos(toRadians(angle)) * distance * SCALE_FACTOR;
                const childY = y + Math.sin(toRadians(angle)) * distance * -SCALE_FACTOR;
    
                let line = this.#jointLineMap.get(joint);
                if (line) {
                    line.set({ x1: x, y1: y, x2: childX, y2: childY });
                    line.setCoords();
                } else {
                    line = makeLine([x, y, childX, childY]);
                    this.#jointLineMap.set(joint, line);
                    canvas.add(line);
                }
    
                this.#drawSubtree(joint, { x: childX, y: childY });
            }
    
            let circle = this.#jointCircleBijection.get(Bijection.FORWARD, root);
            if (circle) {
                circle.set({ left: x, top: y });
                circle.setCoords();
            } else {
                circle = makeCircle(x, y);
                this.#jointCircleBijection.set(root, circle);
                canvas.add(circle);
            }
        }
    }
    
    /**
     * Creates a skeleton in the shape of a human.
     * @param {Posn} posn
     * @returns {Skeleton}
     */
    function buildHumanSkeleton(posn) {
        const root = new Joint("hips");
        const leftFoot = new Joint("left foot");
        const rightFoot = new Joint("left foot");
        const leftKnee = new Joint("left knee");
        const rightKnee = new Joint("left knee");
        const chest = new Joint("chest");
        const leftWrist = new Joint("left wrist");
        const rightWrist = new Joint("right wrist");
        const leftElbow = new Joint("left elbow");
        const rightElbow = new Joint("right elbow");
        const leftHand = new Joint("left hand");
        const rightHand = new Joint("right hand");
        const head = new Joint("head");
    
        rightElbow.addChild(rightWrist.addChild(rightHand, 1.1, 315), 1.75, 315);
        leftElbow.addChild(leftWrist.addChild(leftHand, 1.1, 225), 1.75, 225);
        chest.addChild(leftElbow, 2, 225).addChild(rightElbow, 2, 315).addChild(head, 2, 90);
    
        leftKnee.addChild(leftFoot, 2, 240);
        rightKnee.addChild(rightFoot, 2, 300);
        root.addChild(leftKnee, 3.5, 240).addChild(rightKnee, 3.5, 300).addChild(chest, 2.75, 90);
    
        return new Skeleton(root, posn);
    }
    
    const skeleton = buildHumanSkeleton({ x: 175, y: 175 });
    
    /**
     * @param {[number,number,number,number]} coords
     * @returns {fabric.Line}
     */
    function makeLine(coords) {
        return new fabric.Line(coords, {
            fill: "black",
            stroke: "black",
            strokeWidth: 5,
            selectable: false,
            evented: false,
        });
    }
    
    /**
     * @param {number} x
     * @param {number} y
     * @returns {fabric.Circle}
     */
    function makeCircle(x, y) {
        return new fabric.Circle({
            left: x,
            top: y,
            strokeWidth: 3,
            radius: 10,
            fill: "#fff",
            stroke: "black",
        });
    }
    
    /** @param {number} degrees */
    function toRadians(degrees) {
        return (degrees * Math.PI) / 180;
    }
    
    /** @param {number} radians */
    function toDegrees(radians) {
        return (radians * 180) / Math.PI;
    }
    
    canvas.renderAll();
    canvas.on("object:moving", function (event) {
        const { target, pointer } = event;
        if (target.type === "circle") {
            skeleton.moveJoint(target, pointer);
        }
    });
    <script src="https://unpkg.com/fabric@5.3.0/dist/fabric.min.js"></script>
    <canvas id="c" width="350" height="350" style="border: 1px solid black"></canvas>

    Feel free to ask me any follow-up questions!