define(["lib/jquery", "lib/underscore", "common/network/dispatcher", "common/util/log", "./snapshot", "common/util/id_generator", "common/util/transformations", "common/util/cap_utils", "common/util/binding_list", "common/util/check", "ARCore/coordinate"], function($, _, Dispatcher, Log, Snapshot, IDGenerator, Transformations, CapUtils, BindingList, check, Coordinate) { "use strict"; var logger = Log("Marker"); // map of UUID -> Marker var markers = {}; // given a loaded image, returns the base64-encoded PNG data for the image. var imageToBase64 = function(image) { var canvas = document.createElement("canvas"); var ctx = canvas.getContext("2d"); canvas.width = image.width; canvas.height = image.height; ctx.drawImage(image, 0, 0); return canvas.toDataURL("image/png").split(",")[1]; }; // given image data, mask data, and an id, registers a Marker. var registerMarker = function(id, imageData, maskData) { var data = {id: id, image: imageData}; // parse arguments if(maskData) { data.mask = maskData; } // send registration message after ensuring that markers are on CapUtils.ensure("MarkersModule", logger, function() { Dispatcher._bridge.fireEvent( "marker_register", data ); }); }; // given image data, renders it to an image var renderImage = function(imageData, callback) { var img = new Image(); img.onload = function() { callback(img); }; img.src = "data:image;base64," + imageData; }; // returns the given property of the given object, or null if the object // is null. var getPropOrNull = function(obj, prop) { if(obj === null) { return null; } else { return obj[prop]; } }; // array of marker ids seen in the last update var lastMarkers = []; // subscribe to incoming updates Dispatcher._bridge.subscribe("marker_updates", function(updates) { var currentMarkers = _.keys(updates); var appeared = _.difference(currentMarkers, lastMarkers); var disappeared = _.difference(lastMarkers, currentMarkers); lastMarkers = currentMarkers; if(appeared.length > 0) { logger.info("Markers appeared: ", appeared); } if(disappeared.length > 0) { logger.info("Markers disappeared: ", disappeared); } // inform appearance subscribers _.each(appeared, function(id) { markers[id].appearCallback(); }); // inform disappearance subscribers _.each(disappeared, function(id) { markers[id].disappearCallback(); }); // iterate over each update. apply this app's offset to the translation // terms of the transforms Coordinate.getWindowOffset(function(top, left) { _.each(updates, function(value, key) { value[2] = value[2] - left; value[6] = value[6] - top; // update the marker's transform and mark it dirty markers[key].moveCallback(value); }); }); }); // insert a container for pinned markers or return an existing container var markerContainer = function() { if($("body #lens-marker-container").length === 0) { $("<div />").attr("id", "lens-marker-container") .css( "position", "fixed") .css( "top", "0") .css( "left", "0") .css( "width", $(window).width()) .css( "height", $(window).height()) .css( "pointer-events", "none") .css( "transform-style", "preserve-3d") .css( "perspective", "Infinity") .css( "perspective-origin", "top left") .appendTo("body"); } return $("body #lens-marker-container"); }; /** * Creates a new Marker, given a marker id. * * @param {String} id Marker id. * @param {Number} origWidth Original width of the tracked image. * @param {Number} origHeight Original height of the tracked image. * * @privconstructor * @class * Markers are images that LuminAR watches for and tracks. They can be used * to track physical objects within LuminAR's field of view. They can track * objects even if they are rotated or scaled. * * @property {String} id Unique ID for the Marker. * * @property {Number} origWidth * Original width of the tracked image. May be null for a short time after * marker creation. * * @property {Number} origHeight * Original height of the tracked image. May be null for a short time after * marker creation. * * @memberOf Lens */ var Marker = function(id, origWidth, origHeight) { /** @alias Lens.Marker.prototype */ var self = {}; // The CSS transform representing the marker's position. var transform = null; // The decomposed CSS transform representing the marker's position. var decomposed = null; // Whether the decomposed CSS trasform is out of date and needs to // be regenerated var transformDirty = false; // List of movement subscribers var movementSubscribers = BindingList(); /// List of appearance subscribers var appearanceSubscribers = BindingList(); // List of disappearance subscribers var disappearanceSubscribers = BindingList(); // updates the decomposed trasform of this Marker if needed, and returns // the updated decomposition. var decompose = function() { if(transformDirty) { if(transform) { decomposed = Transformations.decompose(self.getTransform()); } else { decomposed = null; } transformDirty = false; } return decomposed; }; // gets the given properties from the decomposed transform of the marker, // or null if the marker is not visible. var getDecomposedValue = function(prop) { return getPropOrNull(decompose(), prop); }; var appearCallback = function() { appearanceSubscribers.callAll(self); }; var disappearCallback = function() { disappearanceSubscribers.callAll(self); }; var moveCallback = function(newTransform) { transform = newTransform; transformDirty = true; movementSubscribers.callAll(self); }; /** * Sets the original width and height of this marker. Used so factories * can calculate these dimensions asynchronously. * @param {Number} x * @param {Number} y * @private */ self._setOrigDims = function(width, height) { origWidth = width; origHeight = height; }; /** * Subscribes a function to be called any time this marker is moved. * @param {Function} fn The function to subscribe. Gets this marker * as an argument when called. * @return {Binding} A binding that allows this handler to be * cleared. */ self.moved = function(fn) { check(fn, Function); return movementSubscribers.add(fn); }; /** * Subscribes a function to be called any time this marker appears. * @param {Function} fn The function to subscribe. Gets this marker * as an argument when called. * @return {Binding} A binding that allows this handler to be * cleared. */ self.appeared = function(fn) { check(fn, Function); return appearanceSubscribers.add(fn); }; /** * Subscribes a function to be called any time this marker disappears. * @param {Function} fn The function to subscribe. Gets this marker * as an argument when called. * @return {Binding} A binding that allows this handler to be * cleared. */ self.disappeared = function(fn) { check(fn, Function); return disappearanceSubscribers.add(fn); }; /** * Stops tracking of this marker. */ self.deregister = function() { Dispatcher._bridge.fireEvent("marker_deregister", { id: id }); }; /** * Pins an element to this marker. The element's CSS will be modified to * move/resize/rotate/transform it to match the tracked object. * * @param {string | Element | jQuery} el * The element to track with. This can be a CSS selector string, DOM * element, or jQuery object. * * @param {Boolean} [clone] * If true, a clone of the element will be used to track the object. * The clone will be destroyed when the object is no longer visible. * Defaults to false. * * @param {Function} [onPinStart] * Optional. If given, this function will be invoked when we start * pinning the element. It will get the pinned element as an argument. */ self.pin = function(el, clone, onPinStart) { check(el, check.Match.Elementish); check(clone, check.Match.Optional(Boolean)); check(onPinStart, check.Match.Optional(Function)); var pinned; if(clone) { pinned = $(el).clone(); } else { pinned = $(el); } if((pinned.length !== 1) || !(pinned[0] instanceof Element)) { throw "Argument error: invalid element passed to pin()"; } self.appeared(function(marker) { pinned.remove().appendTo(markerContainer()); pinned.css("position", "absolute"); pinned.css("top", 0); pinned.css("left", 0); pinned.css("transform-origin", "top left"); pinned.css("perspective-origin", "top left"); pinned.css("width", marker.origWidth); pinned.css("height", marker.origHeight); }); self.moved(function(marker) { pinned.css("transform", marker.getCSSTransform()); }); }; /** * Return true if this marker is on screen, false otherwise. * @return {Boolean} */ self.visible = function() { return !!transform; }; /** * Returns a 4x4 transformation matrix that represents the * transformation of a rectangle the side of the tracked image anchored * at the top-left corner of the screen to the current positon of the * marker. Returns null if the marker is not visible. * * @return {Array} 16-element array of Numbers representing the 3-D * transformation matrix in row-major order. */ self.getTransform = function() { var t; if(transform === null) { t = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; } else { t = transform; } return [ t[0], t[4], 0, t[8], t[1], t[5], 0, t[9], 0, 0, 1, 0, t[2], t[6], 0, t[10] ]; }; /** * Returns a CSS transform that represents the transformation of a * rectangle the side of the tracked image anchored at the top-left * corner of the screen to the current positon of the marker. Returns * a transform with the identity matrix if the marker is not on screen. * * @return {String} The CSS transform, suitable for use as the value * of the "transform" CSS property. */ self.getCSSTransform = function() { var t = self.getTransform(); return "matrix3d(" + t[0] + "," + t[1] + "," + t[2] + "," + t[3] + "," + t[4] + "," + t[5] + "," + t[6] + "," + t[7] + "," + t[8] + "," + t[9] + "," + t[10] + "," + t[11] + "," + t[12] + "," + t[13] + "," + t[14] + "," + t[15] + ") "; }; /** * Gets the X coordinate of the left side of the marker. Return null if * the marker is not visible. * @return{Number} X coordinate, in pixels. */ self.getX = function() { return getPropOrNull(getDecomposedValue("translate"), "x"); }; /** * Gets the Y coordinate of the top side of the marker. Return null if * the marker is not visible. * @return {Number} Y coordinate, in pixels. */ self.getY = function() { return getPropOrNull(getDecomposedValue("translate"), "y"); }; /** * Gets the angle of rotation of this marker. Return null if the * marker is not visible. * @return {Number} Rotation, in radians, clockwise. */ self.getRotation = function() { return getPropOrNull(getDecomposedValue("rotate"), "z"); }; /** * Gets the scale of the object, e.g. how much larger or smaller it is * than the tracked image. Return null if the marker is not visible. * @return {Number} The scale, as a multiple of the original size (e.g. * a value of 1 indicates no scale). */ self.getScale = function() { var scale = getDecomposedValue("scale"); if(scale === null) { return null; } return (scale.x + scale.y) / 2; }; /** * Gets the skew of the object, e.g. how much is is deformed into a * trapezoid, or null if the marker is not visible. * @return {Number} The skew of the object. */ self.getSkew = function() { return getPropOrNull(getDecomposedValue("skew"), "x"); }; self.id = id; Object.defineProperty(self, "origWidth", { get: function() { return origWidth; } }); Object.defineProperty(self, "origHeight", { get: function() { return origHeight; } }); markers[id] = { marker: self, appearCallback: appearCallback, disappearCallback: disappearCallback, moveCallback: moveCallback }; Object.freeze(self); return self; }; _.extend(Marker, /** @lends Lens.Marker */ { /** * Creates a marker from base64-encoded PNG data. * * @param {String} image * Base64-encoded data. * @param {String} [mask] * Optional base64-encoded mask. The mask should be an image of the * same size as the image with only black and white pixels. Any areas * of black will be ignored when processing the image. * * @return {Lens.Marker} The newly-created Marker. */ fromImageData: function(image, mask) { check(image, String); check(image, check.Match.Optional(String)); var id = IDGenerator.uuid(); registerMarker(id, image, mask); var marker = Marker(id, null, null); // asynchronously render image, update original width and height // when done. renderImage(image, function(rendered) { marker._setOrigDims(rendered.width, rendered.height); }); // return a marker with unkown original width and height return marker; }, /** * Creates a marker from the URL of a PNG image. * * @param {String} imageURL * URL of an image. The image must be on the same host as the Lens * app to comply with the same-origin policy. * @param {String} [maskURL] * Optional URL of a mask image. The image must be on the same host * as the Lens app to comply with the same-origin policy. The mask * should be an image of the same size as the image with only black * and white pixels. Any areas of black will be ignored when * processing the image. * @return {Lens.Marker} The newly-created Marker. */ fromImageURL: function(imageURL, maskURL) { check(imageURL, String); check(maskURL, check.Match.Optional(String)); var image, mask; var id = IDGenerator.uuid(); var marker = Marker(id, null, null); // callback for when image and mask load var onLoad = function() { if(image.complete && (!mask || mask.complete)) { // both loaded, convert to base64 and register the marker var imageData = imageToBase64(image); var maskData = null; if(mask) { maskData = imageToBase64(mask); } registerMarker(id, imageData, maskData); // set the original width and height marker._setOrigDims(image.width, image.height); } }; // start loading mask and image if(maskURL) { mask = new Image(); mask.onload = onLoad; mask.src = maskURL; } image = new Image(); image.onload = onLoad; image.src = imageURL; // return the marker with unknown orig width and height return marker; }, /** * Creates a marker by taking a snapshot of a page element with * the LuminAR camera and tracking that image. * * @param {string | Element | jQuery} el * The snapshot taken by the LuminAR HD camera will be cropped to this * element. This can be a CSS selector string, DOM element, * or jQuery object. This element must be rendered to the screen; it * can't have been hidden with `display: none` in CSS or with jQuery's * `.hide()`. * * @return {Lens.Marker} The newly-created Marker. */ fromSnapshot: function(el) { check(el, check.Match.Elementish); var id = IDGenerator.uuid(); var marker = Marker(id, null, null); Snapshot.capture(el, function(snapshot) { // register the marker registerMarker(id, snapshot.base64); // get the width and height of the snapshot var img = snapshot.toImage(); marker._setOrigDims(img.width, img.height); }); return marker; }, /** * Stops tracking of all markers. */ clear: function() { Dispatcher._bridge.fireEvent("marker_clear"); } }); $(window).unload(function() { Marker.clear(); }); Lens._addMember(Marker, "Marker"); return Marker; });