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?
1 Answers
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!

- 2,085
- 1
- 7
- 16
-
This works very well and the code is quite neat, though it is missing the SVG support on top of the circles. If you could kindly add that, then the bounty is all yours. – Venk Mar 23 '23 at 22:42
-
I can't implement a whole animator for you, that's above my pay grade. Your question was simply about creating a 2D skeleton with forward kinematics, and the code I have provided is a good starting point. Adding SVG support on top of it is outside the scope of your original question. But if you want to do it yourself, the easiest way would probably be to modify the `Skeleton` class's `#jointLineMap` to use SVGs instead, and then assign them to Joints by name. – Dennis Kats Mar 24 '23 at 05:50
-
My answer already took quite a significant time to implement and debug, and if you find it at all helpful to move forward with your project, then I hope you can still reward me the bounty. – Dennis Kats Mar 24 '23 at 06:03
-
1You’re right, sorry, I missed that part. But I believe my point still stands that I provided a decent starting point, and in the spirit of learning, you should try to tackle the remaining parts on your own. If you’re still struggling, I’m still open to answering some follow up question, or you can open another, more focused Stack Overflow question. Good luck with your project! – Dennis Kats Mar 24 '23 at 08:11