From ebb530aaace0ba8d03ea8d6307176cdfaf4463ab Mon Sep 17 00:00:00 2001 From: Brad Davis Date: Fri, 28 Aug 2015 18:36:21 -0700 Subject: [PATCH] More magball work --- examples/toys/magSticks.js | 15 +- examples/toys/magSticks/constants.js | 52 ++ examples/toys/magSticks/graph.js | 270 +++++++ examples/toys/magSticks/handController.js | 65 +- examples/toys/magSticks/magBalls.js | 791 +++++++++++--------- examples/toys/magSticks/springEdgeEntity.js | 143 ++++ examples/toys/magSticks/utils.js | 91 ++- 7 files changed, 1049 insertions(+), 378 deletions(-) create mode 100644 examples/toys/magSticks/graph.js create mode 100644 examples/toys/magSticks/springEdgeEntity.js diff --git a/examples/toys/magSticks.js b/examples/toys/magSticks.js index 7381754de0..9461ffd047 100644 --- a/examples/toys/magSticks.js +++ b/examples/toys/magSticks.js @@ -6,8 +6,9 @@ // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // -Script.include("magSticks/utils.js"); Script.include("magSticks/constants.js"); +Script.include("magSticks/utils.js"); +Script.include("magSticks/graph.js"); Script.include("magSticks/magBalls.js"); Script.include("magSticks/highlighter.js"); Script.include("magSticks/handController.js"); @@ -18,7 +19,7 @@ var magBalls = new MagBalls(); magBalls.clear(); // How close do we need to be to a ball to select it.... radius + 10% -var BALL_SELECTION_RADIUS = BALL_SIZE / 2.0 * 1.1; +var BALL_SELECTION_RADIUS = BALL_SIZE / 2.0 * 1.5; BallController = function(side) { HandController.call(this, side); @@ -30,21 +31,21 @@ BallController = function(side) { BallController.prototype = Object.create( HandController.prototype ); BallController.prototype.onUpdate = function(deltaTime) { - HandController.prototype.updateControllerState.call(this, deltaTime); + HandController.prototype.onUpdate.call(this, deltaTime); if (!this.selected) { // Find the highlight target and set it. - var target = magBalls.findNearestBall(this.tipPosition, BALL_SELECTION_RADIUS); + var target = magBalls.findNearestNode(this.tipPosition, BALL_SELECTION_RADIUS); this.highlighter.highlight(target); return; } this.highlighter.highlight(null); Entities.editEntity(this.selected, { position: this.tipPosition }); - var targetBalls = magBalls.findMatches(this.selected); + var targetBalls = magBalls.findPotentialEdges(this.selected); for (var ballId in targetBalls) { if (!this.ghostEdges[ballId]) { // create the ovleray this.ghostEdges[ballId] = Overlays.addOverlay("line3d", { - start: magBalls.getBallPosition(ballId), + start: magBalls.getNodePosition(ballId), end: this.tipPosition, color: { red: 255, green: 0, blue: 0}, alpha: 1, @@ -84,7 +85,7 @@ BallController.prototype.clearGhostEdges = function() { } BallController.prototype.onCleanup = function() { - HandController.prototype.updateControllerState.call(this); + HandController.prototype.onCleanup.call(this); this.clearGhostEdges(); } diff --git a/examples/toys/magSticks/constants.js b/examples/toys/magSticks/constants.js index 0453d29a79..297fa51c6e 100644 --- a/examples/toys/magSticks/constants.js +++ b/examples/toys/magSticks/constants.js @@ -14,3 +14,55 @@ SCALE = 0.5; BALL_SIZE = 0.08 * SCALE; STICK_LENGTH = 0.24 * SCALE; +DEBUG_MAGSTICKS = true; + +ZERO_VECTOR = { x: 0, y: 0, z: 0 }; + +COLORS = { + WHITE: { + red: 255, + green: 255, + blue: 255, + }, + BLACK: { + red: 0, + green: 0, + blue: 0, + }, + GREY: { + red: 128, + green: 128, + blue: 128, + }, + RED: { + red: 255, + green: 0, + blue: 0 + }, + BLUE: { + red: 0, + green: 0, + blue: 255 + }, + GREEN: { + red: 0, + green: 255, + blue: 0 + }, + CYAN: { + red: 0, + green: 255, + blue: 255 + }, + YELLOW: { + red: 255, + green: 255, + blue: 0 + }, + MAGENTA: { + red: 255, + green: 0, + blue: 255 + } +} + diff --git a/examples/toys/magSticks/graph.js b/examples/toys/magSticks/graph.js new file mode 100644 index 0000000000..8a5c00451d --- /dev/null +++ b/examples/toys/magSticks/graph.js @@ -0,0 +1,270 @@ + +// A collection of nodes and edges connecting them. +Graph = function() { + /* Structure of nodes tree + this.nodes: { + nodeId1: { + edgeId1: true + } + nodeId2: { + edgeId1: true + }, + nodeId3: { + edgeId2: true + edgeId3: true + edgeId4: true + edgeId5: true + }, + ... + } + */ + this.nodes = {}; + /* Structure of edge tree + this.edges: { + edgeId1: { + // Every edge should have exactly two + nodeId1: true + nodeId2: true + }, + edgeId2: { + nodeId3: true + nodeId4: true + }, + ... + } + */ + this.edges = {}; +} + +Graph.prototype.createNodeEntity = function(properties) { + throw "Unimplemented"; +} + +Graph.prototype.createNode = function(properties) { + var nodeId = this.createNodeEntity(properties); + this.nodes[nodeId] = {}; + this.validate(); + return nodeId; +} + +Graph.prototype.createEdgeEntity = function(nodeA, nodeB) { + throw "Unimplemented"; +} + +Graph.prototype.createEdge = function(nodeA, nodeB) { + if (nodeA == nodeB) { + throw "Error: self connection not supported"; + } + var newEdgeId = this.createEdgeEntity(nodeA, nodeB); + + // Create the bidirectional linkage + this.edges[newEdgeId] = {}; + this.edges[newEdgeId][nodeA] = true; + this.edges[newEdgeId][nodeB] = true; + this.nodes[nodeA][newEdgeId] = true; + this.nodes[nodeB][newEdgeId] = true; + + this.validate(); +} + +Graph.prototype.getEdges = function(nodeId) { + var edges = this.nodes[nodeId]; + var result = {}; + for (var edgeId in edges) { + for (var otherNodeId in this.edges[edgeId]) { + if (otherNodeId != nodeId) { + result[edgeId] = otherNodeId; + } + } + } + return result; +} + +Graph.prototype.getConnectedNodes = function(nodeId) { + var edges = this.getEdges(nodeId); + var result = {}; + for (var edgeId in edges) { + var otherNodeId = edges[edgeId]; + result[otherNodeId] = edgeId; + } + return result; +} + +Graph.prototype.getEdgeLength = function(edgeId) { + var nodesInEdge = Object.keys(this.edges[edgeId]); + return this.getNodeDistance(nodesInEdge[0], nodesInEdge[1]); +} + +Graph.prototype.getNodeDistance = function(a, b) { + var apos = this.getNodePosition(a); + var bpos = this.getNodePosition(b); + return Vec3.distance(apos, bpos); +} + +Graph.prototype.getNodePosition = function(node) { + var properties = Entities.getEntityProperties(node); + return properties.position; +} + +Graph.prototype.breakEdges = function(nodeId) { + for (var edgeId in this.nodes[nodeId]) { + this.destroyEdge(edgeId); + } +} + +Graph.prototype.findNearestNode = function(position, maxDist) { + var resultId = null; + var resultDist = 0; + for (var nodeId in this.nodes) { + var nodePosition = this.getNodePosition(nodeId); + var curDist = Vec3.distance(nodePosition, position); + if (!maxDist || curDist <= maxDist) { + if (!resultId || curDist < resultDist) { + resultId = nodeId; + resultDist = curDist; + } + } + } + return resultId; +} + +Graph.prototype.findMatchingNodes = function(selector) { + var result = {}; + for (var nodeId in this.nodes) { + if (selector(nodeId)) { + result[nodeId] = true; + } + } + return result; +} + +Graph.prototype.destroyEdge = function(edgeId) { + logDebug("Deleting edge " + edgeId); + for (var nodeId in this.edges[edgeId]) { + delete this.nodes[nodeId][edgeId]; + } + delete this.edges[edgeId]; + Entities.deleteEntity(edgeId); + this.validate(); +} + +Graph.prototype.destroyNode = function(nodeId) { + logDebug("Deleting node " + nodeId); + this.breakEdges(nodeId); + delete this.nodes[nodeId]; + Entities.deleteEntity(nodeId); + this.validate(); +} + +Graph.prototype.deleteAll = function() { + var nodeIds = Object.keys(this.nodes); + for (var i in nodeIds) { + var nodeId = nodeIds[i]; + this.destroyNode(nodeId); + } +} + +Graph.prototype.areConnected = function(nodeIdA, nodeIdB) { + for (var edgeId in this.nodes[nodeIdA]) { + if (this.nodes[nodeIdB][edgeId]) { + return true; + } + } + return false; +} + +forEachValue = function(val, operation) { + if( typeof val === 'string' ) { + operation(val); + } else if (typeof val === 'object') { + if (val.constructor === Array) { + for (var i in val) { + operation(val[i]); + } + } else { + for (var v in val) { + operation(v); + } + } + } +} + +Graph.prototype.findShortestPath = function(start, end, options) { + var queue = [ start ]; + var prev = {}; + if (options && options.exclude) { + forEachValue(options.exclude, function(value) { + prev[value] = value; + }); + logDebug("exclude " + prev); + } + var found = false; + while (!found && Object.keys(queue).length) { + var current = queue.shift(); + for (var ballId in this.getConnectedNodes(current)) { + if (prev[ballId]) { + // already visited node + continue; + } + // record optimal path + prev[ballId] = current; + if (ballId == end) { + found = true; + break; + } + queue.push(ballId); + } + } + + if (!found) { + logDebug("Exhausted search"); + return; + } + + var result = [ end ]; + while (result[0] != start) { + result.unshift(prev[result[0]]); + } + + return result; +} + +Graph.prototype.validate = function() { + var error = false; + for (nodeId in this.nodes) { + for (edgeId in this.nodes[nodeId]) { + var edge = this.edges[edgeId]; + if (!edge) { + logError("Error: node " + nodeId + " refers to unknown edge " + edgeId); + error = true; + continue; + } + if (!edge[nodeId]) { + logError("Error: node " + nodeId + " refers to edge " + edgeId + " but not vice versa"); + error = true; + continue; + } + } + } + + for (edgeId in this.edges) { + for (nodeId in this.edges[edgeId]) { + var node = this.nodes[nodeId]; + if (!node) { + logError("Error: edge " + edgeId + " refers to unknown node " + nodeId); + error = true; + continue; + } + if (!node[edgeId]) { + logError("Error: edge " + edgeId + " refers to node " + nodeId + " but not vice versa"); + error = true; + continue; + } + } + } + + if (error) { + logDebug(JSON.stringify({ edges: this.edges, balls: this.nodes }, null, 2)); + } + return error; +} \ No newline at end of file diff --git a/examples/toys/magSticks/handController.js b/examples/toys/magSticks/handController.js index 4f893c9cf2..cf3ef96b4a 100644 --- a/examples/toys/magSticks/handController.js +++ b/examples/toys/magSticks/handController.js @@ -2,6 +2,26 @@ LEFT_CONTROLLER = 0; RIGHT_CONTROLLER = 1; +WAND_LIFETIME = 30; + +var WAND_PROPERTIES = { + type: "Model", + modelURL: "file:///f:/Downloads/wand.FBX", + ignoreForCollisions: true, + dimensions: { + x: 0.1, + y: 0.1, + z: 0.1 + }, + lifetime: 30 +}; + +if (!Date.now) { + Date.now = function now() { + return new Date().getTime(); + }; +} + HandController = function(side) { this.side = side; @@ -9,22 +29,17 @@ HandController = function(side) { this.tip = 2 * side + 1; this.action = findAction(side ? "ACTION2" : "ACTION1"); this.active = false; + this.tipScale = 1.4; this.pointer = Overlays.addOverlay("sphere", { - position: { - x: 0, - y: 0, - z: 0 - }, + position: ZERO_VECTOR, size: 0.01, - color: { - red: 255, - green: 255, - blue: 0 - }, + color: COLORS.YELLOW, alpha: 1.0, solid: true, visible: false, }); + //this.wand = Entities.addEntity(WAND_PROPERTIES); + // Connect to desired events var _this = this; @@ -55,26 +70,26 @@ HandController.prototype.setActive = function(active) { if (active == this.active) { return; } - debugPrint("Setting active: " + active); + logDebug("Setting active: " + active); this.active = active; Overlays.editOverlay(this.pointer, { - position: this.tipPosition, + visible: this.active + }); + Entities.editEntity(this.wand, { visible: this.active }); } HandController.prototype.updateControllerState = function() { - var palmPos = Controller.getSpatialControlPosition(this.palm); + this.palmPos = Controller.getSpatialControlPosition(this.palm); + var tipPos = Controller.getSpatialControlPosition(this.tip); + this.tipPosition = scaleLine(this.palmPos, tipPos, this.tipScale); + // When on the base hydras report a position of 0 - this.setActive(Vec3.length(palmPos) > 0.001); + this.setActive(Vec3.length(this.palmPos) > 0.001); if (!this.active) { return; } - var tipPos = Controller.getSpatialControlPosition(this.tip); - this.tipPosition = scaleLine(palmPos, tipPos, 1.4); - Overlays.editOverlay(this.pointer, { - position: this.tipPosition - }); } HandController.prototype.onCleanup = function() { @@ -83,12 +98,20 @@ HandController.prototype.onCleanup = function() { HandController.prototype.onUpdate = function(deltaTime) { this.updateControllerState(); + if (this.active) { + Overlays.editOverlay(this.pointer, { + position: this.tipPosition + }); + Entities.editEntity(this.wand, { + position: this.tipPosition + }); + } } HandController.prototype.onClick = function() { - debugPrint("Base hand controller does nothing on click"); + logDebug("Base hand controller does nothing on click"); } HandController.prototype.onRelease = function() { - debugPrint("Base hand controller does nothing on release"); + logDebug("Base hand controller does nothing on release"); } diff --git a/examples/toys/magSticks/magBalls.js b/examples/toys/magSticks/magBalls.js index 9d67e0e0b7..9a0465c582 100644 --- a/examples/toys/magSticks/magBalls.js +++ b/examples/toys/magSticks/magBalls.js @@ -1,5 +1,5 @@ -var BALL_NAME = "MagBall" +var BALL_NAME = "MagBall" var EDGE_NAME = "MagStick" var BALL_DIMENSIONS = { @@ -31,44 +31,48 @@ var BALL_PROTOTYPE = { collisionsWillMove: false }; +// 2 millimeters +var EPSILON = (.002) / BALL_DISTANCE; + +var LINE_DIMENSIONS = { + x: 5, + y: 5, + z: 5 +} + +var LINE_PROTOTYPE = { + type: "Line", + name: EDGE_NAME, + color: COLORS.CYAN, + dimensions: LINE_DIMENSIONS, + lineWidth: 5, + visible: true, + ignoreCollisions: true, + collisionsWillMove: false, + script: "file:/Users/bdavis/Git/hifi/examples/toys/magSticks/springEdgeEntity.js" +} + +var EDGE_PROTOTYPE = LINE_PROTOTYPE; + +// var EDGE_PROTOTYPE = { +// type: "Sphere", +// name: EDGE_NAME, +// color: { red: 0, green: 255, blue: 255 }, +// //dimensions: STICK_DIMENSIONS, +// dimensions: { x: 0.02, y: 0.02, z: 0.02 }, +// rotation: rotation, +// visible: true, +// ignoreCollisions: true, +// collisionsWillMove: false +// } + + // A collection of balls and edges connecting them. MagBalls = function() { - /* - this.balls: { - ballId1: { - edgeId1: true - } - ballId2: { - edgeId1: true - }, - ballId3: { - edgeId2: true - edgeId3: true - edgeId4: true - edgeId5: true - }, - ... - } - */ - this.balls = {}; - /* - this.edges: { - edgeId1: { - ballId1: true - ballId2: true - }, - edgeId2: { - ballId3: true - ballId4: true - }, - ... - } - */ - - // FIXME initialize from nearby entities - this.edges = {}; - this.selectedBalls = {}; - + this.selectedNodes = {}; + + Graph.call(this); + var _this = this; Script.update.connect(function(deltaTime) { _this.onUpdate(deltaTime); @@ -79,282 +83,463 @@ MagBalls = function() { }); } -MagBalls.prototype.findNearestBall = function(position, maxDist) { - var resultId = null; - var resultDist = 0; - for (var id in this.balls) { - var properties = Entities.getEntityProperties(id); - var curDist = Vec3.distance(properties.position, position); - if (!maxDist || curDist <= maxDist) { - if (!resultId || curDist < resultDist) { - resultId = id; - resultDist = curDist; - } - } - } - return resultId; -} +MagBalls.prototype = Object.create( Graph.prototype ); -// FIXME move to a physics based implementation as soon as bullet -// is exposed to entities MagBalls.prototype.onUpdate = function(deltaTime) { + // FIXME move to a physics based implementation as soon as bullet + // is exposed to entities } -function mergeObjects(proto, custom) { - var result = {}; - for (var attrname in proto) { - result[attrname] = proto[attrname]; - } - for (var attrname in custom) { - result[attrname] = custom[attrname]; - } - return result; +MagBalls.prototype.createNodeEntity = function(customProperies) { + return Entities.addEntity(mergeObjects(BALL_PROTOTYPE, customProperies)); } -MagBalls.prototype.createBall = function(customProperies) { - var ballId = Entities.addEntity(mergeObjects(BALL_PROTOTYPE, customProperies)); - this.balls[ballId] = {}; - this.validate(); - return ballId; +MagBalls.prototype.getEdgeProperties = function(nodeIdA, nodeIdB) { + var apos = this.getNodePosition(nodeIdA); + var bpos = this.getNodePosition(nodeIdB); + return { + position: apos, + linePoints: [ ZERO_VECTOR, Vec3.subtract(bpos, apos) ], + userData: JSON.stringify({ + magBalls: { + start: nodeIdA, + end: nodeIdB, + length: BALL_DISTANCE + } + }) + }; } -MagBalls.prototype.grabBall = function(position, maxDist) { - var selected = this.findNearestBall(position, maxDist); - if (!selected) { - selected = this.createBall({ position: position }); - } - if (selected) { - this.breakEdges(selected); - this.selectedBalls[selected] = true; - } - return selected; +MagBalls.prototype.createEdgeEntity = function(nodeIdA, nodeIdB) { + var customProperties = this.getEdgeProperties(nodeIdA, nodeIdB) + return Entities.addEntity(mergeObjects(EDGE_PROTOTYPE, customProperties)); } -MagBalls.prototype.findMatches = function(ballId) { +MagBalls.prototype.findPotentialEdges = function(nodeId) { var variances = {}; - for (var otherBallId in this.balls) { - if (otherBallId == ballId || this.areConnected(otherBallId, ballId)) { - // can't self connect or doubly connect + for (var otherNodeId in this.nodes) { + // can't self connect + if (otherNodeId == nodeId) { continue; } - var variance = this.getStickLengthVariance(ballId, otherBallId); - if (variance > BALL_DISTANCE / 4) { + + // can't doubly connect + if (this.areConnected(otherNodeId, nodeId)) { continue; } - variances[otherBallId] = variance; + + // Too far to attempt + var distance = this.getNodeDistance(nodeId, otherNodeId); + var variance = this.getVariance(distance); + if (variance > 0.25) { + continue; + } + + variances[otherNodeId] = variance; } return variances; } -MagBalls.prototype.releaseBall = function(releasedBall) { - delete this.selectedBalls[releasedBall]; - debugPrint("Released ball: " + releasedBall); +MagBalls.prototype.grabBall = function(position, maxDist) { + var selected = this.findNearestNode(position, maxDist); + if (!selected) { + selected = this.createNode({ position: position }); + } + if (selected) { + this.breakEdges(selected); + this.selectedNodes[selected] = true; + } + return selected; +} - // sort other balls by distance from the stick length - var edgeTargetBall = null; - do { - var releasePosition = this.getBallPosition(releasedBall); - // Get the list of candidate connections - var variances = this.findMatches(releasedBall); - // sort them by the difference from an ideal distance - var sortedBalls = Object.keys(variances); - if (!sortedBalls.length) { +MagBalls.prototype.releaseBall = function(releasedBall) { + delete this.selectedNodes[releasedBall]; + logDebug("Released ball: " + releasedBall); + + // FIXME iterate through the other balls and ensure we don't intersect with + // any of them. If we do, just delete this ball and return. (play a pop + // sound) + + var releasePosition = this.getNodePosition(releasedBall); + + // Don't overlap other balls + for (var nodeId in this.nodes) { + if (nodeId == releasedBall) { + continue; + } + var distance = this.getNodeDistance(releasedBall, nodeId); + if (distance < BALL_SIZE / 2.0) { + this.destroyNode(nodeId); return; } - sortedBalls.sort(function(a, b){ + } + + var targets = this.findPotentialEdges(releasedBall); + for (var otherBallId in targets) { + this.createEdge(otherBallId, releasedBall); + } + this.clean(); + return; + + // sort other balls by distance from the stick length + var createdEdge = false; + while (true) { + // Get the list of candidate connections + var variances = this.findPotentialEdges(releasedBall); + // sort them by the difference from an ideal distance + var targetBalls = Object.keys(variances); + if (!targetBalls.length) { + break; + } + + // special case when there are 2 potential matches, + // try to create a ring on a plane + if (targetBalls.length == 2 && this.tryCreateRing(targetBalls, releasedBall)) { + createdEdge = true; + break; + } + + // special case when there are 3 potential matches, + // try create a fan + if (targetBalls.length == 3 && this.tryCreateFan(targetBalls, releasedBall)) { + createdEdge = true; + break; + } + + targetBalls.sort(function(a, b){ return variances[a] - variances[b]; }); - // find the nearest matching unconnected ball - edgeTargetBall = sortedBalls[0]; - // note that createEdge will preferentially move the second parameter, the target ball - } while(this.createEdge(edgeTargetBall, releasedBall)) + + // find the nearest matching unconnected ball and create an edge to it + // if possible + // note that createEdge will preferentially move the second entity, the + // released ball + // in order to create a fit + var str = "Attempt to create edge between " + targetBalls[0] + " and " + releasedBall; + if (!this.tryCreateEdge(targetBalls[0], releasedBall)) { + logDebug(str + "failed"); + var nodeDistance = this.getNodeDistance(targetBalls[0], releasedBall); + var variance = this.getVariance(nodeDistance); + logDebug("Distance was " + (nodeDistance * 100).toFixed(2) + "cm with a variance of " + variance); + break; + } + logDebug(str + " succeeded"); + + releasePosition = this.getNodePosition(releasedBall); + + // Record that we created at least one edge + createdEdge = true; + } + + if (createdEdge) { + // FIXME play a snap sound + } this.clean(); + this.validate(); } -// FIXME the Quat should be able to do this -function findOrientation(from, to) { - //float m = sqrt(2.f + 2.f * dot(u, v)); - //vec3 w = (1.f / m) * cross(u, v); - //return quat(0.5f * m, w.x, w.y, w.z); - var v2 = Vec3.normalize(Vec3.subtract(to, from)); - var v1 = { x: 0.0, y: 1.0, z: 0.0 }; - var m = Math.sqrt(2 + 2 * Vec3.dot(v1, v2)); - var w = Vec3.multiply(1.0 / m, Vec3.cross(v1, v2)); - return { - w: 0.5 * m, - x: w.x, - y: w.y, - z: w.z - }; -} - -var LINE_DIMENSIONS = 5; - -var EDGE_PROTOTYPE = { - type: "Line", - name: EDGE_NAME, - color: { red: 0, green: 255, blue: 255 }, - dimensions: { - x: LINE_DIMENSIONS, - y: LINE_DIMENSIONS, - z: LINE_DIMENSIONS - }, - lineWidth: 5, - visible: true, - ignoreCollisions: true, - collisionsWillMove: false -} - -var ZERO_VECTOR = { - x: 0, - y: 0, - z: 0 -}; - -//var EDGE_PROTOTYPE = { -// type: "Sphere", -// name: EDGE_NAME, -// color: { red: 0, green: 255, blue: 255 }, -// //dimensions: STICK_DIMENSIONS, -// dimensions: { x: 0.02, y: 0.02, z: 0.02 }, -// rotation: rotation, -// visible: true, -// ignoreCollisions: true, -// collisionsWillMove: false -//} - -MagBalls.prototype.createEdge = function(from, to) { - // FIXME find the constraints on from an to and determine if there is an intersection. - // Do only first order scanning for now, unless we can expose a mechanism for interacting - // to reach equilibrium via Bullet - // * a ball zero edges is fully free... - // * a ball with one edge free to move on a sphere section - // * a ball with two edges is free to move in a circle - // * a ball with more than two edges is not free - - var fromPos = this.getBallPosition(from); - var toPos = this.getBallPosition(to); +MagBalls.prototype.tryCreateEdge = function(from, to) { + var fromPos = this.getNodePosition(from); + var toPos = this.getNodePosition(to); var vector = Vec3.subtract(toPos, fromPos); - var originalLength = Vec3.length(originalLength); - + var originalLength = Vec3.length(vector); + + var variance = this.getVariance(originalLength); // if they're already at a close enough distance, just create the edge - if ((originalLength - BALL_DISTANCE) > (BALL_DISTANCE * 0.01)) { - //code - } else { - // Attempt to move the ball to match the distance + if (variance < EPSILON) { + logDebug("Length " + originalLength + " with variance of " + (variance * 100).toFixed(2) + " is within epislon " + EPSILON) ; + // close enough for government work + this.createEdge(from, to); + return true; + } + + // FIXME find the constraints on `from` and `to` and determine if there is a + // new positiong + // for 'to' that keeps it's current connections and connects with 'from' + // Do only first order scanning for now, unless we can expose a mechanism + // for interacting + // to reach equilibrium via Bullet + // * a ball zero edges is fully free... + // * a ball with one edge free to move on a sphere section + // * a ball with two edges is free to move in a circle + // * a ball with more than two edges is not free to move + + // Zero edges for the destination + var existingEdges = Object.keys(this.nodes[to]); + var edgeCount = existingEdges.length; + if (!edgeCount) { + // Easy case 1: unconnected ball + // Move the ball along it's current path to match the desired distance vector = Vec3.multiply(BALL_DISTANCE, Vec3.normalize(vector)); - // Zero edges for the destination - var edgeCount = Object.keys(this.balls[to]).length; - if (!edgeCount) { + // Add the vector to the starting position to find the new position + var newPosition = Vec3.sum(vector, fromPos); + // update the entity + Entities.editEntity(to, { position: newPosition }); + moved = true; + } else if (edgeCount > 2) { + // Easy case 2: locked position ball + // FIXME should check the target ball to see if it can be moved. + // Possible easy solution is to recurse into this.createEdge and swap + // the parameters, + // but need to prevert infinite recursion + // for now... + return false; + } else { + var connectedBalls = this.getConnectedNodes(to); + // find the other balls connected, will be either 1 or 2 + var origin = { x: 0, y: 0, z: 0 }; + for (var nodeId in connectedBalls) { + origin = Vec3.sum(origin, this.getNodePosition(nodeId)); + } + + if (edgeCount > 1) { + origin = Vec3.multiply(origin, 1 / edgeCount); + } + // logDebug("Using origin " + vec3toStr(origin)); + + if (edgeCount == 1) { + // vectors from the temp origin to the two balls. + var v1 = Vec3.subtract(toPos, origin); + var v2 = Vec3.subtract(fromPos, origin); + + // ortogonal to the solution plane + var o1 = Vec3.normalize(Vec3.cross(Vec3.normalize(v2), Vec3.normalize(v1))); + // addLine(origin, o1, COLORS.RED); // debugging + + // orthogonal to o1, lying on the solution plane + var o2 = Vec3.normalize(Vec3.cross(o1, Vec3.normalize(v2))); + // addLine(origin, o2, COLORS.YELLOW); // debugging + + // The adjacent side of a right triangle containg the + // solution as one of the points + var v3 = Vec3.multiply(0.5, v2); + + // The length of the adjacent side of the triangle + var l1 = Vec3.length(v3); + // The length of the hypotenuse + var r = BALL_DISTANCE; + + // No connection possible + if (l1 > r) { + return false; + } + + // The length of the opposite side + var l2 = Math.sqrt(r * r - l1 * l1); + + // vector with the length and direction of the opposite side + var v4 = Vec3.multiply(l2, Vec3.normalize(o2)); + + // Add the adjacent vector and the opposite vector to get the + // hypotenuse vector + var result = Vec3.sum(v3, v4); + // move back into world space + result = Vec3.sum(origin, result); // update the entity - var newPosition = Vec3.sum(vector, fromPos); - Entities.editEntity(to, { position: newPosition }); - } else if (1 == edgeCount) { - // FIXME - // find the other end of the edge already connected, call it ball2 - // given two spheres of radius BALL_DISTANCE centered at fromPos and ball2.position, - // find the closest point of intersection to toPos - // move the ball to toPos - } else if (2 == edgeCount) { - // FIXME + Entities.editEntity(to, { position: result }); } else { - // FIXME check for the ability to move fromPos - return false; + // Has a bug of some kind... validation fails after this + + // Debugging marker + //Entities.addEntity(mergeObjects(BALL_PROTOTYPE, { + // position: origin, + // color: COLORS.YELLOW, + // dimensions: Vec3.multiply(0.4, BALL_DIMENSIONS) + //})); + + var v1 = Vec3.subtract(fromPos, origin); + // addLine(origin, v1, COLORS.RED); // debugging + + + var v2 = Vec3.subtract(toPos, origin); + // addLine(origin, v2, COLORS.GREEN); // debugging + + // the lengths of v1 and v2 represent the lengths of two sides + // of the triangle we need to build. + var l1 = Vec3.length(v1); + var l2 = Vec3.length(v2); + // The remaining side is the edge we are trying to create, so + // it will be of length BALL_DISTANCE + var l3 = BALL_DISTANCE; + // given this triangle, we want to know the angle between l1 and l2 + // (this is NOT the same as the angle between v1 and v2, because we + // are trying to rotate v2 around o1 to find a solution + // Use law of cosines to find the angle: cos A = (b^2 + c^2 − a^2) / + // 2bc + var cosA = (l1 * l1 + l2 * l2 - l3 * l3) / (2.0 * l1 * l2); + + // Having this angle gives us all three angles of the right triangle + // containing + // the solution, along with the length of the hypotenuse, which is + // l2 + var hyp = l2; + // We need to find the length of the adjacent and opposite sides + // since cos(A) = adjacent / hypotenuse, then adjacent = hypotenuse + // * cos(A) + var adj = hyp * cosA; + // Pythagoras gives us the opposite side length + var opp = Math.sqrt(hyp * hyp - adj * adj); + + // v1 is the direction vector we need for the adjacent side, so + // resize it to + // the proper length + v1 = Vec3.multiply(adj, Vec3.normalize(v1)); + // addLine(origin, v1, COLORS.GREEN); // debugging + + // FIXME, these are not the right normals, because the ball needs to rotate around the origin + + // This is the normal to the plane on which our solution lies + var o1 = Vec3.cross(v1, v2); + + // Our final side is a normal to the plane defined by o1 and v1 + // and is of length opp + var o2 = Vec3.multiply(opp, Vec3.normalize(Vec3.cross(o1, v1))); + + // Our final result is the sum of v1 and o2 (opposite side vector + + // adjacent side vector) + var result = Vec3.sum(v1, o2); + // Move back into world space + result = Vec3.sum(origin, result); + // update the entity + Entities.editEntity(to, { position: result }); } } - - // Fixup existing edges - for (var edgeId in this.balls[to]) { + // Fixup existing edges if we moved the ball + for (var edgeId in this.nodes[to]) { this.fixupEdge(edgeId); } - - // FIXME, find the correct orientation for a box or model between the two balls - // for now use a line - var newEdge = Entities.addEntity(mergeObjects(EDGE_PROTOTYPE, this.findEdgeParams(from, to))); - - this.edges[newEdge] = {}; - this.edges[newEdge][from] = true; - this.edges[newEdge][to] = true; - this.balls[from][newEdge] = true; - this.balls[to][newEdge] = true; - this.validate(); + + this.createEdge(from, to); return true; } -MagBalls.prototype.findEdgeParams = function(startBall, endBall) { - var startBallPos = this.getBallPosition(startBall); - var endBallPos = this.getBallPosition(endBall); - var vector = Vec3.subtract(endBallPos, startBallPos); - return { - position: startBallPos, - linePoints: [ ZERO_VECTOR, vector ] - }; +MagBalls.prototype.tryCreateRing = function(fromBalls, to) { + // FIXME, if the user tries to connect two points, attempt to + // walk the graph and see if they're creating a ring of 4 or + // more vertices, if so and they're within N percent of lying + // on a plane, then adjust them all so they lie on a plance + return false; +} + +function pausecomp(millis) { + var date = new Date(); + var curDate = null; + do { curDate = new Date(); } while(curDate-date < millis); +} + +// find a normal between three points +function findNormal(a, b, c) { + var aa = Vec3.subtract(a, b); + var cc = Vec3.subtract(c, b); + return Vec3.cross(aa, cc); +} + +MagBalls.prototype.tryCreateFan = function(fromBalls, to) { + logDebug("Attempting to create fan"); + // if the user tries to connect three points, attempt to + // walk the graph and see if they're creating fan, adjust all the + // points to lie on a plane an equidistant from the shared vertex + + // A fan may exist if given three potential connections, two of the connection + // share and edge with the third + var a = fromBalls[0]; + var b = fromBalls[1]; + var c = fromBalls[2]; + var ab = this.areConnected(a, b); + var bc = this.areConnected(b, c); + var ca = this.areConnected(c, a); + if (ab && bc && ca) { + // tetrahedron, let the generic code handle it + return false; + } + var crux = null; + var left = null; + var right = null; + if (ab && bc) { + crux = b; + left = a; + right = c; + } else if (bc && ca) { + crux = a; + left = b; + right = a; + } else if (ca && ab) { + crux = a; + left = c; + right = b; + } + if (crux == null) { + // we don't have two nodes which share edges with the third, so fail + return false; + } + var loop = this.findShortestPath(left, right, { exclude: crux }); + if (!loop) { + return false; + } + + // find the normal to the target plane + var origin = this.getNodePosition(crux); + var normals = []; + var averageNormal = ZERO_VECTOR; + for (var i = 0; i < loop.length - 2; ++i) { + var a = loop[i]; + var b = loop[i + 1]; + var c = loop[i + 2]; + var apos = this.getNodePosition(a); + var bpos = this.getNodePosition(b); + var cpos = this.getNodePosition(c); + var normal = Vec3.normalize(findNormal(apos, bpos, cpos)); + averageNormal = Vec3.sum(averageNormal, normal); + addLine(bpos, normal, COLORS.YELLOW); + normals.push(normal); + } + averageNormal = Vec3.normalize(Vec3.multiply(1 / normals.length, averageNormal)); + + addLine(origin, averageNormal, COLORS.RED); + + // FIXME need to account for locked nodes... if there are 3 locked nodes on the loop, + // then find their cross product + // if there are more than 3 locked nodes on the loop, check if they have matching cross + // products, otherwise fail + + return false; } MagBalls.prototype.fixupEdge = function(edgeId) { var ballsInEdge = Object.keys(this.edges[edgeId]); - Entities.editEntity(edgeId, this.findEdgeParams(ballsInEdge[0], ballsInEdge[1])); + var customProperties = this.getEdgeProperties(ballsInEdge[0], ballsInEdge[1]); + Entities.editEntity(edgeId, customProperties); } -// Given two balls, how big is the difference between their distances and the stick length -MagBalls.prototype.getStickLengthVariance = function(a, b) { - var apos = this.getBallPosition(a); - var bpos = this.getBallPosition(b); - var distance = Vec3.distance(apos, bpos); - var variance = Math.abs(distance - BALL_DISTANCE); - return variance; +MagBalls.prototype.getVariance = function(distance) { + // Given two points, how big is the difference between their distance + // and the desired length length + return (Math.abs(distance - BALL_DISTANCE)) / BALL_DISTANCE; } -// FIXME remove unconnected balls +// remove unconnected balls MagBalls.prototype.clean = function() { - //var deletedBalls = {}; - //if (Object.keys(this.balls).length > 1) { - // for (var ball in this.balls) { - // if (!this.getConnections(ball)) { - // deletedBalls[ball] = true; - // Entities.deleteEntity(ball); - // } - // } - //} - //for (var ball in deletedBalls) { - // delete this.balls[ball]; - //} -} - -MagBalls.prototype.getBallPosition = function(ball) { - var properties = Entities.getEntityProperties(ball); - return properties.position; -} - -MagBalls.prototype.breakEdges = function(ballId) { - for (var edgeId in this.balls[ballId]) { - this.destroyEdge(edgeId); + // do nothing unless there are at least 2 balls and one edge + if (Object.keys(this.nodes).length < 2 || !Object.keys(this.edges).length) { + return; } - // This shouldn't be necessary - this.balls[ballId] = {}; -} - -MagBalls.prototype.destroyEdge = function(edgeId) { - logDebug("Deleting edge " + edgeId); - // Delete the edge from other balls - for (var edgeBallId in this.edges[edgeId]) { - delete this.balls[edgeBallId][edgeId]; + var disconnectedNodes = {}; + for (var nodeId in this.nodes) { + if (!Object.keys(this.nodes[nodeId]).length) { + disconnectedNodes[nodeId] = true; + } + } + for (var nodeId in disconnectedNodes) { + this.destroyNode(nodeId); } - delete this.edges[edgeId]; - Entities.deleteEntity(edgeId); - this.validate(); -} - -MagBalls.prototype.destroyBall = function(ballId) { - logDebug("Deleting ball " + ballId); - breakEdges(ballId); - Entities.deleteEntity(ballId); } +// remove all balls MagBalls.prototype.clear = function() { if (DEBUG_MAGSTICKS) { + this.deleteAll(); var ids = Entities.findEntities(MyAvatar.position, 50); var result = []; ids.forEach(function(id) { @@ -366,89 +551,23 @@ MagBalls.prototype.clear = function() { } } -MagBalls.prototype.areConnected = function(a, b) { - for (var edge in this.balls[a]) { - // edge already exists - if (this.balls[b][edge]) { - return true; - } - } - return false; -} - +// Override to check lengths as well as connection consistency MagBalls.prototype.validate = function() { - var error = false; - for (ballId in this.balls) { - for (edgeId in this.balls[ballId]) { - var edge = this.edges[edgeId]; - if (!edge) { - logError("Error: ball " + ballId + " refers to unknown edge " + edgeId); + var error = Graph.prototype.validate.call(this); + + if (!error) { + for (edgeId in this.edges) { + var length = this.getEdgeLength(edgeId); + var variance = this.getVariance(length); + if (variance > EPSILON) { + Entities.editEntity(edgeId, { color: COLORS.RED }); + + logDebug("Edge " + edgeId + " length " + (length * 100).toFixed(2) + " cm variance " + (variance * 100).toFixed(3) + "%"); error = true; - continue; - } - if (!edge[ballId]) { - logError("Error: ball " + ballId + " refers to edge " + edgeId + " but not vice versa"); - error = true; - continue; } } - } - - for (edgeId in this.edges) { - for (ballId in this.edges[edgeId]) { - var ball = this.balls[ballId]; - if (!ball) { - logError("Error: edge " + edgeId + " refers to unknown ball " + ballId); - error = true; - continue; - } - if (!ball[edgeId]) { - logError("Error: edge " + edgeId + " refers to ball " + ballId + " but not vice versa"); - error = true; - continue; - } + if (error) { + logDebug(EPSILON); } } - if (error) { - logDebug(JSON.stringify({ edges: this.edges, balls: this.balls }, null, 2)); - } } - -// FIXME fetch from a subkey of user data to support non-destructive modifications -MagBalls.prototype.setUserData = function(id, data) { - Entities.editEntity(id, { userData: JSON.stringify(data) }); -} - -// FIXME do non-destructive modification of the existing user data -MagBalls.prototype.getUserData = function(id) { - var results = null; - var properties = Entities.getEntityProperties(id); - if (properties.userData) { - results = JSON.parse(this.properties.userData); - } - return results; -} - -//MagBalls.prototype.findBalls = function() { -// var ids = Entities.findEntities(MyAvatar.position, 50); -// var result = []; -// ids.forEach(function(id) { -// var properties = Entities.getEntityProperties(id); -// if (properties.name == BALL_NAME) { -// result.push(id); -// } -// }, this); -// return result; -//}; -// -//MagBalls.prototype.findEdges = function() { -// var ids = Entities.findEntities(MyAvatar.position, 50); -// var result = []; -// ids.forEach(function(id) { -// var properties = Entities.getEntityProperties(id); -// if (properties.name == EDGE_NAME) { -// result.push(id); -// } -// }, this); -// return result; -//}; diff --git a/examples/toys/magSticks/springEdgeEntity.js b/examples/toys/magSticks/springEdgeEntity.js new file mode 100644 index 0000000000..9e0ebd2115 --- /dev/null +++ b/examples/toys/magSticks/springEdgeEntity.js @@ -0,0 +1,143 @@ +// +// Created by Bradley Austin Davis on 2015/08/29 +// Copyright 2015 High Fidelity, Inc. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + + +(function(){ + this.preload = function(entityId) { + this.MIN_CHECK_INTERVAL = 0.05; + this.MAX_VARIANCE = 0.005; + this.ZERO_VECTOR = { x: 0, y: 0, z: 0 }; + + this.entityId = entityId; + var properties = Entities.getEntityProperties(this.entityId); + var userData = JSON.parse(properties.userData); + this.start = userData.magBalls.start; + this.end = userData.magBalls.end; + this.originalColor = properties.color; + this.desiredLength = userData.magBalls.length; + this.timeSinceLastUpdate = 0; + this.nextCheckInterval = this.MIN_CHECK_INTERVAL; + + print("preload("+entityId+") " + this.start + " -> " + this.end + " " + this.desiredLength); + + var _this = this; + this.updateWrapper = function(deltaTime) { + _this.onUpdate(deltaTime); + }; + Script.update.connect(this.updateWrapper); + Script.scriptEnding.connect(function() { + _this.onCleanup(); + }); + + Entities.deletingEntity.connect(function(entityId) { + if (_this.entityId == entityId) { + _this.onCleanup(); + } + }); + }; + + this.onUpdate = function(deltaTime) { + this.timeSinceLastUpdate += deltaTime; + if (this.timeSinceLastUpdate > this.nextCheckInterval) { + this.updateProperties(); + this.timeSinceLastUpdate = 0; + var length = this.getLength(); + if (length == 0) { + this.onCleanup(); + return; + } + var variance = this.getVariance(length); + if (Math.abs(variance) <= this.MAX_VARIANCE) { + this.incrementCheckInterval(); + return; + } + this.decrementCheckInterval(); + print("Length is wrong: " + (length * 100).toFixed(1) + "cm variance " + variance); + + var adjustmentVector = Vec3.multiply(variance / 4, this.vector); + var newPosition = Vec3.sum(Vec3.multiply(-1, adjustmentVector), this.position); + var newVector = Vec3.sum(Vec3.multiply(2, adjustmentVector), this.vector); + var newLength = Vec3.length(newVector); + var newVariance = this.getVariance(newLength); + var color = { color: this.originalColor } + if (Math.abs(newVariance) > this.MAX_VARIANCE) { + color = { red: 255, green: 0, blue: 0 }; + } + print("Updating entity to new variance " + newVariance); + Entities.editEntity(this.entityId, { + color: color, + position: newPosition, + linePoints: [ this.ZERO_VECTOR, newVector ] + }); + Entities.editEntity(this.start, { + position: newPosition + }); + Entities.editEntity(this.end, { + position: Vec3.sum(newPosition, newVector) + }); + + } + } + + this.incrementCheckInterval = function() { + this.nextCheckInterval = Math.min(this.nextCheckInterval * 2.0, 1.0); + } + + this.decrementCheckInterval = function() { + this.nextCheckInterval = 0.05; + } + + this.onCleanup = function() { + print("Stopping spring script"); + Script.update.disconnect(this.updateWrapper); + } + + this.getVariance = function(length) { + if (!length) { + length = this.getLength(); + } + var difference = this.desiredLength - length; + return difference / this.desiredLength; + } + + this.getLength = function() { + return Vec3.length(this.vector); + } + + this.getPosition = function(entityId) { + var properties = Entities.getEntityProperties(entityId); + return properties.position; + } + + this.updateProperties = function() { + var properties = Entities.getEntityProperties(this.entityId); + var curStart = properties.position; + var curVector = properties.linePoints[1] + var curEnd = Vec3.sum(curVector, curStart); + var startPos = this.getPosition(this.start); + var endPos = this.getPosition(this.end); + var startError = Vec3.distance(curStart, startPos); + var endError = Vec3.distance(curEnd, endPos); + this.vector = Vec3.subtract(endPos, startPos); + if (startError > 0.005 || endError > 0.005) { + Entities.editEntity(this.entityId, { + position: startPos, + linePoints: [ this.ZERO_VECTOR, this.vector ] + }); + } + this.position = startPos; + } + + this.enterEntity = function(entityId) { + print("enterEntity("+entityId+")"); + }; + + this.leaveEntity = function(entityId) { + print("leaveEntity("+entityId+")"); + }; +}); diff --git a/examples/toys/magSticks/utils.js b/examples/toys/magSticks/utils.js index 43e4f800fb..5eaf1791fb 100644 --- a/examples/toys/magSticks/utils.js +++ b/examples/toys/magSticks/utils.js @@ -1,17 +1,7 @@ -DEBUG_MAGSTICKS = true; - -debugPrint = function (str) { - if (DEBUG_MAGSTICKS) { - print(str); - } -} - -vec3toStr = function (v) { - return "{ " + - (Math.round(v.x*1000)/1000) + ", " + - (Math.round(v.y*1000)/1000) + ", " + - (Math.round(v.z*1000)/1000) + " }"; +vec3toStr = function (v, digits) { + if (!digits) { digits = 3; } + return "{ " + v.x.toFixed(digits) + ", " + v.y.toFixed(digits) + ", " + v.z.toFixed(digits)+ " }"; } scaleLine = function (start, end, scale) { @@ -31,6 +21,79 @@ findAction = function(name) { return 0; } + +var LINE_DIMENSIONS = { + x: 5, + y: 5, + z: 5 +} + +var EDGE_NAME = "MagStick" + +var LINE_PROTOTYPE = { + type: "Line", + name: EDGE_NAME, + color: COLORS.CYAN, + dimensions: LINE_DIMENSIONS, + lineWidth: 5, + visible: true, + ignoreCollisions: true, + collisionsWillMove: false +} + + +addLine = function(origin, vector, color) { + if (!color) { + color = COLORS.WHITE + } + return Entities.addEntity(mergeObjects(LINE_PROTOTYPE, { + position: origin, + linePoints: [ + ZERO_VECTOR, + vector, + ], + color: color + })); +} + +// FIXME fetch from a subkey of user data to support non-destructive modifications +setEntityUserData = function(id, data) { + Entities.editEntity(id, { userData: JSON.stringify(data) }); +} + +// FIXME do non-destructive modification of the existing user data +getEntityUserData = function(id) { + var results = null; + var properties = Entities.getEntityProperties(id); + if (properties.userData) { + results = JSON.parse(this.properties.userData); + } + return results; +} + +// Non-destructively modify the user data of an entity. +setEntityCustomData = function(customKey, id, data) { + var userData = getEntityUserData(id); + userData[customKey] = data; + setEntityUserData(id, userData); +} + +getEntityCustomData = function(customKey, id, defaultValue) { + var userData = getEntityUserData(); + return userData[customKey] ? userData[customKey] : defaultValue; +} + +mergeObjects = function(proto, custom) { + var result = {}; + for (var attrname in proto) { + result[attrname] = proto[attrname]; + } + for (var attrname in custom) { + result[attrname] = custom[attrname]; + } + return result; +} + logWarn = function(str) { print(str); } @@ -44,5 +107,5 @@ logInfo = function(str) { } logDebug = function(str) { - debugPrint(str); + print(str); } \ No newline at end of file