diff --git a/scripts/developer/tests/dynamics/dynamics-tests-interface.js b/scripts/developer/tests/dynamics/dynamics-tests-interface.js
new file mode 100644
index 0000000000..53517fab24
--- /dev/null
+++ b/scripts/developer/tests/dynamics/dynamics-tests-interface.js
@@ -0,0 +1,64 @@
+"use strict";
+/* globals $, EventBridge */
+
+var parameters = {
+ "lifetime":"integer"
+};
+
+
+function getQueryArgByName(name, url) {
+ if (!url) {
+ url = window.location.href;
+ }
+ name = name.replace(/[\[\]]/g, "\\$&");
+ var regex = new RegExp("[?&]" + name + "(=([^]*)|&|#|$)"),
+ results = regex.exec(url);
+ if (!results) return null;
+ if (!results[2]) return '';
+ return decodeURIComponent(results[2].replace(/\+/g, " "));
+}
+
+
+function addCommandParameters(params) {
+ // copy from html elements into an associative-array which will get passed (as JSON) through the EventBridge
+ for (var parameterName in parameters) {
+ if (parameters.hasOwnProperty(parameterName)) {
+ var parameterType = parameters[parameterName];
+ var strVal = $("#" + parameterName).val();
+ if (parameterType == "integer") {
+ params[parameterName] = parseInt(strVal);
+ } else if (parameterType == "float") {
+ params[parameterName] = parseFloat(strVal);
+ } else {
+ params[parameterName] = strVal;
+ }
+ }
+ }
+ return params;
+}
+
+
+$(document).ready(function() {
+ // hook all buttons to EventBridge
+ $(":button").each(function(index) {
+ $(this).click(function() {
+ EventBridge.emitWebEvent(JSON.stringify(addCommandParameters({ "dynamics-tests-command": this.id })));
+ });
+ });
+
+ // copy parameters from query-args into elements
+ for (var parameterName in parameters) {
+ if (parameters.hasOwnProperty(parameterName)) {
+ var val = getQueryArgByName(parameterName);
+ if (val) {
+ var parameterType = parameters[parameterName];
+ if (parameterType == "integer") {
+ val = parseInt(val);
+ } else if (parameterType == "float") {
+ val = parseFloat(val);
+ }
+ $("#" + parameterName).val(val.toString());
+ }
+ }
+ }
+});
diff --git a/scripts/developer/tests/dynamics/dynamics-tests.html b/scripts/developer/tests/dynamics/dynamics-tests.html
new file mode 100644
index 0000000000..0f324e121c
--- /dev/null
+++ b/scripts/developer/tests/dynamics/dynamics-tests.html
@@ -0,0 +1,37 @@
+
+
+ Dynamics Tests
+
+
+
+
+
+
+ lifetime:
+
+
+ A platform with a lever. The lever can be moved in a cone and rotated. A spring brings it back to its neutral position.
+
+
+ A grabbable door with a hinge between it and world-space.
+
+
+ A chain of blocks connected by hinges.
+
+
+ The block can only move up and down over a range of 1/2 meter.
+
+
+ A chain of blocks connected by slider constraints.
+
+
+ A chain of spheres connected by ball-and-socket joints between the spheres.
+
+
+ A chain of spheres connected by ball-and-socket joints coincident-with the spheres.
+
+
+ A self-righting ragdoll. The head is on a weak spring vs the body.
+
+
+
diff --git a/scripts/developer/tests/dynamics/dynamicsTests.js b/scripts/developer/tests/dynamics/dynamicsTests.js
new file mode 100644
index 0000000000..18585ef152
--- /dev/null
+++ b/scripts/developer/tests/dynamics/dynamicsTests.js
@@ -0,0 +1,749 @@
+
+"use strict";
+
+/* global Entities, Script, Tablet, MyAvatar, Vec3 */
+
+(function() { // BEGIN LOCAL_SCOPE
+
+ var DYNAMICS_TESTS_URL = Script.resolvePath("dynamics-tests.html");
+ var DEFAULT_LIFETIME = 120; // seconds
+
+ var tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system");
+
+ var button = tablet.addButton({
+ icon: Script.resolvePath("dynamicsTests.svg"),
+ text: "Dynamics",
+ sortOrder: 15
+ });
+
+
+
+ function coneTwistAndSpringLeverTest(params) {
+ var pos = Vec3.sum(MyAvatar.position, Vec3.multiplyQbyV(MyAvatar.orientation, {x: 0, y: -0.5, z: -2}));
+ var lifetime = params.lifetime;
+
+ var baseID = Entities.addEntity({
+ name: "cone-twist test -- base",
+ type: "Box",
+ color: { blue: 128, green: 100, red: 20 },
+ dimensions: { x: 0.5, y: 0.2, z: 0.5 },
+ position: Vec3.sum(pos, { x: 0, y: 0, z:0 }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: 0, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ var leverID = Entities.addEntity({
+ name: "cone-twist test -- lever",
+ type: "Box",
+ color: { blue: 128, green: 100, red: 200 },
+ dimensions: { x: 0.05, y: 1, z: 0.05 },
+ position: Vec3.sum(pos, { x: 0, y: 0.7, z:0 }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: 0, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addEntity({
+ name: "cone-twist test -- handle",
+ type: "Box",
+ color: { blue: 30, green: 100, red: 200 },
+ dimensions: { x: 0.1, y: 0.08, z: 0.08 },
+ position: Vec3.sum(pos, { x: 0, y: 0.7 + 0.5, z:0 }),
+ dynamic: false,
+ collisionless: true,
+ gravity: { x: 0, y: 0, z: 0 },
+ lifetime: lifetime,
+ parentID: leverID,
+ userData: "{ \"grabbableKey\": { \"grabbable\": false } }"
+ });
+
+ Entities.addAction("cone-twist", baseID, {
+ pivot: { x: 0, y: 0.2, z: 0 },
+ axis: { x: 0, y: 1, z: 0 },
+ otherEntityID: leverID,
+ otherPivot: { x: 0, y: -0.55, z: 0 },
+ otherAxis: { x: 0, y: 1, z: 0 },
+ swingSpan1: Math.PI / 4,
+ swingSpan2: Math.PI / 4,
+ twistSpan: Math.PI / 2,
+ tag: "cone-twist test"
+ });
+
+ Entities.addAction("spring", leverID, {
+ targetRotation: { x: 0, y: 0, z: 0, w: 1 },
+ angularTimeScale: 0.2,
+ tag: "cone-twist test spring"
+ });
+
+
+ Entities.editEntity(baseID, { gravity: { x: 0, y: -5, z: 0 } });
+ }
+
+ function doorVSWorldTest(params) {
+ var pos = Vec3.sum(MyAvatar.position, Vec3.multiplyQbyV(MyAvatar.orientation, {x: 0, y: 0.1, z: -2}));
+ var lifetime = params.lifetime;
+
+ var doorID = Entities.addEntity({
+ name: "door test",
+ type: "Box",
+ color: { blue: 128, green: 20, red: 20 },
+ dimensions: { x: 1.0, y: 2, z: 0.1 },
+ position: pos,
+ dynamic: true,
+ collisionless: false,
+ lifetime: lifetime,
+ gravity: { x: 0, y: 0, z: 0 },
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("hinge", doorID, {
+ pivot: { x: -0.5, y: 0, z: 0 },
+ axis: { x: 0, y: 1, z: 0 },
+ low: 0,
+ high: Math.PI,
+ tag: "door hinge test"
+ });
+ }
+
+ function hingeChainTest(params) {
+ var pos = Vec3.sum(MyAvatar.position, Vec3.multiplyQbyV(MyAvatar.orientation, {x: 0, y: 0.1, z: -2}));
+ var lifetime = params.lifetime;
+
+ var offset = 0.28;
+ var prevEntityID = null;
+ for (var i = 0; i < 5; i++) {
+ var newID = Entities.addEntity({
+ name: "hinge test " + i,
+ type: "Box",
+ color: { blue: 128, green: 40 * i, red: 20 },
+ dimensions: { x: 0.2, y: 0.2, z: 0.1 },
+ position: Vec3.sum(pos, {x: 0, y: offset * i, z:0}),
+ dynamic: true,
+ collisionless: false,
+ lifetime: lifetime,
+ gravity: { x: 0, y: 0, z: 0 },
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+ if (prevEntityID) {
+ Entities.addAction("hinge", prevEntityID, {
+ pivot: { x: 0, y: offset / 2, z: 0 },
+ axis: { x: 1, y: 0, z: 0 },
+ otherEntityID: newID,
+ otherPivot: { x: 0, y: -offset / 2, z: 0 },
+ otherAxis: { x: 1, y: 0, z: 0 },
+ tag: "A/B hinge test " + i
+ });
+ }
+ prevEntityID = newID;
+ }
+ }
+
+ function sliderVSWorldTest(params) {
+ var pos = Vec3.sum(MyAvatar.position, Vec3.multiplyQbyV(MyAvatar.orientation, {x: 0, y: 0.1, z: -2}));
+ var lifetime = params.lifetime;
+
+ var sliderEntityID = Entities.addEntity({
+ name: "slider test",
+ type: "Box",
+ color: { blue: 128, green: 20, red: 20 },
+ dimensions: { x: 0.2, y: 0.2, z: 0.2 },
+ position: pos,
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: 0, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("slider", sliderEntityID, {
+ point: { x: -0.5, y: 0, z: 0 },
+ axis: { x: 0, y: 1, z: 0 },
+ linearLow: 0,
+ linearHigh: 0.6,
+ tag: "slider test"
+ });
+ }
+
+ function sliderChainTest(params) {
+ var pos = Vec3.sum(MyAvatar.position, Vec3.multiplyQbyV(MyAvatar.orientation, {x: 0, y: 0.1, z: -2}));
+ var lifetime = params.lifetime;
+
+ var offset = 0.28;
+ var prevEntityID = null;
+ for (var i = 0; i < 7; i++) {
+ var newID = Entities.addEntity({
+ name: "hinge test " + i,
+ type: "Box",
+ color: { blue: 128, green: 40 * i, red: 20 },
+ dimensions: { x: 0.2, y: 0.1, z: 0.2 },
+ position: Vec3.sum(pos, {x: 0, y: offset * i, z:0}),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: 0, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+ if (prevEntityID) {
+ Entities.addAction("slider", prevEntityID, {
+ point: { x: 0, y: 0, z: 0 },
+ axis: { x: 0, y: 1, z: 0 },
+ otherEntityID: newID,
+ otherPoint: { x: 0, y: -offset / 2, z: 0 },
+ otherAxis: { x: 0, y: 1, z: 0 },
+ linearLow: 0,
+ linearHigh: 0.6,
+ tag: "A/B slider test " + i
+ });
+ }
+ prevEntityID = newID;
+ }
+ }
+
+ function ballSocketBetweenTest(params) {
+ var pos = Vec3.sum(MyAvatar.position, Vec3.multiplyQbyV(MyAvatar.orientation, {x: 0, y: 0.1, z: -2}));
+ var lifetime = params.lifetime;
+
+ var offset = 0.2;
+ var diameter = offset - 0.01;
+ var prevEntityID = null;
+ for (var i = 0; i < 7; i++) {
+ var newID = Entities.addEntity({
+ name: "ball and socket test " + i,
+ type: "Sphere",
+ color: { blue: 128, green: 40 * i, red: 20 },
+ dimensions: { x: diameter, y: diameter, z: diameter },
+ position: Vec3.sum(pos, {x: 0, y: offset * i, z:0}),
+ dynamic: true,
+ collisionless: false,
+ lifetime: lifetime,
+ gravity: { x: 0, y: 0, z: 0 },
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+ if (prevEntityID) {
+ Entities.addAction("ball-socket", prevEntityID, {
+ pivot: { x: 0, y: offset / 2, z: 0 },
+ otherEntityID: newID,
+ otherPivot: { x: 0, y: -offset / 2, z: 0 },
+ tag: "A/B ball-and-socket test " + i
+ });
+ }
+ prevEntityID = newID;
+ }
+ }
+
+ function ballSocketCoincidentTest(params) {
+ var pos = Vec3.sum(MyAvatar.position, Vec3.multiplyQbyV(MyAvatar.orientation, {x: 0, y: 0.1, z: -2}));
+ var lifetime = params.lifetime;
+
+ var offset = 0.2;
+ var diameter = offset - 0.01;
+ var prevEntityID = null;
+ for (var i = 0; i < 7; i++) {
+ var newID = Entities.addEntity({
+ name: "ball and socket test " + i,
+ type: "Sphere",
+ color: { blue: 128, green: 40 * i, red: 20 },
+ dimensions: { x: diameter, y: diameter, z: diameter },
+ position: Vec3.sum(pos, {x: 0, y: offset * i, z:0}),
+ dynamic: true,
+ collisionless: false,
+ lifetime: lifetime,
+ gravity: { x: 0, y: 0, z: 0 },
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+ if (prevEntityID) {
+ Entities.addAction("ball-socket", prevEntityID, {
+ pivot: { x: 0, y: 0, z: 0 },
+ otherEntityID: newID,
+ otherPivot: { x: 0, y: offset, z: 0 },
+ tag: "A/B ball-and-socket test " + i
+ });
+ }
+ prevEntityID = newID;
+ }
+ }
+
+ function ragdollTest(params) {
+ var scale = 1.6;
+ var lifetime = 120;
+ var pos = Vec3.sum(MyAvatar.position, Vec3.multiplyQbyV(MyAvatar.orientation, {x: 0, y: 1.0, z: -2}));
+
+ var neckLength = scale * 0.05;
+ var shoulderGap = scale * 0.1;
+ var elbowGap = scale * 0.06;
+ var hipGap = scale * 0.07;
+ var kneeGap = scale * 0.08;
+ var ankleGap = scale * 0.06;
+ var ankleMin = 0;
+ var ankleMax = Math.PI / 4;
+
+ var headSize = scale * 0.2;
+
+ var bodyHeight = scale * 0.4;
+ var bodyWidth = scale * 0.3;
+ var bodyDepth = scale * 0.2;
+
+ var upperArmThickness = scale * 0.05;
+ var upperArmLength = scale * 0.2;
+
+ var lowerArmThickness = scale * 0.05;
+ var lowerArmLength = scale * 0.2;
+
+ var legLength = scale * 0.3;
+ var legThickness = scale * 0.08;
+
+ var shinLength = scale * 0.2;
+ var shinThickness = scale * 0.06;
+
+ var footLength = scale * 0.2;
+ var footThickness = scale * 0.03;
+ var footWidth = scale * 0.08;
+
+
+ //
+ // body
+ //
+
+ var bodyID = Entities.addEntity({
+ name: "ragdoll body",
+ type: "Box",
+ color: { blue: 128, green: 100, red: 20 },
+ dimensions: { x: bodyDepth, y: bodyHeight, z: bodyWidth },
+ position: Vec3.sum(pos, { x: 0, y: scale * 0.0, z:0 }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: 0, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ //
+ // head
+ //
+
+ var headID = Entities.addEntity({
+ name: "ragdoll head",
+ type: "Box",
+ color: { blue: 128, green: 100, red: 20 },
+ dimensions: { x: headSize, y: headSize, z: headSize },
+ position: Vec3.sum(pos, { x: 0, y: bodyHeight / 2 + headSize / 2 + neckLength, z:0 }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: 0.5, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("spring", headID, {
+ targetRotation: { x: 0, y: 0, z: 0, w: 1 },
+ angularTimeScale: 2.0,
+ otherID: bodyID,
+ tag: "cone-twist test spring"
+ });
+
+
+ var noseID = Entities.addEntity({
+ name: "ragdoll nose",
+ type: "Box",
+ color: { blue: 128, green: 100, red: 100 },
+ dimensions: { x: headSize / 5, y: headSize / 5, z: headSize / 5 },
+ localPosition: { x: headSize / 2 + headSize / 10, y: 0, z: 0 },
+ dynamic: false,
+ collisionless: true,
+ lifetime: lifetime,
+ parentID: headID,
+ userData: "{ \"grabbableKey\": { \"grabbable\": false } }"
+ });
+
+ Entities.addAction("cone-twist", headID, {
+ pivot: { x: 0, y: -headSize / 2 - neckLength / 2, z: 0 },
+ axis: { x: 0, y: 1, z: 0 },
+ otherEntityID: bodyID,
+ otherPivot: { x: 0, y: bodyHeight / 2 + neckLength / 2, z: 0 },
+ otherAxis: { x: 0, y: 1, z: 0 },
+ swingSpan1: Math.PI / 4,
+ swingSpan2: Math.PI / 4,
+ twistSpan: Math.PI / 2,
+ tag: "ragdoll neck joint"
+ });
+
+ //
+ // right upper arm
+ //
+
+ var rightUpperArmID = Entities.addEntity({
+ name: "ragdoll right arm",
+ type: "Box",
+ color: { blue: 128, green: 100, red: 20 },
+ dimensions: { x: upperArmThickness, y: upperArmThickness, z: upperArmLength },
+ position: Vec3.sum(pos, { x: 0,
+ y: bodyHeight / 2 + upperArmThickness / 2,
+ z: bodyWidth / 2 + shoulderGap + upperArmLength / 2
+ }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: 0, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("cone-twist", bodyID, {
+ pivot: { x: 0, y: bodyHeight / 2 + upperArmThickness / 2, z: bodyWidth / 2 + shoulderGap / 2 },
+ axis: { x: 0, y: 0, z: 1 },
+ otherEntityID: rightUpperArmID,
+ otherPivot: { x: 0, y: 0, z: -upperArmLength / 2 - shoulderGap / 2 },
+ otherAxis: { x: 0, y: 0, z: 1 },
+ swingSpan1: Math.PI / 2,
+ swingSpan2: Math.PI / 2,
+ twistSpan: 0,
+ tag: "ragdoll right shoulder joint"
+ });
+
+ //
+ // left upper arm
+ //
+
+ var leftUpperArmID = Entities.addEntity({
+ name: "ragdoll left arm",
+ type: "Box",
+ color: { blue: 128, green: 100, red: 20 },
+ dimensions: { x: upperArmThickness, y: upperArmThickness, z: upperArmLength },
+ position: Vec3.sum(pos, { x: 0,
+ y: bodyHeight / 2 + upperArmThickness / 2,
+ z: -bodyWidth / 2 - shoulderGap - upperArmLength / 2
+ }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: 0, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("cone-twist", bodyID, {
+ pivot: { x: 0, y: bodyHeight / 2 + upperArmThickness / 2, z: -bodyWidth / 2 - shoulderGap / 2 },
+ axis: { x: 0, y: 0, z: -1 },
+ otherEntityID: leftUpperArmID,
+ otherPivot: { x: 0, y: 0, z: upperArmLength / 2 + shoulderGap / 2 },
+ otherAxis: { x: 0, y: 0, z: -1 },
+ swingSpan1: Math.PI / 2,
+ swingSpan2: Math.PI / 2,
+ twistSpan: 0,
+ tag: "ragdoll left shoulder joint"
+ });
+
+ //
+ // right lower arm
+ //
+
+ var rightLowerArmID = Entities.addEntity({
+ name: "ragdoll right lower arm",
+ type: "Box",
+ color: { blue: 128, green: 100, red: 20 },
+ dimensions: { x: lowerArmThickness, y: lowerArmThickness, z: lowerArmLength },
+ position: Vec3.sum(pos, { x: 0,
+ y: bodyHeight / 2 - upperArmThickness / 2,
+ z: bodyWidth / 2 + shoulderGap + upperArmLength + elbowGap + lowerArmLength / 2
+ }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: -1, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("hinge", rightLowerArmID, {
+ pivot: { x: 0, y: 0, z: -lowerArmLength / 2 - elbowGap / 2 },
+ axis: { x: 0, y: 1, z: 0 },
+ otherEntityID: rightUpperArmID,
+ otherPivot: { x: 0, y: 0, z: upperArmLength / 2 + elbowGap / 2 },
+ otherAxis: { x: 0, y: 1, z: 0 },
+ low: Math.PI / -2,
+ high: 0,
+ tag: "ragdoll right elbow joint"
+ });
+
+ //
+ // left lower arm
+ //
+
+ var leftLowerArmID = Entities.addEntity({
+ name: "ragdoll left lower arm",
+ type: "Box",
+ color: { blue: 128, green: 100, red: 20 },
+ dimensions: { x: lowerArmThickness, y: lowerArmThickness, z: lowerArmLength },
+ position: Vec3.sum(pos, { x: 0,
+ y: bodyHeight / 2 - upperArmThickness / 2,
+ z: -bodyWidth / 2 - shoulderGap - upperArmLength - elbowGap - lowerArmLength / 2
+ }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: -1, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("hinge", leftLowerArmID, {
+ pivot: { x: 0, y: 0, z: lowerArmLength / 2 + elbowGap / 2 },
+ axis: { x: 0, y: 1, z: 0 },
+ otherEntityID: leftUpperArmID,
+ otherPivot: { x: 0, y: 0, z: -upperArmLength / 2 - elbowGap / 2 },
+ otherAxis: { x: 0, y: 1, z: 0 },
+ low: 0,
+ high: Math.PI / 2,
+ tag: "ragdoll left elbow joint"
+ });
+
+ //
+ // right leg
+ //
+
+ var rightLegID = Entities.addEntity({
+ name: "ragdoll right arm",
+ type: "Box",
+ color: { blue: 20, green: 200, red: 20 },
+ dimensions: { x: legThickness, y: legLength, z: legThickness },
+ position: Vec3.sum(pos, { x: 0, y: -bodyHeight / 2 - hipGap - legLength / 2, z: bodyWidth / 2 - legThickness / 2 }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: 0, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("cone-twist", rightLegID, {
+ pivot: { x: 0, y: legLength / 2 + hipGap / 2, z: 0 },
+ axis: { x: 0, y: 1, z: 0 },
+ otherEntityID: bodyID,
+ otherPivot: { x: 0, y: -bodyHeight / 2 - hipGap / 2, z: bodyWidth / 2 - legThickness / 2 },
+ otherAxis: Vec3.normalize({ x: -1, y: 1, z: 0 }),
+ swingSpan1: Math.PI / 4,
+ swingSpan2: Math.PI / 4,
+ twistSpan: 0,
+ tag: "ragdoll right hip joint"
+ });
+
+ //
+ // left leg
+ //
+
+ var leftLegID = Entities.addEntity({
+ name: "ragdoll left arm",
+ type: "Box",
+ color: { blue: 20, green: 200, red: 20 },
+ dimensions: { x: legThickness, y: legLength, z: legThickness },
+ position: Vec3.sum(pos, { x: 0, y: -bodyHeight / 2 - hipGap - legLength / 2, z: -bodyWidth / 2 + legThickness / 2 }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: 0, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("cone-twist", leftLegID, {
+ pivot: { x: 0, y: legLength / 2 + hipGap / 2, z: 0 },
+ axis: { x: 0, y: 1, z: 0 },
+ otherEntityID: bodyID,
+ otherPivot: { x: 0, y: -bodyHeight / 2 - hipGap / 2, z: -bodyWidth / 2 + legThickness / 2 },
+ otherAxis: Vec3.normalize({ x: -1, y: 1, z: 0 }),
+ swingSpan1: Math.PI / 4,
+ swingSpan2: Math.PI / 4,
+ twistSpan: 0,
+ tag: "ragdoll left hip joint"
+ });
+
+ //
+ // right shin
+ //
+
+ var rightShinID = Entities.addEntity({
+ name: "ragdoll right shin",
+ type: "Box",
+ color: { blue: 20, green: 200, red: 20 },
+ dimensions: { x: shinThickness, y: shinLength, z: shinThickness },
+ position: Vec3.sum(pos, { x: 0,
+ y: -bodyHeight / 2 - hipGap - legLength - kneeGap - shinLength / 2,
+ z: bodyWidth / 2 - legThickness / 2
+ }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: -2, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("hinge", rightShinID, {
+ pivot: { x: 0, y: shinLength / 2 + kneeGap / 2, z: 0 },
+ axis: { x: 0, y: 0, z: 1 },
+ otherEntityID: rightLegID,
+ otherPivot: { x: 0, y: -legLength / 2 - kneeGap / 2, z: 0 },
+ otherAxis: { x: 0, y: 0, z: 1 },
+ low: 0,
+ high: Math.PI / 2,
+ tag: "ragdoll right knee joint"
+ });
+
+
+ //
+ // left shin
+ //
+
+ var leftShinID = Entities.addEntity({
+ name: "ragdoll left shin",
+ type: "Box",
+ color: { blue: 20, green: 200, red: 20 },
+ dimensions: { x: shinThickness, y: shinLength, z: shinThickness },
+ position: Vec3.sum(pos, { x: 0,
+ y: -bodyHeight / 2 - hipGap - legLength - kneeGap - shinLength / 2,
+ z: -bodyWidth / 2 + legThickness / 2
+ }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: -2, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("hinge", leftShinID, {
+ pivot: { x: 0, y: shinLength / 2 + kneeGap / 2, z: 0 },
+ axis: { x: 0, y: 0, z: 1 },
+ otherEntityID: leftLegID,
+ otherPivot: { x: 0, y: -legLength / 2 - kneeGap / 2, z: 0 },
+ otherAxis: { x: 0, y: 0, z: 1 },
+ low: 0,
+ high: Math.PI / 2,
+ tag: "ragdoll left knee joint"
+ });
+
+ //
+ // right foot
+ //
+
+ var rightFootID = Entities.addEntity({
+ name: "ragdoll right foot",
+ type: "Box",
+ color: { blue: 128, green: 100, red: 20 },
+ dimensions: { x: footLength, y: footThickness, z: footWidth },
+ position: Vec3.sum(pos, { x: -shinThickness / 2 + footLength / 2,
+ y: -bodyHeight / 2 - hipGap - legLength - kneeGap - shinLength - ankleGap - footThickness / 2,
+ z: bodyWidth / 2 - legThickness / 2
+ }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: -5, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("hinge", rightFootID, {
+ pivot: { x: -footLength / 2 + shinThickness / 2, y: ankleGap / 2, z: 0 },
+ axis: { x: 0, y: 0, z: 1 },
+ otherEntityID: rightShinID,
+ otherPivot: { x: 0, y: -shinLength / 2 - ankleGap / 2, z: 0 },
+ otherAxis: { x: 0, y: 0, z: 1 },
+ low: ankleMin,
+ high: ankleMax,
+ tag: "ragdoll right ankle joint"
+ });
+
+ //
+ // left foot
+ //
+
+ var leftFootID = Entities.addEntity({
+ name: "ragdoll left foot",
+ type: "Box",
+ color: { blue: 128, green: 100, red: 20 },
+ dimensions: { x: footLength, y: footThickness, z: footWidth },
+ position: Vec3.sum(pos, { x: -shinThickness / 2 + footLength / 2,
+ y: -bodyHeight / 2 - hipGap - legLength - kneeGap - shinLength - ankleGap - footThickness / 2,
+ z: bodyWidth / 2 - legThickness / 2
+ }),
+ dynamic: true,
+ collisionless: false,
+ gravity: { x: 0, y: -5, z: 0 },
+ lifetime: lifetime,
+ userData: "{ \"grabbableKey\": { \"grabbable\": true, \"kinematic\": false } }"
+ });
+
+ Entities.addAction("hinge", leftFootID, {
+ pivot: { x: -footLength / 2 + shinThickness / 2, y: ankleGap / 2, z: 0 },
+ axis: { x: 0, y: 0, z: 1 },
+ otherEntityID: leftShinID,
+ otherPivot: { x: 0, y: -shinLength / 2 - ankleGap / 2, z: 0 },
+ otherAxis: { x: 0, y: 0, z: 1 },
+ low: ankleMin,
+ high: ankleMax,
+ tag: "ragdoll left ankle joint"
+ });
+
+ }
+
+ function onWebEventReceived(eventString) {
+ print("received web event: " + JSON.stringify(eventString));
+ if (typeof eventString === "string") {
+ var event;
+ try {
+ event = JSON.parse(eventString);
+ } catch(e) {
+ return;
+ }
+
+ if (event["dynamics-tests-command"]) {
+ var commandToFunctionMap = {
+ "cone-twist-and-spring-lever-test": coneTwistAndSpringLeverTest,
+ "door-vs-world-test": doorVSWorldTest,
+ "hinge-chain-test": hingeChainTest,
+ "slider-vs-world-test": sliderVSWorldTest,
+ "slider-chain-test": sliderChainTest,
+ "ball-socket-between-test": ballSocketBetweenTest,
+ "ball-socket-coincident-test": ballSocketCoincidentTest,
+ "ragdoll-test": ragdollTest
+ };
+
+ var cmd = event["dynamics-tests-command"];
+ if (commandToFunctionMap.hasOwnProperty(cmd)) {
+ var func = commandToFunctionMap[cmd];
+ func(event);
+ }
+ }
+ }
+ }
+
+
+ var onDynamicsTestsScreen = false;
+ var shouldActivateButton = false;
+
+ function onClicked() {
+ if (onDynamicsTestsScreen) {
+ tablet.gotoHomeScreen();
+ } else {
+ shouldActivateButton = true;
+ tablet.gotoWebScreen(DYNAMICS_TESTS_URL +
+ "?lifetime=" + DEFAULT_LIFETIME.toString()
+ );
+ onDynamicsTestsScreen = true;
+ }
+ }
+
+ function onScreenChanged() {
+ // for toolbar mode: change button to active when window is first openend, false otherwise.
+ button.editProperties({isActive: shouldActivateButton});
+ shouldActivateButton = false;
+ onDynamicsTestsScreen = shouldActivateButton;
+ }
+
+ function cleanup() {
+ button.clicked.disconnect(onClicked);
+ tablet.removeButton(button);
+ }
+
+ button.clicked.connect(onClicked);
+ tablet.webEventReceived.connect(onWebEventReceived);
+ tablet.screenChanged.connect(onScreenChanged);
+ Script.scriptEnding.connect(cleanup);
+}()); // END LOCAL_SCOPE
diff --git a/scripts/developer/tests/dynamics/dynamicsTests.svg b/scripts/developer/tests/dynamics/dynamicsTests.svg
new file mode 100644
index 0000000000..a1407e87d4
--- /dev/null
+++ b/scripts/developer/tests/dynamics/dynamicsTests.svg
@@ -0,0 +1,69 @@
+
+
+
+