From 38b62108afd49586e8cfca71ea9e44688005fc50 Mon Sep 17 00:00:00 2001 From: Brad Davis Date: Tue, 25 Aug 2015 18:54:48 -0700 Subject: [PATCH] Magnetic sticks and balls --- examples/toys/magSticks.js | 92 +++++ examples/toys/magSticks/constants.js | 16 + examples/toys/magSticks/handController.js | 94 +++++ examples/toys/magSticks/highlighter.js | 63 +++ examples/toys/magSticks/magBalls.js | 454 ++++++++++++++++++++++ examples/toys/magSticks/utils.js | 48 +++ 6 files changed, 767 insertions(+) create mode 100644 examples/toys/magSticks.js create mode 100644 examples/toys/magSticks/constants.js create mode 100644 examples/toys/magSticks/handController.js create mode 100644 examples/toys/magSticks/highlighter.js create mode 100644 examples/toys/magSticks/magBalls.js create mode 100644 examples/toys/magSticks/utils.js diff --git a/examples/toys/magSticks.js b/examples/toys/magSticks.js new file mode 100644 index 0000000000..7381754de0 --- /dev/null +++ b/examples/toys/magSticks.js @@ -0,0 +1,92 @@ +// +// Created by Bradley Austin Davis on 2015/08/25 +// 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 +// + +Script.include("magSticks/utils.js"); +Script.include("magSticks/constants.js"); +Script.include("magSticks/magBalls.js"); +Script.include("magSticks/highlighter.js"); +Script.include("magSticks/handController.js"); + +var magBalls = new MagBalls(); + +// Clear any previous balls +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; + +BallController = function(side) { + HandController.call(this, side); + this.highlighter = new Highlighter(); + this.highlighter.setSize(BALL_SIZE); + this.ghostEdges = {}; +} + +BallController.prototype = Object.create( HandController.prototype ); + +BallController.prototype.onUpdate = function(deltaTime) { + HandController.prototype.updateControllerState.call(this, deltaTime); + if (!this.selected) { + // Find the highlight target and set it. + var target = magBalls.findNearestBall(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); + for (var ballId in targetBalls) { + if (!this.ghostEdges[ballId]) { + // create the ovleray + this.ghostEdges[ballId] = Overlays.addOverlay("line3d", { + start: magBalls.getBallPosition(ballId), + end: this.tipPosition, + color: { red: 255, green: 0, blue: 0}, + alpha: 1, + lineWidth: 5, + visible: true, + }); + } else { + Overlays.editOverlay(this.ghostEdges[ballId], { + end: this.tipPosition, + }); + } + } + for (var ballId in this.ghostEdges) { + if (!targetBalls[ballId]) { + Overlays.deleteOverlay(this.ghostEdges[ballId]); + delete this.ghostEdges[ballId]; + } + } +} + +BallController.prototype.onClick = function() { + this.selected = magBalls.grabBall(this.tipPosition, BALL_SELECTION_RADIUS); + this.highlighter.highlight(null); +} + +BallController.prototype.onRelease = function() { + this.clearGhostEdges(); + magBalls.releaseBall(this.selected); + this.selected = null; +} + +BallController.prototype.clearGhostEdges = function() { + for(var ballId in this.ghostEdges) { + Overlays.deleteOverlay(this.ghostEdges[ballId]); + delete this.ghostEdges[ballId]; + } +} + +BallController.prototype.onCleanup = function() { + HandController.prototype.updateControllerState.call(this); + this.clearGhostEdges(); +} + +// FIXME resolve some of the issues with dual controllers before allowing both controllers active +var handControllers = [new BallController(LEFT_CONTROLLER)]; //, new HandController(RIGHT) ]; diff --git a/examples/toys/magSticks/constants.js b/examples/toys/magSticks/constants.js new file mode 100644 index 0000000000..0453d29a79 --- /dev/null +++ b/examples/toys/magSticks/constants.js @@ -0,0 +1,16 @@ +// +// Created by Bradley Austin Davis on 2015/08/27 +// 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 +// + +HIFI_PUBLIC_BUCKET = "http://s3.amazonaws.com/hifi-public/"; +STICK_URL = HIFI_PUBLIC_BUCKET + "models/props/geo_stick.fbx"; + +// FIXME make this editable through some script UI, so the user can customize the size of the structure built +SCALE = 0.5; +BALL_SIZE = 0.08 * SCALE; +STICK_LENGTH = 0.24 * SCALE; + diff --git a/examples/toys/magSticks/handController.js b/examples/toys/magSticks/handController.js new file mode 100644 index 0000000000..4f893c9cf2 --- /dev/null +++ b/examples/toys/magSticks/handController.js @@ -0,0 +1,94 @@ + +LEFT_CONTROLLER = 0; +RIGHT_CONTROLLER = 1; + + +HandController = function(side) { + this.side = side; + this.palm = 2 * side; + this.tip = 2 * side + 1; + this.action = findAction(side ? "ACTION2" : "ACTION1"); + this.active = false; + this.pointer = Overlays.addOverlay("sphere", { + position: { + x: 0, + y: 0, + z: 0 + }, + size: 0.01, + color: { + red: 255, + green: 255, + blue: 0 + }, + alpha: 1.0, + solid: true, + visible: false, + }); + + // Connect to desired events + var _this = this; + Controller.actionEvent.connect(function(action, state) { + _this.onActionEvent(action, state); + }); + + Script.update.connect(function(deltaTime) { + _this.onUpdate(deltaTime); + }); + + Script.scriptEnding.connect(function() { + _this.onCleanup(); + }); +} + +HandController.prototype.onActionEvent = function(action, state) { + if (action == this.action) { + if (state) { + this.onClick(); + } else { + this.onRelease(); + } + } +} + +HandController.prototype.setActive = function(active) { + if (active == this.active) { + return; + } + debugPrint("Setting active: " + active); + this.active = active; + Overlays.editOverlay(this.pointer, { + position: this.tipPosition, + visible: this.active + }); +} + +HandController.prototype.updateControllerState = function() { + var palmPos = Controller.getSpatialControlPosition(this.palm); + // When on the base hydras report a position of 0 + this.setActive(Vec3.length(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() { + Overlays.deleteOverlay(this.pointer); +} + +HandController.prototype.onUpdate = function(deltaTime) { + this.updateControllerState(); +} + +HandController.prototype.onClick = function() { + debugPrint("Base hand controller does nothing on click"); +} + +HandController.prototype.onRelease = function() { + debugPrint("Base hand controller does nothing on release"); +} diff --git a/examples/toys/magSticks/highlighter.js b/examples/toys/magSticks/highlighter.js new file mode 100644 index 0000000000..7316549c84 --- /dev/null +++ b/examples/toys/magSticks/highlighter.js @@ -0,0 +1,63 @@ +var SELECTION_OVERLAY = { + position: { + x: 0, + y: 0, + z: 0 + }, + color: { + red: 255, + green: 255, + blue: 0 + }, + alpha: 1, + size: 1.0, + solid: false, + //colorPulse: 1.0, + //pulseMin: 0.5, + //pulseMax: 1.0, + visible: false, + lineWidth: 1.0, + borderSize: 1.4, +}; + +Highlighter = function() { + this.highlightCube = Overlays.addOverlay("cube", this.SELECTION_OVERLAY); + this.hightlighted = null; + var _this = this; + Script.scriptEnding.connect(function() { + _this.onCleanup(); + }); +}; + +Highlighter.prototype.onCleanup = function() { + Overlays.deleteOverlay(this.highlightCube); +} + +Highlighter.prototype.highlight = function(entityId) { + if (entityId != this.hightlighted) { + this.hightlighted = entityId; + this.updateHighlight(); + } +} + +Highlighter.prototype.setSize = function(newSize) { + Overlays.editOverlay(this.highlightCube, { + size: newSize + }); +} + +Highlighter.prototype.updateHighlight = function() { + if (this.hightlighted) { + var properties = Entities.getEntityProperties(this.hightlighted); + // logDebug("Making highlight " + this.highlightCube + " visible @ " + vec3toStr(properties.position)); + Overlays.editOverlay(this.highlightCube, { + position: properties.position, + visible: true + }); + } else { + // logDebug("Making highlight invisible"); + Overlays.editOverlay(this.highlightCube, { + visible: false + }); + } +} \ No newline at end of file diff --git a/examples/toys/magSticks/magBalls.js b/examples/toys/magSticks/magBalls.js new file mode 100644 index 0000000000..9d67e0e0b7 --- /dev/null +++ b/examples/toys/magSticks/magBalls.js @@ -0,0 +1,454 @@ +var BALL_NAME = "MagBall" + +var EDGE_NAME = "MagStick" + +var BALL_DIMENSIONS = { + x: BALL_SIZE, + y: BALL_SIZE, + z: BALL_SIZE +}; + +var BALL_COLOR = { + red: 128, + green: 128, + blue: 128 +}; + +var STICK_DIMENSIONS = { + x: STICK_LENGTH / 6, + y: STICK_LENGTH / 6, + z: STICK_LENGTH +}; + +var BALL_DISTANCE = STICK_LENGTH + BALL_SIZE; + +var BALL_PROTOTYPE = { + type: "Sphere", + name: BALL_NAME, + dimensions: BALL_DIMENSIONS, + color: BALL_COLOR, + 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 = {}; + + var _this = this; + Script.update.connect(function(deltaTime) { + _this.onUpdate(deltaTime); + }); + + Script.scriptEnding.connect(function() { + _this.onCleanup(); + }); +} + +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; +} + +// FIXME move to a physics based implementation as soon as bullet +// is exposed to entities +MagBalls.prototype.onUpdate = function(deltaTime) { +} + +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.createBall = function(customProperies) { + var ballId = Entities.addEntity(mergeObjects(BALL_PROTOTYPE, customProperies)); + this.balls[ballId] = {}; + this.validate(); + return ballId; +} + +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.findMatches = function(ballId) { + var variances = {}; + for (var otherBallId in this.balls) { + if (otherBallId == ballId || this.areConnected(otherBallId, ballId)) { + // can't self connect or doubly connect + continue; + } + var variance = this.getStickLengthVariance(ballId, otherBallId); + if (variance > BALL_DISTANCE / 4) { + continue; + } + variances[otherBallId] = variance; + } + return variances; +} + +MagBalls.prototype.releaseBall = function(releasedBall) { + delete this.selectedBalls[releasedBall]; + debugPrint("Released ball: " + releasedBall); + + // 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) { + return; + } + sortedBalls.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)) + + this.clean(); +} + +// 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); + var vector = Vec3.subtract(toPos, fromPos); + var originalLength = Vec3.length(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 + vector = Vec3.multiply(BALL_DISTANCE, Vec3.normalize(vector)); + // Zero edges for the destination + var edgeCount = Object.keys(this.balls[to]).length; + if (!edgeCount) { + // 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 + } else { + // FIXME check for the ability to move fromPos + return false; + } + } + + + // Fixup existing edges + for (var edgeId in this.balls[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(); + 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.fixupEdge = function(edgeId) { + var ballsInEdge = Object.keys(this.edges[edgeId]); + Entities.editEntity(edgeId, this.findEdgeParams(ballsInEdge[0], ballsInEdge[1])); +} + +// 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; +} + +// FIXME 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); + } + // 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]; + } + delete this.edges[edgeId]; + Entities.deleteEntity(edgeId); + this.validate(); +} + +MagBalls.prototype.destroyBall = function(ballId) { + logDebug("Deleting ball " + ballId); + breakEdges(ballId); + Entities.deleteEntity(ballId); +} + +MagBalls.prototype.clear = function() { + if (DEBUG_MAGSTICKS) { + var ids = Entities.findEntities(MyAvatar.position, 50); + var result = []; + ids.forEach(function(id) { + var properties = Entities.getEntityProperties(id); + if (properties.name == BALL_NAME || properties.name == EDGE_NAME) { + Entities.deleteEntity(id); + } + }, this); + } +} + +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; +} + +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); + 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(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/utils.js b/examples/toys/magSticks/utils.js new file mode 100644 index 0000000000..43e4f800fb --- /dev/null +++ b/examples/toys/magSticks/utils.js @@ -0,0 +1,48 @@ + +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) + " }"; +} + +scaleLine = function (start, end, scale) { + var v = Vec3.subtract(end, start); + var length = Vec3.length(v); + v = Vec3.multiply(scale, v); + return Vec3.sum(start, v); +} + +findAction = function(name) { + var actions = Controller.getAllActions(); + for (var i = 0; i < actions.length; i++) { + if (actions[i].actionName == name) { + return i; + } + } + return 0; +} + +logWarn = function(str) { + print(str); +} + +logError = function(str) { + print(str); +} + +logInfo = function(str) { + print(str); +} + +logDebug = function(str) { + debugPrint(str); +} \ No newline at end of file