overte/scripts/system/libraries/WebTablet.js
Anthony J. Thibault 836c701cb3 More accurate and responsive stylus and finger touching.
The algorithm used to detect when and where the stylus or finger is touching the tablet has been improved.

* hovering the finger/stylus over the surface of the tablet should cause buttons to highlight.
* flicking or using the stylus like a drum stick, should more accurately click buttons on the tablet.
* stabbing the tablet quickly, should also more accurately trigger button presses.
* moving the hand/stylus from behind the tablet should be less likely to cause press events.
2017-03-07 18:19:32 -08:00

544 lines
21 KiB
JavaScript

//
// WebTablet.js
//
// Created by Anthony J. Thibault on 8/8/2016
// Copyright 2016 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
//
/* global getControllerWorldLocation, Tablet, WebTablet:true, HMD, Settings, Script,
Vec3, Quat, MyAvatar, Entities, Overlays, Camera, Messages, Xform, clamp, Controller, Mat4 */
Script.include(Script.resolvePath("../libraries/utils.js"));
Script.include(Script.resolvePath("../libraries/controllers.js"));
Script.include(Script.resolvePath("../libraries/Xform.js"));
var X_AXIS = {x: 1, y: 0, z: 0};
var Y_AXIS = {x: 0, y: 1, z: 0};
var DEFAULT_DPI = 34;
var DEFAULT_WIDTH = 0.4375;
var DEFAULT_VERTICAL_FIELD_OF_VIEW = 45; // degrees
var SENSOR_TO_ROOM_MATRIX = -2;
var CAMERA_MATRIX = -7;
var ROT_Y_180 = {x: 0, y: 1, z: 0, w: 0};
var TABLET_TEXTURE_RESOLUTION = { x: 480, y: 706 };
var INCHES_TO_METERS = 1 / 39.3701;
var AVATAR_SELF_ID = "{00000000-0000-0000-0000-000000000001}";
var NO_HANDS = -1;
// will need to be recaclulated if dimensions of fbx model change.
var TABLET_NATURAL_DIMENSIONS = {x: 33.797, y: 50.129, z: 2.269};
var HOME_BUTTON_TEXTURE = "http://hifi-content.s3.amazonaws.com/alan/dev/tablet-with-home-button.fbx/tablet-with-home-button.fbm/button-close.png";
// var HOME_BUTTON_TEXTURE = Script.resourcesPath() + "meshes/tablet-with-home-button.fbx/tablet-with-home-button.fbm/button-close.png";
var TABLET_MODEL_PATH = "http://hifi-content.s3.amazonaws.com/alan/dev/tablet-with-home-button.fbx";
var LOCAL_TABLET_MODEL_PATH = Script.resourcesPath() + "meshes/tablet-with-home-button.fbx";
// returns object with two fields:
// * position - position in front of the user
// * rotation - rotation of entity so it faces the user.
function calcSpawnInfo(hand, height) {
var finalPosition;
var headPos = (HMD.active && Camera.mode === "first person") ? HMD.position : Camera.position;
var headRot = (HMD.active && Camera.mode === "first person") ? HMD.orientation : Camera.orientation;
if (HMD.active && hand !== NO_HANDS) {
var handController = getControllerWorldLocation(hand, true);
var controllerPosition = handController.position;
// base of the tablet is slightly above controller position
var TABLET_BASE_DISPLACEMENT = {x: 0, y: 0.1, z: 0};
var tabletBase = Vec3.sum(controllerPosition, TABLET_BASE_DISPLACEMENT);
var d = Vec3.subtract(headPos, tabletBase);
var theta = Math.acos(d.y / Vec3.length(d));
d.y = 0;
if (Vec3.length(d) < 0.0001) {
d = {x: 1, y: 0, z: 0};
} else {
d = Vec3.normalize(d);
}
var w = Vec3.normalize(Vec3.cross(Y_AXIS, d));
var ANGLE_OFFSET = 25;
var q = Quat.angleAxis(theta * (180 / Math.PI) - (90 - ANGLE_OFFSET), w);
var u = Vec3.multiplyQbyV(q, d);
// use u to compute a full lookAt quaternion.
var lookAtRot = Quat.lookAt(tabletBase, Vec3.sum(tabletBase, u), Y_AXIS);
var yDisplacement = (height / 2);
var zDisplacement = 0.05;
var tabletOffset = Vec3.multiplyQbyV(lookAtRot, {x: 0, y: yDisplacement, z: zDisplacement});
finalPosition = Vec3.sum(tabletBase, tabletOffset);
return {
position: finalPosition,
rotation: lookAtRot
};
} else {
var front = Quat.getFront(headRot);
finalPosition = Vec3.sum(headPos, Vec3.multiply(0.6, front));
var orientation = Quat.lookAt({x: 0, y: 0, z: 0}, front, {x: 0, y: 1, z: 0});
return {
position: finalPosition,
rotation: Quat.multiply(orientation, {x: 0, y: 1, z: 0, w: 0})
};
}
}
/**
* WebTablet
* @param url [string] url of content to show on the tablet.
* @param width [number] width in meters of the tablet model
* @param dpi [number] dpi of web surface used to show the content.
* @param hand [number] -1 indicates no hand, Controller.Standard.RightHand or Controller.Standard.LeftHand
* @param clientOnly [bool] true indicates tablet model is only visible to client.
*/
WebTablet = function (url, width, dpi, hand, clientOnly) {
var _this = this;
// scale factor of natural tablet dimensions.
this.width = width || DEFAULT_WIDTH;
var tabletScaleFactor = this.width / TABLET_NATURAL_DIMENSIONS.x;
this.height = TABLET_NATURAL_DIMENSIONS.y * tabletScaleFactor;
this.depth = TABLET_NATURAL_DIMENSIONS.z * tabletScaleFactor;
if (dpi) {
this.dpi = dpi;
} else {
this.dpi = DEFAULT_DPI * (DEFAULT_WIDTH / this.width);
}
var modelURL;
if (Settings.getValue("tabletVisibleToOthers")) {
modelURL = TABLET_MODEL_PATH;
} else {
modelURL = LOCAL_TABLET_MODEL_PATH;
}
var tabletProperties = {
name: "WebTablet Tablet",
type: "Model",
modelURL: modelURL,
url: modelURL, // for overlay
grabbable: true, // for overlay
userData: JSON.stringify({
"grabbableKey": {"grabbable": true}
}),
dimensions: {x: this.width, y: this.height, z: this.depth},
parentID: AVATAR_SELF_ID
};
// compute position, rotation & parentJointIndex of the tablet
this.calculateTabletAttachmentProperties(hand, true, tabletProperties);
this.cleanUpOldTablets();
if (Settings.getValue("tabletVisibleToOthers")) {
this.tabletEntityID = Entities.addEntity(tabletProperties, clientOnly);
this.tabletIsOverlay = false;
} else {
this.tabletEntityID = Overlays.addOverlay("model", tabletProperties);
this.tabletIsOverlay = true;
}
if (this.webOverlayID) {
Overlays.deleteOverlay(this.webOverlayID);
}
var WEB_ENTITY_Z_OFFSET = (this.depth / 2);
var WEB_ENTITY_Y_OFFSET = 0.004;
this.webOverlayID = Overlays.addOverlay("web3d", {
name: "WebTablet Web",
url: url,
localPosition: { x: 0, y: WEB_ENTITY_Y_OFFSET, z: -WEB_ENTITY_Z_OFFSET },
localRotation: Quat.angleAxis(180, Y_AXIS),
resolution: TABLET_TEXTURE_RESOLUTION,
dpi: this.dpi,
color: { red: 255, green: 255, blue: 255 },
alpha: 1.0,
parentID: this.tabletEntityID,
parentJointIndex: -1,
showKeyboardFocusHighlight: false,
isAA: HMD.active
});
var HOME_BUTTON_Y_OFFSET = (this.height / 2) - (this.height / 20);
this.homeButtonID = Overlays.addOverlay("sphere", {
name: "homeButton",
localPosition: {x: -0.001, y: -HOME_BUTTON_Y_OFFSET, z: 0.0},
dimensions: { x: 4 * tabletScaleFactor, y: 4 * tabletScaleFactor, z: 4 * tabletScaleFactor},
alpha: 0.0,
visible: true,
drawInFront: false,
parentID: this.tabletEntityID,
parentJointIndex: -1
});
this.receive = function (channel, senderID, senderUUID, localOnly) {
if (_this.homeButtonID == senderID) {
var tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system");
var onHomeScreen = tablet.onHomeScreen();
if (onHomeScreen) {
HMD.closeTablet();
} else {
tablet.gotoHomeScreen();
_this.setHomeButtonTexture();
}
}
};
this.state = "idle";
this.getRoot = function() {
return Entities.getWebViewRoot(_this.tabletEntityID);
};
this.getLocation = function() {
if (this.tabletIsOverlay) {
var location = Overlays.getProperty(this.tabletEntityID, "localPosition");
var orientation = Overlays.getProperty(this.tabletEntityID, "localOrientation");
return {
localPosition: location,
localRotation: orientation
};
} else {
return Entities.getEntityProperties(_this.tabletEntityID, ["localPosition", "localRotation"]);
}
};
this.clicked = false;
this.myOnHmdChanged = function () {
_this.onHmdChanged();
};
HMD.displayModeChanged.connect(this.myOnHmdChanged);
this.myMousePressEvent = function (event) {
_this.mousePressEvent(event);
};
this.myMouseMoveEvent = function (event) {
_this.mouseMoveEvent(event);
};
this.myMouseReleaseEvent = function (event) {
_this.mouseReleaseEvent(event);
};
Controller.mousePressEvent.connect(this.myMousePressEvent);
Controller.mouseMoveEvent.connect(this.myMouseMoveEvent);
Controller.mouseReleaseEvent.connect(this.myMouseReleaseEvent);
this.dragging = false;
this.initialLocalIntersectionPoint = {x: 0, y: 0, z: 0};
this.initialLocalPosition = {x: 0, y: 0, z: 0};
this.myGeometryChanged = function (geometry) {
_this.geometryChanged(geometry);
};
Window.geometryChanged.connect(this.myGeometryChanged);
this.myCameraModeChanged = function(newMode) {
_this.cameraModeChanged(newMode);
};
Camera.modeUpdated.connect(this.myCameraModeChanged);
};
WebTablet.prototype.setHomeButtonTexture = function() {
Entities.editEntity(this.tabletEntityID, {textures: JSON.stringify({"tex.close": HOME_BUTTON_TEXTURE})});
};
WebTablet.prototype.setURL = function (url) {
Overlays.editOverlay(this.webOverlayID, { url: url });
};
WebTablet.prototype.setScriptURL = function (scriptURL) {
Overlays.editOverlay(this.webOverlayID, { scriptURL: scriptURL });
};
WebTablet.prototype.getOverlayObject = function () {
return Overlays.getOverlayObject(this.webOverlayID);
};
WebTablet.prototype.destroy = function () {
Overlays.deleteOverlay(this.webOverlayID);
if (this.tabletIsOverlay) {
Overlays.deleteOverlay(this.tabletEntityID);
} else {
Entities.deleteEntity(this.tabletEntityID);
}
Overlays.deleteOverlay(this.homeButtonID);
HMD.displayModeChanged.disconnect(this.myOnHmdChanged);
Controller.mousePressEvent.disconnect(this.myMousePressEvent);
Controller.mouseMoveEvent.disconnect(this.myMouseMoveEvent);
Controller.mouseReleaseEvent.disconnect(this.myMouseReleaseEvent);
Window.geometryChanged.disconnect(this.myGeometryChanged);
Camera.modeUpdated.disconnect(this.myCameraModeChanged);
};
WebTablet.prototype.geometryChanged = function (geometry) {
if (!HMD.active) {
var tabletProperties = {};
// compute position, rotation & parentJointIndex of the tablet
this.calculateTabletAttachmentProperties(NO_HANDS, false, tabletProperties);
Entities.editEntity(this.tabletEntityID, tabletProperties);
}
};
function gluPerspective(fovy, aspect, zNear, zFar) {
var cotan = 1 / Math.tan(fovy / 2);
var alpha = -(zFar + zNear) / (zFar - zNear);
var beta = -(2 * zFar * zNear) / (zFar - zNear);
var col0 = {x: cotan / aspect, y: 0, z: 0, w: 0};
var col1 = {x: 0, y: cotan, z: 0, w: 0};
var col2 = {x: 0, y: 0, z: alpha, w: -1};
var col3 = {x: 0, y: 0, z: beta, w: 0};
return Mat4.createFromColumns(col0, col1, col2, col3);
}
// calclulate the appropriate position of the tablet in world space, such that it fits in the center of the screen.
// with a bit of padding on the top and bottom.
// windowPos is used to position the center of the tablet at the given position.
WebTablet.prototype.calculateWorldAttitudeRelativeToCamera = function (windowPos) {
var DEFAULT_DESKTOP_TABLET_SCALE = 75;
var DESKTOP_TABLET_SCALE = Settings.getValue("desktopTabletScale") || DEFAULT_DESKTOP_TABLET_SCALE;
// clamp window pos so 2d tablet is not off-screen.
var TABLET_TEXEL_PADDING = {x: 60, y: 90};
var X_CLAMP = (DESKTOP_TABLET_SCALE / 100) * ((TABLET_TEXTURE_RESOLUTION.x / 2) + TABLET_TEXEL_PADDING.x);
var Y_CLAMP = (DESKTOP_TABLET_SCALE / 100) * ((TABLET_TEXTURE_RESOLUTION.y / 2) + TABLET_TEXEL_PADDING.y);
windowPos.x = clamp(windowPos.x, X_CLAMP, Window.innerWidth - X_CLAMP);
windowPos.y = clamp(windowPos.y, Y_CLAMP, Window.innerHeight - Y_CLAMP);
var fov = (Settings.getValue('fieldOfView') || DEFAULT_VERTICAL_FIELD_OF_VIEW) * (Math.PI / 180);
var MAX_PADDING_FACTOR = 2.2;
var PADDING_FACTOR = Math.min(Window.innerHeight / TABLET_TEXTURE_RESOLUTION.y, MAX_PADDING_FACTOR);
var TABLET_HEIGHT = (TABLET_TEXTURE_RESOLUTION.y / this.dpi) * INCHES_TO_METERS;
var WEB_ENTITY_Z_OFFSET = (this.depth / 2);
// calcualte distance from camera
var dist = (PADDING_FACTOR * TABLET_HEIGHT) / (2 * Math.tan(fov / 2) * (DESKTOP_TABLET_SCALE / 100)) - WEB_ENTITY_Z_OFFSET;
var Z_NEAR = 0.01;
var Z_FAR = 100.0;
// calculate mouse position in clip space
var alpha = -(Z_FAR + Z_NEAR) / (Z_FAR - Z_NEAR);
var beta = -(2 * Z_FAR * Z_NEAR) / (Z_FAR - Z_NEAR);
var clipZ = (beta / dist) - alpha;
var clipMousePosition = {x: (2 * windowPos.x / Window.innerWidth) - 1,
y: (2 * ((Window.innerHeight - windowPos.y) / Window.innerHeight)) - 1,
z: clipZ};
// calculate projection matrix
var aspect = Window.innerWidth / Window.innerHeight;
var projMatrix = gluPerspective(fov, aspect, Z_NEAR, Z_FAR);
// transform mouse clip position into view coordinates.
var viewMousePosition = Mat4.transformPoint(Mat4.inverse(projMatrix), clipMousePosition);
// transform view mouse position into world coordinates.
var viewToWorldMatrix = Mat4.createFromRotAndTrans(Camera.orientation, Camera.position);
var worldMousePosition = Mat4.transformPoint(viewToWorldMatrix, viewMousePosition);
return {
position: worldMousePosition,
rotation: Quat.multiply(Camera.orientation, ROT_Y_180)
};
};
// compute position, rotation & parentJointIndex of the tablet
WebTablet.prototype.calculateTabletAttachmentProperties = function (hand, useMouse, tabletProperties) {
if (HMD.active) {
// in HMD mode, the tablet should be relative to the sensor to world matrix.
tabletProperties.parentJointIndex = SENSOR_TO_ROOM_MATRIX;
// compute the appropriate position of the tablet, near the hand controller that was used to spawn it.
var spawnInfo = calcSpawnInfo(hand, this.height);
tabletProperties.position = spawnInfo.position;
tabletProperties.rotation = spawnInfo.rotation;
} else {
// in desktop mode, the tablet should be relative to the camera
tabletProperties.parentJointIndex = CAMERA_MATRIX;
var windowPos;
if (useMouse) {
// compute the appropriate postion of the tablet such that it fits in the center of the screen nicely.
windowPos = {x: Controller.getValue(Controller.Hardware.Keyboard.MouseX),
y: Controller.getValue(Controller.Hardware.Keyboard.MouseY)};
} else {
windowPos = {x: Window.innerWidth / 2,
y: Window.innerHeight / 2};
}
var attitude = this.calculateWorldAttitudeRelativeToCamera(windowPos);
tabletProperties.position = attitude.position;
tabletProperties.rotation = attitude.rotation;
}
};
WebTablet.prototype.onHmdChanged = function () {
if (HMD.active) {
Controller.mousePressEvent.disconnect(this.myMousePressEvent);
Controller.mouseMoveEvent.disconnect(this.myMouseMoveEvent);
Controller.mouseReleaseEvent.disconnect(this.myMouseReleaseEvent);
} else {
Controller.mousePressEvent.connect(this.myMousePressEvent);
Controller.mouseMoveEvent.connect(this.myMouseMoveEvent);
Controller.mouseReleaseEvent.connect(this.myMouseReleaseEvent);
}
var tabletProperties = {};
// compute position, rotation & parentJointIndex of the tablet
this.calculateTabletAttachmentProperties(NO_HANDS, false, tabletProperties);
Entities.editEntity(this.tabletEntityID, tabletProperties);
// Full scene FXAA should be disabled on the overlay when the tablet in desktop mode.
// This should make the text more readable.
Overlays.editOverlay(this.webOverlayID, { isAA: HMD.active });
};
WebTablet.prototype.pickle = function () {
return JSON.stringify({ webOverlayID: this.webOverlayID, tabletEntityID: this.tabletEntityID });
};
WebTablet.prototype.register = function() {
Messages.subscribe("home");
Messages.messageReceived.connect(this.receive);
};
WebTablet.prototype.cleanUpOldTabletsOnJoint = function(jointIndex) {
var children = Entities.getChildrenIDsOfJoint(MyAvatar.sessionUUID, jointIndex);
children = children.concat(Entities.getChildrenIDsOfJoint(AVATAR_SELF_ID, jointIndex));
children.forEach(function(childID) {
var props = Entities.getEntityProperties(childID, ["name"]);
if (props.name === "WebTablet Tablet") {
Entities.deleteEntity(childID);
}
});
};
WebTablet.prototype.cleanUpOldTablets = function() {
this.cleanUpOldTabletsOnJoint(-1);
this.cleanUpOldTabletsOnJoint(SENSOR_TO_ROOM_MATRIX);
this.cleanUpOldTabletsOnJoint(CAMERA_MATRIX);
this.cleanUpOldTabletsOnJoint(65529);
this.cleanUpOldTabletsOnJoint(65534);
};
WebTablet.prototype.unregister = function() {
Messages.unsubscribe("home");
Messages.messageReceived.disconnect(this.receive);
};
WebTablet.unpickle = function (string) {
if (!string) {
return;
}
var tablet = JSON.parse(string);
tablet.__proto__ = WebTablet.prototype;
return tablet;
};
WebTablet.prototype.getPosition = function () {
return Overlays.getProperty(this.webOverlayID, "position");
};
WebTablet.prototype.mousePressEvent = function (event) {
var pickRay = Camera.computePickRay(event.x, event.y);
var entityPickResults;
if (this.tabletIsOverlay) {
entityPickResults = Overlays.findRayIntersection(pickRay, true, [this.tabletEntityID]);
} else {
entityPickResults = Entities.findRayIntersection(pickRay, true, [this.tabletEntityID]);
}
if (entityPickResults.intersects && (entityPickResults.entityID === this.tabletEntityID ||
entityPickResults.overlayID === this.tabletEntityID)) {
var overlayPickResults = Overlays.findRayIntersection(pickRay, true, [this.webOverlayID, this.homeButtonID], []);
if (overlayPickResults.intersects && overlayPickResults.overlayID === this.homeButtonID) {
var tablet = Tablet.getTablet("com.highfidelity.interface.tablet.system");
var onHomeScreen = tablet.onHomeScreen();
if (onHomeScreen) {
HMD.closeTablet();
} else {
tablet.gotoHomeScreen();
this.setHomeButtonTexture();
}
} else if (!HMD.active && (!overlayPickResults.intersects || overlayPickResults.overlayID !== this.webOverlayID)) {
this.dragging = true;
var invCameraXform = new Xform(Camera.orientation, Camera.position).inv();
this.initialLocalIntersectionPoint = invCameraXform.xformPoint(entityPickResults.intersection);
if (this.tabletIsOverlay) {
this.initialLocalPosition = Overlays.getProperty(this.tabletEntityID, "localPosition");
} else {
this.initialLocalPosition = Entities.getEntityProperties(this.tabletEntityID, ["localPosition"]).localPosition;
}
}
}
};
WebTablet.prototype.cameraModeChanged = function (newMode) {
// reposition the tablet.
// This allows HMD.position to reflect the new camera mode.
if (HMD.active) {
var self = this;
var tabletProperties = {};
// compute position, rotation & parentJointIndex of the tablet
self.calculateTabletAttachmentProperties(NO_HANDS, false, tabletProperties);
Entities.editEntity(self.tabletEntityID, tabletProperties);
}
};
function rayIntersectPlane(planePosition, planeNormal, rayStart, rayDirection) {
var rayDirectionDotPlaneNormal = Vec3.dot(rayDirection, planeNormal);
if (rayDirectionDotPlaneNormal > 0.00001 || rayDirectionDotPlaneNormal < -0.00001) {
var rayStartDotPlaneNormal = Vec3.dot(Vec3.subtract(planePosition, rayStart), planeNormal);
var distance = rayStartDotPlaneNormal / rayDirectionDotPlaneNormal;
return {hit: true, distance: distance};
} else {
// ray is parallel to the plane
return {hit: false, distance: 0};
}
}
WebTablet.prototype.mouseMoveEvent = function (event) {
if (this.dragging) {
var pickRay = Camera.computePickRay(event.x, event.y);
// transform pickRay into camera local coordinates
var invCameraXform = new Xform(Camera.orientation, Camera.position).inv();
var localPickRay = {
origin: invCameraXform.xformPoint(pickRay.origin),
direction: invCameraXform.xformVector(pickRay.direction)
};
var NORMAL = {x: 0, y: 0, z: -1};
var result = rayIntersectPlane(this.initialLocalIntersectionPoint, NORMAL, localPickRay.origin, localPickRay.direction);
if (result.hit) {
var localIntersectionPoint = Vec3.sum(localPickRay.origin, Vec3.multiply(localPickRay.direction, result.distance));
var localOffset = Vec3.subtract(localIntersectionPoint, this.initialLocalIntersectionPoint);
var localPosition = Vec3.sum(this.initialLocalPosition, localOffset);
if (this.tabletIsOverlay) {
Overlays.editOverlay(this.tabletEntityID, {
localPosition: localPosition
});
} else {
Entities.editEntity(this.tabletEntityID, {
localPosition: localPosition
});
}
}
}
};
WebTablet.prototype.mouseReleaseEvent = function (event) {
this.dragging = false;
};