-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/js/datacrafting-revised.html b/js/datacrafting-revised.html
deleted file mode 100644
index 44ae3103a..000000000
--- a/js/datacrafting-revised.html
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
Canvas Data-crafting
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/js/datacrafting-revised.js b/js/datacrafting-revised.js
deleted file mode 100644
index 0333b0829..000000000
--- a/js/datacrafting-revised.js
+++ /dev/null
@@ -1,976 +0,0 @@
-///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-// Data Structures - Definitions
-///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-// function Logic() {
-// this.links = {};
-// this.blocks = {};
-// }
-////////////////////////////////////////////////////////////////////////////////
-
-// TODO: initialize simply by passing in the total width, total height, and ratio of w/h for block and margins
-// the grid is the overall data structure for managing block locations and calculating routes between them
-function Grid(blockColWidth, blockRowHeight, marginColWidth, marginRowHeight) {
- this.size = 7; // number of rows and columns
- this.blockColWidth = blockColWidth; // width of cells in columns with blocks
- this.blockRowHeight = blockRowHeight; // height of cells in columns with blocks
- this.marginColWidth = marginColWidth; // width of cells in columns without blocks (the margins)
- this.marginRowHeight = marginRowHeight; // height of cells in rows without blocks (the margins)
-
- this.cells = []; // array of [Cell] objects
- // this.links = []; // array of [Link] objects
- // this.tempLink = null; // Link object - null when not drawing a new link
-
- // initialize list of cells using the size of the grid
- for (var row = 0; row < this.size; row++) {
- for (var col = 0; col < this.size; col++) {
- var cellLocation = new CellLocation(col, row);
- var cell = new Cell(cellLocation);
- this.cells.push(cell);
- }
- }
-}
-
-// the cell has a location in the grid, possibly an associated Block object
-// and DOM element, and a list of which routes pass through the cell
-function Cell(location) {
- this.location = location; // CellLocation
- this.routeTrackers = []; // [RouteTracker]
- // this.block = null;
- this.domElement = null; //
element //TODO: remove DOM element to decouple frontend from backend
-}
-
-function CellLocation(col,row) {
- this.col = col;
- this.row = row;
- this.offsetX = 0;
- this.offsetY = 0;
-}
-
-// the route contains the corner points and the list of all cells it passes through
-function Route(initialCellLocations) {
- this.cellLocations = []; // [CellLocation]
- this.allCells = []; // [Cell]
-
- if (initialCellLocations !== undefined) {
- var that = this;
- initialCellLocations.forEach( function(location) {
- that.addLocation(location.col,location.row);
- });
- }
- this.pointData = null; // list of [{screenX, screenY}]
-}
-
-// TODO: poorly named / designed
-// contains useful data for keeping track of how a route passes through a cell
-function RouteTracker(route, params) {
- this.route = route;
- this.containsVertical = params["vertical"]; // todo: convert all dictionaries to {vertical: vertical} instead of {"vertical":vertical} syntax
- this.containsHorizontal = params["horizontal"];
- // todo: add this.isStart and this.isEnd
- this.isStart = false;
- this.isEnd = false;
-}
-
-///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-// Data Structures - Methods
-///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-/////////////////////////////
-// CELL LOCATION METHODS //
-/////////////////////////////
-
-// *** Public (to app?)
-// gets the center x coordinate of this cell row/column location
-// varies depending on whether this is in a block row or margin row
-CellLocation.prototype.getCenterX = function(blockColWidth, marginColWidth) {
- var leftEdgeX = 0;
- if (this.col % 2 === 0) { // this is a block cell
- leftEdgeX = (this.col / 2) * (blockColWidth + marginColWidth);
- return leftEdgeX + blockColWidth/2;
-
- } else { // this is a margin cell
- leftEdgeX = Math.ceil(this.col / 2) * blockColWidth + Math.floor(this.col / 2) * marginColWidth;
- return leftEdgeX + marginColWidth/2;
- }
-};
-
-// *** Public (to app?)
-// gets the center y coordinate of this cell row/column location
-// varies depending on whether this is in a block row or margin row
-CellLocation.prototype.getCenterY = function(blockRowHeight, marginRowHeight) {
- var topEdgeY = 0;
- if (this.row % 2 === 0) { // this is a block cell
- topEdgeY = (this.row / 2) * (blockRowHeight + marginRowHeight);
- return topEdgeY + blockRowHeight/2;
-
- } else { // this is a margin cell
- topEdgeY = Math.ceil(this.row / 2) * blockRowHeight + Math.floor(this.row / 2) * marginRowHeight;
- return topEdgeY + marginRowHeight/2;
- }
-};
-
-////////////////////
-// CELL METHODS //
-////////////////////
-
-// *** Public to app
-Cell.prototype.canHaveBlock = function() {
- return (this.location.col % 2 === 0) && (this.location.row % 2 === 0);
-}
-
-// *** Public to app
-// utility - gets the hue for cells in a given column
-Cell.prototype.getColorHSL = function() {
- var blockColumn = Math.floor(this.location.col / 2);
- var colorMap = { blue: {h: 180}, green: {h: 122}, yellow: {h: 59}, red: {h:333} };
- var colorName = ['blue','green','yellow','red'][blockColumn];
- return colorMap[colorName];
-};
-
-// *** Public
-// utility - counts the number of horizontal routes in a cell
-Cell.prototype.countHorizontalRoutes = function() {
- return this.routeTrackers.filter(function(value) { return value.containsHorizontal; }).length;
-};
-
-// *** Public
-// utility - counts the number of vertical routes in a cell
-// optionally excludes start or endpoints so that routes starting in a
-// block cell don't count as overlapping routes ending in a block cell
-Cell.prototype.countVerticalRoutes = function(excludeStartPoints, excludeEndPoints) {
- return this.routeTrackers.filter(function(value) {
- return value.containsVertical && !((value.isStart && excludeStartPoints) || (value.isEnd && excludeEndPoints));
- }).length;
-};
-
-// *** Public
-// utility - checks whether the cell has a vertical route tracker for the given route
-Cell.prototype.containsVerticalSegmentOfRoute = function(route) {
- var containsVerticalSegment = false;
- this.routeTrackers.forEach( function(routeTracker) {
- if (routeTracker.route === route && routeTracker.containsVertical) {
- containsVerticalSegment = true;
- }
- });
- return containsVerticalSegment;
-};
-
-// *** Public
-// utility - checks whether the cell has a horizontal route tracker for the given route
-Cell.prototype.containsHorizontalSegmentOfRoute = function(route) {
- var containsHorizontalSegment = false;
- this.routeTrackers.forEach( function(routeTracker) {
- if (routeTracker.route === route && routeTracker.containsHorizontal) {
- containsHorizontalSegment = true;
- }
- });
- return containsHorizontalSegment;
-};
-
-Cell.prototype.blockAtThisLocation = function() {
- if (!this.canHaveBlock()) return null;
- var blockPos = convertGridPosToBlockPos(this.location.col, this.location.row);
- return getBlockXY(blockPos.x, blockPos.y);
-}
-
-/////////////////////
-// ROUTE METHODS //
-/////////////////////
-
-// *** Public
-// adds a new corner location to a route
-Route.prototype.addLocation = function(col, row) {
- var skip = false;
- this.cellLocations.forEach(function(cellLocation) {
- if (cellLocation.col === col && cellLocation.row === row) { // implicitly prevent duplicate points from being added
- skip = true;
- }
- });
- if (!skip) {
- this.cellLocations.push(new CellLocation(col, row));
- }
-};
-
-// *** Public
-// utility - outputs how far a route travels left/right and up/down, for
-// use in choosing the order of routes so that they usually don't cross
-Route.prototype.getOrderPreferences = function() {
- var lastCell = this.cellLocations[this.cellLocations.length-1];
- var firstCell = this.cellLocations[0];
- return {
- horizontal: lastCell.col - firstCell.col,
- vertical: lastCell.row - firstCell.row
- };
-};
-
-// *** Public ?
-// points is an array like [{screenX: x1, screenY: y1}, ...]
-// calculates useful pointData for drawing lines with varying color/weight/etc,
-// by determining how far along the line each corner is located (as a percentage)
-
-
-// *** Public to app
-Route.prototype.getXYPositionAtPercentage = function(percent) {
- var pointData = this.pointData;
- if (percent >= 0 && percent <= 1) {
- var indexBefore = 0;
- for (var i = 1; i < pointData.points.length; i++) {
- var nextPercent = pointData.percentages[i];
- if (nextPercent > percent) {
- indexBefore = i-1;
- break;
- }
- }
-
- var x1 = pointData.points[indexBefore].screenX;
- var y1 = pointData.points[indexBefore].screenY;
- var x2 = pointData.points[indexBefore+1].screenX;
- var y2 = pointData.points[indexBefore+1].screenY;
-
- var percentOver = percent - pointData.percentages[indexBefore];
- var alpha = percentOver / (pointData.percentages[indexBefore+1] - pointData.percentages[indexBefore]);
- var x = (1 - alpha) * x1 + alpha * x2;
- var y = (1 - alpha) * y1 + alpha * y2;
-
- return {
- screenX: x,
- screenY: y
- };
-
- } else {
- return null;
- }
-}
-
-///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-// GRID METHODS
-///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-function getTimestamp() {
- return Math.round(new Date().getTime());
-}
-
-function addBlockLink(blockA, blockB, itemA, itemB) {
- if (blockA && blockB) {
- var blockLink = new BlockLink();
- blockLink.blockA = blockA;
- blockLink.blockB = blockB;
- blockLink.itemA = itemA;
- blockLink.itemB = itemB;
- blockLinkKey = "blockLink" + getTimestamp();
- if (!doesLinkAlreadyExist(blockLink)) {
- logic1.links[blockLinkKey] = blockLink;
- return blockLink;
- }
- }
- return null;
-}
-
-function setTempLink(newTempLink) {
- if (!doesLinkAlreadyExist(newTempLink)) {
- logic1.tempLink = newTempLink;
- }
-}
-
-function removeBlockLink(blockLinkKey) {
- delete logic1.links[blockLinkKey];
-}
-
-function clearAllBlockLinks() {
- for (var blockLinkKey in logic1.blocks) {
- removeBlockLink(blockLinkKey);
- }
- logic1.tempLink = null;
-}
-
-function doesLinkAlreadyExist(blockLink) {
- for (var blockLinkKey in logic1.links) {
- var thatBlockLink = logic1.links[blockLinkKey];
- if (areBlockLinksEqual(blockLink, thatBlockLink)) {
- return true;
- }
- }
- return false;
-}
-
-function areBlockLinksEqual(blockLink1, blockLink2) {
- if (blockLink1.blockA === blockLink2.blockA && blockLink1.itemA === blockLink2.itemA) {
- if (blockLink1.blockB === blockLink2.blockB && blockLink1.itemB === blockLink2.itemB) {
- return true;
- }
- }
- return false;
-}
-
-
-function preprocessPointsForDrawing(points) { //putting it in here makes it private... only ever used here.. could just inline it
- // adds up the total length the route points travel
- var lengths = []; // size = lines.length-1
- for (var i = 1; i < points.length; i++) {
- var dx = points[i].screenX - points[i-1].screenX;
- var dy = points[i].screenY - points[i-1].screenY;
- lengths.push(Math.sqrt(dx * dx + dy * dy));
- }
- var totalLength = lengths.reduce(function(a,b){return a + b;}, 0);
- // calculates the percentage along the path of each point
- var prevPercent = 0.0;
- var percentages = [prevPercent];
- percentages.push.apply(percentages, lengths.map(function(length){ prevPercent += length/totalLength; return prevPercent; }));
-
- // TODO: we could just return this data here and assign it to this.point data in another function...
- return {
- points: points,
- totalLength: totalLength,
- lengths: lengths,
- percentages: percentages
- };
-};
-
-// TODO: where does temp link live now?
-// function setTempBlockLink = function(newTempBlockLink) {
-// if (!doesLinkAlreadyExist(newTempBlockLink)) {
-
-// }
-// }
-
-////////////////////////////////////////////////////////////////////////////////
-// GRID UTILITIES
-////////////////////////////////////////////////////////////////////////////////
-
-// *** Public to app
-// utility - returns the x,y coordinates of corners for a link so that they can be rendered
-// (includes the offsets - these are the actual points to draw on the screen exactly as is)
-Grid.prototype.getPointsForLink = function(blockLink) {
- var points = [];
- if (blockLink.route !== null) {
- var that = this;
- blockLink.route.cellLocations.forEach( function(location) {
- var screenX = that.getColumnCenterX(location.col) + location.offsetX;
- var screenY = that.getRowCenterY(location.row) + location.offsetY;
- points.push({
- "screenX": screenX,
- "screenY": screenY
- });
- });
-
- }
- return points;
-};
-
-// *** Public to app // TODO: is this still used?
-// utility - calculates the total width and height of the grid using the sizes of the cells
-Grid.prototype.getPixelDimensions = function() {
- var width = Math.ceil(this.size/2) * this.blockColWidth + Math.floor(this.size/2) * this.marginColWidth;
- var height = Math.ceil(this.size/2) * this.blockRowHeight + Math.floor(this.size/2) * this.marginRowHeight;
- return {
- "width": width,
- "height": height
- };
-}
-
-// utility - gets a cell at a given grid location
-Grid.prototype.getCell = function(col, row) {
- if (row >= 0 && row < this.size && col >= 0 && col < this.size) {
- return this.cells[row * this.size + col];
- }
-};
-
-// utility - gets width of cell, which differs for cols with blocks vs margins
-Grid.prototype.getCellWidth = function(col) {
- return (col % 2 === 0) ? this.blockColWidth : this.marginColWidth;
-};
-
-// utility - gets height of cell, which differs for rows with blocks vs margins
-Grid.prototype.getCellHeight = function(row) {
- return (row % 2 === 0) ? this.blockRowHeight : this.marginRowHeight;
-};
-
-// utility - gets x position of cell
-Grid.prototype.getCellCenterX = function(cell) {
- return cell.location.getCenterX(this.blockColWidth, this.marginColWidth);
-};
-
-// utility - gets y position of cell
-Grid.prototype.getCellCenterY = function(cell) {
- return cell.location.getCenterY(this.blockRowHeight, this.marginRowHeight);
-};
-
-// utility - gets x position for a column
-Grid.prototype.getColumnCenterX = function(col) {
- return this.getCellCenterX(this.getCell(col,0));
-};
-
-// utility - gets y position for a row
-Grid.prototype.getRowCenterY = function(row) {
- return this.getCellCenterY(this.getCell(0,row));
-};
-
-Grid.prototype.forEachLink = function(action) { // TODO: this doesn't need to be in Grid anymore
- for (var blockLinkKey in logic1.links) {
- action(logic1.links[blockLinkKey]);
- }
- if (logic1.tempLink) {
- action(logic1.tempLink);
- }
-}
-
-Grid.prototype.allLinks = function(action) { // TODO: change this after figuring out where tempLink goes
- var linksArray = [];
- this.forEachLink(function(link) {
- linksArray.push(link);
- });
- return linksArray;
-}
-
-// performs action on all cells that can have a block (not the empty margins)
-Grid.prototype.forEachPossibleBlockCell = function(action) {
- this.cells.filter( function(cell) {
- return cell.canHaveBlock();
- }).forEach( function(cell) {
- action(cell);
- });
-};
-
-// utility - true iff cells are in same row
-Grid.prototype.areCellsHorizontal = function(cell1, cell2) {
- if (cell1 && cell2) {
- return cell1.location.row === cell2.location.row;
- }
- return false;
-};
-
-// utility - true iff cells are in same column
-Grid.prototype.areCellsVertical = function(cell1, cell2) {
- if (cell1 && cell2) {
- return cell1.location.col === cell2.location.col;
- }
- return false;
-};
-
-// utility - if cells are in a line horizontally or vertically, returns all the cells in between them
-Grid.prototype.getCellsBetween = function(cell1, cell2) {
- var cellsBetween = [];
- if (this.areCellsHorizontal(cell1, cell2)) {
- var minCol = Math.min(cell1.location.col, cell2.location.col);
- var maxCol = Math.max(cell1.location.col, cell2.location.col);
- cellsBetween.push.apply(cellsBetween, this.cells.filter( function(cell) {
- return cell.location.row === cell1.location.row && cell.location.col > minCol && cell.location.col < maxCol;
- }));
-
- } else if (this.areCellsVertical(cell1, cell2)) {
- var minRow = Math.min(cell1.location.row, cell2.location.row);
- var maxRow = Math.max(cell1.location.row, cell2.location.row);
- cellsBetween.push.apply(cellsBetween, this.cells.filter( function(cell) {
- return cell.location.col === cell1.location.col && cell.location.row > minRow && cell.location.row < maxRow;
- }));
- }
- return cellsBetween;
-};
-
-// utility - true iff a cell between the start and end actually contains a block
-Grid.prototype.areBlocksBetween = function(startCell, endCell) {
- var blocksBetween = this.getCellsBetween(startCell, endCell).filter( function(cell) {
- return cell.blockAtThisLocation() !== null;
- });
- return blocksBetween.length > 0;
-};
-
-// utility - looks vertically below a location until it finds a block, or null if none in that column
-Grid.prototype.getFirstBlockBelow = function(col, row) {
- for (var r = row+1; r < this.size; r++) {
- var cell = this.getCell(col,r);
- if (cell.blockAtThisLocation() !== null) {
- return cell.blockAtThisLocation();
- }
- }
- return null;
-};
-
-// resets the number of "horizontal" or "vertical" segments contained to 0 for all cells
-Grid.prototype.resetCellRouteCounts = function() {
- this.cells.forEach(function(cell) {
- cell.routeTrackers = [];
- });
-};
-
-// utility - for a given cell in a route, looks at the previous and next cells in the route to
-// figure out if the cell contains a vertical path, horizontal path, or both (it's a corner)
-Grid.prototype.getLineSegmentDirections = function(prevCell,currentCell,nextCell) {
- var containsHorizontal = false;
- var containsVertical = false;
- if (this.areCellsHorizontal(currentCell, prevCell) ||
- this.areCellsHorizontal(currentCell, nextCell)) {
- containsHorizontal = true;
- }
-
- if (this.areCellsVertical(currentCell, prevCell) ||
- this.areCellsVertical(currentCell, nextCell)) {
- containsVertical = true;
- }
- return {
- "horizontal": containsHorizontal,
- "vertical": containsVertical
- };
-};
-
-////////////////////////////////////////////////////////////////////////////////
-// GRID ROUTING ALGORITHM
-////////////////////////////////////////////////////////////////////////////////
-
-
-// *** main method for routing ***
-// first, calculates the routes (which cells they go thru)
-// next, offsets each so that they don't visually overlap
-// lastly, prepares points so that they can be easily rendered
-Grid.prototype.recalculateAllRoutes = function() {
- var that = this;
-
- that.resetCellRouteCounts(); // step 1 works
-
- that.forEachLink( function(link) {
- that.calculateLinkRoute(link); // step 2 works
- });
- var overlaps = that.determineMaxOverlaps();
-//////////// ^ yes
- that.calculateOffsets(overlaps);
-
- that.forEachLink( function(link) {
- var points = that.getPointsForLink(link);
- link.route.pointData = preprocessPointsForDrawing(points);
- });
-};
-
-// given a link, calculates all the corner points between the start block and end block,
-// and sets the route of the link to contain the corner points and all the cells between
-Grid.prototype.calculateLinkRoute = function(link) {
- //TODO: need to account for itemA, itemB in this algorithm
- var startLocation = convertBlockPosToGridPos(link.blockA.x, link.blockA.y); //link.startBlock.cell.location;
- var endLocation = convertBlockPosToGridPos(link.blockB.x, link.blockB.y); //link.endBlock.cell.location;
- var route = new Route([startLocation]);
-
- // by default lines loop around the right of blocks, except for last column or if destination is to left of start
- var sideToApproachOn = 1; // to the right
- if (endLocation.col < startLocation.col || startLocation.col === 6) {
- sideToApproachOn = -1; // to the left
- }
-
- if (startLocation.row < endLocation.row) {
- // simplifies edge case when block is directly below by skipping rest of points
- var areBlocksBetweenInStartColumn = this.areBlocksBetween(this.getCell(startLocation.col, startLocation.row), this.getCell(startLocation.col, endLocation.row));// new CellLocation(startLocation.col, endLocation.row));
-
- if (startLocation.col !== endLocation.col || areBlocksBetweenInStartColumn) {
-
- // first point continues down vertically as far as it can go without hitting another block
- var firstBlockBelow = this.getFirstBlockBelow(startLocation.col, startLocation.row);
- var rowToDrawDownTo = endLocation.row-1;
- if (firstBlockBelow !== null) {
- var firstBlockRowBelow = convertBlockPosToGridPos(firstBlockBelow.x, firstBlockBelow.y).row;
- rowToDrawDownTo = Math.min(firstBlockRowBelow-1, rowToDrawDownTo); //Math.min(firstBlockBelow.cell.location.row-1, rowToDrawDownTo);
- }
- route.addLocation(startLocation.col, rowToDrawDownTo);
-
- if (rowToDrawDownTo < endLocation.row-1) {
- // second point goes horizontally to the side of the start column
- route.addLocation(startLocation.col+sideToApproachOn, rowToDrawDownTo);
- // fourth point goes vertically to the side of the end column
- route.addLocation(startLocation.col+sideToApproachOn, endLocation.row-1);
- }
-
- // fifth point goes horizontally until it is directly above center of end block
- route.addLocation(endLocation.col, endLocation.row-1);
- }
-
- } else {
-
- if (startLocation.row < this.size-1) { // first point is vertically below the start, except for bottom row
- route.addLocation(startLocation.col, startLocation.row+1);
- route.addLocation(startLocation.col + sideToApproachOn, startLocation.row+1);
- } else { // start from side of bottom row
- route.addLocation(startLocation.col + sideToApproachOn, startLocation.row);
- }
-
- // different things happen if destination is top row or not...
- if (endLocation.row > 0) {
- // if not top row, next point is above and to the side of the destination
- route.addLocation(startLocation.col + sideToApproachOn, endLocation.row-1);
- // last point is directly vertically above the end block
- route.addLocation(endLocation.col, endLocation.row-1);
-
- } else { // if it's going to the top row, approach from the side rather than above it
-
- // if there's nothing blocking the line from getting to the side of the end block, last point goes there
- var cellsBetween = this.getCellsBetween(this.getCell(startLocation.col, 0), this.getCell(endLocation.col, endLocation.row)); //new CellLocation(startLocation.col,0), endLocation);
- var blocksBetween = cellsBetween.filter(function(cell){
- return cell.blockAtThisLocation() !== null;
- // return cell.block !== null;
- });
- if (blocksBetween.length === 0) {
- route.addLocation(startLocation.col + sideToApproachOn, 0);
-
- } else { // final exception! if there are blocks horizontally between start and end in top row, go under and up
- // first extra point stops below top row in the column next to the start block, creating a vertical line
- route.addLocation(startLocation.col + sideToApproachOn, 1);
- // next extra point goes horizontally over to the column of the last block
- route.addLocation(endLocation.col - sideToApproachOn, 1);
- // final extra point goes vertically up to the direct side of the end block
- route.addLocation(endLocation.col - sideToApproachOn, 0);
- }
- }
- }
-
- route.addLocation(endLocation.col, endLocation.row);
- route.allCells = this.calculateAllCellsContainingRoute(route);
- link.route = route;
-};
-
-// Given the corner points for a route, finds all the cells in between, and labels each with
-// "horizontal", "vertical", or both depending on which way the route goes thru that cell
-Grid.prototype.calculateAllCellsContainingRoute = function(route) {
- var allCells = [];
- for (var i=0; i < route.cellLocations.length; i++) {
-
- var prevCell = null;
- var currentCell = null;
- var nextCell = null;
-
- currentCell = this.getCell(route.cellLocations[i].col, route.cellLocations[i].row);
- if (i > 0) {
- prevCell = this.getCell(route.cellLocations[i-1].col, route.cellLocations[i-1].row);
- }
- if (i < route.cellLocations.length-1) {
- nextCell = this.getCell(route.cellLocations[i+1].col, route.cellLocations[i+1].row);
- }
- var segmentDirections = this.getLineSegmentDirections(prevCell, currentCell, nextCell);
-
- var routeTracker = new RouteTracker(route, segmentDirections); // corners have both vertical and horizontal. end point has only vertical //todo: except for top/bottom row
- if (prevCell === null) {
- routeTracker.isStart = true;
- }
- if (nextCell === null) {
- routeTracker.isEnd = true;
- }
- currentCell.routeTrackers.push(routeTracker);
- allCells.push(currentCell); // add endpoint cell for each segment
-
- var cellsBetween = this.getCellsBetween(currentCell, nextCell);
- var areNextHorizontal = this.areCellsHorizontal(currentCell, nextCell);
- var areNextVertical = !areNextHorizontal; // mutually exclusive
- cellsBetween.forEach( function(cell) {
- var routeTracker = new RouteTracker(route, {"horizontal": areNextHorizontal, "vertical": areNextVertical});
- cell.routeTrackers.push(routeTracker);
- });
- allCells.push.apply(allCells, cellsBetween);
- }
- return allCells;
-};
-
-// counts how many routes overlap eachother in each row and column, and sorts them, so that
-// they can be displaced around the center of the row/column and not overlap one another
-Grid.prototype.determineMaxOverlaps = function() {
- var colRouteOverlaps = [];
- var horizontallySortedLinks;
- for (var c = 0; c < this.size; c++) {
- var thisColRouteOverlaps = [];
- // for each route in column
- var that = this;
-
- // decreases future overlaps of links in the grid by sorting them left/right
- // so that links going to the left don't need to cross over links going to the right
- horizontallySortedLinks = that.allLinks().sort(function(link1, link2){
- var p1 = link1.route.getOrderPreferences();
- var p2 = link2.route.getOrderPreferences();
- var horizontalOrder = p1.horizontal - p2.horizontal;
- var verticalOrder = p1.vertical - p2.vertical;
-
- var startCellLocation1 = convertBlockPosToGridPos(link1.blockA.x, link1.blockA.y);
- var endCellLocation1 = convertBlockPosToGridPos(link1.blockB.x, link1.blockB.y);
-
- var startCellLocation2 = convertBlockPosToGridPos(link2.blockA.x, link2.blockA.y);
- var endCellLocation2 = convertBlockPosToGridPos(link2.blockB.x, link2.blockB.y);
-
- // special case if link stays in same column as the start block
- var dCol1 = endCellLocation1.col - startCellLocation1.col;
- var dCol2 = endCellLocation2.col - startCellLocation2.col;
-
- if (p1.vertical >= 0 && p2.vertical >= 0) {
- if (dCol1 === 0 && dCol2 === 0) { // in start col, bottom -> last
- return verticalOrder;
- }
- if (dCol1 === 0 && dCol2 !== 0) { // lines to right of start col -> last, those to left -> first
- return -1 * dCol2;
- }
- if (dCol1 > 0 && dCol2 > 0) { // to right of start col, topright diagonal bands -> last
- var diagonalOrder = horizontalOrder - verticalOrder;
- if (diagonalOrder === 0) { // within same diagonal band, top -> last
- return -1 * verticalOrder;
- } else {
- return diagonalOrder;
- }
- }
- if (dCol1 < 0 && dCol2 < 0) { // to left of start col, bottomright diagonal bands -> last
- var diagonalOrder = horizontalOrder + verticalOrder;
- if (diagonalOrder === 0) { // within same diagonal band, bottom -> last
- return verticalOrder;
- } else {
- return diagonalOrder;
- }
- }
- }
-
- // by default, if it doesn't fit into one of those special cases, just sort by horizontal distance
- return horizontalOrder;
- //return 10 * (p1.horizontal - p2.horizontal) + 1 * (Math.abs(p2.vertical) - Math.abs(p1.vertical));
- });
-
- horizontallySortedLinks.forEach( function(link) {
- // filter a list of cells containing that route and that column
- var routeCellsInThisCol = link.route.allCells.filter(function(cell){return cell.location.col === c;});
- if (routeCellsInThisCol.length > 0) { // does this route contain this column?
- var maxOverlappingVertical = 0;
- // get the max vertical overlap of those cells
- // only need to do this step for columns not rows because it has to do with vertical start/end points in block cells
- var firstCellInRoute = that.getCell(link.route.cellLocations[0].col,link.route.cellLocations[0].row);
- var lastCellInRoute = that.getCell(link.route.cellLocations[link.route.cellLocations.length-1].col, link.route.cellLocations[link.route.cellLocations.length-1].row);
- routeCellsInThisCol.forEach(function(cell) {
- var excludeStartPoints = (cell === lastCellInRoute);
- var excludeEndPoints = (cell === firstCellInRoute);
- //excludeStartPoints = false;
- //excludeEndPoints = false;
- maxOverlappingVertical = Math.max(maxOverlappingVertical, cell.countVerticalRoutes(excludeStartPoints,excludeEndPoints)); //todo: should we also keep references to the routes this overlaps?
- });
- // store value in a data structure for that col,route pair
- thisColRouteOverlaps.push({
- route: link.route, // column index can be determined from position in array
- maxOverlap: maxOverlappingVertical
- });
- }
- });
- colRouteOverlaps.push(thisColRouteOverlaps);
- }
-
- var rowRouteOverlaps = [];
- // for each route in column
- for (var r = 0; r < this.size; r++) {
- var thisRowRouteOverlaps = [];
- that.allLinks().sort(function(link1, link2){
- // vertically sorts them so that links starting near horizontal center of block are below those
- // starting near edges, so they don't overlap. requires that we sort horizontally before vertically
- var centerIndex = Math.ceil((horizontallySortedLinks.length-1)/2);
- var index1 = horizontallySortedLinks.indexOf(link1);
- var distFromCenter1 = Math.abs(index1 - centerIndex);
- var index2 = horizontallySortedLinks.indexOf(link2);
- var distFromCenter2 = Math.abs(index2 - centerIndex);
- return distFromCenter2 - distFromCenter1;
- //return 10 * (p1.vertical - p2.vertical) + 1 * (Math.abs(p2.horizontal) - Math.abs(p1.horizontal));
-
- }).forEach( function(link) {
-
- //this.forEachLink( function(link) {
- var routeCellsInThisRow = link.route.allCells.filter(function(cell){return cell.location.row === r;});
- if (routeCellsInThisRow.length > 0) { // does this route contain this column?
- var maxOverlappingHorizontal = 0;
- routeCellsInThisRow.forEach(function(cell) {
- maxOverlappingHorizontal = Math.max(maxOverlappingHorizontal, cell.countHorizontalRoutes());
- });
- thisRowRouteOverlaps.push({
- route: link.route, // column index can be determined from position in array
- maxOverlap: maxOverlappingHorizontal
- });
- }
- });
- rowRouteOverlaps.push(thisRowRouteOverlaps);
- }
- return {
- colRouteOverlaps: colRouteOverlaps,
- rowRouteOverlaps: rowRouteOverlaps
- };
-};
-
-// After routes have been calculated and overlaps have been counted, determines the x,y offset for
-// each point so that routes don't overlap one another and are spaced evenly within the cells
-Grid.prototype.calculateOffsets = function(overlaps) {
- var colRouteOverlaps = overlaps.colRouteOverlaps;
- var rowRouteOverlaps = overlaps.rowRouteOverlaps;
-
- var that = this;
-
- for (var c = 0; c < this.size; c++) {
- var maxOffset = 0.5 * this.getCellWidth(c);
- var minOffset = -1 * maxOffset;
-
- var routeOverlaps = colRouteOverlaps[c];
-
- var numRoutesProcessed = new Array(this.size).fill(0);
- var numRoutesProcessedExcludingStart = new Array(this.size).fill(0);
- var numRoutesProcessedExcludingEnd = new Array(this.size).fill(0);
-
- routeOverlaps.forEach( function(routeOverlap) {
- var route = routeOverlap.route;
- var maxOverlap = routeOverlap.maxOverlap;
-
- var firstCellInRoute = that.getCell(route.cellLocations[0].col, route.cellLocations[0].row);
- var lastCellInRoute = that.getCell(route.cellLocations[route.cellLocations.length-1].col, route.cellLocations[route.cellLocations.length-1].row);
-
- var lineNumber = 0;
- route.allCells.filter(function(cell){return cell.location.col === c;}).forEach( function(cell) {
- var numProcessed = 0;
-
- if (cell === firstCellInRoute) {
- // exclude endpoints... use numRoutesProcessedExcludingEnd
- numProcessed = numRoutesProcessedExcludingEnd[cell.location.row];
- } else if (cell === lastCellInRoute) {
- // exclude startpoints... use numRoutesProcessedExcludingStart
- numProcessed = numRoutesProcessedExcludingStart[cell.location.row];
- } else {
- numProcessed = numRoutesProcessed[cell.location.row];
- }
-
- if (cell.containsVerticalSegmentOfRoute(route)) {
- lineNumber = Math.max(lineNumber, numProcessed);
- }
- });
- lineNumber += 1;
-
- // todo: use maxOverlap of any route in this cell? or does maxOverlap already take care of that?
- var numPartitions = maxOverlap + 1;
- var width = maxOffset - minOffset;
- var spacing = width/(numPartitions);
- var offsetX = minOffset + lineNumber * spacing;
- if (maxOverlap === 0) offsetX = 0; // edge case - never adjust lines that don't overlap anything
-
- route.cellLocations.filter(function(location){return location.col === c;}).forEach( function(location) {
- location.offsetX = offsetX;
- });
-
- route.allCells.filter(function(cell){return cell.location.col === c}).forEach( function(cell) {
- if (cell !== firstCellInRoute) {
- // exclude endpoints... use numRoutesProcessedExcludingEnd
- numRoutesProcessedExcludingStart[cell.location.row] += 1;
-
- }
- if (cell !== lastCellInRoute) {
- // exclude startpoints... use numRoutesProcessedExcludingStart
- numRoutesProcessedExcludingEnd[cell.location.row] += 1;
-
- } //else {
-
- if (cell.containsVerticalSegmentOfRoute(route)) {
- numRoutesProcessed[cell.location.row] += 1;
- }
- });
- });
- //console.log("col numRoutesProcessed", numRoutesProcessed);
- }
-
- for (var r = 0; r < this.size; r++) {
- var maxOffset = 0.5 * this.getCellHeight(r);
- var minOffset = -1 * maxOffset;
- var routeOverlaps = rowRouteOverlaps[r];
- var numRoutesProcessed = new Array(this.size).fill(0);
-
- routeOverlaps.forEach( function(routeOverlap) {
- var route = routeOverlap.route;
- var maxOverlap = routeOverlap.maxOverlap;
-
- var lineNumber = 0;
- route.allCells.filter(function(cell){return cell.location.row === r;}).forEach( function(cell) {
- if (cell.containsHorizontalSegmentOfRoute(route)) {
- lineNumber = Math.max(lineNumber, numRoutesProcessed[cell.location.col]);
- }
- });
- lineNumber += 1; // actual number is one bigger than the number of routes processed
- // note: line number should never exceed maxOverlap... something went wrong if it did...
-
- // todo: use maxOverlap of any route in this cell? causes more things to shift but would make more correct
- var numPartitions = maxOverlap + 1;
- var width = maxOffset - minOffset;
- var spacing = width/(numPartitions);
- var offsetY = minOffset + lineNumber * spacing;
- if (maxOverlap === 0) offsetY = 0; // edge case - never adjust lines that don't overlap anything
-
- route.cellLocations.filter(function(location){return location.row === r;}).forEach( function(location) {
- location.offsetY = offsetY;
- });
-
- route.allCells.filter(function(cell){return cell.location.row === r}).forEach( function(cell) {
- if (cell.containsHorizontalSegmentOfRoute(route)) {
- numRoutesProcessed[cell.location.col] += 1;
- }
- });
- });
- //console.log("row numRoutesProcessed", numRoutesProcessed);
- }
-};
-
-
-////////////////////////////////////////////////////////////////////////////////
-// misc functions for working with blocks and grids
-////////////////////////////////////////////////////////////////////////////////
-
-function createBlock(x,y,blockSize,name) {
- var block = new Block();
- block.x = x;
- block.y = y;
- block.blockSize = blockSize;
- block.name = name;
- return block;
-}
-
-function getBlock(x,y) {
- for (var blockKey in logic1.blocks) {
- var block = logic1.blocks[blockKey];
- if (block.x === x && block.y === y) {
- return block;
- }
- }
- return null;
-}
-
-function getCellForBlock(grid, block) {
- return grid.getCellXY(block.x, block.y);
-}
-
-Grid.prototype.getCellXY = function(x, y) {
- var gridPos = convertBlockPosToGridPos(x,y);
- return this.getCell(gridPos.col, gridPos.row);
-};
-
-// gets a block overlapping the cell at this x,y location
-function getBlockXY(x, y) {
- // check if block of size >= 1 is at (x, y)
- var block = null;
- block = getBlock(x,y);
- if (block && block.blockSize >= 1) {
- return block;
- }
- // else check if block of size >= 2 is at (x-1, y)
- block = getBlock(x-1,y);
- if (block && block.blockSize >= 2) {
- return block;
- }
- // else check if block of size >= 3 is at (x-2, y)
- block = getBlock(x-2,y);
- if (block && block.blockSize >= 3) {
- return block;
- }
-
- // else check if block of size == 4 is at (x-3, y)
- block = getBlock(x-3,y);
- if (block && block.blockSize >= 4) {
- return block;
- }
- return null;
-}
-
-function convertGridPosToBlockPos(col, row) {
-// Grid.prototype.convertGridPosToBlockPos = function(col, row) {
- return {
- x: Math.floor(col/2),
- y: Math.floor(row/2)
- };
-}
-
-function convertBlockPosToGridPos(x, y) {
-//Grid.prototype.convertBlockPosToGridPos = function(x, y) {
- return new CellLocation(x * 2, y * 2);
-}
diff --git a/js/datacrafting.js b/js/datacrafting.js
deleted file mode 100644
index a15d407a6..000000000
--- a/js/datacrafting.js
+++ /dev/null
@@ -1,917 +0,0 @@
-/**
- * Created by Benjamin Reynolds on 9/02/16.
- *
- * Copyright (c) 2016 Benjamin Reynolds
- *
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
- */
-
-/*
- * This file contains the backend for the grid-based routing system used in the
- * datacrafting environment.
- *
- * To use, instantiate a new Grid object with a given
- * size (only size = 7 has been tested), and pixel dimensions for rows and
- * columns. Blocks and Links can be added to the Grid.
- *
- * Calling recalculateAllRoutes computes routes for each link. Then calling
- * getPointsForLink for each link returns x,y coordinates for drawing.
- */
-
-/*
- * TODO: expose only the public methods using a module exports, and keep internal utilities private
- */
-
-///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-// Data Structures - Definitions
-///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-// the grid is the overall data structure for managing block locations and calculating routes between them
-function Grid(size, blockColWidth, blockRowHeight, marginColWidth, marginRowHeight) {
- this.size = size; // number of rows and columns
- this.blockColWidth = blockColWidth; // width of cells in columns with blocks
- this.blockRowHeight = blockRowHeight; // height of cells in columns with blocks
- this.marginColWidth = marginColWidth; // width of cells in columns without blocks (the margins)
- this.marginRowHeight = marginRowHeight; // height of cells in rows without blocks (the margins)
-
- this.cells = []; // array of [Cell] objects
- this.links = []; // array of [Link] objects
- this.tempLink = null; // Link object - null when not drawing a new link
-
- // initialize list of cells using the size of the grid
- for (var row = 0; row < this.size; row++) {
- for (var col = 0; col < this.size; col++) {
- var cellLocation = new CellLocation(col, row);
- var cell = new Cell(cellLocation);
- this.cells.push(cell);
- }
- }
-}
-
-// the cell has a location in the grid, possibly an associated Block object
-// and DOM element, and a list of which routes pass through the cell
-function Cell(location) {
- this.location = location; // CellLocation
- this.routeTrackers = []; // [RouteTracker]
- this.block = null;
- this.domElement = null; //
element
-}
-
-function Block(cell) {
- this.cell = cell; // Cell
- this.domElement = null;
-}
-
-// represents the row/column location of a cell, and optionally an x/y offset from the center of that cell
-function CellLocation(col,row) {
- this.col = col;
- this.row = row;
- this.offsetX = 0;
- this.offsetY = 0;
-}
-
-// the link contains the start and end blocks that it connects, the route between them,
-// and some additional data for rendering it
-function Link(startBlock, endBlock) {
- this.startBlock = startBlock; // Block object
- this.endBlock = endBlock; // Block object
- this.route = null; // Route object
- this.pointData = null; // list of [{screenX, screenY}]
- this.ballAnimationCount = 0;
-}
-
-// the route contains the corner points and the list of all cells it passes through
-function Route(initialCellLocations) {
- this.cellLocations = []; // [CellLocation]
- this.allCells = []; // [Cell]
-
- if (initialCellLocations !== undefined) {
- var that = this;
- initialCellLocations.forEach( function(location) {
- that.addLocation(location.col,location.row);
- });
- }
-}
-
-// contains useful data for keeping track of how a route passes through a cell
-function RouteTracker(route, params) {
- this.route = route;
- this.containsVertical = params["vertical"]; // todo: convert all dictionaries to {vertical: vertical} instead of {"vertical":vertical} syntax
- this.containsHorizontal = params["horizontal"];
- // todo: add this.isStart and this.isEnd
- this.isStart = false;
- this.isEnd = false;
-}
-
-///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-// Data Structures - Methods
-///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-/////////////////////////////
-// CELL LOCATION METHODS //
-/////////////////////////////
-
-// gets the center x coordinate of this cell row/column location
-// varies depending on whether this is in a block row or margin row
-CellLocation.prototype.getCenterX = function(blockColWidth, marginColWidth) {
- var leftEdgeX = 0;
- if (this.col % 2 === 0) { // this is a block cell
- leftEdgeX = (this.col / 2) * (blockColWidth + marginColWidth);
- return leftEdgeX + blockColWidth/2;
-
- } else { // this is a margin cell
- leftEdgeX = Math.ceil(this.col / 2) * blockColWidth + Math.floor(this.col / 2) * marginColWidth;
- return leftEdgeX + marginColWidth/2;
- }
-};
-
-// gets the center y coordinate of this cell row/column location
-// varies depending on whether this is in a block row or margin row
-CellLocation.prototype.getCenterY = function(blockRowHeight, marginRowHeight) {
- var topEdgeY = 0;
- if (this.row % 2 === 0) { // this is a block cell
- topEdgeY = (this.row / 2) * (blockRowHeight + marginRowHeight);
- return topEdgeY + blockRowHeight/2;
-
- } else { // this is a margin cell
- topEdgeY = Math.ceil(this.row / 2) * blockRowHeight + Math.floor(this.row / 2) * marginRowHeight;
- return topEdgeY + marginRowHeight/2;
- }
-};
-
-////////////////////
-// CELL METHODS //
-////////////////////
-
-Cell.prototype.canHaveBlock = function() {
- return (this.location.col % 2 == 0) && (this.location.row % 2 == 0);
-}
-
-// utility - counts the number of horizontal routes in a cell
-Cell.prototype.countHorizontalRoutes = function() {
- return this.routeTrackers.filter(function(value) { return value.containsHorizontal; }).length;
-};
-
-// utility - counts the number of vertical routes in a cell
-// optionally excludes start or endpoints so that routes starting in a
-// block cell don't count as overlapping routes ending in a block cell
-Cell.prototype.countVerticalRoutes = function(excludeStartPoints, excludeEndPoints) {
- return this.routeTrackers.filter(function(value) {
- return value.containsVertical && !((value.isStart && excludeStartPoints) || (value.isEnd && excludeEndPoints));
- }).length;
-};
-
-// utility - checks whether the cell has a vertical route tracker for the given route
-Cell.prototype.containsVerticalSegmentOfRoute = function(route) {
- var containsVerticalSegment = false;
- this.routeTrackers.forEach( function(routeTracker) {
- if (routeTracker.route === route && routeTracker.containsVertical) {
- containsVerticalSegment = true;
- }
- });
- return containsVerticalSegment;
-};
-
-// utility - checks whether the cell has a horizontal route tracker for the given route
-Cell.prototype.containsHorizontalSegmentOfRoute = function(route) {
- var containsHorizontalSegment = false;
- this.routeTrackers.forEach( function(routeTracker) {
- if (routeTracker.route === route && routeTracker.containsHorizontal) {
- containsHorizontalSegment = true;
- }
- });
- return containsHorizontalSegment;
-};
-
-// utility - gets the hue for cells in a given column
-Cell.prototype.getColorHSL = function() {
- var blockColumn = Math.floor(this.location.col / 2);
- var colorMap = { blue: {h: 180}, green: {h: 122}, yellow: {h: 59}, red: {h:333} };
- var colorName = ['blue','green','yellow','red'][blockColumn];
- return colorMap[colorName];
-};
-
-/////////////////////
-// ROUTE METHODS //
-/////////////////////
-
-// adds a new corner location to a route
-Route.prototype.addLocation = function(col, row) {
- var skip = false;
- this.cellLocations.forEach(function(cellLocation) {
- if (cellLocation.col === col && cellLocation.row === row) { // implicitly prevent duplicate points from being added
- skip = true;
- }
- });
- if (!skip) {
- this.cellLocations.push(new CellLocation(col, row));
- }
-};
-
-// utility - outputs how far a route travels left/right and up/down, for
-// use in choosing the order of routes so that they usually don't cross
-Route.prototype.getOrderPreferences = function() {
- var lastCell = this.cellLocations[this.cellLocations.length-1];
- var firstCell = this.cellLocations[0];
- return {
- horizontal: lastCell.col - firstCell.col,
- vertical: lastCell.row - firstCell.row
- };
-};
-
-////////////////////
-// LINK METHODS //
-////////////////////
-
-// calculates useful pointData for drawing lines with varying color/weight/etc,
-// by determining how far along the line each corner is located (as a percentage)
-Link.prototype.preprocessPointsForDrawing = function(points) {
- var lengths = []; // size = lines.length-1
- for (var i = 1; i < points.length; i++) {
- var p1 = points[i-1];
- var p2 = points[i];
-
- var dx = p2.screenX - p1.screenX;
- var dy = p2.screenY - p1.screenY;
-
- var dist = Math.sqrt(dx * dx + dy * dy);
- lengths.push(dist);
- }
-
- var totalLength = lengths.reduce(function(a,b){return a + b;}, 0);
-
- var prevPercent = 0.0;
- var percentages = [prevPercent];
- percentages.push.apply(percentages, lengths.map(function(length){ prevPercent += length/totalLength; return prevPercent; }));
-
- this.pointData = {
- points: points,
- totalLength: totalLength,
- lengths: lengths,
- percentages: percentages
- };
-};
-
-
-Link.prototype.getXYPositionAtPercentage = function(percent) {
- var pointData = this.pointData;
- if (percent >= 0 && percent <= 1) {
- var indexBefore = 0;
- for (var i = 1; i < pointData.points.length; i++) {
- var nextPercent = pointData.percentages[i];
- if (nextPercent > percent) {
- indexBefore = i-1;
- break;
- }
- }
-
- var x1 = pointData.points[indexBefore].screenX;
- var y1 = pointData.points[indexBefore].screenY;
- var x2 = pointData.points[indexBefore+1].screenX;
- var y2 = pointData.points[indexBefore+1].screenY;
-
- var percentOver = percent - pointData.percentages[indexBefore];
- var alpha = percentOver / (pointData.percentages[indexBefore+1] - pointData.percentages[indexBefore]);
- var x = (1 - alpha) * x1 + alpha * x2;
- var y = (1 - alpha) * y1 + alpha * y2;
-
- return {
- screenX: x,
- screenY: y
- };
-
- } else {
- return null;
- }
-}
-
-////////////////////
-// GRID METHODS //
-////////////////////
-
-// utility - returns the x,y coordinates of corners for a link so that they can be rendered
-// (includes the offsets - these are the actual points to draw on the screen exactly as is)
-Grid.prototype.getPointsForLink = function(link) {
- var points = [];
- if (link.route !== null) {
- var that = this;
- link.route.cellLocations.forEach( function(location) {
- var screenX = that.getColumnCenterX(location.col) + location.offsetX;
- var screenY = that.getRowCenterY(location.row) + location.offsetY;
- points.push({
- "screenX": screenX,
- "screenY": screenY
- });
- });
-
- }
- return points;
-};
-
-// creates a link from start to end locations if they contain blocks and don't already have a link
-Grid.prototype.addLinkFromTo = function(col1, row1, col2, row2) {
- var startBlock = this.getCell(col1,row1).block;
- var endBlock = this.getCell(col2,row2).block;
- if (startBlock !== null && endBlock !== null) {
- var link = new Link(startBlock, endBlock);
- if (!this.doesLinkAlreadyExist(link)) {
- this.links.push(link);
- return link;
- }
- }
- return null;
-};
-
-// removes a given link
-Grid.prototype.removeLink = function(link) {
- var index = this.links.indexOf(link);
- if (index > -1) {
- this.links.splice(index, 1);
- }
-};
-
-// removes all links
-Grid.prototype.clearLinks = function() {
- this.links = [];
- this.tempLink = null;
-};
-
-// utility - looks at all permanent links to see whether new link is a duplicate
-Grid.prototype.doesLinkAlreadyExist = function(newLink) {
- var alreadyExists = false;
- this.links.forEach( function(link) { // note: intentionally used grid.links.forEach rather than grid.forEachLink because we don't want to compare this with tempLink or we'll always get false positives
- if (newLink.startBlock.cell.location === link.startBlock.cell.location && newLink.endBlock.cell.location === link.endBlock.cell.location) {
- alreadyExists = true;
- }
- });
- return alreadyExists;
-};
-
-// sets the tempLink if it isn't a duplicate
-Grid.prototype.setTempLink = function(newTempLink) {
- if (!this.doesLinkAlreadyExist(newTempLink)) {
- this.tempLink = newTempLink;
- }
-};
-
-// utility - calculates the total width and height of the grid using the sizes of the cells
-Grid.prototype.getPixelDimensions = function() {
- var width = Math.ceil(this.size/2) * this.blockColWidth + Math.floor(this.size/2) * this.marginColWidth;
- var height = Math.ceil(this.size/2) * this.blockRowHeight + Math.floor(this.size/2) * this.marginRowHeight;
- return {
- "width": width,
- "height": height
- };
-}
-
-// utility - gets a cell at a given grid location
-Grid.prototype.getCell = function(col, row) {
- if (row >= 0 && row < this.size && col >= 0 && col < this.size) {
- return this.cells[row * this.size + col];
- }
-};
-
-// utility - gets width of cell, which differs for cols with blocks vs margins
-Grid.prototype.getCellWidth = function(col) {
- return (col % 2 === 0) ? this.blockColWidth : this.marginColWidth;
-};
-
-// utility - gets height of cell, which differs for rows with blocks vs margins
-Grid.prototype.getCellHeight = function(row) {
- return (row % 2 === 0) ? this.blockRowHeight : this.marginRowHeight;
-};
-
-// utility - gets x position of cell
-Grid.prototype.getCellCenterX = function(cell) {
- return cell.location.getCenterX(this.blockColWidth, this.marginColWidth);
-};
-
-// utility - gets y position of cell
-Grid.prototype.getCellCenterY = function(cell) {
- return cell.location.getCenterY(this.blockRowHeight, this.marginRowHeight);
-};
-
-// utility - gets x position for a column
-Grid.prototype.getColumnCenterX = function(col) {
- return this.getCellCenterX(this.getCell(col,0));
-};
-
-// utility - gets y position for a row
-Grid.prototype.getRowCenterY = function(row) {
- return this.getCellCenterY(this.getCell(0,row));
-};
-
-// performs actions on all links (including tempLink if it exists)
-Grid.prototype.forEachLink = function(action) {
- this.links.forEach(action);
- if (this.tempLink !== null) {
- action(this.tempLink);
- }
-};
-
-// returns a list containing grid.links and also grid.tempLink if it exists
-Grid.prototype.allLinks = function() {
- var allLinks = [];
- allLinks.push.apply(allLinks, this.links);
- if (this.tempLink !== null) {
- allLinks.push(this.tempLink);
- }
- return allLinks;
-};
-
-// performs action on all cells that can have a block (not the empty margins)
-Grid.prototype.forEachPossibleBlockCell = function(action) {
- this.cells.filter( function(cell) {
- return cell.canHaveBlock();
- }).forEach( function(cell) {
- action(cell);
- });
-};
-
-// utility - true iff a cell between the start and end actually contains a block
-Grid.prototype.areBlocksBetween = function(startCell, endCell) {
- var blocksBetween = this.getCellsBetween(startCell, endCell).filter( function(cell) {
- return cell.block !== null;
- });
- return blocksBetween.length > 0;
-};
-
-// utility - looks vertically below a location until it finds a block, or null if none in that column
-Grid.prototype.getFirstBlockBelow = function(col, row) {
- for (var r = row+1; r < this.size; r++) {
- var cell = this.getCell(col,r);
- if (cell.block !== null) {
- return cell.block;
- }
- }
- return null;
-};
-
-// *** main method for routing ***
-// first, calculates the routes (which cells they go thru)
-// next, offsets each so that they don't visually overlap
-// lastly, prepares points so that they can be easily rendered
-Grid.prototype.recalculateAllRoutes = function() {
- var that = this;
-
- that.resetCellRouteCounts(); // step 1 works
-
- that.forEachLink( function(link) {
- that.calculateLinkRoute(link); // step 2 works
- });
-
- var overlaps = that.determineMaxOverlaps();
- that.calculateOffsets(overlaps);
-
- that.forEachLink( function(link) {
- var points = that.getPointsForLink(link);
- link.preprocessPointsForDrawing(points);
- });
-};
-
-// resets the number of "horizontal" or "vertical" segments contained to 0 for all cells
-Grid.prototype.resetCellRouteCounts = function() {
- this.cells.forEach(function(cell) {
- cell.routeTrackers = [];
- });
-};
-
-// given a link, calculates all the corner points between the start block and end block,
-// and sets the route of the link to contain the corner points and all the cells between
-Grid.prototype.calculateLinkRoute = function(link) {
- var startLocation = link.startBlock.cell.location;
- var endLocation = link.endBlock.cell.location;
-
- var route = new Route([startLocation]);
-
- // by default lines loop around the right of blocks, except for last column or if destination is to left of start
- var sideToApproachOn = 1; // to the right
- if (endLocation.col < startLocation.col || startLocation.col === 6) {
- sideToApproachOn = -1; // to the left
- }
-
- if (startLocation.row < endLocation.row) {
- // simplifies edge case when block is directly below by skipping rest of points
- var areBlocksBetweenInStartColumn = this.areBlocksBetween(this.getCell(startLocation.col, startLocation.row), this.getCell(startLocation.col, endLocation.row));// new CellLocation(startLocation.col, endLocation.row));
-
- if (startLocation.col !== endLocation.col || areBlocksBetweenInStartColumn) {
-
- // first point continues down vertically as far as it can go without hitting another block
- var firstBlockBelow = this.getFirstBlockBelow(startLocation.col, startLocation.row);
- var rowToDrawDownTo = endLocation.row-1;
- if (firstBlockBelow !== null) {
- rowToDrawDownTo = Math.min(firstBlockBelow.cell.location.row-1, rowToDrawDownTo);
- }
- route.addLocation(startLocation.col, rowToDrawDownTo);
-
- if (rowToDrawDownTo < endLocation.row-1) {
- // second point goes horizontally to the side of the start column
- route.addLocation(startLocation.col+sideToApproachOn, rowToDrawDownTo);
- // fourth point goes vertically to the side of the end column
- route.addLocation(startLocation.col+sideToApproachOn, endLocation.row-1);
- }
-
- // fifth point goes horizontally until it is directly above center of end block
- route.addLocation(endLocation.col, endLocation.row-1);
- }
-
- } else {
-
- if (startLocation.row < this.size-1) { // first point is vertically below the start, except for bottom row
- route.addLocation(startLocation.col, startLocation.row+1);
- route.addLocation(startLocation.col + sideToApproachOn, startLocation.row+1);
- } else { // start from side of bottom row
- route.addLocation(startLocation.col + sideToApproachOn, startLocation.row);
- }
-
- // different things happen if destination is top row or not...
- if (endLocation.row > 0) {
- // if not top row, next point is above and to the side of the destination
- route.addLocation(startLocation.col + sideToApproachOn, endLocation.row-1);
- // last point is directly vertically above the end block
- route.addLocation(endLocation.col, endLocation.row-1);
-
- } else { // if it's going to the top row, approach from the side rather than above it
-
- // if there's nothing blocking the line from getting to the side of the end block, last point goes there
- var cellsBetween = this.getCellsBetween(this.getCell(startLocation.col, 0), this.getCell(endLocation.col, endLocation.row)); //new CellLocation(startLocation.col,0), endLocation);
- var blocksBetween = cellsBetween.filter(function(cell){return cell.block !== null;});
- if (blocksBetween.length === 0) {
- route.addLocation(startLocation.col + sideToApproachOn, 0);
-
- } else { // final exception! if there are blocks horizontally between start and end in top row, go under and up
- // first extra point stops below top row in the column next to the start block, creating a vertical line
- route.addLocation(startLocation.col + sideToApproachOn, 1);
- // next extra point goes horizontally over to the column of the last block
- route.addLocation(endLocation.col - sideToApproachOn, 1);
- // final extra point goes vertically up to the direct side of the end block
- route.addLocation(endLocation.col - sideToApproachOn, 0);
- }
- }
- }
-
- route.addLocation(endLocation.col, endLocation.row);
- this.calculateAllCellsContainingRoute(route);
- link.route = route;
-};
-
-// utility - true iff cells are in same row
-Grid.prototype.areCellsHorizontal = function(cell1, cell2) {
- if (cell1 === null || cell2 === null || cell1 === undefined || cell2 === undefined) { return false; }
- return cell1.location.row === cell2.location.row;
-};
-
-// utility - true iff cells are in same column
-Grid.prototype.areCellsVertical = function(cell1, cell2) {
- if (cell1 === null || cell2 === null || cell1 === undefined || cell2 === undefined) { return false; }
- return cell1.location.col === cell2.location.col;
-};
-
-// utility - for a given cell in a route, looks at the previous and next cells in the route to
-// figure out if the cell contains a vertical path, horizontal path, or both (it's a corner)
-Grid.prototype.getLineSegmentDirections = function(prevCell,currentCell,nextCell) {
- var containsHorizontal = false;
- var containsVertical = false;
- if (this.areCellsHorizontal(currentCell, prevCell) ||
- this.areCellsHorizontal(currentCell, nextCell)) {
- containsHorizontal = true;
- }
-
- if (this.areCellsVertical(currentCell, prevCell) ||
- this.areCellsVertical(currentCell, nextCell)) {
- containsVertical = true;
- }
- return {
- "horizontal": containsHorizontal,
- "vertical": containsVertical
- };
-};
-
-// utility - if cells are in a line horizontally or vertically, returns all the cells in between them
-Grid.prototype.getCellsBetween = function(cell1, cell2) {
- var cellsBetween = [];
- if (this.areCellsHorizontal(cell1, cell2)) {
- var minCol = Math.min(cell1.location.col, cell2.location.col);
- var maxCol = Math.max(cell1.location.col, cell2.location.col);
- cellsBetween.push.apply(cellsBetween, this.cells.filter( function(cell) {
- return cell.location.row === cell1.location.row && cell.location.col > minCol && cell.location.col < maxCol;
- }));
-
- } else if (this.areCellsVertical(cell1, cell2)) {
- var minRow = Math.min(cell1.location.row, cell2.location.row);
- var maxRow = Math.max(cell1.location.row, cell2.location.row);
- cellsBetween.push.apply(cellsBetween, this.cells.filter( function(cell) {
- return cell.location.col === cell1.location.col && cell.location.row > minRow && cell.location.row < maxRow;
- }));
- }
- return cellsBetween;
-};
-
-// Given the corner points for a route, finds all the cells in between, and labels each with
-// "horizontal", "vertical", or both depending on which way the route goes thru that cell
-Grid.prototype.calculateAllCellsContainingRoute = function(route) {
- var allCells = [];
-
- for (var i=0; i < route.cellLocations.length; i++) {
-
- var prevCell = null;
- var currentCell = null;
- var nextCell = null;
-
- currentCell = this.getCell(route.cellLocations[i].col, route.cellLocations[i].row);
- if (i > 0) {
- prevCell = this.getCell(route.cellLocations[i-1].col, route.cellLocations[i-1].row);
- }
- if (i < route.cellLocations.length-1) {
- nextCell = this.getCell(route.cellLocations[i+1].col, route.cellLocations[i+1].row);
- }
- var segmentDirections = this.getLineSegmentDirections(prevCell, currentCell, nextCell);
-
- var routeTracker = new RouteTracker(route, segmentDirections); // corners have both vertical and horizontal. end point has only vertical //todo: except for top/bottom row
- if (prevCell === null) {
- routeTracker.isStart = true;
- }
- if (nextCell === null) {
- routeTracker.isEnd = true;
- }
- currentCell.routeTrackers.push(routeTracker);
- allCells.push(currentCell); // add endpoint cell for each segment
-
- var cellsBetween = this.getCellsBetween(currentCell, nextCell);
- var areNextHorizontal = this.areCellsHorizontal(currentCell, nextCell);
- var areNextVertical = !areNextHorizontal; // mutually exclusive
- cellsBetween.forEach( function(cell) {
- var routeTracker = new RouteTracker(route, {"horizontal": areNextHorizontal, "vertical": areNextVertical});
- cell.routeTrackers.push(routeTracker);
- });
- allCells.push.apply(allCells, cellsBetween);
- }
- route.allCells = allCells;
-};
-
-// After routes have been calculated and overlaps have been counted, determines the x,y offset for
-// each point so that routes don't overlap one another and are spaced evenly within the cells
-Grid.prototype.calculateOffsets = function(overlaps) {
- var colRouteOverlaps = overlaps.colRouteOverlaps;
- var rowRouteOverlaps = overlaps.rowRouteOverlaps;
-
- var that = this;
-
- for (var c = 0; c < this.size; c++) {
- var maxOffset = 0.5 * this.getCellWidth(c);
- var minOffset = -1 * maxOffset;
-
- var routeOverlaps = colRouteOverlaps[c];
-
- var numRoutesProcessed = new Array(this.size).fill(0);
- var numRoutesProcessedExcludingStart = new Array(this.size).fill(0);
- var numRoutesProcessedExcludingEnd = new Array(this.size).fill(0);
-
- routeOverlaps.forEach( function(routeOverlap) {
- var route = routeOverlap.route;
- var maxOverlap = routeOverlap.maxOverlap;
-
- var firstCellInRoute = that.getCell(route.cellLocations[0].col, route.cellLocations[0].row);
- var lastCellInRoute = that.getCell(route.cellLocations[route.cellLocations.length-1].col, route.cellLocations[route.cellLocations.length-1].row);
-
- var lineNumber = 0;
- route.allCells.filter(function(cell){return cell.location.col === c;}).forEach( function(cell) {
- var numProcessed = 0;
-
- if (cell === firstCellInRoute) {
- // exclude endpoints... use numRoutesProcessedExcludingEnd
- numProcessed = numRoutesProcessedExcludingEnd[cell.location.row];
- } else if (cell === lastCellInRoute) {
- // exclude startpoints... use numRoutesProcessedExcludingStart
- numProcessed = numRoutesProcessedExcludingStart[cell.location.row];
- } else {
- numProcessed = numRoutesProcessed[cell.location.row];
- }
-
- if (cell.containsVerticalSegmentOfRoute(route)) {
- lineNumber = Math.max(lineNumber, numProcessed);
- }
- });
- lineNumber += 1;
-
- // todo: use maxOverlap of any route in this cell? or does maxOverlap already take care of that?
- var numPartitions = maxOverlap + 1;
- var width = maxOffset - minOffset;
- var spacing = width/(numPartitions);
- var offsetX = minOffset + lineNumber * spacing;
- if (maxOverlap === 0) offsetX = 0; // edge case - never adjust lines that don't overlap anything
-
- route.cellLocations.filter(function(location){return location.col === c;}).forEach( function(location) {
- location.offsetX = offsetX;
- });
-
- route.allCells.filter(function(cell){return cell.location.col === c}).forEach( function(cell) {
- if (cell !== firstCellInRoute) {
- // exclude endpoints... use numRoutesProcessedExcludingEnd
- numRoutesProcessedExcludingStart[cell.location.row] += 1;
-
- }
- if (cell !== lastCellInRoute) {
- // exclude startpoints... use numRoutesProcessedExcludingStart
- numRoutesProcessedExcludingEnd[cell.location.row] += 1;
-
- } //else {
-
- if (cell.containsVerticalSegmentOfRoute(route)) {
- numRoutesProcessed[cell.location.row] += 1;
- }
- });
- });
- //console.log("col numRoutesProcessed", numRoutesProcessed);
- }
-
- for (var r = 0; r < this.size; r++) {
- var maxOffset = 0.5 * this.getCellHeight(r);
- var minOffset = -1 * maxOffset;
- var routeOverlaps = rowRouteOverlaps[r];
- var numRoutesProcessed = new Array(this.size).fill(0);
-
- routeOverlaps.forEach( function(routeOverlap) {
- var route = routeOverlap.route;
- var maxOverlap = routeOverlap.maxOverlap;
-
- var lineNumber = 0;
- route.allCells.filter(function(cell){return cell.location.row === r;}).forEach( function(cell) {
- if (cell.containsHorizontalSegmentOfRoute(route)) {
- lineNumber = Math.max(lineNumber, numRoutesProcessed[cell.location.col]);
- }
- });
- lineNumber += 1; // actual number is one bigger than the number of routes processed
- // note: line number should never exceed maxOverlap... something went wrong if it did...
-
- // todo: use maxOverlap of any route in this cell? causes more things to shift but would make more correct
- var numPartitions = maxOverlap + 1;
- var width = maxOffset - minOffset;
- var spacing = width/(numPartitions);
- var offsetY = minOffset + lineNumber * spacing;
- if (maxOverlap === 0) offsetY = 0; // edge case - never adjust lines that don't overlap anything
-
- route.cellLocations.filter(function(location){return location.row === r;}).forEach( function(location) {
- location.offsetY = offsetY;
- });
-
- route.allCells.filter(function(cell){return cell.location.row === r}).forEach( function(cell) {
- if (cell.containsHorizontalSegmentOfRoute(route)) {
- numRoutesProcessed[cell.location.col] += 1;
- }
- });
- });
- //console.log("row numRoutesProcessed", numRoutesProcessed);
- }
-};
-
-// counts how many routes overlap eachother in each row and column, and sorts them, so that
-// they can be displaced around the center of the row/column and not overlap one another
-Grid.prototype.determineMaxOverlaps = function() {
- var colRouteOverlaps = [];
- var horizontallySortedLinks;
- for (var c = 0; c < this.size; c++) {
- var thisColRouteOverlaps = [];
- // for each route in column
- var that = this;
-
- // decreases future overlaps of links in the grid by sorting them left/right
- // so that links going to the left don't need to cross over links going to the right
- horizontallySortedLinks = that.allLinks().sort(function(link1, link2){
- var p1 = link1.route.getOrderPreferences();
- var p2 = link2.route.getOrderPreferences();
- var horizontalOrder = p1.horizontal - p2.horizontal;
- var verticalOrder = p1.vertical - p2.vertical;
- // special case if link stays in same column as the start block
- var dCol1 = link1.endBlock.cell.location.col - link1.startBlock.cell.location.col;
- var dCol2 = link2.endBlock.cell.location.col - link2.startBlock.cell.location.col;
-
- if (p1.vertical >= 0 && p2.vertical >= 0) {
- if (dCol1 === 0 && dCol2 === 0) { // in start col, bottom -> last
- return verticalOrder;
- }
- if (dCol1 === 0 && dCol2 !== 0) { // lines to right of start col -> last, those to left -> first
- return -1 * dCol2;
- }
- if (dCol1 > 0 && dCol2 > 0) { // to right of start col, topright diagonal bands -> last
- var diagonalOrder = horizontalOrder - verticalOrder;
- if (diagonalOrder === 0) { // within same diagonal band, top -> last
- return -1 * verticalOrder;
- } else {
- return diagonalOrder;
- }
- }
- if (dCol1 < 0 && dCol2 < 0) { // to left of start col, bottomright diagonal bands -> last
- var diagonalOrder = horizontalOrder + verticalOrder;
- if (diagonalOrder === 0) { // within same diagonal band, bottom -> last
- return verticalOrder;
- } else {
- return diagonalOrder;
- }
- }
- }
-
- // by default, if it doesn't fit into one of those special cases, just sort by horizontal distance
- return horizontalOrder;
- //return 10 * (p1.horizontal - p2.horizontal) + 1 * (Math.abs(p2.vertical) - Math.abs(p1.vertical));
- });
-
- horizontallySortedLinks.forEach( function(link) {
- // filter a list of cells containing that route and that column
- var routeCellsInThisCol = link.route.allCells.filter(function(cell){return cell.location.col === c;});
- if (routeCellsInThisCol.length > 0) { // does this route contain this column?
- var maxOverlappingVertical = 0;
- // get the max vertical overlap of those cells
- // only need to do this step for columns not rows because it has to do with vertical start/end points in block cells
- var firstCellInRoute = that.getCell(link.route.cellLocations[0].col,link.route.cellLocations[0].row);
- var lastCellInRoute = that.getCell(link.route.cellLocations[link.route.cellLocations.length-1].col, link.route.cellLocations[link.route.cellLocations.length-1].row);
- routeCellsInThisCol.forEach(function(cell) {
- var excludeStartPoints = (cell === lastCellInRoute);
- var excludeEndPoints = (cell === firstCellInRoute);
- //excludeStartPoints = false;
- //excludeEndPoints = false;
- maxOverlappingVertical = Math.max(maxOverlappingVertical, cell.countVerticalRoutes(excludeStartPoints,excludeEndPoints)); //todo: should we also keep references to the routes this overlaps?
- });
- // store value in a data structure for that col,route pair
- thisColRouteOverlaps.push({
- route: link.route, // column index can be determined from position in array
- maxOverlap: maxOverlappingVertical
- });
- }
- });
- colRouteOverlaps.push(thisColRouteOverlaps);
- }
-
- var rowRouteOverlaps = [];
- // for each route in column
- for (var r = 0; r < this.size; r++) {
- var thisRowRouteOverlaps = [];
- that.allLinks().sort(function(link1, link2){
- // vertically sorts them so that links starting near horizontal center of block are below those
- // starting near edges, so they don't overlap. requires that we sort horizontally before vertically
- var centerIndex = Math.ceil((horizontallySortedLinks.length-1)/2);
- var index1 = horizontallySortedLinks.indexOf(link1);
- var distFromCenter1 = Math.abs(index1 - centerIndex);
- var index2 = horizontallySortedLinks.indexOf(link2);
- var distFromCenter2 = Math.abs(index2 - centerIndex);
- return distFromCenter2 - distFromCenter1;
- //return 10 * (p1.vertical - p2.vertical) + 1 * (Math.abs(p2.horizontal) - Math.abs(p1.horizontal));
-
- }).forEach( function(link) {
-
- //this.forEachLink( function(link) {
- var routeCellsInThisRow = link.route.allCells.filter(function(cell){return cell.location.row === r;});
- if (routeCellsInThisRow.length > 0) { // does this route contain this column?
- var maxOverlappingHorizontal = 0;
- routeCellsInThisRow.forEach(function(cell) {
- maxOverlappingHorizontal = Math.max(maxOverlappingHorizontal, cell.countHorizontalRoutes());
- });
- thisRowRouteOverlaps.push({
- route: link.route, // column index can be determined from position in array
- maxOverlap: maxOverlappingHorizontal
- });
- }
- });
- rowRouteOverlaps.push(thisRowRouteOverlaps);
- }
- return {
- colRouteOverlaps: colRouteOverlaps,
- rowRouteOverlaps: rowRouteOverlaps
- };
-};
-
-///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-// Block Placement Methods
-///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-Grid.prototype.getCellFromXY = function(xCoord, yCoord) {
- var col;
- var row;
-
- var colPairIndex = xCoord / (this.blockColWidth + this.marginColWidth);
- var fraction = colPairIndex - Math.floor(colPairIndex);
-
- if (fraction <= this.blockColWidth / (this.blockColWidth + this.marginColWidth)) {
- col = Math.floor(colPairIndex) * 2;
- } else {
- col = Math.floor(colPairIndex) * 2 + 1;
- }
-
- var rowPairIndex = yCoord / (this.blockRowHeight + this.marginRowHeight);
- var fraction = rowPairIndex - Math.floor(rowPairIndex);
-
- if (fraction <= this.blockRowHeight / (this.blockRowHeight + this.marginRowHeight)) {
- row = Math.floor(rowPairIndex) * 2;
- } else {
- row = Math.floor(rowPairIndex) * 2 + 1;
- }
-
- return this.getCell(col, row);
-}
-
-
-
-
-
diff --git a/js/eventHandlers.js b/js/eventHandlers.js
deleted file mode 100644
index adadcc2d1..000000000
--- a/js/eventHandlers.js
+++ /dev/null
@@ -1,832 +0,0 @@
-/**
- * @preserve
- *
- * .,,,;;,'''..
- * .'','... ..',,,.
- * .,,,,,,',,',;;:;,. .,l,
- * .,',. ... ,;, :l.
- * ':;. .'.:do;;. .c ol;'.
- * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
- * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
- * .oxddl;::,,. ', .'''. .... .'. ,:;..
- * .'cOX0OOkdoc. .,'. .. ..... 'lc.
- * .:;,,::co0XOko' ....''..'.'''''''.
- * .dxk0KKdc:cdOXKl............. .. ..,c....
- * .',lxOOxl:'':xkl,',......'.... ,'.
- * .';:oo:... .
- * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
- * .l; โโฃ โโโ โ โ โโโฌโ '
- * 'l. โโโโโดโโด โด โโโโดโโ '.
- * .o. ...
- * .''''','.;:''.........
- * .' .l
- * .:. l'
- * .:. .l.
- * .x: :k;,.
- * cxlc; cdc,,;;.
- * 'l :.. .c ,
- * o.
- * .,
- *
- * โฆ โฆโฌ โฌโโ โฌโโโฌโโฌโ โโโโโ โฌโโโโโโโโฌโโโโ
- * โ โโฃโโฌโโโดโโโฌโโ โโ โ โโโดโ โโโค โ โ โโโ
- * โฉ โฉ โด โโโโดโโโดโโดโ โโโโโโโโโโโโโโ โด โโโ
- *
- *
- * Created by Valentin on 10/22/14.
- *
- * Copyright (c) 2015 Valentin Heun
- *
- * All ascii characters above must be included in any redistribution.
- *
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
- */
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param evt
- **/
-
-function touchDown(evt) {
- if (!globalStates.editingMode) {
- if (!globalStates.guiButtonState) {
- if (!globalProgram.objectA) {
- globalProgram.objectA = this.objectId;
- globalProgram.nodeA = this.nodeId;
- }
- }
- } else {
- globalStates.editingModeObject = this.objectId;
- globalStates.editingModeLocation = this.nodeId;
- globalStates.editingModeHaveObject = true;
- }
- cout("touchDown");
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- **/
-
-function falseTouchUp() {
- if (!globalStates.guiButtonState) {
- globalProgram.objectA = false;
- globalProgram.nodeA = false;
- }
- globalCanvas.hasContent = true;
- cout("falseTouchUp");
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- **/
-
-function trueTouchUp() {
- if (!globalStates.guiButtonState) {
- if (globalProgram.objectA) {
-
- var thisTempObject = objects[globalProgram.objectA];
- var thisTempObjectLinks = thisTempObject.links;
-
- globalProgram.objectB = this.objectId;
- globalProgram.nodeB = this.nodeId;
- var thisOtherTempObject = objects[globalProgram.objectB];
-
- var okForNewLink = checkForNetworkLoop(globalProgram.objectA, globalProgram.nodeA, globalProgram.objectB, globalProgram.nodeB);
-
- // window.location.href = "of://event_" + objects[globalProgram.objectA].visible;
-
- if (okForNewLink) {
- var thisKeyId = uuidTimeShort();
-
- thisTempObjectLinks[thisKeyId] = {
- objectA: globalProgram.objectA,
- objectB: globalProgram.objectB,
- nodeA: globalProgram.nodeA,
- nodeB: globalProgram.nodeB,
- namesA: [thisTempObject.name, thisTempObject.nodes[globalProgram.nodeA].name],
- namesB: [thisOtherTempObject.name, thisOtherTempObject.nodes[globalProgram.nodeB].name]
- };
-
- // push new connection to objectA
- uploadNewLink(thisTempObject.ip, globalProgram.objectA, thisKeyId, thisTempObjectLinks[thisKeyId]);
- }
-
- // set everything back to false
- globalProgram.objectA = false;
- globalProgram.nodeA = false;
- globalProgram.objectB = false;
- globalProgram.nodeB = false;
- }
- }
- globalCanvas.hasContent = true;
-
- cout("trueTouchUp");
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param evt
- **/
-
-function canvasPointerDown(evt) {
- if (!globalStates.guiButtonState && !globalStates.editingMode) {
- if (!globalProgram.objectA) {
- globalStates.drawDotLine = true;
- globalStates.drawDotLineX = evt.clientX;
- globalStates.drawDotLineY = evt.clientY;
-
- }
- }
-
- cout("canvasPointerDown");
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param evt
- **/
-
-function getPossition(evt) {
-
- globalStates.pointerPosition = [evt.clientX, evt.clientY];
-
- overlayDiv.style.left = evt.clientX - 60;
- overlayDiv.style.top = evt.clientY - 60;
-
-
- if(pocketItem.pocket.logic[pocketItemId]){
-
- var thisItem = pocketItem.pocket.logic[pocketItemId];
-
- if(globalLogic.farFrontElement==="") {
- thisItem.x = evt.clientX - (globalStates.height / 2);
- thisItem.y = evt.clientY - (globalStates.width / 2);
- }
- else {
- if(thisItem.screenZ !==2 && thisItem.screenZ) {
- // console.log(screenCoordinatesToMatrixXY(thisItem, [evt.clientX, evt.clientY]));
- var matrixTouch = screenCoordinatesToMatrixXY(thisItem, [evt.clientX, evt.clientY]);
-
- thisItem.x = matrixTouch[0];
- thisItem.y = matrixTouch[1];
- }
- }
-
-
- // pocketItem.pocket.x = evt.clientX;
- // pocketItem.pocket.y = evt.clientY;
-
-
-
- }
-
-
- cout("getPossition");
-
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param evt
- **/
-
-function documentPointerUp(evt) {
-
- globalStates.pointerPosition = [-1, -1];
-
- pocketItem.pocket.objectVisible = false;
-
-
-
- if(pocketItem.pocket.logic[pocketItemId]) {
-
- var thisItem = pocketItem.pocket.logic[pocketItemId];
-
- if (globalLogic.farFrontElement !== "" && thisItem.screenZ !== 2 && thisItem.screenZ) {
- objects[globalLogic.farFrontElement].logic[pocketItemId] = thisItem;
- }
- }
-
- hideTransformed("pocket", pocketItemId, pocketItem.pocket.logic[pocketItemId], "logic");
- delete pocketItem.pocket.logic[pocketItemId];
-
- globalStates.overlay = 0;
-
- if (!globalStates.guiButtonState) {
- falseTouchUp();
- if (!globalProgram.objectA && globalStates.drawDotLine) {
- deleteLines(globalStates.drawDotLineX, globalStates.drawDotLineY, evt.clientX, evt.clientY);
- }
- globalStates.drawDotLine = false;
- }
- globalCanvas.hasContent = true;
-
- overlayDiv.style.display = "none";
-
- cout("documentPointerUp");
-};
-
-/**
- * @desc
- * @param evt
- **/
-
-function documentPointerDown(evt) {
-
- globalStates.pointerPosition = [evt.clientX, evt.clientY];
-
- // overlayImg.src = overlayImage[globalStates.overlay].src;
-
- overlayDiv.style.display = "inline";
- overlayDiv.style.left = evt.clientX - 60;
- overlayDiv.style.top = evt.clientY - 60;
-
-
-
- // todo for testing only
-
- pocketItemId = uuidTime();
-
-
- pocketItem.pocket.logic[pocketItemId] = new Logic();
-
-
- var thisItem = pocketItem.pocket.logic[pocketItemId];
-
-
- if(globalLogic.farFrontElement==="") {
- thisItem.x = evt.clientX - (globalStates.height / 2);
- thisItem.y = evt.clientY - (globalStates.width / 2);
- }
- /* else {
- var matrixTouch = screenCoordinatesToMatrixXY(thisItem, [evt.clientX,evt.clientY]);
- thisItem.x = matrixTouch[0];
- thisItem.y = matrixTouch[1];
- }*/
- thisItem.scale = 1;
- thisItem.loaded = false;
-
- var thisObject = pocketItem.pocket;
- // this is a work around to set the state of an objects to not being visible.
- thisObject.objectId = "pocket";
- thisObject.name = "pocket";
- thisObject.objectVisible = false;
- thisObject.screenZ = 1000;
- thisObject.fullScreen = false;
- thisObject.sendMatrix = false;
- thisObject.loaded = false;
- thisObject.integerVersion = 170;
- thisObject.matrix = [];
- // thisObject.logic = {};
- thisObject.protocol = "R1";
-
-
-
-
-
-
- thisObject.visibleCounter = timeForContentLoaded;
- thisObject.objectVisible = true;
-
- //addElement("pocket", pocketItemId, "nodes/" + thisItem.appearance + "/index.html", pocketItem.pocket, "logic",globalStates);
-
-
-
- cout("documentPointerDown");
-}
-
-/**
- * @desc
- * @param evt
- **/
-
-function MultiTouchStart(evt) {
- evt.preventDefault();
-// generate action for all links to be reloaded after upload
-
- if (globalStates.editingMode && evt.targetTouches.length === 1) {
- globalStates.editingModeObject = this.objectId;
- globalStates.editingModeLocation = this.nodeId;
- globalStates.editingModeHaveObject = true;
- }
- globalMatrix.matrixtouchOn = this.nodeId;
- globalMatrix.copyStillFromMatrixSwitch = true;
- cout("MultiTouchStart");
-}
-
-/**
- * @desc
- * @param evt
- **/
-
-function MultiTouchMove(evt) {
- evt.preventDefault();
-// generate action for all links to be reloaded after upload
-
- // cout(globalStates.editingModeHaveObject + " " + globalStates.editingMode + " " + globalStates.editingModeHaveObject + " " + globalStates.editingMode);
-
- if (globalStates.editingModeHaveObject && globalStates.editingMode && evt.targetTouches.length === 1) {
-
- var touch = evt.touches[0];
-
- globalStates.editingModeObjectX = touch.pageX;
- globalStates.editingModeObjectY = touch.pageY;
-
- var tempThisObject = {};
- if (globalStates.editingModeObject !== globalStates.editingModeLocation) {
- tempThisObject = objects[globalStates.editingModeObject].nodes[globalStates.editingModeLocation];
- } else {
- tempThisObject = objects[globalStates.editingModeObject];
- }
-
- var matrixTouch = screenCoordinatesToMatrixXY(tempThisObject, [touch.pageX, touch.pageY]);
-
- if (matrixTouch) {
- tempThisObject.x = matrixTouch[0];
- tempThisObject.y = matrixTouch[1];
- }
- }
-
- if (globalStates.editingModeHaveObject && globalStates.editingMode && evt.targetTouches.length === 2) {
- scaleEvent(evt.touches[1]);
- }
-
- cout("MultiTouchMove");
-}
-
-/**
- * @desc
- * @param evt
- **/
-
-function MultiTouchEnd(evt) {
-
-
- evt.preventDefault();
-// generate action for all links to be reloaded after upload
- if (globalStates.editingModeHaveObject) {
-
- cout("start");
- // this is where it should be send to the object..
-
- var tempThisObject = {};
- if (globalStates.editingModeObject != globalStates.editingModeLocation) {
- tempThisObject = objects[globalStates.editingModeObject].nodes[globalStates.editingModeLocation];
- } else {
- tempThisObject = objects[globalStates.editingModeObject];
- }
-
- var content = {};
- content.x = tempThisObject.x;
- content.y = tempThisObject.y;
- content.scale = tempThisObject.scale;
-
- if (globalStates.unconstrainedPositioning === true) {
- multiplyMatrix(tempThisObject.begin, invertMatrix(tempThisObject.temp),tempThisObject.matrix);
- content.matrix = tempThisObject.matrix;
-
- }
-
- if (typeof content.x === "number" && typeof content.y === "number" && typeof content.scale === "number") {
- postData('http://' + objects[globalStates.editingModeObject].ip + ':' + httpPort + '/object/' + globalStates.editingModeObject + "/size/" + globalStates.editingModeLocation, content);
- }
-
- globalStates.editingModeHaveObject = false;
- globalCanvas.hasContent = true;
- globalMatrix.matrixtouchOn = "";
- }
- cout("MultiTouchEnd");
-}
-
-/**
- * @desc
- * @param evt
- **/
-
-function MultiTouchCanvasStart(evt) {
-
- globalStates.overlay = 1;
-
- evt.preventDefault();
-// generate action for all links to be reloaded after upload
- if (globalStates.editingModeHaveObject && globalStates.editingMode && evt.targetTouches.length === 1) {
-
-//todo this will move in to the virtual pocket.
- var touch = evt.touches[1];
-
-
- globalStates.editingScaleX = touch.pageX;
- globalStates.editingScaleY = touch.pageY;
- globalStates.editingScaledistance = Math.sqrt(Math.pow((globalStates.editingModeObjectX - globalStates.editingScaleX), 2) + Math.pow((globalStates.editingModeObjectY - globalStates.editingScaleY), 2));
-
- var tempThisObject = {};
- if (globalStates.editingModeObject != globalStates.editingModeLocation) {
- tempThisObject = objects[globalStates.editingModeObject].nodes[globalStates.editingModeLocation];
- } else {
- tempThisObject = objects[globalStates.editingModeObject];
- }
- globalStates.editingScaledistanceOld = tempThisObject.scale;
- }
- cout("MultiTouchCanvasStart");
-}
-
-/**
- * @desc
- * @param evt
- **/
-
-function MultiTouchCanvasMove(evt) {
- evt.preventDefault();
-// generate action for all links to be reloaded after upload
- if (globalStates.editingModeHaveObject && globalStates.editingMode && evt.targetTouches.length === 1) {
- var touch = evt.touches[1];
-
- //globalStates.editingModeObjectY
- //globalStates.editingScaleX
- scaleEvent(touch)
-
- }
- cout("MultiTouchCanvasMove");
-}
-
-/**
- * @desc
- * @param touch
- **/
-
-function scaleEvent(touch) {
- var thisRadius = Math.sqrt(Math.pow((globalStates.editingModeObjectX - touch.pageX), 2) + Math.pow((globalStates.editingModeObjectY - touch.pageY), 2));
- var thisScale = (thisRadius - globalStates.editingScaledistance) / 300 + globalStates.editingScaledistanceOld;
-
- // cout(thisScale);
-
- var tempThisObject = {};
- if (globalStates.editingModeObject != globalStates.editingModeLocation) {
- tempThisObject = objects[globalStates.editingModeObject].nodes[globalStates.editingModeLocation];
- } else {
- tempThisObject = objects[globalStates.editingModeObject];
- }
- if (thisScale < 0.2)thisScale = 0.2;
- if (typeof thisScale === "number" && thisScale > 0) {
- tempThisObject.scale = thisScale;
- }
- globalCanvas.context.clearRect(0, 0, globalCanvas.canvas.width, globalCanvas.canvas.height);
- //drawRed(globalCanvas.context, [globalStates.editingModeObjectX,globalStates.editingModeObjectY],[touch.pageX,touch.pageY],globalStates.editingScaledistance);
- drawBlue(globalCanvas.context, [globalStates.editingModeObjectX, globalStates.editingModeObjectY], [touch.pageX, touch.pageY], globalStates.editingScaledistance);
-
- if (thisRadius < globalStates.editingScaledistance) {
-
- drawRed(globalCanvas.context, [globalStates.editingModeObjectX, globalStates.editingModeObjectY], [touch.pageX, touch.pageY], thisRadius);
-
- } else {
- drawGreen(globalCanvas.context, [globalStates.editingModeObjectX, globalStates.editingModeObjectY], [touch.pageX, touch.pageY], thisRadius);
-
- }
- cout("scaleEvent");
-}
-
-/**
- * @desc
- * @param url
- * @param body
- **/
-
-function postData(url, body) {
-
- var request = new XMLHttpRequest();
- var params = JSON.stringify(body);
- request.open('POST', url, true);
- request.onreadystatechange = function () {
- if (request.readyState == 4) cout("It worked!");
- };
- request.setRequestHeader("Content-type", "application/json");
- //request.setRequestHeader("Content-length", params.length);
- // request.setRequestHeader("Connection", "close");
- request.send(params);
- cout("postData");
-}
-
-/**
- * @desc
- * @param url
- **/
-
-function deleteData(url) {
-
- var request = new XMLHttpRequest();
- request.open('DELETE', url, true);
- request.onreadystatechange = function () {
- if (request.readyState == 4) cout("It deleted!");
- };
- request.setRequestHeader("Content-type", "application/json");
- //request.setRequestHeader("Content-length", params.length);
- // request.setRequestHeader("Connection", "close");
- request.send();
- cout("deleteData");
-}
-
-/**
- * @desc
- * @param ip
- * @param thisObjectKey
- * @param thisKey
- * @param content
- **/
-
-function uploadNewLink(ip, thisObjectKey, thisKey, content) {
-// generate action for all links to be reloaded after upload
- cout("sending Link");
- postData('http://' + ip + ':' + httpPort + '/object/' + thisObjectKey + "/link/" + thisKey, content);
- // postData('http://' +ip+ ':' + httpPort+"/", content);
- cout("uploadNewLink");
-
-}
-
-/**
- * @desc
- * @param ip
- * @param thisObjectKey
- * @param thisKey
- * @return
- **/
-
-function deleteLinkFromObject(ip, thisObjectKey, thisKey) {
-// generate action for all links to be reloaded after upload
- cout("I am deleting a link: " + ip);
- deleteData('http://' + ip + ':' + httpPort + '/object/' + thisObjectKey + "/link/" + thisKey);
- cout("deleteLinkFromObject");
-}
-
-/**
- * @desc
- **/
-
-function addEventHandlers() {
-
- globalCanvas.canvas.addEventListener("touchstart", MultiTouchCanvasStart, false);
- ec++;
- globalCanvas.canvas.addEventListener("touchmove", MultiTouchCanvasMove, false);
- ec++;
-
- for (var thisKey in objects) {
- var generalObject2 = objects[thisKey];
-
- if (generalObject2.developer) {
-
- if (document.getElementById(thisKey)) {
- var thisObject3 = document.getElementById(thisKey);
- // if (globalStates.guiButtonState) {
- thisObject3.style.visibility = "visible";
-
- var thisObject4 = document.getElementById("canvas" + thisKey);
- thisObject4.style.display = "inline";
-
- // }
-
- // thisObject3.className = "mainProgram";
-
- thisObject3.addEventListener("touchstart", MultiTouchStart, false);
- ec++;
- thisObject3.addEventListener("touchmove", MultiTouchMove, false);
- ec++;
- thisObject3.addEventListener("touchend", MultiTouchEnd, false);
- ec++;
- //}
- }
-
- for (var thisSubKey in generalObject2.nodes) {
- if (document.getElementById(thisSubKey)) {
- var thisObject2 = document.getElementById(thisSubKey);
-
- //thisObject2.className = "mainProgram";
-
- var thisObject5 = document.getElementById("canvas" + thisSubKey);
- thisObject5.style.display = "inline";
-
- //if(thisObject.developer) {
- thisObject2.addEventListener("touchstart", MultiTouchStart, false);
- ec++;
- thisObject2.addEventListener("touchmove", MultiTouchMove, false);
- ec++;
- thisObject2.addEventListener("touchend", MultiTouchEnd, false);
- ec++;
- //}
- }
- }
- }
- }
-
- cout("addEventHandlers");
-}
-
-/**
- * @desc
- **/
-
-function removeEventHandlers() {
-
- globalCanvas.canvas.removeEventListener("touchstart", MultiTouchCanvasStart, false);
- ec--;
- globalCanvas.canvas.removeEventListener("touchmove", MultiTouchCanvasMove, false);
- ec--;
- for (var thisKey in objects) {
- var generalObject2 = objects[thisKey];
- if (generalObject2.developer) {
- if (document.getElementById(thisKey)) {
- var thisObject3 = document.getElementById(thisKey);
- thisObject3.style.visibility = "hidden";
- // this is a typo but maybe relevant?
- // thisObject3.className = "mainEditing";
-
- document.getElementById("canvas" + thisKey).style.display = "none";
-
- thisObject3.removeEventListener("touchstart", MultiTouchStart, false);
- thisObject3.removeEventListener("touchmove", MultiTouchMove, false);
- thisObject3.removeEventListener("touchend", MultiTouchEnd, false);
- ec--;
- ec--;
- ec--;
- // }
- }
-
- for (var thisSubKey in generalObject2.nodes) {
- if (document.getElementById(thisSubKey)) {
- var thisObject2 = document.getElementById(thisSubKey);
- //thisObject2.className = "mainEditing";
- document.getElementById("canvas" + thisSubKey).style.display = "none";
-
- // if(thisObject.developer) {
- thisObject2.removeEventListener("touchstart", MultiTouchStart, false);
- thisObject2.removeEventListener("touchmove", MultiTouchMove, false);
- thisObject2.removeEventListener("touchend", MultiTouchEnd, false);
- ec--;
- ec--;
- ec--;
- // }
- }
- }
-
- }
- }
-
- cout("removeEventHandlers");
-}
-
-/**********************************************************************************************************************
- ************************************** datacrafting event handlers *************************************************
- **********************************************************************************************************************/
-
-// clicking down on a block enables drawing a temporary link from this block
-// (this behavior continues in the blockPointerLeave method)
-function blockPointerDown(e) {
- e.preventDefault();
-
- if (e.target.cell.blockAtThisLocation() !== null) {
- isPointerDown = true;
- }
-}
-
-// if your pointer leaves a filled block and the pointer is down, start drawing temp link from this source
-function blockPointerLeave(e) {
- e.preventDefault();
- isPointerInActiveBlock = false;
- if (e.target.cell.blockAtThisLocation() === null) return;
-
- if (isPointerDown && !isTempLinkBeingDrawn) {
- isTempLinkBeingDrawn = true;
- tempStartBlock = e.target.cell.blockAtThisLocation();
- console.log("left block, isTempLinkBeingDrawn");
- }
-}
-
-// if your pointer enters a different block while temp link is being drawn, render a new link to that destination
-function blockPointerEnter(e) {
- var grid = logic1.grid;
- e.preventDefault();
- if (e.target.cell.blockAtThisLocation() === null) return;
-
- isPointerInActiveBlock = true;
- if (isTempLinkBeingDrawn) {
- tempEndBlock = e.target.cell.blockAtThisLocation();
-
- // create temp link if you can
- if (tempStartBlock === null || tempEndBlock === null) { return; }
- // erases temp link if you enter the start block again
- if (tempStartBlock === tempEndBlock) {
- logic1.tempLink = null;
- //renderLinks();
- updateGrid(grid); // need to recalculate routes without temp link
- console.log("entered same block, remove temp link");
- return;
- }
-
- var newTempLink = new BlockLink();
- newTempLink.blockA = tempStartBlock;
- newTempLink.blockB = tempEndBlock;
- newTempLink.itemA = 0;
- newTempLink.itemB = 0;
- setTempLink(newTempLink);
- updateGrid(grid); // need to recalculate routes with new temp link
- console.log("entered new block, new temp link");
- }
-}
-
-// if you release the pointer over a block, the temporary link becomes permanent
-function blockPointerUp(e) {
- var grid = logic1.grid;
- e.preventDefault();
- if (e.target.cell.blockAtThisLocation() === null) return;
-
- isPointerDown = false;
- isTempLinkBeingDrawn = false;
-
- if (logic1.tempLink !== null) {
- //only create link if identical link doesn't already exist
- if (!doesLinkAlreadyExist(logic1.tempLink)) {
- // add link to data structure
- // var startLocation = logic1.tempLink.blockA;//.cell.location;
- // var endLocation = logic1.tempLink.blockB;//.cell.location;
- // var addedLink = grid.addLinkFromTo(startLocation.col, startLocation.row, endLocation.col, endLocation.row);
-
- var addedLink = addBlockLink(logic1.tempLink.blockA, logic1.tempLink.blockB, 0, 0);
-
- if (addedLink !== null) {
- addedLink.route = logic1.tempLink.route; // copy over the route rather than recalculating everything
- // addedLink.pointData = logic1.tempLink.pointData; // copy over rather than recalculate
- addedLink.ballAnimationCount = logic1.tempLink.ballAnimationCount;
- }
- }
- logic1.tempLink = null;
- }
-}
-
-// releasing pointer anywhere on datacrafting container deletes a temp link
-// if drawing one, or executes a cut line to delete links it crosses
-function datacraftingContainerPointerUp(e) {
- var grid = logic1.grid;
- e.preventDefault();
-
- if (isCutLineBeingDrawn) {
- isCutLineBeingDrawn = false;
- if (cutLine.start !== null && cutLine.end !== null){
- checkForCutIntersections();
- }
- cutLine.start = null;
- cutLine.end = null;
- }
-
- if (!isPointerInActiveBlock) {
- isPointerDown = false;
- isTempLinkBeingDrawn = false;
- if (logic1.tempLink !== null) {
- logic1.tempLink = null;
- }
- }
-}
-
-// clicking down in datacrafting container outside of blocks creates a new cut line
-function datacraftingContainerPointerDown(e) {
- e.preventDefault();
-
- if (!isCutLineBeingDrawn && !isPointerInActiveBlock) {
- isCutLineBeingDrawn = true;
- cutLine.start = {
- x: e.pageX,
- y: e.pageY
- };
- }
-}
-
-// moving pointer in datacrafting container updates endpoint of cut line
-function datacraftingContainerPointerMove(e) {
- e.preventDefault();
-
- if (isCutLineBeingDrawn) {
- cutLine.end = {
- x: e.pageX,
- y: e.pageY
- };
- }
-}
-
-
diff --git a/js/globalVariables.js b/js/globalVariables.js
deleted file mode 100644
index 5f1b2faf0..000000000
--- a/js/globalVariables.js
+++ /dev/null
@@ -1,236 +0,0 @@
-/**
- * @preserve
- *
- * .,,,;;,'''..
- * .'','... ..',,,.
- * .,,,,,,',,',;;:;,. .,l,
- * .,',. ... ,;, :l.
- * ':;. .'.:do;;. .c ol;'.
- * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
- * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
- * .oxddl;::,,. ', .'''. .... .'. ,:;..
- * .'cOX0OOkdoc. .,'. .. ..... 'lc.
- * .:;,,::co0XOko' ....''..'.'''''''.
- * .dxk0KKdc:cdOXKl............. .. ..,c....
- * .',lxOOxl:'':xkl,',......'.... ,'.
- * .';:oo:... .
- * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
- * .l; โโฃ โโโ โ โ โโโฌโ '
- * 'l. โโโโโดโโด โด โโโโดโโ '.
- * .o. ...
- * .''''','.;:''.........
- * .' .l
- * .:. l'
- * .:. .l.
- * .x: :k;,.
- * cxlc; cdc,,;;.
- * 'l :.. .c ,
- * o.
- * .,
- *
- * โฆ โฆโฌ โฌโโ โฌโโโฌโโฌโ โโโโโ โฌโโโโโโโโฌโโโโ
- * โ โโฃโโฌโโโดโโโฌโโ โโ โ โโโดโ โโโค โ โ โโโ
- * โฉ โฉ โด โโโโดโโโดโโดโ โโโโโโโโโโโโโโ โด โโโ
- *
- *
- * Created by Valentin on 10/22/14.
- *
- * Copyright (c) 2015 Valentin Heun
- *
- * All ascii characters above must be included in any redistribution.
- *
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
- */
-/*********************************************************************************************************************
- ******************************************** TODOS *******************************************************************
- **********************************************************************************************************************
-
- **
- * TODO
- **
-
- **********************************************************************************************************************
- ******************************************** constant settings *******************************************************
- **********************************************************************************************************************/
-
-var ec = 0;
-var disp = {};
-
-var uiButtons;
-var guiButtonImage;
-var httpPort = 8080;
-var timeForContentLoaded = 240; // temporary set to 1000x with the UI Recording mode for video recording
-var timeCorrection = {delta: 0, now: 0, then: 0};
-
-/**********************************************************************************************************************
- ******************************************** global variables *******************************************************
- **********************************************************************************************************************/
-
-
-
-
-
-var globalStates = {
- debug: false,
- overlay: 0,
- device: "",
- // drawWithLines
- ballDistance: 14,
- ballSize: 6,
- ballAnimationCount: 0,
-
- width: window.screen.width,
- height: window.screen.height,
- guiButtonState: true,
- UIOffMode: false,
- preferencesButtonState: false,
- extendedTracking: false,
- datacraftingVisible: true,
-
- extendedTrackingState: false,
- developerState: false,
- clearSkyState: false,
- externalState: "",
- sendMatrix3d: false,
- sendAcl: false,
-
- feezeButtonState: false,
- logButtonState: false,
- editingMode: false,
- guiURL: "",
- newURLText: "",
- platform: navigator.platform,
- lastLoop: 0,
- notLoading: "",
- drawDotLine: false,
- drawDotLineX: 0,
- drawDotLineY: 0,
- pointerPosition: [0, 0],
- projectionMatrix: [
- 1, 0, 0, 0,
- 0, 1, 0, 0,
- 0, 0, 1, 0,
- 0, 0, 0, 1
- ],
- realProjectionMatrix: [
- 1, 0, 0, 0,
- 0, 1, 0, 0,
- 0, 0, 1, 0,
- 0, 0, 0, 1
- ],
- editingModeHaveObject: false,
- angX: 0,
- angY: 0,
- angZ: 0,
- unconstrainedPositioning: false
-};
-
-var globalCanvas = {};
-
-var globalLogic ={
- size:0,
- x:0,
-y:0,
- rectPoints: [],
- farFrontElement:"",
- frontDepth: 1000000,
-
-
-};
-
-var pocketItem = {"pocket" : new Objects()};
-var pocketItemId = "";
-
-
-var globalSVGCach = {};
-
-var globalDOMCach = {};
-
-var globalObjects = "";
-
-var globalProgram = {
- objectA: false,
- nodeA: false,
- objectB: false,
- nodeB: false
-};
-
-var objects = {};
-
-var globalMatrix = {
- temp: [
- 1, 0, 0, 0,
- 0, 1, 0, 0,
- 0, 0, 1, 0,
- 0, 0, 0, 1
- ],
- begin: [
- 1, 0, 0, 0,
- 0, 1, 0, 0,
- 0, 0, 1, 0,
- 0, 0, 0, 1
- ],
- end: [
- 1, 0, 0, 0,
- 0, 1, 0, 0,
- 0, 0, 1, 0,
- 0, 0, 0, 1
- ],
- r: [
- 1, 0, 0, 0,
- 0, 1, 0, 0,
- 0, 0, 1, 0,
- 0, 0, 0, 1
- ],
- r2: [
- 1, 0, 0, 0,
- 0, 1, 0, 0,
- 0, 0, 1, 0,
- 0, 0, 0, 1
- ],
- matrixtouchOn: false,
- copyStillFromMatrixSwitch: false
-};
-
-var consoleText = "";
-var rotateX = [
- 1, 0, 0, 0,
- 0, -1, 0, 0,
- 0, 0, 1, 0,
- 0, 0, 0, 1
-];
-
-var testInterlink = {};
-
-var overlayDiv;
-//var overlayImg;
-//var overlayImage = [];
-
-/**********************************************************************************************************************
- ***************************************** datacrafting variables ****************************************************
- **********************************************************************************************************************/
-
-// const gridSize = 7;
-// var grid = null;
-var logic1 = null;
-
-var tempStartBlock = null; // the block you started dragging from
-var tempEndBlock = null; // the block you dragged onto
-var isPointerDown = false; // always tells you whether the pointer is currently down or up
-var isTempLinkBeingDrawn = false; // becomes true when you start dragging out of a block
-var isPointerInActiveBlock = false; // always tells you whether the pointer is over a filled block
-var isCutLineBeingDrawn = false;
-
-// stores the images for the blocks in each column
-var blockImgMap = {
- "filled":["png/datacrafting/blue.png", "png/datacrafting/green.png", "png/datacrafting/yellow.png", "png/datacrafting/red.png"],
- "empty":["png/datacrafting/blue-empty.png", "png/datacrafting/green-empty.png", "png/datacrafting/yellow-empty.png", "png/datacrafting/red-empty.png"]
-};
-
-var cutLine = {
- start: null,
- end: null
-};
-
diff --git a/js/gui.js b/js/gui.js
deleted file mode 100644
index 585bba468..000000000
--- a/js/gui.js
+++ /dev/null
@@ -1,541 +0,0 @@
-/**
- * @preserve
- *
- * .,,,;;,'''..
- * .'','... ..',,,.
- * .,,,,,,',,',;;:;,. .,l,
- * .,',. ... ,;, :l.
- * ':;. .'.:do;;. .c ol;'.
- * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
- * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
- * .oxddl;::,,. ', .'''. .... .'. ,:;..
- * .'cOX0OOkdoc. .,'. .. ..... 'lc.
- * .:;,,::co0XOko' ....''..'.'''''''.
- * .dxk0KKdc:cdOXKl............. .. ..,c....
- * .',lxOOxl:'':xkl,',......'.... ,'.
- * .';:oo:... .
- * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
- * .l; โโฃ โโโ โ โ โโโฌโ '
- * 'l. โโโโโดโโด โด โโโโดโโ '.
- * .o. ...
- * .''''','.;:''.........
- * .' .l
- * .:. l'
- * .:. .l.
- * .x: :k;,.
- * cxlc; cdc,,;;.
- * 'l :.. .c ,
- * o.
- * .,
- *
- * โฆ โฆโฌ โฌโโ โฌโโโฌโโฌโ โโโโโ โฌโโโโโโโโฌโโโโ
- * โ โโฃโโฌโโโดโโโฌโโ โโ โ โโโดโ โโโค โ โ โโโ
- * โฉ โฉ โด โโโโดโโโดโโดโ โโโโโโโโโโโโโโ โด โโโ
- *
- *
- * Created by Valentin on 10/22/14.
- *
- * Copyright (c) 2015 Valentin Heun
- *
- * All ascii characters above must be included in any redistribution.
- *
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
- */
-/*********************************************************************************************************************
- ******************************************** TODOS *******************************************************************
- **********************************************************************************************************************
-
- **
- * TODO -
- **
-
- **********************************************************************************************************************
- ******************************************** GUI content *********************+++*************************************
- **********************************************************************************************************************/
-
-
-var freezeButtonImage = [];
-var guiButtonImage = [];
-var preferencesButtonImage = [];
-var reloadButtonImage = [];
-var resetButtonImage = [];
-var unconstButtonImage = [];
-var editingButtonImage = [];
-var loadNewUiImage = [];
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- **/
-
-function GUI() {
-
- preload(freezeButtonImage,
- 'png/freeze.png', 'png/freezeOver.png', 'png/freezeSelect.png', 'png/freezeEmpty.png'
- );
- preload(guiButtonImage,
- 'png/intOneOver.png', 'png/intOneSelect.png', 'png/intTwoOver.png', 'png/intTwoSelect.png', 'png/intEmpty.png'
- );
- preload(preferencesButtonImage,
- 'png/pref.png', 'png/prefOver.png', 'png/prefSelect.png', 'png/prefEmpty.png'
- );
- preload(reloadButtonImage,
- 'png/reloadOver.png', 'png/reload.png', 'png/reloadEmpty.png'
- );
- preload(resetButtonImage,
- 'png/reset.png', 'png/resetOver.png', 'png/resetSelect.png', 'png/resetEmpty.png'
- );
-
- preload(unconstButtonImage,
- 'png/unconst.png', 'png/unconstOver.png', 'png/unconstSelect.png', 'png/unconstEmpty.png'
- );
-
- preload(loadNewUiImage,
- 'png/load.png', 'png/loadOver.png'
- );
-
- document.getElementById("guiButtonImage1").addEventListener("touchstart", function () {
- if (!globalStates.UIOffMode) document.getElementById('guiButtonImage').src = guiButtonImage[0].src;
- // kickoff();
- });
- ec++;
-
- document.getElementById("guiButtonImage1").addEventListener("touchend", function () {
- if (globalStates.guiButtonState === false) {
- if (!globalStates.UIOffMode) document.getElementById('guiButtonImage').src = guiButtonImage[1].src;
- globalStates.guiButtonState = true;
- datacraftingVisible();
- }
- else {
- if (!globalStates.UIOffMode) document.getElementById('guiButtonImage').src = guiButtonImage[1].src;
- }
-
- });
- ec++;
-
- document.getElementById("guiButtonImage2").addEventListener("touchstart", function () {
- if (!globalStates.UIOffMode) document.getElementById('guiButtonImage').src = guiButtonImage[2].src;
- });
- ec++;
-
- document.getElementById("guiButtonImage2").addEventListener("touchend", function () {
- if (globalStates.guiButtonState === true) {
- if (!globalStates.UIOffMode) document.getElementById('guiButtonImage').src = guiButtonImage[3].src;
- globalStates.guiButtonState = false;
- datacraftingHide();
- }
- else {
- if (!globalStates.UIOffMode) document.getElementById('guiButtonImage').src = guiButtonImage[3].src;
- }
- });
- ec++;
-
- document.getElementById("extendedTrackingSwitch").addEventListener("change", function () {
- if (document.getElementById("extendedTrackingSwitch").checked) {
- globalStates.extendedTracking = true;
- window.location.href = "of://extendedTrackingOn";
- } else {
- globalStates.extendedTracking = false;
- window.location.href = "of://extendedTrackingOff";
- }
- });
- ec++;
-
- document.getElementById("editingModeSwitch").addEventListener("change", function () {
-
- if (document.getElementById("editingModeSwitch").checked) {
- addEventHandlers();
- globalStates.editingMode = true;
- window.location.href = "of://developerOn";
- globalMatrix.matrixtouchOn = "";
- } else {
- removeEventHandlers();
- globalStates.editingMode = false;
- window.location.href = "of://developerOff";
- }
- });
- ec++;
-
- document.getElementById("turnOffUISwitch").addEventListener("change", function () {
- if (document.getElementById("turnOffUISwitch").checked) {
- globalStates.UIOffMode = true;
- timeForContentLoaded = 240000;
- window.location.href = "of://clearSkyOn";
-
- } else {
- globalStates.UIOffMode = false;
- timeForContentLoaded = 240;
- window.location.href = "of://clearSkyOff";
-
- }
- });
- ec++;
-
- document.getElementById("resetButton").addEventListener("touchstart", function () {
- if (!globalStates.UIOffMode) document.getElementById('resetButton').src = resetButtonImage[1].src;
-
- });
- ec++;
-
- document.getElementById("resetButton").addEventListener("touchend", function () {
-
- if (!globalStates.UIOffMode) document.getElementById('resetButton').src = resetButtonImage[0].src;
- // window.location.href = "of://loadNewUI"+globalStates.newURLText;
-
- for (var key in objects) {
- if (!globalObjects.hasOwnProperty(key)) {
- continue;
- }
-
- var tempResetObject = objects[key];
-
- if (globalStates.guiButtonState) {
- tempResetObject.matrix = [];
-
- tempResetObject.x = 0;
- tempResetObject.y = 0;
- tempResetObject.scale = 1;
-
- sendResetContent(key, key);
- }
-
- for (var subKey in tempResetObject.nodes) {
- var tempResetValue = tempResetObject.nodes[subKey];
-
- if (!globalStates.guiButtonState) {
-
- tempResetValue.matrix = [];
-
- tempResetValue.x = randomIntInc(0, 200) - 100;
- tempResetValue.y = randomIntInc(0, 200) - 100;
- tempResetValue.scale = 1;
-
- sendResetContent(key, subKey);
- }
-
- }
-
- }
-
- });
- ec++;
-
- /**
- * @desc
- * @param object
- * @param node
- **/
-
- function sendResetContent(object, node) {
-// generate action for all links to be reloaded after upload
-
- var tempThisObject = {};
- if (object != node) {
- tempThisObject = objects[object].nodes[node];
- } else {
- tempThisObject = objects[object];
- }
-
- var content = {};
- content.x = tempThisObject.x;
- content.y = tempThisObject.y;
- content.scale = tempThisObject.scale;
-
- if (typeof tempThisObject.matrix === "object") {
- content.matrix = tempThisObject.matrix;
- }
-
- if (typeof content.x === "number" && typeof content.y === "number" && typeof content.scale === "number") {
- postData('http://' + objects[object].ip + ':' + httpPort + '/object/' + object + "/size/" + node, content);
- }
-
- }
-
- document.getElementById("unconstButton").addEventListener("touchstart", function () {
- if (!globalStates.UIOffMode) document.getElementById('unconstButton').src = unconstButtonImage[1].src;
- });
- ec++;
-
- document.getElementById("unconstButton").addEventListener("touchend", function () {
- if (globalStates.unconstrainedPositioning === true) {
- if (!globalStates.UIOffMode) document.getElementById('unconstButton').src = unconstButtonImage[0].src;
- globalStates.unconstrainedPositioning = false;
-
- }
- else {
- if (!globalStates.UIOffMode) document.getElementById('unconstButton').src = unconstButtonImage[2].src;
- globalStates.unconstrainedPositioning = true;
-
- }
-
- });
- ec++;
-
- document.getElementById("loadNewUI").addEventListener("touchstart", function () {
- if (globalStates.extendedTracking === true) {
- if (!globalStates.UIOffMode) document.getElementById('loadNewUI').src = loadNewUiImage[3].src;
- }
- else {
- if (!globalStates.UIOffMode) document.getElementById('loadNewUI').src = loadNewUiImage[1].src;
- }
- });
- ec++;
-
- document.getElementById("loadNewUI").addEventListener("touchend", function () {
-
- if (!globalStates.UIOffMode) document.getElementById('loadNewUI').src = loadNewUiImage[0].src;
- window.location.href = "of://loadNewUI" + globalStates.newURLText;
-
- });
- ec++;
-
- document.getElementById("preferencesButton").addEventListener("touchstart", function () {
- if (!globalStates.UIOffMode) document.getElementById('preferencesButton').src = preferencesButtonImage[1].src;
- });
- ec++;
-
- document.getElementById("preferencesButton").addEventListener("touchend", function () {
- if (globalStates.preferencesButtonState === true) {
- preferencesHide();
- overlayDiv.style.display = "none";
-
- if (globalStates.editingMode) {
- document.getElementById('resetButton').style.visibility = "visible";
- document.getElementById('unconstButton').style.visibility = "visible";
- document.getElementById('resetButtonDiv').style.display = "inline";
- document.getElementById('unconstButtonDiv').style.display = "inline";
- }
-
- if (globalStates.UIOffMode) {
- document.getElementById('preferencesButton').src = preferencesButtonImage[3].src;
- document.getElementById('feezeButton').src = freezeButtonImage[3].src;
- document.getElementById('reloadButton').src = reloadButtonImage[2].src;
- document.getElementById('guiButtonImage').src = guiButtonImage[4].src;
- document.getElementById('resetButton').src = resetButtonImage[3].src;
- document.getElementById('unconstButton').src = unconstButtonImage[3].src;
- }
-
- }
- else {
-
- document.getElementById('resetButton').style.visibility = "hidden";
- document.getElementById('unconstButton').style.visibility = "hidden";
- document.getElementById('resetButtonDiv').style.display = "none";
- document.getElementById('unconstButtonDiv').style.display = "none";
-
- addElementInPreferences();
-
- preferencesVisible();
-
- overlayDiv.style.display = "inline";
-
- if (globalStates.UIOffMode) {
- document.getElementById('preferencesButton').src = preferencesButtonImage[0].src;
- document.getElementById('feezeButton').src = freezeButtonImage[0].src;
- document.getElementById('reloadButton').src = reloadButtonImage[0].src;
- document.getElementById('guiButtonImage').src = guiButtonImage[1].src;
- document.getElementById('resetButton').src = resetButtonImage[0].src;
- document.getElementById('unconstButton').src = unconstButtonImage[0].src;
- }
-
- }
-
- });
- ec++;
-
- document.getElementById("feezeButton").addEventListener("touchstart", function () {
- if (!globalStates.UIOffMode) document.getElementById('feezeButton').src = freezeButtonImage[1].src;
- });
- ec++;
- document.getElementById("feezeButton").addEventListener("touchend", function () {
- if (globalStates.feezeButtonState === true) {
- if (!globalStates.UIOffMode) document.getElementById('feezeButton').src = freezeButtonImage[0].src;
- globalStates.feezeButtonState = false;
- window.location.href = "of://unfreeze";
- }
- else {
- if (!globalStates.UIOffMode) document.getElementById('feezeButton').src = freezeButtonImage[2].src;
- globalStates.feezeButtonState = true;
- window.location.href = "of://freeze";
- }
-
- });
-
- ec++;
- document.getElementById("reloadButton").addEventListener("touchstart", function () {
- if (!globalStates.UIOffMode) document.getElementById('reloadButton').src = reloadButtonImage[0].src;
- window.location.href = "of://reload";
- });
- ec++;
- document.getElementById("reloadButton").addEventListener("touchend", function () {
- // location.reload(true);
-
- window.open("index.html?v=" + Math.floor((Math.random() * 100) + 1));
- });
- ec++;
- cout("GUI");
-}
-
-/**
- * @desc
- **/
-
-function preferencesHide() {
- if (!globalStates.UIOffMode) document.getElementById('preferencesButton').src = preferencesButtonImage[0].src;
- globalStates.preferencesButtonState = false;
- document.getElementById("preferences").style.visibility = "hidden"; //= "hidden";
- document.getElementById("preferences").style.dispaly = "none"; //= "hidden";
- cout("preferencesHide");
-}
-
-/**
- * @desc
- **/
-
-function preferencesVisible() {
- if (!globalStates.UIOffMode) document.getElementById('preferencesButton').src = preferencesButtonImage[2].src;
- globalStates.preferencesButtonState = true;
- document.getElementById("preferences").style.visibility = "visible"; //
- document.getElementById("preferences").style.display = "inline"; //= "hidden";
- cout("preferencesVisible");
-}
-
-/**********************************************************************************************************************
- ******************************************* datacrafting GUI *******************************************************
- **********************************************************************************************************************/
-
-function datacraftingVisible() {
- globalStates.datacraftingVisible = true;
- document.getElementById("datacrafting-container").style.display = 'inline';
- addDatacraftingEventListeners();
-}
-
-function datacraftingHide() {
- globalStates.datacraftingVisible = false;
- document.getElementById("datacrafting-container").style.display = 'none';
- removeDatacraftingEventListeners();
-}
-
-function addDatacraftingEventListeners() {
- logic1.grid.cells.forEach( function(cell) {
- if (cell.domElement) {
- cell.domElement.addEventListener("pointerdown", blockPointerDown);
- cell.domElement.addEventListener("pointerenter", blockPointerEnter);
- cell.domElement.addEventListener("pointerleave", blockPointerLeave);
- cell.domElement.addEventListener("pointerup", blockPointerUp);
- }
- });
- var blocksContainer = document.getElementById('blocks');
- blocksContainer.addEventListener("pointerup", datacraftingContainerPointerUp);
- blocksContainer.addEventListener("pointerdown", datacraftingContainerPointerDown);
- blocksContainer.addEventListener("pointermove", datacraftingContainerPointerMove);
-}
-
-function removeDatacraftingEventListeners() {
- logic1.grid.cells.forEach( function(cell) {
- if (cell.domElement) {
- cell.domElement.removeEventListener("pointerdown", blockPointerDown);
- cell.domElement.removeEventListener("pointerenter", blockPointerEnter);
- cell.domElement.removeEventListener("pointerleave", blockPointerLeave);
- cell.domElement.removeEventListener("pointerup", blockPointerUp);
- }
- });
- var blocksContainer = document.getElementById('blocks');
- blocksContainer.removeEventListener("pointerup", datacraftingContainerPointerUp);
- blocksContainer.removeEventListener("pointerdown", datacraftingContainerPointerDown);
- blocksContainer.removeEventListener("pointermove", datacraftingContainerPointerMove);
-}
-
-// should only be called once to initialize a blank datacrafting interface and data model
-function initializeDatacraftingGrid() {
- var container = document.getElementById('datacrafting-container');
- var containerWidth = container.clientWidth;
- var containerHeight = container.clientHeight;
-
- var blockWidth = 2 * (containerWidth / 11);
- var blockHeight = (containerHeight / 7);
- var marginWidth = (containerWidth / 11);
- var marginHeight = blockHeight;
-
- logic1 = new Logic();
-
- // grid = new Grid(gridSize, blockWidth, blockHeight, marginWidth, marginHeight); //130, 65, 65, 65);
- logic1.grid = new Grid(blockWidth, blockHeight, marginWidth, marginHeight); //130, 65, 65, 65);
- var datacraftingCanvas = document.getElementById("datacraftingCanvas");
- var dimensions = logic1.grid.getPixelDimensions();
-
- datacraftingCanvas.width = dimensions.width;
- datacraftingCanvas.style.width = dimensions.width;
- datacraftingCanvas.height = dimensions.height;
- datacraftingCanvas.style.height = dimensions.height;
-
- ///////////
- // debugging only... shouldn't have blocks by default
- logic1.grid.cells.forEach(function(cell) {
- if (cell.canHaveBlock()) {
- // cell.block = new Block(cell);
- var blockPos = convertGridPosToBlockPos(cell.location.col, cell.location.row);
- var block = createBlock(blockPos.x, blockPos.y, 1, "test");
- var blockKey = "block_" + blockPos.x + "_" + blockPos.y + "_" + getTimestamp();
- logic1.blocks[blockKey] = block;
- }
- });
- ///////////
-
- // initialize by adding a grid of images for the blocks
- // and associating them with the data model and assigning event handlers
- var blocksContainer = document.getElementById('blocks');
- blocksContainer.setAttribute("touch-action", "none");
-
- for (var rowNum = 0; rowNum < logic1.grid.size; rowNum+=2) {
-
- var rowDiv = document.createElement('div');
- rowDiv.setAttribute("class", "row");
- rowDiv.setAttribute("id", "row" + rowNum);
- blocksContainer.appendChild(rowDiv);
-
- for (var colNum = 0; colNum < logic1.grid.size; colNum+=2) {
-
- var blockImg = document.createElement('img');
- blockImg.setAttribute("class", "block");
- if (colNum === logic1.grid.size - 1) {
- blockImg.setAttribute("class", "blockRight");
- }
- blockImg.setAttribute("id", "block" + colNum);
- blockImg.setAttribute("src", blockImgMap["filled"][colNum/2]);
- blockImg.setAttribute("touch-action", "none");
- //var block = new Block(colNum, rowNum, true, blockImg);
- var thisCell = logic1.grid.getCell(colNum, rowNum);
- thisCell.domElement = blockImg;
- blockImg.cell = thisCell;
-
- rowDiv.appendChild(blockImg);
- }
- }
-}
-
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param array
- **/
-
-function preload(array) {
- for (var i = 0; i < preload.arguments.length - 1; i++) {
- array[i] = new Image();
- array[i].src = preload.arguments[i + 1];
- }
-
- cout("preload");
-}
-
-
-
-
diff --git a/js/index.js b/js/index.js
deleted file mode 100755
index fce744021..000000000
--- a/js/index.js
+++ /dev/null
@@ -1,1474 +0,0 @@
-/**
- * @preserve
- *
- * .,,,;;,'''..
- * .'','... ..',,,.
- * .,,,,,,',,',;;:;,. .,l,
- * .,',. ... ,;, :l.
- * ':;. .'.:do;;. .c ol;'.
- * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
- * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
- * .oxddl;::,,. ', .'''. .... .'. ,:;..
- * .'cOX0OOkdoc. .,'. .. ..... 'lc.
- * .:;,,::co0XOko' ....''..'.'''''''.
- * .dxk0KKdc:cdOXKl............. .. ..,c....
- * .',lxOOxl:'':xkl,',......'.... ,'.
- * .';:oo:... .
- * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
- * .l; โโฃ โโโ โ โ โโโฌโ '
- * 'l. โโโโโดโโด โด โโโโดโโ '.
- * .o. ...
- * .''''','.;:''.........
- * .' .l
- * .:. l'
- * .:. .l.
- * .x: :k;,.
- * cxlc; cdc,,;;.
- * 'l :.. .c ,
- * o.
- * .,
- *
- * โฆ โฆโฌ โฌโโ โฌโโโฌโโฌโ โโโโโ โฌโโโโโโโโฌโโโโ
- * โ โโฃโโฌโโโดโโโฌโโ โโ โ โโโดโ โโโค โ โ โโโ
- * โฉ โฉ โด โโโโดโโโดโโดโ โโโโโโโโโโโโโโ โด โโโ
- *
- *
- * Created by Valentin on 10/22/14.
- *
- * Copyright (c) 2015 Valentin Heun
- *
- * All ascii characters above must be included in any redistribution.
- *
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
- */
-/*********************************************************************************************************************
- ******************************************** TODOS *******************************************************************
- **********************************************************************************************************************
-
- **
- * TODO + Data is loaded from the Object
- * TODO + Generate and delete link
- * TODO + DRAw interface based on Object
- * TODO + Check the coordinates of targets. Incoperate the target size
- * TODO - Check if object is in the right range
- * TODO - add reset button on every target
- * TODO - Documentation before I leave
- * TODO - Arduino Library
- **
-
- /**********************************************************************************************************************
- ******************************************** Data IO *******************************************
- **********************************************************************************************************************/
-
-// Functions to fill the data of the object
-
-/**
- * @desc Adding new objects to the reality editor database via http ajax
- * @param {String|Array} beat an array in the form {id: "", ip: ""}
- **/
-
-function addHeartbeatObject(beat) {
- /*
- if (globalStates.platform) {
- window.location.href = "of://gotbeat_" + beat.id;
- }
- */
- if (beat.id) {
- if (!objects[beat.id]) {
- getData('http://' + beat.ip + ':' + httpPort + '/object/' + beat.id, beat.id, function (req, thisKey) {
- if (req && thisKey) {
- objects[thisKey] = req;
- var thisObject = objects[thisKey];
- // this is a work around to set the state of an objects to not being visible.
- thisObject.objectVisible = false;
- thisObject.screenZ = 1000;
- thisObject.fullScreen = false;
- thisObject.sendMatrix = false;
- thisObject.integerVersion = parseInt(objects[thisKey].version.replace(/\./g, ""));
-
- if (thisObject.matrix === null || typeof thisObject.matrix !== "object") {
- thisObject.matrix = [];
- }
-
- if (thisObject.logic === null || typeof thisObject.logic !== "object") {
- thisObject.logic = {};
- }
-
- for (var nodeKey in objects[thisKey].nodes) {
- thisObject = objects[thisKey].nodes[nodeKey];
- if (thisObject.matrix === null || typeof thisObject.matrix !== "object") {
- thisObject.matrix = [];
- }
- }
-
- if (!thisObject.protocol) {
- thisObject.protocol = "R0";
- }
-
- if (thisObject.integerVersion < 170) {
-
- rename(thisObject, "folder", "name");
- rename(thisObject, "objectValues", "nodes");
- rename(thisObject, "objectLinks", "links");
- delete thisObject["matrix3dMemory"];
-
- for (var linkKey in objects[thisKey].links) {
- thisObject = objects[thisKey].links[linkKey];
-
- rename(thisObject, "ObjectA", "objectA");
- rename(thisObject, "locationInA", "nodeA");
- rename(thisObject, "ObjectNameA", "nameA");
-
- rename(thisObject, "ObjectB", "objectB");
- rename(thisObject, "locationInB", "nodeB");
- rename(thisObject, "ObjectNameB", "nameB");
- rename(thisObject, "endlessLoop", "loop");
- rename(thisObject, "countLinkExistance", "health");
- }
-
- for (var nodeKey in objects[thisKey].nodes) {
- thisObject = objects[thisKey].nodes[nodeKey];
- rename(thisObject, "plugin", "appearance");
- thisObject.item = {
- number: thisObject.value,
- mode: thisObject.mode,
- unit: "",
- unitMin: 0,
- unitMax: 1
- };
- delete thisObject.value;
- delete thisObject.mode;
-
- }
-
- }
- cout(JSON.stringify(objects[thisKey]));
- addElementInPreferences();
- }
- });
- }
- }
-}
-
-/**
- * @desc
- * @param deviceName
- **/
-
-function setDeviceName(deviceName) {
- globalStates.device = deviceName;
- console.log("The Reality Editor is loaded on a " + globalStates.device);
-}
-
-/**
- * @desc
- * @param developerState
- * @param extendedTrackingState
- * @param clearSkyState
- * @param externalState
- **/
-
-function setStates(developerState, extendedTrackingState, clearSkyState, externalState) {
-
- globalStates.extendedTrackingState = extendedTrackingState;
- globalStates.developerState = developerState;
- globalStates.clearSkyState = clearSkyState;
- globalStates.externalState = externalState;
-
- if (clearSkyState) {
- // globalStates.UIOffMode = true;
- // timeForContentLoaded = 240000;
- // document.getElementById("turnOffUISwitch").checked = true;
- }
-
- if (developerState) {
- addEventHandlers();
- globalStates.editingMode = true;
- document.getElementById("editingModeSwitch").checked = true;
- }
-
- if (extendedTrackingState) {
- globalStates.extendedTracking = true;
- document.getElementById("extendedTrackingSwitch").checked = true;
- }
-
- if (globalStates.externalState !== "") {
- document.getElementById("newURLText").value = globalStates.externalState;
- }
-
- if (globalStates.editingMode) {
- document.getElementById('resetButton').style.visibility = "visible";
- document.getElementById('unconstButton').style.visibility = "visible";
- document.getElementById('resetButtonDiv').style.display = "inline";
- document.getElementById('unconstButtonDiv').style.display = "inline";
- }
-
- // Once all the states are send the alternative checkbox is loaded
- // Its a bad hack to place it here, but it works
-
- if (typeof checkBoxElements === "undefined") {
- var checkBoxElements = Array.prototype.slice.call(document.querySelectorAll('.js-switch'));
-
- checkBoxElements.forEach(function (html) {
- var switchery = new Switchery(html, {size: 'large', speed: '0.2s', color: '#1ee71e'});
-
- });
- }
-}
-
-/**
- * @desc
- * @param action
- **/
-
-function action(action) {
- var thisAction = JSON.parse(action);
-
- if (thisAction.reloadLink) {
- getData('http://' + thisAction.reloadLink.ip + ':' + httpPort + '/object/' + thisAction.reloadLink.id, thisAction.reloadLink.id, function (req, thisKey) {
-
- if (objects[thisKey].integerVersion < 170) {
- objects[thisKey].links = req.links;
- for (var linkKey in objects[thisKey].links) {
- thisObject = objects[thisKey].links[linkKey];
-
- rename(thisObject, "objectA", "objectA");
- rename(thisObject, "nodeA", "nodeA");
- rename(thisObject, "nameA", "nameA");
-
- rename(thisObject, "objectB", "objectB");
- rename(thisObject, "nodeB", "nodeB");
- rename(thisObject, "nameB", "nameB");
- rename(thisObject, "endlessLoop", "loop");
- rename(thisObject, "countLinkExistance", "health");
- }
- }
- else {
- objects[thisKey].links = req.links;
- }
-
- // cout(objects[thisKey]);
- cout("got links");
- });
-
- }
-
- if (thisAction.reloadObject) {
- getData('http://' + thisAction.reloadObject.ip + ':' + httpPort + '/object/' + thisAction.reloadObject.id, thisAction.reloadObject.id, function (req, thisKey) {
- objects[thisKey].x = req.x;
- objects[thisKey].y = req.y;
- objects[thisKey].scale = req.scale;
-
- if (objects[thisKey].integerVersion < 170) {
- objects[thisKey].nodes = req.objectValues;
-
- for (var nodeKey in objects[thisKey].nodes) {
- thisObject = objects[thisKey].nodes[nodeKey];
- rename(thisObject, "plugin", "appearance");
- thisObject.item = {
- number: thisObject.value,
- mode: thisObject.mode,
- unit: "",
- unitMin: 0,
- unitMax: 1
- };
- delete thisObject.value;
- delete thisObject.mode;
- }
- }
- else {
- objects[thisKey].nodes = req.nodes;
- }
-
- // cout(objects[thisKey]);
- cout("got links");
- });
- }
-
- cout("found action: " + action);
-
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param url
- * @param thisKey
- * @param callback
- * @return
- **/
-
-function getData(url, thisKey, callback) {
- var req = new XMLHttpRequest();
- try {
- req.open('GET', url, true);
- // Just like regular ol' XHR
- req.onreadystatechange = function () {
- if (req.readyState === 4) {
- if (req.status >= 200 && req.status < 400) {
- // JSON.parse(req.responseText) etc.
- callback(JSON.parse(req.responseText), thisKey)
- } else {
- // Handle error case
- cout("could not load content");
- }
- }
- };
- req.send();
-
- }
- catch (e) {
- cout("could not connect to" + url);
- }
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-// set projection matrix
-
-/**
- * @desc
- * @param matrix
- **/
-
-function setProjectionMatrix(matrix) {
- // globalStates.projectionMatrix = matrix;
-
- // generate all transformations for the object that needs to be done ASAP
- var scaleZ = [
- 1, 0, 0, 0,
- 0, 1, 0, 0,
- 0, 0, 2, 0,
- 0, 0, 0, 1
- ];
-
- var corX = 0;
- var corY = 0;
-
- // iPhone 5(GSM), iPhone 5 (GSM+CDMA)
- if (globalStates.device === "iPhone5,1" || globalStates.device === "iPhone5,2") {
- corX = 0;
- corY = -3;
- }
-
- // iPhone 5c (GSM), iPhone 5c (GSM+CDMA)
- if (globalStates.device === "iPhone5,3" || globalStates.device === "iPhone5,4") {
- // not yet tested todo add values
- corX = 0;
- corY = 0;
- }
-
- // iPhone 5s (GSM), iPhone 5s (GSM+CDMA)
- if (globalStates.device === "iPhone6,1" || globalStates.device === "iPhone6,2") {
- corX = -3;
- corY = -1;
-
- }
-
- // iPhone 6 plus
- if (globalStates.device === "iPhone7,1") {
- // not yet tested todo add values
- corX = 0;
- corY = 0;
- }
-
- // iPhone 6
- if (globalStates.device === "iPhone7,2") {
- corX = -4.5;
- corY = -6;
- }
-
- // iPhone 6s
- if (globalStates.device === "iPhone8,1") {
- // not yet tested todo add values
- corX = 0;
- corY = 0;
- }
-
- // iPhone 6s Plus
- if (globalStates.device === "iPhone8,2") {
- corX = -0.3;
- corY = -1.5;
- }
-
- // iPad
- if (globalStates.device === "iPad1,1") {
- // not yet tested todo add values
- corX = 0;
- corY = 0;
- }
-
- // iPad 2 (WiFi), iPad 2 (GSM), iPad 2 (CDMA), iPad 2 (WiFi)
- if (globalStates.device === "iPad2,1" || globalStates.device === "iPad2,2" || globalStates.device === "iPad2,3" || globalStates.device === "iPad2,4") {
- corX = -31;
- corY = -5;
- }
-
- // iPad Mini (WiFi), iPad Mini (GSM), iPad Mini (GSM+CDMA)
- if (globalStates.device === "iPad2,5" || globalStates.device === "iPad2,6" || globalStates.device === "iPad2,7") {
- // not yet tested todo add values
- corX = 0;
- corY = 0;
- }
-
- // iPad 3 (WiFi), iPad 3 (GSM+CDMA), iPad 3 (GSM)
- if (globalStates.device === "iPad3,1" || globalStates.device === "iPad3,2" || globalStates.device === "iPad3,3") {
- corX = -3;
- corY = -1;
- }
- //iPad 4 (WiFi), iPad 4 (GSM), iPad 4 (GSM+CDMA)
- if (globalStates.device === "iPad3,4" || globalStates.device === "iPad3,5" || globalStates.device === "iPad3,6") {
- corX = -5;
- corY = 17;
- }
-
- // iPad Air (WiFi), iPad Air (Cellular)
- if (globalStates.device === "iPad4,1" || globalStates.device === "iPad4,2") {
- // not yet tested todo add values
- corX = 0;
- corY = 0;
- }
-
- // iPad mini 2G (WiFi) iPad mini 2G (Cellular)
- if (globalStates.device === "iPad4,4" || globalStates.device === "iPad4,5") {
- corX = -11;
- corY = 6.5;
- }
-
- var viewportScaling = [
- globalStates.height, 0, 0, 0,
- 0, -globalStates.width, 0, 0,
- 0, 0, 1, 0,
- corX, corY, 0, 1
- ];
-
- var r = [];
- globalStates.realProjectionMatrix = matrix;
-
- multiplyMatrix(scaleZ, matrix, r);
- multiplyMatrix(r, viewportScaling, globalStates.projectionMatrix);
- window.location.href = "of://gotProjectionMatrix";
-
-}
-
-/**********************************************************************************************************************
- ******************************************** update and draw the 3D Interface ****************************************
- **********************************************************************************************************************/
-
-/**
- * @desc main update loop called 30 fps with an array of found transformation matrices
- * @param visibleObjects
- **/
-
-
-
-function update(visibleObjects) {
-
-// console.log(JSON.stringify(visibleObjects));
- timeSynchronizer(timeCorrection);
- //disp = uiButtons.style.display;
- //uiButtons.style.display = 'none';
-
- if (globalStates.datacraftingVisible) {
- updateDatacrafting();
- }
-
- if (globalStates.feezeButtonState == false) {
- globalObjects = visibleObjects;
- }
- /* if (consoleText !== "") {
- consoleText = "";
- document.getElementById("consolelog").innerHTML = "";
- }
- conalt = "";*/
- var thisGlobalCanvas = globalCanvas;
- if (thisGlobalCanvas.hasContent === true) {
- thisGlobalCanvas.context.clearRect(0, 0, globalCanvas.canvas.width, globalCanvas.canvas.height);
- thisGlobalCanvas.hasContent = false;
- }
-
- var destinationString;
-
- var thisGlobalStates = globalStates;
-
- var thisGlobalLogic = globalLogic;
- var thisGlobalDOMCach = globalDOMCach;
- var thisGlobalMatrix = globalMatrix;
-
- for (var objectKey in objects) {
- if (!objects.hasOwnProperty(objectKey)) {
- continue;
- }
-
- var generalObject = objects[objectKey];
-
- //if( globalStates.pointerPosition[0]>0)
- //console.log(generalObject);
- // I changed this to has property.
- if (globalObjects.hasOwnProperty(objectKey)) {
-
- generalObject.visibleCounter = timeForContentLoaded;
- generalObject.objectVisible = true;
-
- // tempMatrix = multiplyMatrix(rotateX, multiplyMatrix(globalObjects[objectKey], globalStates.projectionMatrix));
-
- var tempMatrix = [];
- var r = globalMatrix.r;
- multiplyMatrix(globalObjects[objectKey], globalStates.projectionMatrix, r);
- multiplyMatrix(rotateX, r, tempMatrix);
-
- // var tempMatrix2 = multiplyMatrix(globalObjects[objectKey], globalStates.projectionMatrix);
-
- // document.getElementById("controls").innerHTML = (toAxisAngle(tempMatrix2)[0]).toFixed(1)+" "+(toAxisAngle(tempMatrix2)[1]).toFixed(1);
-
- if (globalStates.guiButtonState || Object.keys(generalObject.nodes).length === 0) {
- drawTransformed(objectKey, objectKey, generalObject, tempMatrix, "ui", thisGlobalStates, thisGlobalCanvas, thisGlobalLogic, thisGlobalDOMCach, thisGlobalMatrix);
- addElement(objectKey, objectKey, "http://" + generalObject.ip + ":" + httpPort + "/obj/" + generalObject.name + "/", generalObject, "ui", thisGlobalStates);
- }
- else {
- hideTransformed(objectKey, objectKey, generalObject, "ui");
- }
-
- // do this for staying compatible with older versions but use new routing after some time.
- // dataPointInterfaces are clearly their own thing and should not be part of obj
- // once added, they will be associated with the object via the editor postMessages anyway.
- if (generalObject.integerVersion >= 170) {
- destinationString = "/nodes/";
- } else {
- if (generalObject.integerVersion > 40) {
- destinationString = "/dataPointInterfaces/";
- } else {
- destinationString = "/obj/dataPointInterfaces/";
- }
- }
-
- var generalNode;
- for (nodeKey in generalObject.nodes) {
- // if (!generalObject.nodes.hasOwnProperty(nodeKey)) { continue; }
-
- generalNode = generalObject.nodes[nodeKey];
-
- if (!globalStates.guiButtonState) {
- drawTransformed(objectKey, nodeKey, generalNode, tempMatrix, "node", thisGlobalStates, thisGlobalCanvas, thisGlobalLogic, thisGlobalDOMCach, thisGlobalMatrix);
-
- addElement(objectKey, nodeKey, "nodes/" + generalNode.appearance + "/index.html", generalNode, "node", thisGlobalStates);
-
- } else {
- hideTransformed(objectKey, nodeKey, generalNode, "node");
- }
- }
-
- for (var nodeKey in generalObject.logic) {
- // if (!generalObject.nodes.hasOwnProperty(nodeKey)) { continue; }
-
- generalNode = generalObject.logic[nodeKey];
-
- if (!globalStates.guiButtonState) {
- drawTransformed(objectKey, nodeKey, generalNode, tempMatrix, "logic", thisGlobalStates, thisGlobalCanvas, thisGlobalLogic, thisGlobalDOMCach, thisGlobalMatrix);
-
- addElement(objectKey, nodeKey, "nodes/" + generalNode.appearance + "/index.html", generalNode, "logic", thisGlobalStates);
-
- } else {
- hideTransformed(objectKey, nodeKey, generalNode, "logic");
- }
- }
- }
-
- else {
- generalObject.objectVisible = false;
-
- hideTransformed(objectKey, objectKey, generalObject, "ui");
-
- for (var nodeKey in generalObject.nodes) {
- // if (!generalObject.nodes.hasOwnProperty(nodeKey)) { continue; }
- hideTransformed(objectKey, nodeKey, generalObject.nodes[nodeKey], "node");
- }
-
- for (var nodeKey in generalObject.logic) {
- // if (!generalObject.nodes.hasOwnProperty(nodeKey)) { continue; }
- hideTransformed(objectKey, nodeKey, generalObject.logic[nodeKey], "logic");
- }
-
- killObjects(objectKey, generalObject);
- }
-
- }
-
- // draw all lines
- if (!globalStates.guiButtonState && !globalStates.editingMode) {
- for (var objectKey in objects) {
- drawAllLines(objects[objectKey], thisGlobalCanvas.context);
-
- }
- drawInteractionLines();
- // cout("drawlines");
- }
-
- // todo this is a test for the pocket
-
- // todo finishing up this
-
- var generalObject = pocketItem["pocket"];
- // if( globalStates.pointerPosition[0]>0)
- //console.log(generalObject);
- generalObject.visibleCounter = timeForContentLoaded;
- generalObject.objectVisible = true;
-
- var generalNode;
- objectKey = "pocket";
-
- var thisMatrix = [];
-
- if (globalLogic.farFrontElement in globalObjects) {
-
- var r = globalMatrix.r;
- multiplyMatrix(globalObjects[globalLogic.farFrontElement], globalStates.projectionMatrix, r);
- multiplyMatrix(rotateX, r, thisMatrix);
-
- } else {
-
- thisMatrix = [
- 1, 0, 0, 0,
- 0, 1, 0, 0,
- 0, 0, 1, 0,
- 0, 0, 2, 1
- ]
- }
-
- for (var nodeKey in generalObject.logic) {
- //console.log(document.getElementById("iframe"+ nodeKey));
- generalNode = generalObject.logic[nodeKey];
-
- drawTransformed(objectKey, nodeKey, generalNode,
- thisMatrix, "logic", globalStates, globalCanvas, globalLogic, globalDOMCach, globalMatrix);
-
- addElement(objectKey, nodeKey, "nodes/" + generalNode.appearance + "/index.html", generalNode, "logic", globalStates);
-
- /* } else {
- hideTransformed("pocket", nodeKey, generalNode, "logic");
- }*/
- }
-
- /// todo Test
-
-}
-
-/**********************************************************************************************************************
- ******************************************** 3D Transforms & Utilities ***********************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param objectKey
- * @param nodeKey
- * @param thisObject
- * @param thisTransform2
- * @return
- **/
-
-var finalMatrixTransform2;
-var thisTransform = [];
-var thisKey;
-var thisSubKey;
-
-function drawTransformed(objectKey, nodeKey, thisObject, thisTransform2, kind, globalStates, globalCanvas, globalLogic, globalDOMCach, globalMatrix) {
- var objectKey = objectKey;
- var nodeKey = nodeKey;
- var thisObject = thisObject;
- var thisTransform2 = thisTransform2;
- var kind = kind;
- var globalCanvas = globalCanvas;
- var globalStates = globalStates;
- var globalLogic = globalLogic;
- var globalDOMCach = globalDOMCach;
- var globalMatrix = globalMatrix;
- //console.log(JSON.stringify(thisTransform2));
-
- if (globalStates.notLoading !== nodeKey && thisObject.loaded === true) {
- if (!thisObject.visible) {
- thisObject.visible = true;
- globalDOMCach["thisObject" + nodeKey].style.display = 'inline';
- globalDOMCach["iframe" + nodeKey].style.visibility = 'visible';
- globalDOMCach["iframe" + nodeKey].contentWindow.postMessage(
- JSON.stringify(
- {
- visibility: "visible"
- }), '*');
-
- if (kind === "node") {
- globalDOMCach[nodeKey].style.visibility = 'visible';
- // document.getElementById("text" + nodeKey).style.visibility = 'visible';
- if (globalStates.editingMode) {
- globalDOMCach["canvas" + nodeKey].style.display = 'inline';
- } else {
- globalDOMCach["canvas" + nodeKey].style.display = 'none';
- }
- } else if (kind === "ui") {
- if (globalStates.editingMode) {
- if (!thisObject.visibleEditing && thisObject.developer) {
- thisObject.visibleEditing = true;
- globalDOMCach[nodeKey].style.visibility = 'visible';
- // showEditingStripes(nodeKey, true);
- globalDOMCach["canvas" + nodeKey].style.display = 'inline';
-
- //document.getElementById(nodeKey).className = "mainProgram";
- }
- } else {
- globalDOMCach["canvas" + nodeKey].style.display = 'none';
- }
- } else if (kind === "logic") {
- thisObject.temp = copyMatrix(thisTransform2);
- if (globalStates.editingMode) {
- if (!thisObject.visibleEditing && thisObject.developer) {
- thisObject.visibleEditing = true;
- globalDOMCach[nodeKey].style.visibility = 'visible';
- // showEditingStripes(nodeKey, true);
- globalDOMCach["canvas" + nodeKey].style.display = 'inline';
-
- //document.getElementById(nodeKey).className = "mainProgram";
- }
- } else {
- globalDOMCach["canvas" + nodeKey].style.display = 'none';
- }
- }
-
- } else {
- // this needs a better solution
- if (thisObject.fullScreen !== true) {
-
- finalMatrixTransform2 = [
- thisObject.scale, 0, 0, 0,
- 0, thisObject.scale, 0, 0,
- 0, 0, 1, 0,
- thisObject.x, thisObject.y, 0, 1
- ];
-
- if (globalStates.editingMode) {
- if (globalMatrix.matrixtouchOn === nodeKey) {
- //if(globalStates.unconstrainedPositioning===true)
- thisObject.temp = copyMatrix(thisTransform2);
-
- if (globalMatrix.copyStillFromMatrixSwitch) {
- globalMatrix.visual = copyMatrix(thisTransform2);
- if (typeof thisObject.matrix === "object")
- if (thisObject.matrix.length > 0)
- // thisObject.begin = copyMatrix(multiplyMatrix(thisObject.matrix, thisObject.temp));
- multiplyMatrix(thisObject.matrix, thisObject.temp, thisObject.begin);
-
- else
- thisObject.begin = copyMatrix(thisObject.temp);
- else
- thisObject.begin = copyMatrix(thisObject.temp);
-
- if (globalStates.unconstrainedPositioning === true)
- // thisObject.matrix = copyMatrix(multiplyMatrix(thisObject.begin, invertMatrix(thisObject.temp)));
-
- multiplyMatrix(thisObject.begin, invertMatrix(thisObject.temp), thisObject.matrix);
-
- globalMatrix.copyStillFromMatrixSwitch = false;
- }
-
- if (globalStates.unconstrainedPositioning === true)
- thisTransform2 = globalMatrix.visual;
-
- }
-
- if (typeof thisObject.matrix[1] !== "undefined") {
- if (thisObject.matrix.length > 0) {
- if (globalStates.unconstrainedPositioning === false) {
- //thisObject.begin = copyMatrix(multiplyMatrix(thisObject.matrix, thisObject.temp));
- multiplyMatrix(thisObject.matrix, thisObject.temp, thisObject.begin);
- }
-
- var r = globalMatrix.r, r2 = globalMatrix.r2;
- multiplyMatrix(thisObject.begin, invertMatrix(thisObject.temp), r);
- multiplyMatrix(finalMatrixTransform2, r, r2);
- estimateIntersection(nodeKey, r2, thisObject);
- } else {
- estimateIntersection(nodeKey, null, thisObject);
- }
-
- } else {
-
- estimateIntersection(nodeKey, null, thisObject);
- }
- }
-
- if (thisObject.matrix.length < 13) {
-
- multiplyMatrix(finalMatrixTransform2, thisTransform2, thisTransform);
- } else {
- var r = globalMatrix.r;
- multiplyMatrix(thisObject.matrix, thisTransform2, r);
- multiplyMatrix(finalMatrixTransform2, r, thisTransform);
-
- // thisTransform = multiplyMatrix(finalMatrixTransform2, multiplyMatrix(thisObject.matrix, thisTransform2));
- }
-
- // else {
- // multiplyMatrix(finalMatrixTransform2, thisTransform2,thisTransform);
- // }
-
- // console.log(nodeKey);
- // console.log(globalDOMCach["thisObject" + nodeKey]);
- // console.log(globalDOMCach["thisObject" + nodeKey].visibility);
-
- webkitTransformMatrix3d(globalDOMCach["thisObject" + nodeKey], thisTransform);
-
- // this is for later
- // The matrix has been changed from Vuforia 3 to 4 and 5. Instead of thisTransform[3][2] it is now thisTransform[3][3]
- thisObject.screenX = thisTransform[12] / thisTransform[15] + (globalStates.height / 2);
- thisObject.screenY = thisTransform[13] / thisTransform[15] + (globalStates.width / 2);
- thisObject.screenZ = thisTransform[14];
-
- }
- if (kind === "ui") {
- if (thisObject.sendMatrix === true) {
- cout(globalObjects[objectKey]);
- globalDOMCach["iframe" + nodeKey].contentWindow.postMessage(
- JSON.stringify({modelViewMatrix: globalObjects[objectKey]}), '*');
- }
- } else if ("node") {
-
- thisObject.screenLinearZ = (((10001 - (20000 / thisObject.screenZ)) / 9999) + 1) / 2;
- // map the linearized zBuffer to the final ball size
- thisObject.screenLinearZ = map(thisObject.screenLinearZ, 0.996, 1, 25, 1);
-
- if (globalStates.pointerPosition[0] > -1) {
-
- var size = (thisObject.screenLinearZ * 20) * thisObject.scale;
- var x = thisObject.screenX;
- var y = thisObject.screenY;
-
- globalCanvas.hasContent = true;
-
- globalLogic.rectPoints = [
- [x - (-1 * size), y - (-0.42 * size)],
- [x - (-1 * size), y - (0.42 * size)],
- [x - (-0.42 * size), y - (size)],
- [x - (0.42 * size), y - (size)],
- [x - (size), y - (0.42 * size)],
- [x - (size), y - (-0.42 * size)],
- [x - (0.42 * size), y - (-1 * size)],
- [x - (-0.42 * size), y - (-1 * size)]
- ];
- var context = globalCanvas.context;
- context.setLineDash([]);
- // context.restore();
- context.beginPath();
- context.moveTo(globalLogic.rectPoints[0][0], globalLogic.rectPoints[0][1]);
- context.lineTo(globalLogic.rectPoints[1][0], globalLogic.rectPoints[1][1]);
- context.lineTo(globalLogic.rectPoints[2][0], globalLogic.rectPoints[2][1]);
- context.lineTo(globalLogic.rectPoints[3][0], globalLogic.rectPoints[3][1]);
- context.lineTo(globalLogic.rectPoints[4][0], globalLogic.rectPoints[4][1]);
- context.lineTo(globalLogic.rectPoints[5][0], globalLogic.rectPoints[5][1]);
- context.lineTo(globalLogic.rectPoints[6][0], globalLogic.rectPoints[6][1]);
- context.lineTo(globalLogic.rectPoints[7][0], globalLogic.rectPoints[7][1]);
- context.closePath();
-
- globalLogic.farFrontElement = "";
- globalLogic.frontDepth = 10000000000;
-
- for (thisKey in globalObjects) {
- if (objects[thisKey].screenZ < globalLogic.frontDepth) {
- globalLogic.frontDepth = objects[thisKey].screenZ;
- globalLogic.farFrontElement = thisKey;
- }
- }
-
- //console.log(globalLogic.farFrontElement);
-
- /*if (kind === "node") {
- for (var thisKey in globalObjects) {
- if (objects[thisKey]) {
- //console.log(objects[thisKey]);
- //console.log(objects[thisKey].screenZ);
- for (var thisSubKey in objects[thisKey].nodes) {
- // console.log(objects[thisKey].nodes[thisSubKey].screenZ);
- if (objects[thisKey].nodes[thisSubKey].screenZ < globalLogic.frontDepth) {
- globalLogic.frontDepth = objects[thisKey].nodes[thisSubKey].screenZ;
- globalLogic.farFrontElement = thisSubKey;
-
- }
- }
-
- }
-
- }
-
-
- }*/
-
- if (globalLogic.farFrontElement === nodeKey) {
- context.strokeStyle = "#ff0000";
- } else {
- context.strokeStyle = "#f0f0f0";
- }
-
- if (insidePoly(globalStates.pointerPosition, globalLogic.rectPoints))
- context.strokeStyle = "#ff00ff";
-
- context.stroke();
-
- //console.log(globalStates.pointerPosition);
-
- }
- }
-
- }
- }
-
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-function webkitTransformMatrix3d(thisDom, thisTransform) {
- thisDom.style.webkitTransform = 'matrix3d(' +
- thisTransform.toString() + ')';
-}
-/*
- function renderText(thisTransform){
-
- return thisTransform[0] + ',' + thisTransform[1] + ',' + thisTransform[2] + ',' + thisTransform[3] + ',' +
- thisTransform[4] + ',' + thisTransform[5] + ',' + thisTransform[6] + ',' + thisTransform[7] + ',' +
- thisTransform[8] + ',' + thisTransform[9] + ',' + thisTransform[10] + ',' + thisTransform[11] + ',' +
- thisTransform[12] + ',' + thisTransform[13] + ',' + thisTransform[14] + ',' + thisTransform[15];
- }
-
- */
-// thisDom.style.webkitTransform = 'matrix3d('+thisTransform.toString()+')';
-/**
- * @desc
- * @param objectKey
- * @param nodeKey
- * @param thisObject
- * @return
- **/
-
-function hideTransformed(objectKey, nodeKey, thisObject, kind) {
- if (thisObject.visible === true) {
- globalDOMCach["thisObject" + nodeKey].style.display = 'none';
- globalDOMCach["iframe" + nodeKey].style.visibility = 'hidden';
- globalDOMCach["iframe" + nodeKey].contentWindow.postMessage(
- JSON.stringify(
- {
- visibility: "hidden"
- }), '*');
-
- thisObject.visible = false;
- thisObject.visibleEditing = false;
-
- globalDOMCach[nodeKey].style.visibility = 'hidden';
- globalDOMCach["canvas" + nodeKey].style.display = 'none';
-
- cout("hideTransformed");
- }
-}
-
-/**********************************************************************************************************************
- ****************************************** datacrafting update ******************************************************
- **********************************************************************************************************************/
-
-function updateGrid(grid) {
- // *** this does all the backend work ***
- grid.recalculateAllRoutes();
-
- // updates the visuals for the blocks
- grid.forEachPossibleBlockCell( function(cell) {
- if (cell.block !== null) {
- displayCellBlock(cell);
- } else {
- hideCellBlock(cell);
- }
- });
-}
-
-// updates the visuals for the datacrafting each frame
-function updateDatacrafting() {
- window.requestAnimationFrame(redrawDatacrafting);
-}
-
-// renders all the links for a datacrafting grid, and draws a cut line if present
-function redrawDatacrafting() {
- var grid = logic1.grid;
-
- var canvas = document.getElementById("datacraftingCanvas");
- var ctx = canvas.getContext('2d');
- ctx.clearRect(0,0,canvas.width,canvas.height);
-
- grid.forEachLink( function(link) {
- var startCell = grid.getCellXY(link.blockA.x, link.blockA.y);
- var endCell = grid.getCellXY(link.blockB.x, link.blockB.y);
-
- drawDatacraftingLine(ctx, link, 5, startCell.getColorHSL(), endCell.getColorHSL(), timeCorrection);
- });
-
- if (cutLine.start !== null && cutLine.end !== null) {
- ctx.strokeStyle = "#FFFFFF";
- ctx.beginPath();
- ctx.moveTo(cutLine.start.x, cutLine.start.y);
- ctx.lineTo(cutLine.end.x, cutLine.end.y);
- ctx.stroke();
- }
-}
-
-// function checkForCutIntersections() {
-// var grid = logic1.grid;
-
-// var didRemoveAnyLinks = false;
-// for (var i = grid.links.length-1; i >= 0; i--) {
-// var didIntersect = false;
-// var points = grid.getPointsForLink(grid.links[i]);
-// for (var j = 1; j < points.length; j++) {
-// var start = points[j - 1];
-// var end = points[j];
-// if (checkLineCross(start.screenX, start.screenY, end.screenX, end.screenY, cutLine.start.x, cutLine.start.y, cutLine.end.x, cutLine.end.y)) {
-// didIntersect = true;
-// }
-// }
-// if (didIntersect) {
-// grid.links.splice(i, 1);
-// didRemoveAnyLinks = true;
-// }
-// }
-// if (didRemoveAnyLinks) {
-// updateGrid(grid);
-// }
-// }
-
-function checkForCutIntersections() {
- var grid = logic1.grid;
- var didRemoveAnyLinks = false;
- for (var blockLinkKey in logic1.links) {
- var didIntersect = false;
- var blockLink = logic1.links[blockLinkKey];
- var points = grid.getPointsForLink(blockLink);
- for (var j = 1; j < points.length; j++) {
- var start = points[j - 1];
- var end = points[j];
- if (checkLineCross(start.screenX, start.screenY, end.screenX, end.screenY, cutLine.start.x, cutLine.start.y, cutLine.end.x, cutLine.end.y)) {
- didIntersect = true;
- }
- if (didIntersect) {
- // grid.links.splice(i, 1);
- removeBlockLink(blockLinkKey);
- didRemoveAnyLinks = true;
- }
- }
- }
- if (didRemoveAnyLinks) {
- updateGrid(grid);
- }
-}
-
-// TODO: reimplement
-function removeBlockAtCell(col, row) {
- grid.getCell(col,row).block = null;
-}
-
-// add or remove the block
-function toggleCellBlock(cell) {
- // remove a block if it's already there
- if (cell.block !== null) {
-
- // remove any links connected to this block
- grid.forEachLink( function(link) {
- if (link.startBlock === cell.block || link.endBlock === cell.block) {
- grid.removeLink(link);
- }
- });
-
- // remove any links connected to the block being removed // TODO: is this redundant? is this doing anything? which is better - this or before?
- grid.links = grid.links.filter(function(link) {
- return (link.startBlock !== cell.block && link.endBlock !== cell.block);
- });
-
- cell.block = null;
- hideCellBlock(cell);
- updateGrid(grid); // need to recalculate routes if block removed
-
- // add a block if it's not there
- } else {
- cell.block = new Block(cell);
- displayCellBlock(cell);
- updateGrid(grid); // need to recalculate routes if block added
- }
-}
-
-function displayCellBlock(cell) {
- cell.domElement.setAttribute("src", blockImgMap["filled"][cell.location.col/2]);
- cell.domElement.style.opacity = '1.00';
-}
-
-function hideCellBlock(cell) {
- cell.domElement.setAttribute("src", blockImgMap["empty"][cell.location.col/2]);
- cell.domElement.style.opacity = '0.50';
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- **/
-
-function addElementInPreferences() {
- cout("addedObject");
-
- var htmlContent = "";
-
- htmlContent += "
" +
- "Name
";
- htmlContent += "
" +
- "IP
";
-
- htmlContent += "
" +
- "Version
";
-
- htmlContent += "
" +
- "Nodes
";
-
- htmlContent += "
" +
- "Links
";
-
- var bgSwitch = false;
- var bgcolor = "";
- for (var keyPref in objects) {
-
- if (bgSwitch) {
- bgcolor = "background-color: #a0a0a0;";
- bgSwitch = false;
- } else {
- bgcolor = "background-color: #aaaaaa;";
- bgSwitch = true;
- }
-
- htmlContent += "
";
-
- htmlContent += objects[keyPref].name;
-
- htmlContent += "
" +
- objects[keyPref].ip
- + "
";
-
- htmlContent += "
" +
- objects[keyPref].version
- + "
";
-
- var anzahl = 0;
-
- for (var subkeyPref2 in objects[keyPref].nodes) {
- anzahl++;
- }
-
- htmlContent += "
" +
- anzahl
- + "
";
-
- anzahl = 0;
-
- for (var subkeyPref in objects[keyPref].links) {
- anzahl++;
- }
-
- htmlContent += "
" +
- anzahl
- + "
";
-
- }
-
- document.getElementById("content2").innerHTML = htmlContent;
-
- cout("addElementInPreferences");
-}
-
-/**
- * @desc
- * @param objectKey
- * @param nodeKey
- * @param thisUrl
- * @param thisObject
- * @return
- **/
-
-function addElement(objectKey, nodeKey, thisUrl, thisObject, kind, globalStates) {
-
- if (globalStates.notLoading !== true && globalStates.notLoading !== nodeKey && thisObject.loaded !== true) {
-
- thisObject.loaded = true;
- thisObject.visibleEditing = false;
- globalStates.notLoading = nodeKey;
-
- if (typeof thisObject.begin !== "object") {
- thisObject.begin = [
- 1, 0, 0, 0,
- 0, 1, 0, 0,
- 0, 0, 1, 0,
- 0, 0, 0, 1
- ];
-
- }
-
- if (typeof thisObject.temp !== "object") {
- thisObject.temp = [
- 1, 0, 0, 0,
- 0, 1, 0, 0,
- 0, 0, 1, 0,
- 0, 0, 0, 1
- ];
-
- }
-
- var addContainer = document.createElement('div');
- addContainer.id = "thisObject" + nodeKey;
- addContainer.style.width = globalStates.height + "px";
- addContainer.style.height = globalStates.width + "px";
- addContainer.style.display = "none";
- addContainer.style.border = 0;
- addContainer.setAttribute("background-color", "lightblue");
-
- addContainer.className = "main";
-
- var addIframe = document.createElement('iframe');
- addIframe.id = "iframe" + nodeKey;
- addIframe.frameBorder = 0;
- addIframe.style.width = "0px";
- addIframe.style.height = "0px";
- addIframe.style.left = ((globalStates.height - thisObject.frameSizeY) / 2) + "px";
- addIframe.style.top = ((globalStates.width - thisObject.frameSizeX) / 2) + "px";
- addIframe.style.visibility = "hidden";
- addIframe.src = thisUrl;
- addIframe.className = "main";
- addIframe.setAttribute("onload", 'on_load("' + objectKey + '","' + nodeKey + '")');
- addIframe.setAttribute("sandbox", "allow-forms allow-pointer-lock allow-same-origin allow-scripts");
-
- var addOverlay = document.createElement('div');
- // addOverlay.style.backgroundColor = "red";
- addOverlay.id = nodeKey;
- addOverlay.frameBorder = 0;
- addOverlay.style.width = thisObject.frameSizeX + "px";
- addOverlay.style.height = thisObject.frameSizeY + "px";
- addOverlay.style.left = ((globalStates.height - thisObject.frameSizeY) / 2) + "px";
- addOverlay.style.top = ((globalStates.width - thisObject.frameSizeX) / 2) + "px";
- addOverlay.style.visibility = "hidden";
- addOverlay.className = "mainEditing";
-
- var addCanvas = document.createElement('canvas');
- addCanvas.id = "canvas" + nodeKey;
- addCanvas.style.width = "100%";
- addCanvas.style.height = "100%";
- addCanvas.className = "mainCanvas";
-
- document.getElementById("GUI").appendChild(addContainer);
-
- addContainer.appendChild(addIframe);
- addOverlay.appendChild(addCanvas);
- addContainer.appendChild(addOverlay);
-
- globalDOMCach["thisObject" + nodeKey] = addContainer;
- globalDOMCach["iframe" + nodeKey] = addIframe;
- globalDOMCach[nodeKey] = addOverlay;
- globalDOMCach["canvas" + nodeKey] = addCanvas;
-
- var theObject = addOverlay;
- theObject.style["touch-action"] = "none";
- theObject["handjs_forcePreventDefault"] = true;
- theObject.addEventListener("pointerdown", touchDown, false);
- ec++;
- theObject.addEventListener("pointerup", trueTouchUp, false);
- ec++;
- theObject.addEventListener("pointerenter", function (e) {
- var contentForFeedback;
-
- if (globalProgram.nodeA === this.id || globalProgram.nodeA === false) {
- contentForFeedback = 3;
- globalSVGCach["overlayImgRing"].setAttribute("r", "58");
- globalSVGCach["overlayImgRing"].setAttribute("stroke", '#f9f90a');
-
- } else {
-
- if (checkForNetworkLoop(globalProgram.objectA, globalProgram.nodeA, this.objectId, this.nodeId)) {
- contentForFeedback = 2; // overlayImg.src = overlayImage[2].src;
- globalSVGCach["overlayImgRing"].setAttribute("r", "58");
- globalSVGCach["overlayImgRing"].setAttribute("stroke", '#3af431');
- }
-
- else {
- contentForFeedback = 0; // overlayImg.src = overlayImage[0].src;
- globalSVGCach["overlayImgRing"].setAttribute("r", "58");
- globalSVGCach["overlayImgRing"].setAttribute("stroke", '#ff019f');
- }
- }
-
- globalDOMCach["iframe" + this.nodeId].contentWindow.postMessage(
- JSON.stringify(
- {
- uiActionFeedback: contentForFeedback
- })
- , "*");
-
- // document.getElementById('overlayImg').src = overlayImage[contentForFeedback].src;
-
- }, false);
- ec++;
-
- theObject.addEventListener("pointerleave", function () {
- globalSVGCach["overlayImgRing"].setAttribute("r", "30");
- globalSVGCach["overlayImgRing"].setAttribute("stroke", '#00ffff');
-
- // document.getElementById('overlayImg').src = overlayImage[1].src;
-
- cout("leave");
-
- globalDOMCach["iframe" + this.nodeId].contentWindow.postMessage(
- JSON.stringify(
- {
- uiActionFeedback: 1
- })
- , "*");
-
- }, false);
- ec++;
-
- if (globalStates.editingMode) {
- // todo this needs to be changed backword
- // if (objects[objectKey].developer) {
- theObject.addEventListener("touchstart", MultiTouchStart, false);
- ec++;
- theObject.addEventListener("touchmove", MultiTouchMove, false);
- ec++;
- theObject.addEventListener("touchend", MultiTouchEnd, false);
- ec++;
- theObject.className = "mainProgram";
- // }
- }
- theObject.objectId = objectKey;
- theObject.nodeId = nodeKey;
-
- if (kind === "node") {
- theObject.style.visibility = "visible";
- // theObject.style.display = "initial";
- } else if (kind === "logic") {
- theObject.style.visibility = "visible";
- }
- else {
- theObject.style.visibility = "hidden";
- //theObject.style.display = "none";
- }
- cout("addElementInPreferences");
- }
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param objectKey
- * @param thisObject
- * @return
- **/
-
-function killObjects(objectKey, thisObject) {
-
- if (thisObject.visibleCounter > 0) {
- thisObject.visibleCounter--;
- } else if (thisObject.loaded) {
- thisObject.loaded = false;
-
- globalDOMCach["thisObject" + objectKey].parentNode.removeChild(globalDOMCach["thisObject" + objectKey]);
- delete globalDOMCach["thisObject" + objectKey];
- delete globalDOMCach["iframe" + objectKey];
- delete globalDOMCach[objectKey];
- delete globalDOMCach["canvas" + objectKey];
-
- delete globalDOMCach[objectKey];
-
- for (var nodeKey in thisObject.nodes) {
- try {
-
- globalDOMCach["thisObject" + nodeKey].parentNode.removeChild(globalDOMCach["thisObject" + nodeKey]);
- delete globalDOMCach["thisObject" + nodeKey];
- delete globalDOMCach["iframe" + nodeKey];
- delete globalDOMCach[nodeKey];
- delete globalDOMCach["canvas" + nodeKey];
-
- } catch (err) {
- cout("could not find any");
- }
- thisObject.nodes[nodeKey].loaded = false;
- }
- cout("killObjects");
- }
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param objectKey
- * @param nodeKey
- * @return
- **/
-
-
-function on_load(objectKey, nodeKey) {
-
- globalStates.notLoading = false;
- // window.location.href = "of://event_test_"+nodeKey;
-
- // cout("posting Msg");
- var nodes;
- var version = 170;
- if (!objects[objectKey]) {
- nodes = {};
- } else {
- nodes = objects[objectKey].nodes;
- version = objects[objectKey].integerVersion;
- }
-
- var oldStyle = {
- obj: objectKey,
- pos: nodeKey,
- objectValues: nodes
- };
-
- var newStyle = {
- object: objectKey,
- node: nodeKey,
- nodes: nodes
- };
-
- if (version < 170) {
- newStyle = oldStyle;
- }
- globalDOMCach["iframe" + nodeKey].contentWindow.postMessage(
- JSON.stringify(newStyle), '*');
- cout("on_load");
-}
\ No newline at end of file
diff --git a/js/interactWithLines.js b/js/interactWithLines.js
deleted file mode 100644
index 3aecb2bcb..000000000
--- a/js/interactWithLines.js
+++ /dev/null
@@ -1,372 +0,0 @@
-/**
- * @preserve
- *
- * .,,,;;,'''..
- * .'','... ..',,,.
- * .,,,,,,',,',;;:;,. .,l,
- * .,',. ... ,;, :l.
- * ':;. .'.:do;;. .c ol;'.
- * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
- * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
- * .oxddl;::,,. ', .'''. .... .'. ,:;..
- * .'cOX0OOkdoc. .,'. .. ..... 'lc.
- * .:;,,::co0XOko' ....''..'.'''''''.
- * .dxk0KKdc:cdOXKl............. .. ..,c....
- * .',lxOOxl:'':xkl,',......'.... ,'.
- * .';:oo:... .
- * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
- * .l; โโฃ โโโ โ โ โโโฌโ '
- * 'l. โโโโโดโโด โด โโโโดโโ '.
- * .o. ...
- * .''''','.;:''.........
- * .' .l
- * .:. l'
- * .:. .l.
- * .x: :k;,.
- * cxlc; cdc,,;;.
- * 'l :.. .c ,
- * o.
- * .,
- *
- * โฆ โฆโฌ โฌโโ โฌโโโฌโโฌโ โโโโโ โฌโโโโโโโโฌโโโโ
- * โ โโฃโโฌโโโดโโโฌโโ โโ โ โโโดโ โโโค โ โ โโโ
- * โฉ โฉ โด โโโโดโโโดโโดโ โโโโโโโโโโโโโโ โด โโโ
- *
- *
- * Created by Valentin on 10/22/14.
- *
- * Copyright (c) 2015 Valentin Heun
- *
- * All ascii characters above must be included in any redistribution.
- *
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
- */
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param x21 position x 1
- * @param y21 position y 1
- * @param x22 position x 2
- * @param y22 position y 2
- **/
-
-function deleteLines(x21, y21, x22, y22) {
- // window.location.href = "of://gotsome";
- for (var keysome in objects) {
- if (!objects.hasOwnProperty(keysome)) {
- continue;
- }
-
- var thisObject = objects[keysome];
- for (var subKeysome in thisObject.links) {
- if (!thisObject.links.hasOwnProperty(subKeysome)) {
- continue;
- }
- var l = thisObject.links[subKeysome];
- var oA = thisObject;
- var oB = objects[l.objectB];
- var bA = oA.nodes[l.nodeA];
- var bB = oB.nodes[l.nodeB];
-
- if (bA === undefined || bB === undefined || oA === undefined || oB === undefined) {
- continue; //should not be undefined
- }
- if (checkLineCross(bA.screenX, bA.screenY, bB.screenX, bB.screenY, x21, y21, x22, y22, globalCanvas.canvas.width, globalCanvas.canvas.height)) {
- delete thisObject.links[subKeysome];
- cout("iam executing link deletion");
- deleteLinkFromObject(thisObject.ip, keysome, subKeysome);
- }
- }
- }
-
-}
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param thisObject is a reference to an Hybrid Object
- * @param context is a reference to a html5 canvas object
- **/
-
-function drawAllLines(thisObject, context) {
- for (var subKey in thisObject.links) {
- if (!thisObject.links.hasOwnProperty(subKey)) {
- continue;
- }
- var l = thisObject.links[subKey];
- var oA = thisObject;
-
- if (isNaN(l.ballAnimationCount))
- l.ballAnimationCount = 0;
-
- if (!objects.hasOwnProperty(l.objectB)) {
- continue;
- }
- var oB = objects[l.objectB];
- if (!oA.nodes.hasOwnProperty(l.nodeA)) {
- continue;
- }
- if (!oB.nodes.hasOwnProperty(l.nodeB)) {
- continue;
- }
- var bA = oA.nodes[l.nodeA];
- var bB = oB.nodes[l.nodeB];
-
- if (bA === undefined || bB === undefined || oA === undefined || oB === undefined) {
- continue; //should not be undefined
- }
-
- if (!oB.objectVisible) {
- bB.screenX = bA.screenX;
- bB.screenY = -10;
- bB.screenZ = bA.screenZ;
- }
-
- if (!oA.objectVisible) {
- bA.screenX = bB.screenX;
- bA.screenY = -10;
- bA.screenZ = bB.screenZ;
- }
-
- // linearize a non linear zBuffer
- var bAScreenZ = bA.screenLinearZ;
- var bBScreenZ = bB.screenLinearZ;
-
- drawLine(context, [bA.screenX, bA.screenY], [bB.screenX, bB.screenY], bAScreenZ, bBScreenZ, l, timeCorrection);
- }
- // context.fill();
- globalCanvas.hasContent = true;
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- **/
-
-function drawInteractionLines() {
-
- // this function here needs to be more precise
-
- if (globalProgram.objectA) {
-
- var oA = objects[globalProgram.objectA];
-
- var tempStart = objects[globalProgram.objectA].nodes[globalProgram.nodeA];
-
-
- // this is for making sure that the line is drawn out of the screen... Don't know why this got lost somewhere down the road.
- // linearize a non linear zBuffer
-
- // map the linearized zBuffer to the final ball size
- if (!oA.objectVisible) {
- tempStart.screenX = globalStates.pointerPosition[0];
- tempStart.screenY = -10;
- tempStart.screenZ = 6;
- } else {
- tempStart.screenZ = tempStart.screenLinearZ;
- }
-
- drawLine(globalCanvas.context, [tempStart.screenX, tempStart.screenY], [globalStates.pointerPosition[0], globalStates.pointerPosition[1]], tempStart.screenZ, tempStart.screenZ, globalStates, timeCorrection);
- }
-
- if (globalStates.drawDotLine) {
- drawDotLine(globalCanvas.context, [globalStates.drawDotLineX, globalStates.drawDotLineY], [globalStates.pointerPosition[0], globalStates.pointerPosition[1]], 1, 1);
- }
-
- globalCanvas.hasContent = true;
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param context is html5 canvas object
- * @param lineStartPoint is an array of two numbers indicating the start for a line
- * @param lineEndPoint is an array of two numbers indicating the end for a line
- * @param lineStartWeight is a number indicating the weight of a line at start
- * @param lineEndWeight is a number indicating the weight of a line at end
- * @param linkObject that contains ballAnimationCount
- * @param timeCorrector is a number that is regulating the animation speed according to the frameRate
- * @return
- **/
-
-function drawLine(context, lineStartPoint, lineEndPoint, lineStartWeight, lineEndWeight, linkObject, timeCorrector) {
-
- var angle = Math.atan2((lineStartPoint[1] - lineEndPoint[1]), (lineStartPoint[0] - lineEndPoint[0]));
- var possitionDelta = 0;
- var length1 = lineEndPoint[0] - lineStartPoint[0];
- var length2 = lineEndPoint[1] - lineStartPoint[1];
- var lineVectorLength = Math.sqrt(length1 * length1 + length2 * length2);
- var keepColor = lineVectorLength / 6;
- var spacer = 2.3;
- var mathPI = 2*Math.PI;
-
- if (linkObject.ballAnimationCount >= lineStartWeight * spacer) linkObject.ballAnimationCount = 0;
-
- while (possitionDelta + linkObject.ballAnimationCount < lineVectorLength) {
- var ballPossition = possitionDelta + linkObject.ballAnimationCount;
- var color = "hsl(" + map(ballPossition, keepColor, lineVectorLength - keepColor, 180, 59) + ", 100%, 50%)";
- var ballSize = map(ballPossition, 0, lineVectorLength, lineStartWeight, lineEndWeight);
- var x__ = lineStartPoint[0] - Math.cos(angle) * ballPossition;
- var y__ = lineStartPoint[1] - Math.sin(angle) * ballPossition;
- possitionDelta += ballSize * spacer;
- context.beginPath();
- context.fillStyle = color;
- context.arc(x__, y__, ballSize, 0, mathPI);
- context.fill();
- }
- linkObject.ballAnimationCount += (lineStartWeight * timeCorrector.delta);
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
- function drawDatacraftingLine(context, linkObject, lineStartWeight, startColor, endColor, timeCorrector ) {
- var mathPI = 2*Math.PI;
- var spacer = 2.3;
-
- var pointData = linkObject.route.pointData;
-
- var blueToRed = (startColor.h === 180) && (endColor.h === 333);
- var redToBlue = (startColor.h === 333) && (endColor.h === 180);
-
- var percentIncrement = (lineStartWeight * spacer)/pointData.totalLength;
-
- if (linkObject.ballAnimationCount >= percentIncrement) {
- linkObject.ballAnimationCount = 0;
- }
-
- var hue = startColor;
- var transitionColorRight = (endColor.h - startColor.h > 180 || blueToRed);
- var transitionColorLeft = (endColor.h - startColor.h < -180 || redToBlue);
- var color;
-
- for (var i = 0; i < 1.0; i += percentIncrement) {
- var percentage = i + linkObject.ballAnimationCount;
- var position = linkObject.route.getXYPositionAtPercentage(percentage);
- if (position !== null) {
- if (transitionColorRight) {
- // looks better to go down rather than up
- hue = ((1.0 - percentage) * startColor.h + percentage * (endColor.h - 360)) % 360;
- } else if (transitionColorLeft) {
- // looks better to go up rather than down
- hue = ((1.0 - percentage) * startColor.h + percentage * (endColor.h + 360)) % 360;
- } else {
- hue = (1.0 - percentage) * startColor.h + percentage * endColor.h;
- }
- context.beginPath();
- context.fillStyle = 'hsl(' + hue + ', 100%, 60%)';
- context.arc(position.screenX, position.screenY, lineStartWeight, 0, mathPI);
- context.fill();
- }
- }
-
- var numFramesForAnimationLoop = 30;
- linkObject.ballAnimationCount += percentIncrement/numFramesForAnimationLoop;
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param context
- * @param lineStartPoint
- * @param lineEndPoint
- * @param b1
- * @param b2
- **/
-
-function drawDotLine(context, lineStartPoint, lineEndPoint, b1, b2) {
- context.beginPath();
- context.moveTo(lineStartPoint[0], lineStartPoint[1]);
- context.lineTo(lineEndPoint[0], lineEndPoint[1]);
- context.setLineDash([7]);
- context.lineWidth = 2;
- context.strokeStyle = "#ff019f";//"#00fdff";
- context.stroke();
- context.closePath();
-}
-
-/**
- * @desc
- * @param context
- * @param lineStartPoint
- * @param lineEndPoint
- * @param radius
- **/
-
-function drawGreen(context, lineStartPoint, lineEndPoint, radius) {
- context.beginPath();
- context.arc(lineStartPoint[0], lineStartPoint[1], radius, 0, Math.PI * 2);
- context.strokeStyle = "#7bff08";
- context.lineWidth = 2;
- context.setLineDash([7]);
- context.stroke();
- context.closePath();
-
-}
-
-/**
- * @desc
- * @param context
- * @param lineStartPoint
- * @param lineEndPoint
- * @param radius
- **/
-
-function drawRed(context, lineStartPoint, lineEndPoint, radius) {
- context.beginPath();
- context.arc(lineStartPoint[0], lineStartPoint[1], radius, 0, Math.PI * 2);
- context.strokeStyle = "#ff036a";
- context.lineWidth = 2;
- context.setLineDash([7]);
- context.stroke();
- context.closePath();
-}
-
-/**
- * @desc
- * @param context
- * @param lineStartPoint
- * @param lineEndPoint
- * @param radius
- **/
-
-function drawBlue(context, lineStartPoint, lineEndPoint, radius) {
- context.beginPath();
- context.arc(lineStartPoint[0], lineStartPoint[1], radius, 0, Math.PI * 2);
- context.strokeStyle = "#01fffd";
- context.lineWidth = 2;
- context.setLineDash([7]);
- context.stroke();
- context.closePath();
-}
-
-/**
- * @desc
- * @param context
- * @param lineStartPoint
- * @param lineEndPoint
- * @param radius
- **/
-
-function drawYellow(context, lineStartPoint, lineEndPoint, radius) {
- context.beginPath();
- context.arc(lineStartPoint[0], lineStartPoint[1], radius, 0, Math.PI * 2);
- context.strokeStyle = "#FFFF00";
- context.lineWidth = 2;
- context.setLineDash([7]);
- context.stroke();
- context.closePath();
-}
-
diff --git a/js/onload.js b/js/onload.js
deleted file mode 100644
index f62662567..000000000
--- a/js/onload.js
+++ /dev/null
@@ -1,297 +0,0 @@
-/**
- * @preserve
- *
- * .,,,;;,'''..
- * .'','... ..',,,.
- * .,,,,,,',,',;;:;,. .,l,
- * .,',. ... ,;, :l.
- * ':;. .'.:do;;. .c ol;'.
- * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
- * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
- * .oxddl;::,,. ', .'''. .... .'. ,:;..
- * .'cOX0OOkdoc. .,'. .. ..... 'lc.
- * .:;,,::co0XOko' ....''..'.'''''''.
- * .dxk0KKdc:cdOXKl............. .. ..,c....
- * .',lxOOxl:'':xkl,',......'.... ,'.
- * .';:oo:... .
- * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
- * .l; โโฃ โโโ โ โ โโโฌโ '
- * 'l. โโโโโดโโด โด โโโโดโโ '.
- * .o. ...
- * .''''','.;:''.........
- * .' .l
- * .:. l'
- * .:. .l.
- * .x: :k;,.
- * cxlc; cdc,,;;.
- * 'l :.. .c ,
- * o.
- * .,
- *
- * โฆ โฆโฌ โฌโโ โฌโโโฌโโฌโ โโโโโ โฌโโโโโโโโฌโโโโ
- * โ โโฃโโฌโโโดโโโฌโโ โโ โ โโโดโ โโโค โ โ โโโ
- * โฉ โฉ โด โโโโดโโโดโโดโ โโโโโโโโโโโโโโ โด โโโ
- *
- *
- * Created by Valentin on 10/22/14.
- *
- * Copyright (c) 2015 Valentin Heun
- *
- * All ascii characters above must be included in any redistribution.
- *
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
- */
-/*********************************************************************************************************************
- ******************************************** TODOS *******************************************************************
- **********************************************************************************************************************
-
- **
- * TODO -
- **
-
- **********************************************************************************************************************
- ******************************************** onload content **********************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- **/
-
-window.onload = function () {
- uiButtons = document.getElementById("GUI");
- guiButtonImage= document.getElementById("guiButtonImage");
- overlayDiv = document.getElementById('overlay');
- globalSVGCach["overlayImgRing"] = document.getElementById('overlayImg').getElementById('overlayImgRing');
-
- GUI();
-
- if (globalStates.platform !== 'iPad' && globalStates.platform !== 'iPhone' && globalStates.platform !== 'iPod') {
- globalStates.platform = false;
- }
-
- if (globalStates.platform === 'iPhone') {
- document.getElementById("logButtonDiv").style.visibility = "hidden";
- // document.getElementById("reloadButtonDiv").style.visibility = "hidden";
- // document.getElementById("preferencesButtonDiv").style.bottom = "36px";
-
- var editingInterface = document.getElementById("content2title");
- editingInterface.style.fontSize = "12px";
- editingInterface.style.left = "38%";
- editingInterface.style.right = "22%";
-
- editingInterface = document.getElementById("content1title");
- editingInterface.style.fontSize = "12px";
- editingInterface.style.left = "2%";
- editingInterface.style.right = "65%";
-
- editingInterface = document.getElementById("content2");
- editingInterface.style.fontSize = "9px";
- editingInterface.style.left = "38%";
- editingInterface.style.right = "22%";
- editingInterface.style.bottom = "14%";
-
- editingInterface = document.getElementById("content11");
- editingInterface.style.fontSize = "12px";
- editingInterface.style.width = "40%";
-
- editingInterface = document.getElementById("content12");
- editingInterface.style.fontSize = "12px";
- editingInterface.style.width = "60%";
-
- editingInterface = document.getElementById("content13");
- editingInterface.style.fontSize = "12px";
- editingInterface.style.width = "40%";
-
- editingInterface = document.getElementById("content14");
- editingInterface.style.fontSize = "12px";
- editingInterface.style.width = "60%";
-
- editingInterface = document.getElementById("content15");
- editingInterface.style.fontSize = "12px";
- editingInterface.style.width = "40%";
- editingInterface.innerHTML = '
External Interface ';
-
- editingInterface = document.getElementById("content16");
- editingInterface.style.fontSize = "12px";
- editingInterface.style.width = "60%";
-
- editingInterface = document.getElementById("content18");
- editingInterface.style.visibility = 'hidden';
-
- editingInterface = document.getElementById("content1");
- editingInterface.style.fontSize = "12px";
- editingInterface.style.left = "2%";
- editingInterface.style.right = "65%";
- editingInterface.style.bottom = "14%";
-
- } else {
- editingInterface = document.getElementById("content15");
- editingInterface.style.paddingTop = "13px";
-
- editingInterface = document.getElementById("content20");
- editingInterface.innerHTML = '
";
- }
-
- globalCanvas.canvas = document.getElementById('canvas');
- globalCanvas.canvas.width = globalStates.height;
- globalCanvas.canvas.height = globalStates.width;
-
- globalCanvas.context = canvas.getContext('2d');
-
- if (globalStates.platform) {
- window.location.href = "of://kickoff";
- }
-
- document.handjs_forcePreventDefault = true;
- globalCanvas.canvas.handjs_forcePreventDefault = true;
-
- globalCanvas.canvas.addEventListener("pointerdown", canvasPointerDown, false);
- ec++;
-
- document.addEventListener("pointermove", getPossition, false);
- ec++;
- document.addEventListener("pointerdown", documentPointerDown, false);
- //document.addEventListener("pointerdown", getPossition, false);
- ec++;
- document.addEventListener("pointerup", documentPointerUp, false);
- ec++;
- window.addEventListener("message", postMessage, false);
- ec++;
- overlayDiv.addEventListener('touchstart', function (e) {
- e.preventDefault();
- });
-
- initializeDatacraftingGrid();
- datacraftingVisible();
-
- cout("onload");
-
-};
-
-/**
- * @desc
- * @param e
- * @return {}
- **/
-
-function postMessage(e) {
-
-
- var msgContent ={};
- if(e.data){
- msgContent = JSON.parse(e.data);
-
- } else {
- msgContent = JSON.parse(e);
- }
-
- var tempThisObject = {};
- var thisVersionNumber;
-
- if (!msgContent.version) {
- thisVersionNumber = 0;
- }
- else {
- thisVersionNumber = msgContent.version;
- }
-
- if (thisVersionNumber >= 170) {
- if ((!msgContent.object) || (!msgContent.object)) return;
- } else {
- if ((!msgContent.obj) || (!msgContent.pos)) return;
- msgContent.object = msgContent.obj;
- msgContent.node = msgContent.pos;
- }
-
- if (msgContent.object in objects) {
- if (msgContent.node === msgContent.object) {
- tempThisObject = objects[msgContent.object];
- } else
- if (msgContent.node in objects[msgContent.object].nodes) {
- tempThisObject = objects[msgContent.object].nodes[msgContent.node];
- } else
- if (msgContent.node in objects[msgContent.object].logic) {
- tempThisObject = objects[msgContent.object].logic[msgContent.node];
- } else return;
-
- } else if(msgContent.object in pocketItem){
- if (msgContent.node === msgContent.object) {
- tempThisObject = pocketItem[msgContent.object];
- } else {
- if (msgContent.node in pocketItem[msgContent.object].logic) {
- tempThisObject = pocketItem[msgContent.object].logic[msgContent.node];
- } else return;
- }
-
- } else return;
-
- if (msgContent.width && msgContent.height) {
- var thisMsgNode = document.getElementById(msgContent.node);
- thisMsgNode.style.width = msgContent.width;
- thisMsgNode.style.height = msgContent.height;
- thisMsgNode.style.top = ((globalStates.width - msgContent.height) / 2);
- thisMsgNode.style.left = ((globalStates.height - msgContent.width) / 2);
-
- thisMsgNode = document.getElementById("iframe" + msgContent.node);
- thisMsgNode.style.width = msgContent.width;
- thisMsgNode.style.height = msgContent.height;
- thisMsgNode.style.top = ((globalStates.width - msgContent.height) / 2);
- thisMsgNode.style.left = ((globalStates.height - msgContent.width) / 2);
-
- }
-
- if (typeof msgContent.sendMatrix !== "undefined") {
-
- if (msgContent.sendMatrix === true) {
-
- if (tempThisObject.integerVersion >= 32) {
-
- tempThisObject.sendMatrix = true;
- document.getElementById("iframe" + msgContent.node).contentWindow.postMessage(
- '{"projectionMatrix":' + JSON.stringify(globalStates.realProjectionMatrix) + "}", '*');
- }
- }
- }
-
- if (msgContent.globalMessage) {
- var iframes = document.getElementsByTagName('iframe');
- for (var i = 0; i < iframes.length; i++) {
-
- if (iframes[i].id !== "iframe" + msgContent.node && iframes[i].style.visibility !== "hidden") {
- if (iframes[i].integerVersion >= 32) {
- var msg = {};
- if (iframes[i].integerVersion >= 170) {
- msg = {globalMessage: msgContent.globalMessage};
- } else {
- msg = {ohGlobalMessage: msgContent.ohGlobalMessage};
- }
- iframes[i].contentWindow.postMessage(JSON.stringify(msg), "*");
- }
- }
- }
-
- if (typeof msgContent.fullScreen === "boolean") {
- // console.log("gotfullscreenmessage");
- if (msgContent.fullScreen === true) {
- tempThisObject.fullScreen = true;
-
- document.getElementById("thisObject" + msgContent.node).style.webkitTransform =
- 'matrix3d(1, 0, 0, 0,' +
- '0, 1, 0, 0,' +
- '0, 0, 1, 0,' +
- '0, 0, 0, 1)';
-
- }
- if (msgContent.fullScreen === false) {
-
- tempThisObject.fullScreen = false;
- }
-
- }
- }
-};
diff --git a/js/utilities.js b/js/utilities.js
deleted file mode 100644
index 7c4928a04..000000000
--- a/js/utilities.js
+++ /dev/null
@@ -1,850 +0,0 @@
-/**
- * @preserve
- *
- * .,,,;;,'''..
- * .'','... ..',,,.
- * .,,,,,,',,',;;:;,. .,l,
- * .,',. ... ,;, :l.
- * ':;. .'.:do;;. .c ol;'.
- * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
- * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
- * .oxddl;::,,. ', .'''. .... .'. ,:;..
- * .'cOX0OOkdoc. .,'. .. ..... 'lc.
- * .:;,,::co0XOko' ....''..'.'''''''.
- * .dxk0KKdc:cdOXKl............. .. ..,c....
- * .',lxOOxl:'':xkl,',......'.... ,'.
- * .';:oo:... .
- * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
- * .l; โโฃ โโโ โ โ โโโฌโ '
- * 'l. โโโโโดโโด โด โโโโดโโ '.
- * .o. ...
- * .''''','.;:''.........
- * .' .l
- * .:. l'
- * .:. .l.
- * .x: :k;,.
- * cxlc; cdc,,;;.
- * 'l :.. .c ,
- * o.
- * .,
- *
- * โฆ โฆโฌ โฌโโ โฌโโโฌโโฌโ โโโโโ โฌโโโโโโโโฌโโโโ
- * โ โโฃโโฌโโโดโโโฌโโ โโ โ โโโดโ โโโค โ โ โโโ
- * โฉ โฉ โด โโโโดโโโดโโดโ โโโโโโโโโโโโโโ โด โโโ
- *
- *
- * Created by Valentin on 10/22/14.
- *
- * Copyright (c) 2015 Valentin Heun
- *
- * All ascii characters above must be included in any redistribution.
- *
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
- */
-/*********************************************************************************************************************
- ******************************************** TODOS *******************************************************************
- **********************************************************************************************************************
-
- **
- * TODO
- **
-
- **********************************************************************************************************************
- ******************************************** Utilities Section ******************************************************
- **********************************************************************************************************************/
-
-var newURLTextLoad = function () {
- globalStates.newURLText = encodeURIComponent(document.getElementById('newURLText').value);
- cout("newURLTextLoad");
-};
-
-/**
- * @desc This function multiplies one m16 matrix with a second m16 matrix
- * @param m2 origin matrix to be multiplied with
- * @param m1 second matrix that multiplies.
- * @return {Number|Array} m16 matrix result of the muliplication
- **/
-
-function multiplyMatrix(m2, m1, r) {
- // var r = [];
- // Cm1che only the current line of the second mm1trix
- r[0] = m2[0] * m1[0] + m2[1] * m1[4] + m2[2] * m1[8] + m2[3] * m1[12];
- r[1] = m2[0] * m1[1] + m2[1] * m1[5] + m2[2] * m1[9] + m2[3] * m1[13];
- r[2] = m2[0] * m1[2] + m2[1] * m1[6] + m2[2] * m1[10] + m2[3] * m1[14];
- r[3] = m2[0] * m1[3] + m2[1] * m1[7] + m2[2] * m1[11] + m2[3] * m1[15];
-
- r[4] = m2[4] * m1[0] + m2[5] * m1[4] + m2[6] * m1[8] + m2[7] * m1[12];
- r[5] = m2[4] * m1[1] + m2[5] * m1[5] + m2[6] * m1[9] + m2[7] * m1[13];
- r[6] = m2[4] * m1[2] + m2[5] * m1[6] + m2[6] * m1[10] + m2[7] * m1[14];
- r[7] = m2[4] * m1[3] + m2[5] * m1[7] + m2[6] * m1[11] + m2[7] * m1[15];
-
- r[8] = m2[8] * m1[0] + m2[9] * m1[4] + m2[10] * m1[8] + m2[11] * m1[12];
- r[9] = m2[8] * m1[1] + m2[9] * m1[5] + m2[10] * m1[9] + m2[11] * m1[13];
- r[10] = m2[8] * m1[2] + m2[9] * m1[6] + m2[10] * m1[10] + m2[11] * m1[14];
- r[11] = m2[8] * m1[3] + m2[9] * m1[7] + m2[10] * m1[11] + m2[11] * m1[15];
-
- r[12] = m2[12] * m1[0] + m2[13] * m1[4] + m2[14] * m1[8] + m2[15] * m1[12];
- r[13] = m2[12] * m1[1] + m2[13] * m1[5] + m2[14] * m1[9] + m2[15] * m1[13];
- r[14] = m2[12] * m1[2] + m2[13] * m1[6] + m2[14] * m1[10] + m2[15] * m1[14];
- r[15] = m2[12] * m1[3] + m2[13] * m1[7] + m2[14] * m1[11] + m2[15] * m1[15];
- // return r;
-}
-
-/**
- * @desc mutpliply m4 matrix with m16 matrix
- * @param m1 origin m4 matrix
- * @param m2 m16 matrix to multiplay with
- * @return {Number|Array} is m16 matrix
- **/
-
-function multiplyMatrix4(m1, m2) {
- var r = [];
- var x = m1[0], y = m1[1], z = m1[2], w = m1[3];
- r[0] = m2[0] * x + m2[4] * y + m2[8] * z + m2[12] * w;
- r[1] = m2[1] * x + m2[5] * y + m2[9] * z + m2[13] * w;
- r[2] = m2[2] * x + m2[6] * y + m2[10] * z + m2[14] * w;
- r[3] = m2[3] * x + m2[7] * y + m2[11] * z + m2[15] * w;
- return r;
-};
-
-/**
- * @desc copies one m16 matrix in to another m16 matrix
- * @param matrix source matrix
- * @return {Number|Array} resulting copy of the matrix
- **/
-
-function copyMatrix(matrix) {
- var r = []; //new Array(16);
- r[0] = matrix[0];
- r[1] = matrix[1];
- r[2] = matrix[2];
- r[3] = matrix[3];
- r[4] = matrix[4];
- r[5] = matrix[5];
- r[6] = matrix[6];
- r[7] = matrix[7];
- r[8] = matrix[8];
- r[9] = matrix[9];
- r[10] = matrix[10];
- r[11] = matrix[11];
- r[12] = matrix[12];
- r[13] = matrix[13];
- r[14] = matrix[14];
- r[15] = matrix[15];
- return r;
-}
-
-/**
- * @desc inverting a matrix
- * @param a origin matrix
- * @return {Number|Array} a inverted copy of the origin matrix
- **/
-
-var invertMatrix = function (a) {
- var b = [];
- var c = a[0], d = a[1], e = a[2], g = a[3], f = a[4], h = a[5], i = a[6], j = a[7], k = a[8], l = a[9], o = a[10], m = a[11], n = a[12], p = a[13], r = a[14], s = a[15], A = c * h - d * f, B = c * i - e * f, t = c * j - g * f, u = d * i - e * h, v = d * j - g * h, w = e * j - g * i, x = k * p - l * n, y = k * r - o * n, z = k * s - m * n, C = l * r - o * p, D = l * s - m * p, E = o * s - m * r, q = 1 / (A * E - B * D + t * C + u * z - v * y + w * x);
- b[0] = (h * E - i * D + j * C) * q;
- b[1] = ( -d * E + e * D - g * C) * q;
- b[2] = (p * w - r * v + s * u) * q;
- b[3] = ( -l * w + o * v - m * u) * q;
- b[4] = ( -f * E + i * z - j * y) * q;
- b[5] = (c * E - e * z + g * y) * q;
- b[6] = ( -n * w + r * t - s * B) * q;
- b[7] = (k * w - o * t + m * B) * q;
- b[8] = (f * D - h * z + j * x) * q;
- b[9] = ( -c * D + d * z - g * x) * q;
- b[10] = (n * v - p * t + s * A) * q;
- b[11] = ( -k * v + l * t - m * A) * q;
- b[12] = ( -f * C + h * y - i * x) * q;
- b[13] = (c * C - d * y + e * x) * q;
- b[14] = ( -n * u + p * B - r * A) * q;
- b[15] = (k * u - l * B + o * A) * q;
- return b;
-};
-
-/**
- * @desc returns the x and y angles from origin matrix. todo needs some improvement
- * @param matrix origin m16 matrix
- * @return {Number|Array}
- **/
-
-function toAxisAngle(matrix) {
- var rX = Math.atan(matrix[6], matrix[10]);
- var rY = Math.atan(matrix[2], matrix[10]);
- var rZ = Math.atan2(matrix[1], matrix[5]);
-
- return [rX, rY, rZ];
-
-}
-
-function screenCoordinatesToMatrixXY(thisObject, touch){
-
- var tempMatrix;
- if (globalStates.unconstrainedPositioning === true)
- tempMatrix = copyMatrix(thisObject.begin);
- else
- tempMatrix = copyMatrix(thisObject.temp);
-
- // calculate angles
- var angles = toAxisAngle(tempMatrix);
-
- var angX = angles[0] * Math.sin(angles[2]) + angles[1] * Math.cos(angles[2]);
- var angY = angles[0] * Math.cos(angles[2]) - angles[1] * Math.sin(angles[2]);
-
- // calculate new x and y
- var possitionX = thisObject.screenZ * ((touch[0] - globalStates.height / 2) *(Math.abs(angX/2)+1));
- var possitionY = thisObject.screenZ * ((touch[1] - globalStates.width / 2)*(Math.abs(angY/2)+1));
-
- // replace old x and y with new
-
- var tempObjectMatrix = [
- tempMatrix[0], tempMatrix[1], tempMatrix[2], tempMatrix[3],
- tempMatrix[4], tempMatrix[5], tempMatrix[6], tempMatrix[7],
- tempMatrix[8], tempMatrix[9], tempMatrix[10], tempMatrix[11],
- possitionX, possitionY, tempMatrix[14], tempMatrix[15]
- ];
-
- // and multiply this manipulated matrix with its original inverted.
-
- // var invertedObjectMatrix = invertMatrix(tempMatrix);
- var resultMatrix = [];
- multiplyMatrix(tempObjectMatrix, invertMatrix(tempMatrix), resultMatrix);
-
- // results in the new x and y
-
- if (typeof resultMatrix[12] === "number" && typeof resultMatrix[13] === "number")
- return [resultMatrix[12],resultMatrix[13]];
- else
- return null;
-
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param
- * @param
- * @return {Boolean}
- **/
-var checkLineCross = function (x11, y11, x12, y12, x21, y21, x22, y22, w, h) {
- var l1 = lineEq(x11, y11, x12, y12),
- l2 = lineEq(x21, y21, x22, y22);
-
- var interX = calculateX(l1, l2); //calculate the intersection X value
- if (interX > w || interX < 0) {
- return false; //false if intersection of lines is output of canvas
- }
- var interY = calculateY(l1, interX);
- // cout("interX, interY",interX, interY);
-
- if (!interY || !interX) {
- return false;
- }
- if (interY > h || interY < 0) {
- return false; //false if intersection of lines is output of canvas
- }
- // cout("point on line --- checking on segment now");
- return (checkBetween(x11, x12, interX) && checkBetween(y11, y12, interY)
- && checkBetween(x21, x22, interX) && checkBetween(y21, y22, interY));
-};
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-//function for calculating the line equation.
-//returns [m, b], where this corresponds to y = mx + b
-//y = [(y1-y2)/(x1-x2), -(y1-y2)/(x1-x2)*x1 + y1]
-
-/**
- * @desc
- * @param
- * @param
- * @return {Number|Array}
- **/
-
-var lineEq = function (x1, y1, x2, y2) {
- var m = slopeCalc(x1, y1, x2, y2);
- // if(m == 'vertical'){
- // return ['vertical', 'vertical'];
- // }
- return [m, -1 * m * x1 + y1];
-
-};
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-//function for calucating the slope of given points
-//slope has to be multiplied by -1 because the y-axis value increases we we go down
-
-/**
- * @desc
- * @param
- * @param
- * @return {Number}
- **/
-
-var slopeCalc = function (x1, y1, x2, y2) {
- if ((x1 - x2) == 0) {
- return 9999; //handle cases when slope is infinity
- }
- return (y1 - y2) / (x1 - x2);
-};
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-//calculate the intersection x value given two line segment
-//param: [m1,b1], [m2,b2]
-//return x -> the x value
-
-/**
- * @desc
- * @param
- * @param
- * @return {Number}
- **/
-
-var calculateX = function (seg1, seg2) {
- return (seg2[1] - seg1[1]) / (seg1[0] - seg2[0]);
-};
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-//calculate y given x and the line equation
-
-/**
- * @desc
- * @param
- * @param
- * @return {Number}
- **/
-
-var calculateY = function (seg1, x) {
- return seg1[0] * x + seg1[1];
-};
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-//given two end points of the segment and some other point p,
-//return true - if p is between thw two segment points, false otherwise
-
-/**
- * @desc
- * @param
- * @param
- * @return {Boolean}
- **/
-
-var checkBetween = function (e1, e2, p) {
- const marg2 = 2;
- // cout("e1,e2,p :",e1,e2,p);
- if (e1 - marg2 <= p && p <= e2 + marg2) {
- return true;
- }
- if (e2 - marg2 <= p && p <= e1 + marg2) {
- return true;
- }
-
- return false;
-};
-
-/**
- * @desc
- * @param
- * @param
- * @return {Number}
- **/
-
-function map(x, in_min, in_max, out_min, out_max) {
- if (x > in_max) x = in_max;
- if (x < in_min) x = in_min;
- return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
-}
-
-/**
- * @desc
- * @param
- * @param
- * @return {String}
- **/
-
-function uuidTime() {
- var dateUuidTime = new Date();
- var abcUuidTime = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- var stampUuidTime = parseInt(Math.floor((Math.random() * 199) + 1) + "" + dateUuidTime.getTime()).toString(36);
- while (stampUuidTime.length < 12) stampUuidTime = abcUuidTime.charAt(Math.floor(Math.random() * abcUuidTime.length)) + stampUuidTime;
- return stampUuidTime
-};
-
-/**
- * @desc
- * @param
- * @param
- * @return {String}
- **/
-
-function uuidTimeShort() {
- var dateUuidTime = new Date();
- var abcUuidTime = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- var stampUuidTime = parseInt("" + dateUuidTime.getMilliseconds() + dateUuidTime.getMinutes() + dateUuidTime.getHours() + dateUuidTime.getDay()).toString(36);
- while (stampUuidTime.length < 8) stampUuidTime = abcUuidTime.charAt(Math.floor(Math.random() * abcUuidTime.length)) + stampUuidTime;
- return stampUuidTime
-};
-
-/**
- * @desc
- * @param
- * @param
- * @return {Number}
- **/
-
-function randomIntInc(min, max) {
- return Math.floor(Math.random() * (max - min + 1) + min);
-};
-
-/**
- * @desc rename an object (more or less)
- * @param
- * @param
- * @return {Object}
- **/
-
-function rename(object, before, after) {
- if (typeof object[before] !== "undefined") {
- object[after] = object[before];
- delete object[before];
- }
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-/**
- * @desc
- * @param
- * @param
- * @return {Boolean}
- **/
-
-function checkForNetworkLoop(globalObjectA, globalLocationInA, globalObjectB, globalLocationInB) {
-
- var signalIsOk = true;
- var thisTempObject = objects[globalObjectA];
- var thisTempObjectLinks = thisTempObject.links;
-
- // check if connection is with it self
- if (globalObjectA === globalObjectB && globalLocationInA === globalLocationInB) {
- signalIsOk = false;
- }
-
- // todo check that objects are making these checks as well for not producing overlapeses.
- // check if this connection already exists?
- if (signalIsOk) {
- for (var thisSubKey in thisTempObjectLinks) {
- if (thisTempObjectLinks[thisSubKey].objectA === globalObjectA &&
- thisTempObjectLinks[thisSubKey].objectB === globalObjectB &&
- thisTempObjectLinks[thisSubKey].nodeA === globalLocationInA &&
- thisTempObjectLinks[thisSubKey].nodeB === globalLocationInB) {
- signalIsOk = false;
- }
-
- }
- }
- // check that there is no endless loops through it self or any other connections
- if (signalIsOk) {
- searchL(globalLocationInB, globalObjectB, globalLocationInA, globalObjectA);
-
- function searchL(nodeB, objectB, nodeA, objectA) {
- for (var key in objects[objectB].links) {
- cout(objectB);
- var Bn = objects[objectB].links[key];
- if (nodeB === Bn.nodeA) {
- if (nodeA === Bn.nodeB && objectA === Bn.objectB) {
- signalIsOk = false;
- break;
- } else {
- searchL(Bn.nodeB, Bn.objectB, nodeA, objectA);
- }
- }
- }
- }
- }
-
- return signalIsOk;
-}
-
-/**
- * @desc function to print to console based on debug mode set to true
- * @param {String} e any text that should be printed
- **/
-
-function cout(e) {
- if (globalStates.debug) {
- console.log(e);
- }
-}
-
-/**
- * @desc
- * @param {Object} timeing
- **/
-
-function timeSynchronizer(timeing) {
- timeing.now = Date.now();
- timeing.delta = (timeing.now - timeing.then) / 198;
- timeing.then = timeing.now;
-}
-
-/**********************************************************************************************************************
- **********************************************************************************************************************/
-
-//@author Ben Reynolds
-// given a 4x4 matrix and a
-// return true - if p is between thw two segment points, false otherwise
-
-/**
- * @desc Given a 4x4 transformation matrix and an x, y coordinate pair,
- calculates the z-position of the ring point
- * @return {Number|Array} the ring z-coordinate
- * @author Ben Reynolds
- **/
-
-
-function getCenterOfPoints(points) {
- if (points.length < 1) {
- return [0, 0];
- }
- var sumX = 0;
- var sumY = 0;
- points.forEach(function (point) {
- sumX += point[0];
- sumY += point[1];
- });
- var avgX = sumX / points.length;
- var avgY = sumY / points.length;
- return [avgX, avgY];
-}
-
-/**
- * @desc
- * @param {Number|Array} points
- * @return {Number|Array}
- **/
-
-function sortPointsClockwise(points) {
- var centerPoint = getCenterOfPoints(points);
- var centerX = centerPoint[0];
- var centerY = centerPoint[1];
-
- var comparePoints = function (a, b) {
- var atanA = Math.atan2(a[1] - centerY, a[0] - centerX);
- var atanB = Math.atan2(b[1] - centerY, b[0] - centerX);
- if (atanA < atanB) return -1;
- else if (atanB > atanA) return 1;
- return 0;
- }
-
- return points.sort(comparePoints);
-}
-
-/**
- * @desc
- * @param {Object} thisCanvas
- **/
-
-function getCornersClockwise(thisCanvas) {
- return [[0, 0, 0],
- [thisCanvas.width, 0, 0],
- [thisCanvas.width, thisCanvas.height, 0],
- [0, thisCanvas.height, 0]];
-}
-
-/**
- * @desc
- * @param
- * @param
- * @return
- **/
-
-function areCornersEqual(corner1, corner2) {
- return (corner1[0] === corner2[0] && corner1[1] === corner2[1]);
-}
-
-/**
- * @desc
- * @param
- * @param
- * @return
- **/
-
-function areCornerPairsIdentical(c1a, c1b, c2a, c2b) {
- return (areCornersEqual(c1a, c2a) && areCornersEqual(c1b, c2b));
-}
-
-/**
- * @desc
- * @param
- * @param
- * @return
- **/
-
-function areCornerPairsSymmetric(c1a, c1b, c2a, c2b) {
- return (areCornersEqual(c1a, c2b) && areCornersEqual(c1b, c2a));
-}
-
-/**
- * @desc
- * @param
- * @param
- * @return
- **/
-
-function areCornersAdjacent(corner1, corner2) {
- return (corner1[0] === corner2[0] || corner1[1] === corner2[1]);
-}
-
-/**
- * @desc
- * @param
- * @param
- * @return
- **/
-
-function areCornersOppositeZ(corner1, corner2) {
- var z1 = corner1[2];
- var z2 = corner2[2];
- var oppositeSign = ((z1 * z2) < 0);
- return oppositeSign;
-}
-
-/**
- * @desc
- * @param
- * @param
- * @return
- **/
-// makes sure we don't add symmetric pairs to list
-function addCornerPairToOppositeCornerPairs(cornerPair, oppositeCornerPairs) {
- var corner1 = cornerPair[0];
- var corner2 = cornerPair[1];
- var safeToAdd = true;
- if (oppositeCornerPairs.length > 0) {
- oppositeCornerPairs.forEach(function (pairList) {
- var existingCorner1 = pairList[0];
- var existingCorner2 = pairList[1];
- if (areCornerPairsSymmetric(existingCorner1, existingCorner2, corner1, corner2)) {
- // console.log("symmetric", existingCorner1, existingCorner2, corner1, corner2);
- safeToAdd = false;
- return;
- }
- if (areCornerPairsIdentical(existingCorner1, existingCorner2, corner1, corner2)) {
- // console.log("identical", existingCorner1, existingCorner2, corner1, corner2);
- safeToAdd = false;
- return;
- }
- });
- }
- if (safeToAdd) {
- oppositeCornerPairs.push([corner1, corner2]);
- }
-}
-
-/**
- * @desc
- * @param
- * @param
- * @return
- **/
-
-function estimateIntersection(theObject, mCanvas, thisObject) {
- var thisCanvas = globalDOMCach["canvas" + theObject];
- if(!mCanvas){
-
-
- if(!thisObject.hasCTXContent) {
- thisObject.hasCTXContent = true;
- var ctx = thisCanvas.getContext("2d");
- var diagonalLineWidth = 22;
- ctx.lineWidth = diagonalLineWidth;
- ctx.strokeStyle = '#01FFFC';
- for (var i = -thisCanvas.height; i < thisCanvas.width; i += 2.5 * diagonalLineWidth) {
- ctx.beginPath();
- ctx.moveTo(i, -diagonalLineWidth / 2);
- ctx.lineTo(i + thisCanvas.height + diagonalLineWidth / 2, thisCanvas.height + diagonalLineWidth / 2);
- ctx.stroke();
- }
- }
- return null;
- } else {
- thisObject.hasCTXContent = false;
- }
-
- if (globalStates.pointerPosition[0] === -1) return null;
-
- // var newMatrix = copyMatrix(multiplyMatrix(globalMatrix.begin, invertMatrix(globalMatrix.temp)));
-
- // var mCanvas = mat1x16From4x4(matrix);
- // var mCanvas = getTransformMatrixForDiv(theDiv);
-
- // console.log("mCanvas: ", mCanvas);
- // console.log("newMatrix: ", newMatrix);
-
- // console.log("estimate");
- ////////////////////////////////////////
-
- var corners = getCornersClockwise(thisCanvas);
- var out = [0, 0, 0, 0];
- corners.forEach(function (corner, index) {
- var x = corner[0] - thisCanvas.width / 2;
- var y = corner[1] - thisCanvas.height / 2;
- var input = [x, y, 0, 1]; // assumes z-position of corner is always 0
- // console.log(out, input, mCanvas);
-
- out = multiplyMatrix4(input, mCanvas);
- // var z = getTransformedZ(matrix,x,y)
- corner[2] = out[2]; // sets z position of corner to its eventual transformed value
- });
-
- // console.log("corners", corners);
-
- var oppositeCornerPairs = [];
- corners.forEach(function (corner1) {
- corners.forEach(function (corner2) {
- // only check adjacent pairs of corners
- // ignore same corner
- if (areCornersEqual(corner1, corner2)) {
- return;
- }
-
- // x or y should be the same
- if (areCornersAdjacent(corner1, corner2)) {
- if (areCornersOppositeZ(corner1, corner2)) {
- addCornerPairToOppositeCornerPairs([corner1, corner2], oppositeCornerPairs);
- }
- }
- });
- });
-
- // console.log("oppositeCornerPairs", oppositeCornerPairs);
-
- // for each opposite corner pair, binary search for the x,y location that will correspond with 0 z-pos
- // .... or can it be calculated directly....? it's just a linear equation!!!
- var interceptPoints = [];
- oppositeCornerPairs.forEach(function (cornerPair) {
- var c1 = cornerPair[0];
- var c2 = cornerPair[1];
- var x1 = c1[0];
- var y1 = c1[1];
- var z1 = c1[2];
- var x2 = c2[0];
- var y2 = c2[1];
- var z2 = c2[2];
-
- if (Math.abs(x2 - x1) > Math.abs(y2 - y1)) {
- // console.log("dx");
- var slope = ((z2 - z1) / (x2 - x1));
- var x_intercept = x1 - (z1 / slope);
- interceptPoints.push([x_intercept, y1]);
- } else {
- // console.log("dy");
- var slope = ((z2 - z1) / (y2 - y1));
- var y_intercept = y1 - (z1 / slope);
- interceptPoints.push([x1, y_intercept]);
- }
- });
-
- // console.log("interceptPoints", interceptPoints);
-
- ////////////////////////////////////////
-
- // get corners, add in correct order so they get drawn clockwise
-
- corners.forEach(function (corner) {
- if (corner[2] < 0) {
- interceptPoints.push(corner);
- }
- });
-
- // console.log("interceptPoints+corners", interceptPoints);
-
- var sortedPoints = sortPointsClockwise(interceptPoints);
- // console.log("sortedPoints", sortedPoints);
-
- // draws blue and purple diagonal lines to mask the image
- var ctx = thisCanvas.getContext("2d");
- ctx.clearRect(0, 0, thisCanvas.width, thisCanvas.height);
-
- var diagonalLineWidth = 22;
- ctx.lineWidth = diagonalLineWidth;
- ctx.strokeStyle = '#01FFFC';
- for (var i = -thisCanvas.height; i < thisCanvas.width; i += 2.5 * diagonalLineWidth) {
- ctx.beginPath();
- ctx.moveTo(i, -diagonalLineWidth / 2);
- ctx.lineTo(i + thisCanvas.height + diagonalLineWidth / 2, thisCanvas.height + diagonalLineWidth / 2);
- ctx.stroke();
- }
-
- // Save the state, so we can undo the clipping
- ctx.save();
-
- // Create a circle
- ctx.beginPath();
-
- if (sortedPoints.length > 2) {
- ctx.beginPath();
- ctx.moveTo(sortedPoints[0][0], sortedPoints[0][1]);
- sortedPoints.forEach(function (point) {
- ctx.lineTo(point[0], point[1]);
- });
- ctx.closePath();
- // ctx.fill();
- }
- // Clip to the current path
- ctx.clip();
-
- // draw whatever needs to get masked here!
-
- var diagonalLineWidth = 22;
- ctx.lineWidth = diagonalLineWidth;
- ctx.strokeStyle = '#FF01FC';
- for (var i = -thisCanvas.height; i < thisCanvas.width; i += 2.5 * diagonalLineWidth) {
- ctx.beginPath();
- ctx.moveTo(i, -diagonalLineWidth / 2);
- ctx.lineTo(i + thisCanvas.height + diagonalLineWidth / 2, thisCanvas.height + diagonalLineWidth / 2);
- ctx.stroke();
- }
-
- // Undo the clipping
- ctx.restore();
-}
-
-
-function insidePoly(point, vs) {
- // ray-casting algorithm based on
- // http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html
- // Copyright (c) 2016 James Halliday
- // The MIT License (MIT)
-
- var x = point[0], y = point[1];
-
- if(x <=0 || y <= 0) return false;
-
- var inside = false;
- for (var i = 0, j = vs.length - 1; i < vs.length; j = i++) {
- var xi = vs[i][0], yi = vs[i][1];
- var xj = vs[j][0], yj = vs[j][1];
-
- var intersect = ((yi > y) != (yj > y))
- && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
- if (intersect) inside = !inside;
- }
-
- return inside;
-};
\ No newline at end of file
diff --git a/logo.html b/logo.html
deleted file mode 100644
index 7ec18823d..000000000
--- a/logo.html
+++ /dev/null
@@ -1,136 +0,0 @@
-
-
-
-
-
-
Reality Editor Animated Logo
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/nodes/default/index.html b/nodes/default/index.html
deleted file mode 100755
index 3db1ab9c9..000000000
--- a/nodes/default/index.html
+++ /dev/null
@@ -1,79 +0,0 @@
-
-
-
-
-
IO
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/nodes/invisible/index.html b/nodes/invisible/index.html
deleted file mode 100755
index 06dcfa71f..000000000
--- a/nodes/invisible/index.html
+++ /dev/null
@@ -1,81 +0,0 @@
-
-
-
-
-
-
IO
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/nodes/logicNode/index.html b/nodes/logicNode/index.html
deleted file mode 100755
index ec4629336..000000000
--- a/nodes/logicNode/index.html
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
IO
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/p4Test.html b/p4Test.html
new file mode 100644
index 000000000..74119ac80
--- /dev/null
+++ b/p4Test.html
@@ -0,0 +1,38 @@
+
+
+
+
+
Title
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 000000000..af61d46ca
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,3041 @@
+{
+ "name": "userinterface",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "userinterface",
+ "version": "1.0.0",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "@microsoft/teams-js": "^2.22.0"
+ },
+ "devDependencies": {
+ "@vitest/coverage-v8": "^1.5.0",
+ "eslint": "^8.57.0",
+ "jsdoc": "^4.0.0",
+ "vite": "^5.2.10",
+ "vitest": "^1.3.0"
+ }
+ },
+ "node_modules/@aashutoshrathi/word-wrap": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
+ "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz",
+ "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.0",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
+ "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+ "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.23.6",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz",
+ "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==",
+ "dev": true,
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.23.6",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz",
+ "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.23.4",
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "to-fast-properties": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
+ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
+ "dev": true
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
+ "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
+ "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
+ "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
+ "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
+ "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
+ "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
+ "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
+ "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
+ "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
+ "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
+ "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
+ "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
+ "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
+ "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
+ "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
+ "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
+ "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
+ "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
+ "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
+ "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
+ "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
+ "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
+ "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.2.0.tgz",
+ "integrity": "sha512-gB8T4H4DEfX2IV9zGDJPOBgP1e/DbfCPDTtEqUMckpvzS1OYtva8JdFYBqMwYk7xAQ429WGF/UPqn8uQ//h2vQ==",
+ "dev": true,
+ "dependencies": {
+ "eslint-visitor-keys": "^3.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.6.2.tgz",
+ "integrity": "sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==",
+ "dev": true,
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.6.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "8.57.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
+ "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+ "dev": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.11.14",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
+ "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
+ "dev": true,
+ "dependencies": {
+ "@humanwhocodes/object-schema": "^2.0.2",
+ "debug": "^4.3.1",
+ "minimatch": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=10.10.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz",
+ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==",
+ "dev": true
+ },
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
+ "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/set-array": "^1.0.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
+ "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+ "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.4.15",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
+ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.25",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+ "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@jsdoc/salty": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.5.tgz",
+ "integrity": "sha512-TfRP53RqunNe2HBobVBJ0VLhK1HbfvBYeTC1ahnN64PWvyYyGebmMiPkuwvD9fpw2ZbkoPb8Q7mwy0aR8Z9rvw==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.21"
+ },
+ "engines": {
+ "node": ">=v12.0.0"
+ }
+ },
+ "node_modules/@microsoft/teams-js": {
+ "version": "2.22.0",
+ "resolved": "https://registry.npmjs.org/@microsoft/teams-js/-/teams-js-2.22.0.tgz",
+ "integrity": "sha512-n1UVJbxOxoeY/ATS9R1J0UGx3xhw5PgF7e3RyHHctAansYUTmszbC1/qGPxXyjekBIpGabDCx20zWW+WHdlZlA==",
+ "dependencies": {
+ "debug": "^4.3.3"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz",
+ "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz",
+ "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz",
+ "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz",
+ "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz",
+ "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz",
+ "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz",
+ "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz",
+ "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz",
+ "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz",
+ "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz",
+ "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz",
+ "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz",
+ "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sinclair/typebox": {
+ "version": "0.27.8",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
+ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
+ "dev": true
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
+ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
+ "dev": true
+ },
+ "node_modules/@types/linkify-it": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz",
+ "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==",
+ "dev": true
+ },
+ "node_modules/@types/markdown-it": {
+ "version": "12.2.3",
+ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz",
+ "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/linkify-it": "*",
+ "@types/mdurl": "*"
+ }
+ },
+ "node_modules/@types/mdurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz",
+ "integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==",
+ "dev": true
+ },
+ "node_modules/@types/node": {
+ "version": "20.8.10",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.10.tgz",
+ "integrity": "sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w==",
+ "dev": true,
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
+ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+ "dev": true
+ },
+ "node_modules/@vitest/coverage-v8": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.5.0.tgz",
+ "integrity": "sha512-1igVwlcqw1QUMdfcMlzzY4coikSIBN944pkueGi0pawrX5I5Z+9hxdTR+w3Sg6Q3eZhvdMAs8ZaF9JuTG1uYOQ==",
+ "dev": true,
+ "dependencies": {
+ "@ampproject/remapping": "^2.2.1",
+ "@bcoe/v8-coverage": "^0.2.3",
+ "debug": "^4.3.4",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-lib-source-maps": "^5.0.4",
+ "istanbul-reports": "^3.1.6",
+ "magic-string": "^0.30.5",
+ "magicast": "^0.3.3",
+ "picocolors": "^1.0.0",
+ "std-env": "^3.5.0",
+ "strip-literal": "^2.0.0",
+ "test-exclude": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "vitest": "1.5.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.5.0.tgz",
+ "integrity": "sha512-0pzuCI6KYi2SIC3LQezmxujU9RK/vwC1U9R0rLuGlNGcOuDWxqWKu6nUdFsX9tH1WU0SXtAxToOsEjeUn1s3hA==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/spy": "1.5.0",
+ "@vitest/utils": "1.5.0",
+ "chai": "^4.3.10"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.5.0.tgz",
+ "integrity": "sha512-7HWwdxXP5yDoe7DTpbif9l6ZmDwCzcSIK38kTSIt6CFEpMjX4EpCgT6wUmS0xTXqMI6E/ONmfgRKmaujpabjZQ==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/utils": "1.5.0",
+ "p-limit": "^5.0.0",
+ "pathe": "^1.1.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner/node_modules/p-limit": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
+ "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
+ "dev": true,
+ "dependencies": {
+ "yocto-queue": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@vitest/runner/node_modules/yocto-queue": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz",
+ "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.5.0.tgz",
+ "integrity": "sha512-qpv3fSEuNrhAO3FpH6YYRdaECnnRjg9VxbhdtPwPRnzSfHVXnNzzrpX4cJxqiwgRMo7uRMWDFBlsBq4Cr+rO3A==",
+ "dev": true,
+ "dependencies": {
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.5.0.tgz",
+ "integrity": "sha512-vu6vi6ew5N5MMHJjD5PoakMRKYdmIrNJmyfkhRpQt5d9Ewhw9nZ5Aqynbi3N61bvk9UvZ5UysMT6ayIrZ8GA9w==",
+ "dev": true,
+ "dependencies": {
+ "tinyspy": "^2.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.5.0.tgz",
+ "integrity": "sha512-BDU0GNL8MWkRkSRdNFvCUCAVOeHaUlVJ9Tx0TYBZyXaaOTmGtUFObzchCivIBrIwKzvZA7A9sCejVhXM2aY98A==",
+ "dev": true,
+ "dependencies": {
+ "diff-sequences": "^29.6.3",
+ "estree-walker": "^3.0.3",
+ "loupe": "^2.3.7",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.11.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz",
+ "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
+ "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true
+ },
+ "node_modules/assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "node_modules/bluebird": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+ "dev": true
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/catharsis": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz",
+ "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.15"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/chai": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz",
+ "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==",
+ "dev": true,
+ "dependencies": {
+ "assertion-error": "^1.1.0",
+ "check-error": "^1.0.3",
+ "deep-eql": "^4.1.3",
+ "get-func-name": "^2.0.2",
+ "loupe": "^2.3.6",
+ "pathval": "^1.1.1",
+ "type-detect": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
+ "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
+ "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
+ "dev": true,
+ "dependencies": {
+ "get-func-name": "^2.0.2"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+ "dev": true
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz",
+ "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==",
+ "dev": true,
+ "dependencies": {
+ "type-detect": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true
+ },
+ "node_modules/diff-sequences": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
+ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
+ "dev": true,
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz",
+ "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
+ "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.20.2",
+ "@esbuild/android-arm": "0.20.2",
+ "@esbuild/android-arm64": "0.20.2",
+ "@esbuild/android-x64": "0.20.2",
+ "@esbuild/darwin-arm64": "0.20.2",
+ "@esbuild/darwin-x64": "0.20.2",
+ "@esbuild/freebsd-arm64": "0.20.2",
+ "@esbuild/freebsd-x64": "0.20.2",
+ "@esbuild/linux-arm": "0.20.2",
+ "@esbuild/linux-arm64": "0.20.2",
+ "@esbuild/linux-ia32": "0.20.2",
+ "@esbuild/linux-loong64": "0.20.2",
+ "@esbuild/linux-mips64el": "0.20.2",
+ "@esbuild/linux-ppc64": "0.20.2",
+ "@esbuild/linux-riscv64": "0.20.2",
+ "@esbuild/linux-s390x": "0.20.2",
+ "@esbuild/linux-x64": "0.20.2",
+ "@esbuild/netbsd-x64": "0.20.2",
+ "@esbuild/openbsd-x64": "0.20.2",
+ "@esbuild/sunos-x64": "0.20.2",
+ "@esbuild/win32-arm64": "0.20.2",
+ "@esbuild/win32-ia32": "0.20.2",
+ "@esbuild/win32-x64": "0.20.2"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "8.57.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
+ "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
+ "dev": true,
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.6.1",
+ "@eslint/eslintrc": "^2.1.4",
+ "@eslint/js": "8.57.0",
+ "@humanwhocodes/config-array": "^0.11.14",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "@ungap/structured-clone": "^1.2.0",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.2.2",
+ "eslint-visitor-keys": "^3.4.3",
+ "espree": "^9.6.1",
+ "esquery": "^1.4.2",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3",
+ "strip-ansi": "^6.0.1",
+ "text-table": "^0.2.0"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/espree": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "dev": true,
+ "dependencies": {
+ "acorn": "^8.9.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.2.tgz",
+ "integrity": "sha512-JVSoLdTlTDkmjFmab7H/9SL9qGSyjElT3myyKp7krqjVFQCDLmj1QFaCLRFBszBKI0XVZaiiXvuPIX3ZwHe1Ng==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/execa": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
+ "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+ "dev": true,
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^8.0.1",
+ "human-signals": "^5.0.0",
+ "is-stream": "^3.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^5.1.0",
+ "onetime": "^6.0.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.17"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true
+ },
+ "node_modules/fastq": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
+ "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
+ "dev": true,
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "dev": true,
+ "dependencies": {
+ "flat-cache": "^3.0.4"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+ "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+ "dev": true,
+ "dependencies": {
+ "flatted": "^3.1.0",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz",
+ "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==",
+ "dev": true
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+ "dev": true
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-func-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+ "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
+ "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+ "dev": true,
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.1.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
+ "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
+ "dev": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.0.4",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "13.23.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz",
+ "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==",
+ "dev": true,
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true
+ },
+ "node_modules/human-signals": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+ "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=16.17.0"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz",
+ "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "dev": true,
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+ "dev": true,
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
+ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+ "dev": true,
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
+ "dev": true
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.4.tgz",
+ "integrity": "sha512-wHOoEsNJTVltaJp8eVkm8w+GVkVNHT2YDYo53YdzQEL2gWm1hBX5cGFR9hQJtuGLebidVX7et3+dmDZrmclduw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.23",
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.1.6",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz",
+ "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==",
+ "dev": true,
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-8.0.3.tgz",
+ "integrity": "sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==",
+ "dev": true
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/js2xmlparser": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz",
+ "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==",
+ "dev": true,
+ "dependencies": {
+ "xmlcreate": "^2.0.4"
+ }
+ },
+ "node_modules/jsdoc": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.2.tgz",
+ "integrity": "sha512-e8cIg2z62InH7azBBi3EsSEqrKx+nUtAS5bBcYTSpZFA+vhNPyhv8PTFZ0WsjOPDj04/dOLlm08EDcQJDqaGQg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.20.15",
+ "@jsdoc/salty": "^0.2.1",
+ "@types/markdown-it": "^12.2.3",
+ "bluebird": "^3.7.2",
+ "catharsis": "^0.9.0",
+ "escape-string-regexp": "^2.0.0",
+ "js2xmlparser": "^4.0.2",
+ "klaw": "^3.0.0",
+ "markdown-it": "^12.3.2",
+ "markdown-it-anchor": "^8.4.1",
+ "marked": "^4.0.10",
+ "mkdirp": "^1.0.4",
+ "requizzle": "^0.2.3",
+ "strip-json-comments": "^3.1.0",
+ "underscore": "~1.13.2"
+ },
+ "bin": {
+ "jsdoc": "jsdoc.js"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
+ "dev": true
+ },
+ "node_modules/jsonc-parser": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
+ "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==",
+ "dev": true
+ },
+ "node_modules/klaw": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz",
+ "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.9"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/linkify-it": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz",
+ "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==",
+ "dev": true,
+ "dependencies": {
+ "uc.micro": "^1.0.1"
+ }
+ },
+ "node_modules/local-pkg": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz",
+ "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==",
+ "dev": true,
+ "dependencies": {
+ "mlly": "^1.4.2",
+ "pkg-types": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true
+ },
+ "node_modules/loupe": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
+ "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
+ "dev": true,
+ "dependencies": {
+ "get-func-name": "^2.0.1"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.5",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
+ "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.3.tgz",
+ "integrity": "sha512-ZbrP1Qxnpoes8sz47AM0z08U+jW6TyRgZzcWy3Ma3vDhJttwMwAFDMMQFobwdBxByBD46JYmxRzeF7w2+wJEuw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.23.6",
+ "@babel/types": "^7.23.6",
+ "source-map-js": "^1.0.2"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/markdown-it": {
+ "version": "12.3.2",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz",
+ "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^2.0.1",
+ "entities": "~2.1.0",
+ "linkify-it": "^3.0.1",
+ "mdurl": "^1.0.1",
+ "uc.micro": "^1.0.5"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.js"
+ }
+ },
+ "node_modules/markdown-it-anchor": {
+ "version": "8.6.7",
+ "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz",
+ "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==",
+ "dev": true,
+ "peerDependencies": {
+ "@types/markdown-it": "*",
+ "markdown-it": "*"
+ }
+ },
+ "node_modules/marked": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
+ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
+ "dev": true,
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/mdurl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
+ "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
+ "dev": true
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true
+ },
+ "node_modules/mimic-fn": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
+ "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "dev": true,
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/mlly": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.2.tgz",
+ "integrity": "sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==",
+ "dev": true,
+ "dependencies": {
+ "acorn": "^8.10.0",
+ "pathe": "^1.1.1",
+ "pkg-types": "^1.0.3",
+ "ufo": "^1.3.0"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
+ "dev": true
+ },
+ "node_modules/npm-run-path": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz",
+ "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm-run-path/node_modules/path-key": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+ "dev": true,
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
+ "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
+ "dev": true,
+ "dependencies": {
+ "mimic-fn": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
+ "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==",
+ "dev": true,
+ "dependencies": {
+ "@aashutoshrathi/word-wrap": "^1.2.3",
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.1.tgz",
+ "integrity": "sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==",
+ "dev": true
+ },
+ "node_modules/pathval": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "dev": true
+ },
+ "node_modules/pkg-types": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz",
+ "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==",
+ "dev": true,
+ "dependencies": {
+ "jsonc-parser": "^3.2.0",
+ "mlly": "^1.2.0",
+ "pathe": "^1.1.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.4.38",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
+ "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.7",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.2.0"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
+ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "dev": true,
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^18.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/react-is": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
+ "dev": true
+ },
+ "node_modules/requizzle": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz",
+ "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.21"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true,
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz",
+ "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.5"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.13.0",
+ "@rollup/rollup-android-arm64": "4.13.0",
+ "@rollup/rollup-darwin-arm64": "4.13.0",
+ "@rollup/rollup-darwin-x64": "4.13.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.13.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.13.0",
+ "@rollup/rollup-linux-arm64-musl": "4.13.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.13.0",
+ "@rollup/rollup-linux-x64-gnu": "4.13.0",
+ "@rollup/rollup-linux-x64-musl": "4.13.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.13.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.13.0",
+ "@rollup/rollup-win32-x64-msvc": "4.13.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.5.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+ "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
+ "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true
+ },
+ "node_modules/std-env": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.6.0.tgz",
+ "integrity": "sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg==",
+ "dev": true
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-final-newline": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
+ "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.0.0.tgz",
+ "integrity": "sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA==",
+ "dev": true,
+ "dependencies": {
+ "js-tokens": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/test-exclude": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+ "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+ "dev": true,
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^7.1.4",
+ "minimatch": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
+ "dev": true
+ },
+ "node_modules/tinybench": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.5.1.tgz",
+ "integrity": "sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==",
+ "dev": true
+ },
+ "node_modules/tinypool": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.3.tgz",
+ "integrity": "sha512-Ud7uepAklqRH1bvwy22ynrliC7Dljz7Tm8M/0RBUW+YRa4YHhZ6e4PpgE+fu1zr/WqB1kbeuVrdfeuyIBpy4tw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
+ "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-detect": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/uc.micro": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
+ "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
+ "dev": true
+ },
+ "node_modules/ufo": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz",
+ "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==",
+ "dev": true
+ },
+ "node_modules/underscore": {
+ "version": "1.13.6",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz",
+ "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==",
+ "dev": true
+ },
+ "node_modules/undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "dev": true,
+ "optional": true,
+ "peer": true
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.2.10",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.10.tgz",
+ "integrity": "sha512-PAzgUZbP7msvQvqdSD+ErD5qGnSFiGOoWmV5yAKUEI0kdhjbH6nMWVyZQC/hSc4aXwc0oJ9aEdIiF9Oje0JFCw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.20.1",
+ "postcss": "^8.4.38",
+ "rollup": "^4.13.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.5.0.tgz",
+ "integrity": "sha512-tV8h6gMj6vPzVCa7l+VGq9lwoJjW8Y79vst8QZZGiuRAfijU+EEWuc0kFpmndQrWhMMhet1jdSF+40KSZUqIIw==",
+ "dev": true,
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.4",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.5.0.tgz",
+ "integrity": "sha512-d8UKgR0m2kjdxDWX6911uwxout6GHS0XaGH1cksSIVVG8kRlE7G7aBw7myKQCvDI5dT4j7ZMa+l706BIORMDLw==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/expect": "1.5.0",
+ "@vitest/runner": "1.5.0",
+ "@vitest/snapshot": "1.5.0",
+ "@vitest/spy": "1.5.0",
+ "@vitest/utils": "1.5.0",
+ "acorn-walk": "^8.3.2",
+ "chai": "^4.3.10",
+ "debug": "^4.3.4",
+ "execa": "^8.0.1",
+ "local-pkg": "^0.5.0",
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "std-env": "^3.5.0",
+ "strip-literal": "^2.0.0",
+ "tinybench": "^2.5.1",
+ "tinypool": "^0.8.3",
+ "vite": "^5.0.0",
+ "vite-node": "1.5.0",
+ "why-is-node-running": "^2.2.2"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "1.5.0",
+ "@vitest/ui": "1.5.0",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz",
+ "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==",
+ "dev": true,
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+ "dev": true
+ },
+ "node_modules/xmlcreate": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz",
+ "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==",
+ "dev": true
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 000000000..8feae7b8e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "userinterface",
+ "version": "1.0.0",
+ "description": "User interface of the Reality Editor",
+ "devDependencies": {
+ "@vitest/coverage-v8": "^1.5.0",
+ "eslint": "^8.57.0",
+ "jsdoc": "^4.0.0",
+ "vite": "^5.2.10",
+ "vitest": "^1.3.0"
+ },
+ "scripts": {
+ "test": "npm run lint",
+ "lint": "eslint .",
+ "build": "vite build && cp -r svg css png thirdPartyCode src dist/"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/PTCInc/RE-userinterface.git"
+ },
+ "author": "Reality Editor Team",
+ "license": "MPL-2.0",
+ "bugs": {
+ "url": "https://github.com/PTCInc/RE-userinterface/issues"
+ },
+ "homepage": "https://github.com/PTCInc/RE-userinterface#readme",
+ "dependencies": {
+ "@microsoft/teams-js": "^2.22.0"
+ }
+}
diff --git a/page.css b/page.css
deleted file mode 100644
index 381e78a27..000000000
--- a/page.css
+++ /dev/null
@@ -1,20 +0,0 @@
-/* This code is only meant for previewing your Reflow design. */
-.primaryContainer {
- height: auto;
- margin-left: auto;
- margin-right: auto;
- min-height: 100%;
- width: 100%;
-}
-
-#box {
- float: left;
- height: 66px;
- margin-left: 1.270878%;
- margin-top: 10px;
- clear: none;
- width: 12.6193%;
- border: 1px solid rgb(255, 0, 0);
- background-color: rgb(255, 0, 0);
-}
-
diff --git a/png/bigPocket.png b/png/bigPocket.png
new file mode 100644
index 000000000..8ed292b44
Binary files /dev/null and b/png/bigPocket.png differ
diff --git a/png/bigPocketEmpty.png b/png/bigPocketEmpty.png
new file mode 100644
index 000000000..2d61bebab
Binary files /dev/null and b/png/bigPocketEmpty.png differ
diff --git a/png/bigPocketOver.png b/png/bigPocketOver.png
new file mode 100644
index 000000000..faa21d489
Binary files /dev/null and b/png/bigPocketOver.png differ
diff --git a/png/bigPocketSelect.png b/png/bigPocketSelect.png
new file mode 100644
index 000000000..e67fe310a
Binary files /dev/null and b/png/bigPocketSelect.png differ
diff --git a/png/bigTrash.png b/png/bigTrash.png
new file mode 100644
index 000000000..9cb2583c7
Binary files /dev/null and b/png/bigTrash.png differ
diff --git a/png/bigTrashEmpty.png b/png/bigTrashEmpty.png
new file mode 100644
index 000000000..3e4ec31c5
Binary files /dev/null and b/png/bigTrashEmpty.png differ
diff --git a/png/bigTrashOver.png b/png/bigTrashOver.png
new file mode 100644
index 000000000..4105338ee
Binary files /dev/null and b/png/bigTrashOver.png differ
diff --git a/png/bigTrashSelect.png b/png/bigTrashSelect.png
new file mode 100644
index 000000000..db92c0ddf
Binary files /dev/null and b/png/bigTrashSelect.png differ
diff --git a/png/blockPocket.png b/png/blockPocket.png
new file mode 100644
index 000000000..90da8a071
Binary files /dev/null and b/png/blockPocket.png differ
diff --git a/png/blockPocketOver.png b/png/blockPocketOver.png
new file mode 100644
index 000000000..32564f448
Binary files /dev/null and b/png/blockPocketOver.png differ
diff --git a/png/blockPocketSelect.png b/png/blockPocketSelect.png
new file mode 100644
index 000000000..d0a2c5c4d
Binary files /dev/null and b/png/blockPocketSelect.png differ
diff --git a/png/blockPref.png b/png/blockPref.png
new file mode 100644
index 000000000..b8a3528f1
Binary files /dev/null and b/png/blockPref.png differ
diff --git a/png/blockPrefOver.png b/png/blockPrefOver.png
new file mode 100644
index 000000000..02d4f0108
Binary files /dev/null and b/png/blockPrefOver.png differ
diff --git a/png/blockPrefSelect.png b/png/blockPrefSelect.png
new file mode 100644
index 000000000..41465c685
Binary files /dev/null and b/png/blockPrefSelect.png differ
diff --git a/png/datacrafting/add-block-button.png b/png/datacrafting/add-block-button.png
deleted file mode 100644
index 63bcecdc0..000000000
Binary files a/png/datacrafting/add-block-button.png and /dev/null differ
diff --git a/png/datacrafting/back.png b/png/datacrafting/back.png
deleted file mode 100644
index 2eecbbcdb..000000000
Binary files a/png/datacrafting/back.png and /dev/null differ
diff --git a/png/datacrafting/blue-empty.png b/png/datacrafting/blue-empty.png
deleted file mode 100644
index 9086620e0..000000000
Binary files a/png/datacrafting/blue-empty.png and /dev/null differ
diff --git a/png/datacrafting/blue.png b/png/datacrafting/blue.png
deleted file mode 100644
index 6065621e0..000000000
Binary files a/png/datacrafting/blue.png and /dev/null differ
diff --git a/png/datacrafting/green-empty.png b/png/datacrafting/green-empty.png
deleted file mode 100644
index 7273b0e44..000000000
Binary files a/png/datacrafting/green-empty.png and /dev/null differ
diff --git a/png/datacrafting/green.png b/png/datacrafting/green.png
deleted file mode 100644
index ea36d1ef2..000000000
Binary files a/png/datacrafting/green.png and /dev/null differ
diff --git a/png/datacrafting/new-block.png b/png/datacrafting/new-block.png
deleted file mode 100644
index 70c8f0aef..000000000
Binary files a/png/datacrafting/new-block.png and /dev/null differ
diff --git a/png/datacrafting/red-empty.png b/png/datacrafting/red-empty.png
deleted file mode 100644
index 8def8f357..000000000
Binary files a/png/datacrafting/red-empty.png and /dev/null differ
diff --git a/png/datacrafting/red.png b/png/datacrafting/red.png
deleted file mode 100644
index e2e9e2578..000000000
Binary files a/png/datacrafting/red.png and /dev/null differ
diff --git a/png/datacrafting/yellow-empty.png b/png/datacrafting/yellow-empty.png
deleted file mode 100644
index 090b96c61..000000000
Binary files a/png/datacrafting/yellow-empty.png and /dev/null differ
diff --git a/png/datacrafting/yellow.png b/png/datacrafting/yellow.png
deleted file mode 100644
index 1dba0172b..000000000
Binary files a/png/datacrafting/yellow.png and /dev/null differ
diff --git a/png/emptyLogicIcon.png b/png/emptyLogicIcon.png
new file mode 100644
index 000000000..1deb78a5a
Binary files /dev/null and b/png/emptyLogicIcon.png differ
diff --git a/png/halfPocket.png b/png/halfPocket.png
new file mode 100644
index 000000000..550811abc
Binary files /dev/null and b/png/halfPocket.png differ
diff --git a/png/halfPocketOver.png b/png/halfPocketOver.png
new file mode 100644
index 000000000..7dcc9b557
Binary files /dev/null and b/png/halfPocketOver.png differ
diff --git a/png/halfTrash.png b/png/halfTrash.png
new file mode 100644
index 000000000..80c8381c4
Binary files /dev/null and b/png/halfTrash.png differ
diff --git a/png/halfTrashOver.png b/png/halfTrashOver.png
new file mode 100644
index 000000000..9ccecfecd
Binary files /dev/null and b/png/halfTrashOver.png differ
diff --git a/png/iconBlocks.png b/png/iconBlocks.png
new file mode 100644
index 000000000..10ae96e01
Binary files /dev/null and b/png/iconBlocks.png differ
diff --git a/png/iconEvents.png b/png/iconEvents.png
new file mode 100644
index 000000000..68a88560a
Binary files /dev/null and b/png/iconEvents.png differ
diff --git a/png/iconMath.png b/png/iconMath.png
new file mode 100644
index 000000000..7fb19eefb
Binary files /dev/null and b/png/iconMath.png differ
diff --git a/png/iconSignals.png b/png/iconSignals.png
new file mode 100644
index 000000000..919d60160
Binary files /dev/null and b/png/iconSignals.png differ
diff --git a/png/iconWeb.png b/png/iconWeb.png
new file mode 100644
index 000000000..90a5406f6
Binary files /dev/null and b/png/iconWeb.png differ
diff --git a/png/intThree.png b/png/intThree.png
new file mode 100644
index 000000000..d317f786b
Binary files /dev/null and b/png/intThree.png differ
diff --git a/png/memoryWeb.png b/png/memoryWeb.png
new file mode 100644
index 000000000..5de708adb
Binary files /dev/null and b/png/memoryWeb.png differ
diff --git a/png/memoryWebOver.png b/png/memoryWebOver.png
new file mode 100644
index 000000000..3f67bea40
Binary files /dev/null and b/png/memoryWebOver.png differ
diff --git a/png/memoryWebSelect.png b/png/memoryWebSelect.png
new file mode 100644
index 000000000..cb09a14a5
Binary files /dev/null and b/png/memoryWebSelect.png differ
diff --git a/png/paused.png b/png/paused.png
new file mode 100644
index 000000000..7312b954b
Binary files /dev/null and b/png/paused.png differ
diff --git a/png/playing.png b/png/playing.png
new file mode 100644
index 000000000..f19cc99e5
Binary files /dev/null and b/png/playing.png differ
diff --git a/png/pocket.png b/png/pocket.png
new file mode 100644
index 000000000..703b4b95f
Binary files /dev/null and b/png/pocket.png differ
diff --git a/png/pocketEmpty.png b/png/pocketEmpty.png
new file mode 100644
index 000000000..eea928b1e
Binary files /dev/null and b/png/pocketEmpty.png differ
diff --git a/png/pocketOver.png b/png/pocketOver.png
new file mode 100644
index 000000000..73f94ef8b
Binary files /dev/null and b/png/pocketOver.png differ
diff --git a/png/pocketSelect.png b/png/pocketSelect.png
new file mode 100644
index 000000000..9539f5c61
Binary files /dev/null and b/png/pocketSelect.png differ
diff --git a/png/pref.png b/png/pref.png
index fa9ab17da..9e33b296d 100644
Binary files a/png/pref.png and b/png/pref.png differ
diff --git a/png/prefOver.png b/png/prefOver.png
index 03d91f07c..9a4713a91 100644
Binary files a/png/prefOver.png and b/png/prefOver.png differ
diff --git a/png/prefSelect.png b/png/prefSelect.png
index 385ff1526..c309a7854 100644
Binary files a/png/prefSelect.png and b/png/prefSelect.png differ
diff --git a/src/addons/index.js b/src/addons/index.js
new file mode 100644
index 000000000..2cc2124c0
--- /dev/null
+++ b/src/addons/index.js
@@ -0,0 +1,129 @@
+createNameSpace("realityEditor.addons");
+
+(function(exports) {
+ /**
+ * @param {Element} element
+ * @return {Promise} resolved on load or on error of element
+ */
+ function wrapLoadOrError(element) {
+ return new Promise(resolve => {
+ function onEvent() {
+ resolve();
+ element.removeEventListener('load', onEvent);
+ element.removeEventListener('error', onEvent);
+ }
+ element.addEventListener('load', onEvent);
+ element.addEventListener('error', onEvent);
+ });
+ }
+
+ // Fetch the list of all add-ons to inject
+ let allScriptsLoaded = fetch('addons/sources').then((res) => {
+ return res.json();
+ }).then((addonSources) => {
+ // Inject all scripts, counting on them to load asynchronously and add
+ // their own callbacks
+ const loePromises = addonSources.map(source => {
+ const scriptNode = document.createElement('script');
+ const loe = wrapLoadOrError(scriptNode);
+ if (source.startsWith('/')) {
+ source = '.' + source;
+ }
+ scriptNode.src = source;
+ scriptNode.type = 'module';
+ document.head.appendChild(scriptNode);
+ return loe;
+ });
+ return Promise.all(loePromises);
+ });
+
+ // Also fetch CSS addons
+ fetch('addons/styles').then((res) => {
+ return res.json();
+ }).then((addonSources) => {
+ // Inject all stylesheets
+ for (let source of addonSources) {
+ const styleNode = document.createElement('link');
+ if (source.startsWith('/')) {
+ source = '.' + source;
+ }
+ styleNode.rel = 'stylesheet';
+ styleNode.type = 'text/css';
+ styleNode.href = source;
+ document.head.appendChild(styleNode);
+ }
+ });
+
+ // Also fetch image resources and store references to them at the correct path
+ let resourcePaths = [];
+ fetch('addons/resources').then((res) => {
+ return res.json();
+ }).then((addonSources) => {
+ resourcePaths = addonSources;
+ onResourcesLoaded(addonSources);
+ });
+
+ const callbacks = {
+ init: [],
+ networkSetSettings: [],
+ resourcesLoaded: []
+ };
+
+ // Whether our onInit function has been called
+ let initialized = false;
+
+ /**
+ * On init call all init callbacks
+ */
+ async function onInit() {
+ return allScriptsLoaded.finally(() => {
+ initialized = true;
+ callbacks['init'].forEach(cb => {
+ cb();
+ });
+ });
+ }
+
+ /**
+ * On receiving a network message with a setSettings payload call all
+ * callbacks
+ * @param {Object} setSettings - the payload
+ */
+ function onNetworkSetSettings(setSettings) {
+ callbacks['networkSetSettings'].forEach(cb => {
+ cb(setSettings);
+ });
+ }
+
+ /**
+ * When img resource paths are retrieved from the server, call all callbacks
+ * @param {Array.
} resourcePaths
+ */
+ function onResourcesLoaded(resourcePaths) {
+ callbacks['resourcesLoaded'].forEach(cb => {
+ cb(JSON.parse(JSON.stringify(resourcePaths)));
+ });
+ }
+
+ /**
+ * Add a callback to an event, throws if eventName is unknown
+ * @param {string} eventName
+ * @param {Function} callback
+ */
+ function addCallback(eventName, callback) {
+ callbacks[eventName].push(callback);
+
+ // Invoke onInit callbacks if they missed the boat
+ if (eventName === 'init' && initialized) {
+ callback();
+ }
+
+ if (eventName === 'resourcesLoaded' && resourcePaths.length > 0) {
+ callback(resourcePaths);
+ }
+ }
+
+ exports.onInit = onInit;
+ exports.onNetworkSetSettings = onNetworkSetSettings;
+ exports.addCallback = addCallback;
+}(realityEditor.addons));
diff --git a/src/ai/crc.js b/src/ai/crc.js
new file mode 100644
index 000000000..b8706ec01
--- /dev/null
+++ b/src/ai/crc.js
@@ -0,0 +1,121 @@
+createNameSpace("realityEditor.ai.crc");
+
+/**
+ * @fileOverview realityEditor.ai.crc
+ * Contains a method to convert a tool / avatar id (eg: _WORLD_sessionDPjwiU0r_P4jnv2zz9aaspatialDraw1rXagcg6zvdk0)
+ * into a 6-digit alphanumeric "scrambled id" (eg: 2yM7oX).
+ * The scrambled id is semantic-agnostic, so that AI process it as a whole unique id.
+ */
+
+(function(exports) {
+
+ // generate crc32 and checksum
+ var crcTable = [0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA,
+ 0x076DC419, 0x706AF48F, 0xE963A535, 0x9E6495A3,
+ 0x0EDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988,
+ 0x09B64C2B, 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91,
+ 0x1DB71064, 0x6AB020F2, 0xF3B97148, 0x84BE41DE,
+ 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7,
+ 0x136C9856, 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC,
+ 0x14015C4F, 0x63066CD9, 0xFA0F3D63, 0x8D080DF5,
+ 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172,
+ 0x3C03E4D1, 0x4B04D447, 0xD20D85FD, 0xA50AB56B,
+ 0x35B5A8FA, 0x42B2986C, 0xDBBBC9D6, 0xACBCF940,
+ 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59,
+ 0x26D930AC, 0x51DE003A, 0xC8D75180, 0xBFD06116,
+ 0x21B4F4B5, 0x56B3C423, 0xCFBA9599, 0xB8BDA50F,
+ 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924,
+ 0x2F6F7C87, 0x58684C11, 0xC1611DAB, 0xB6662D3D,
+ 0x76DC4190, 0x01DB7106, 0x98D220BC, 0xEFD5102A,
+ 0x71B18589, 0x06B6B51F, 0x9FBFE4A5, 0xE8B8D433,
+ 0x7807C9A2, 0x0F00F934, 0x9609A88E, 0xE10E9818,
+ 0x7F6A0DBB, 0x086D3D2D, 0x91646C97, 0xE6635C01,
+ 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E,
+ 0x6C0695ED, 0x1B01A57B, 0x8208F4C1, 0xF50FC457,
+ 0x65B0D9C6, 0x12B7E950, 0x8BBEB8EA, 0xFCB9887C,
+ 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65,
+ 0x4DB26158, 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2,
+ 0x4ADFA541, 0x3DD895D7, 0xA4D1C46D, 0xD3D6F4FB,
+ 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0,
+ 0x44042D73, 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9,
+ 0x5005713C, 0x270241AA, 0xBE0B1010, 0xC90C2086,
+ 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F,
+ 0x5EDEF90E, 0x29D9C998, 0xB0D09822, 0xC7D7A8B4,
+ 0x59B33D17, 0x2EB40D81, 0xB7BD5C3B, 0xC0BA6CAD,
+ 0xEDB88320, 0x9ABFB3B6, 0x03B6E20C, 0x74B1D29A,
+ 0xEAD54739, 0x9DD277AF, 0x04DB2615, 0x73DC1683,
+ 0xE3630B12, 0x94643B84, 0x0D6D6A3E, 0x7A6A5AA8,
+ 0xE40ECF0B, 0x9309FF9D, 0x0A00AE27, 0x7D079EB1,
+ 0xF00F9344, 0x8708A3D2, 0x1E01F268, 0x6906C2FE,
+ 0xF762575D, 0x806567CB, 0x196C3671, 0x6E6B06E7,
+ 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC,
+ 0xF9B9DF6F, 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5,
+ 0xD6D6A3E8, 0xA1D1937E, 0x38D8C2C4, 0x4FDFF252,
+ 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B,
+ 0xD80D2BDA, 0xAF0A1B4C, 0x36034AF6, 0x41047A60,
+ 0xDF60EFC3, 0xA867DF55, 0x316E8EEF, 0x4669BE79,
+ 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236,
+ 0xCC0C7795, 0xBB0B4703, 0x220216B9, 0x5505262F,
+ 0xC5BA3BBE, 0xB2BD0B28, 0x2BB45A92, 0x5CB36A04,
+ 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D,
+ 0x9B64C2B0, 0xEC63F226, 0x756AA39C, 0x026D930A,
+ 0x9C0906A9, 0xEB0E363F, 0x72076785, 0x05005713,
+ 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0x0CB61B38,
+ 0x92D28E9B, 0xE5D5BE0D, 0x7CDCEFB7, 0x0BDBDF21,
+ 0x86D3D2D4, 0xF1D4E242, 0x68DDB3F8, 0x1FDA836E,
+ 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777,
+ 0x88085AE6, 0xFF0F6A70, 0x66063BCA, 0x11010B5C,
+ 0x8F659EFF, 0xF862AE69, 0x616BFFD3, 0x166CCF45,
+ 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2,
+ 0xA7672661, 0xD06016F7, 0x4969474D, 0x3E6E77DB,
+ 0xAED16A4A, 0xD9D65ADC, 0x40DF0B66, 0x37D83BF0,
+ 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9,
+ 0xBDBDF21C, 0xCABAC28A, 0x53B39330, 0x24B4A3A6,
+ 0xBAD03605, 0xCDD70693, 0x54DE5729, 0x23D967BF,
+ 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94,
+ 0xB40BBE37, 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D];
+
+
+ var crc = 0xffffffff;
+
+ function crc32(data) {
+ for (var i = 0, l = data.length; i < l; i++) {
+ crc = crc >>> 8 ^ crcTable[crc & 255 ^ data[i]];
+ }
+ return (crc ^ -1) >>> 0;
+ }
+
+
+ function crc16reset() {
+ crc = 0xffffffff;
+ }
+
+ function itob62(i) {
+ var u = i;
+ var b32 = '';
+ do {
+ var d = Math.floor(u % 62);
+ if (d < 10) {
+
+ b32 = String.fromCharCode('0'.charCodeAt(0) + d) + b32;
+ } else if (d < 36) {
+ b32 = String.fromCharCode('a'.charCodeAt(0) + d - 10) + b32;
+ } else {
+ b32 = String.fromCharCode('A'.charCodeAt(0) + d - 36) + b32;
+ }
+
+ u = Math.floor(u / 62);
+
+ } while (u > 0);
+
+ return b32;
+ }
+
+ function generateChecksum(data) {
+ crc16reset();
+ return itob62(crc32(data));
+ }
+
+ exports.generateChecksum = generateChecksum;
+
+}(realityEditor.ai.crc));
diff --git a/src/ai/index.js b/src/ai/index.js
new file mode 100644
index 000000000..d7ed4c5bc
--- /dev/null
+++ b/src/ai/index.js
@@ -0,0 +1,551 @@
+createNameSpace("realityEditor.ai");
+
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+
+(function(exports) {
+
+ let aiPrompt = '';
+ let categorize_prompt = 'Which one of the following items best describes my question? 1. "summary", 2. "debug", 3. "tools", 4. "pdf", 5. "tool content", 6. "not relevant". You can only return one of these items in string.';
+ let callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('ai');
+
+ function registerCallback(functionName, callback) {
+ if (!callbackHandler) {
+ callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('ai');
+ }
+ callbackHandler.registerCallback(functionName, callback);
+ }
+
+ function setupSystemEventListeners() {
+ map.setupEventListeners();
+ realityEditor.gui.pocket.registerCallback('frameAdded', (params) => {
+ let avatarId = realityEditor.avatar.getAvatarObjectKeyFromSessionId(globalStates.tempUuid);
+ onFrameAdded(params, avatarId);
+ });
+ realityEditor.network.registerCallback('frameAdded', (params) => {
+ onFrameAdded(params, params.additionalInfo.avatarName);
+ });
+ // todo Steve: add tool reposition event triggering for the user who added the tool themselves
+ realityEditor.network.registerCallback('frameRepositioned', (params) => {
+ onFrameRepositioned(params, params.additionalInfo.avatarName);
+ });
+ realityEditor.device.registerCallback('vehicleDeleted', (params) => {
+ let avatarId = realityEditor.avatar.getAvatarObjectKeyFromSessionId(globalStates.tempUuid);
+ onFrameDeleted(params, avatarId);
+ });
+ realityEditor.network.registerCallback('vehicleDeleted', (params) => {
+ onFrameDeleted(params, params.additionalInfo.avatarName);
+ });
+ }
+
+ function focusOnFrame(frameKey) {
+ let framePosition = realityEditor.gui.threejsScene.getToolPosition(frameKey);
+ let cameraPosition = realityEditor.gui.threejsScene.getCameraPosition();
+ let frameDirection = cameraPosition.clone().sub(framePosition).normalize();
+ callbackHandler.triggerCallbacks('shouldFocusVirtualCamera', {
+ pos: {x: framePosition.x, y: framePosition.y, z: framePosition.z},
+ dir: {x: frameDirection.x, y: frameDirection.y, z: frameDirection.z}
+ });
+ }
+
+ function onFrameAdded(params, avatarId = 'Anonymous id') {
+ let objectId = params.objectKey;
+ let frameId = params.frameKey;
+ let frameType = params.frameType;
+ let frame = realityEditor.getFrame(objectId, frameId);
+
+ let m = frame.ar.matrix;
+ let position = new THREE.Vector3(m[12], m[13], m[14]);
+ let groundPlaneMatrix = realityEditor.sceneGraph.getGroundPlaneNode().worldMatrix;
+ let inverseGroundPlaneMatrix = new realityEditor.gui.threejsScene.THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(inverseGroundPlaneMatrix, groundPlaneMatrix);
+ inverseGroundPlaneMatrix.invert();
+ position.applyMatrix4(inverseGroundPlaneMatrix);
+
+ let timestamp = getFormattedTime();
+ let avatarName = realityEditor.avatar.getAvatarNameFromObjectKey(avatarId);
+ let avatarScrambledId = realityEditor.ai.crc.generateChecksum(avatarId);
+ let frameScrambledId = realityEditor.ai.crc.generateChecksum(frameId);
+ map.addToMap(avatarId, avatarName, avatarScrambledId);
+ map.addToMap(frameId, frameType, frameScrambledId);
+ let newInfo = `User ${avatarScrambledId} added a ${frameScrambledId} tool at ${timestamp} at (${position.x.toFixed(0)},${position.y.toFixed(0)},${position.z.toFixed(0)})`;
+ // let newInfo = `${avatarId} added ${frameId} at ${timestamp} at (${position.x.toFixed(0)},${position.y.toFixed(0)},${position.z.toFixed(0)})`;
+ // let newInfo = `The user ${avatarName} id:${avatarId} added a tool ${frameType} id:${frameId} at ${timestamp} at (${position.x.toFixed(0)},${position.y.toFixed(0)},${position.z.toFixed(0)})`;
+ aiPrompt += `\n${newInfo}`;
+ }
+
+ function onFrameRepositioned(params, avatarId = 'Anonymous id') {
+ let objectId = params.objectKey;
+ let frameId = params.frameKey;
+ let frameType = params.additionalInfo.frameType;
+ let frame = realityEditor.getFrame(objectId, frameId);
+
+ let m = frame.ar.matrix;
+ let position = new THREE.Vector3(m[12], m[13], m[14]);
+ let groundPlaneMatrix = realityEditor.sceneGraph.getGroundPlaneNode().worldMatrix;
+ let inverseGroundPlaneMatrix = new realityEditor.gui.threejsScene.THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(inverseGroundPlaneMatrix, groundPlaneMatrix);
+ inverseGroundPlaneMatrix.invert();
+ position.applyMatrix4(inverseGroundPlaneMatrix);
+
+ let timestamp = getFormattedTime();
+ let avatarName = realityEditor.avatar.getAvatarNameFromObjectKey(avatarId);
+ let avatarScrambledId = realityEditor.ai.crc.generateChecksum(avatarId);
+ let frameScrambledId = realityEditor.ai.crc.generateChecksum(frameId);
+ map.addToMap(avatarId, avatarName, avatarScrambledId);
+ map.addToMap(frameId, frameType, frameScrambledId);
+ let newInfo = `User ${avatarScrambledId} repositioned a ${frameScrambledId} tool at ${timestamp} to (${position.x.toFixed(0)},${position.y.toFixed(0)},${position.z.toFixed(0)})`;
+ // let newInfo = `The user ${avatarName} id:${avatarId} repositioned a tool ${frameType} id:${frameId} at ${timestamp} to (${position.x.toFixed(0)},${position.y.toFixed(0)},${position.z.toFixed(0)})`;
+ aiPrompt += `\n${newInfo}`;
+ }
+
+ function onFrameDeleted(params, avatarId = 'Anonymous id') {
+ if (params.objectKey && params.frameKey && !params.nodeKey) { // only send message about frames, not nodes
+ let frameId = params.frameKey;
+ let frameType = params.additionalInfo.frameType;
+
+ let timestamp = getFormattedTime();
+ let avatarName = realityEditor.avatar.getAvatarNameFromObjectKey(avatarId);
+ let avatarScrambledId = realityEditor.ai.crc.generateChecksum(avatarId);
+ let frameScrambledId = realityEditor.ai.crc.generateChecksum(frameId);
+ map.addToMap(avatarId, avatarName, avatarScrambledId);
+ map.addToMap(frameId, frameType, frameScrambledId);
+ let newInfo = `User ${avatarScrambledId} deleted a ${frameScrambledId} tool at ${timestamp}`;
+ // let newInfo = `The user ${avatarName} id:${avatarId} deleted a tool ${frameType} id:${frameId} at ${timestamp}`;
+ aiPrompt += `\n${newInfo}`;
+ }
+ }
+
+ function onOpen(envelope, avatarId = 'Anonymous id') {
+ const object = objects[envelope.object];
+ if (!object) {
+ return;
+ }
+ const frame = object.frames[envelope.frame];
+ if (!frame) {
+ return;
+ }
+
+ let timestamp = getFormattedTime();
+ let frameId = frame.uuid;
+ let frameType = frame.src;
+ let additionalDescription = '';
+ if (frameType === 'spatialDraw') {
+ additionalDescription = ' and annotated the space';
+ } else if (frameType === 'spatialAnalytics') {
+ additionalDescription = " and started recording the worker's pose for later analysis";
+ } else if (frameType === 'spatialMeasure') {
+ additionalDescription = ' and measured some objects in the space';
+ } else if (frameType === 'communication') {
+ additionalDescription = ' and discussed about some issues';
+ }
+ let avatarName = realityEditor.avatar.getAvatarNameFromObjectKey(avatarId);
+ let avatarScrambledId = realityEditor.ai.crc.generateChecksum(avatarId);
+ let frameScrambledId = realityEditor.ai.crc.generateChecksum(frameId);
+ map.addToMap(avatarId, avatarName, avatarScrambledId);
+ map.addToMap(frameId, frameType, frameScrambledId);
+ let newInfo = `User ${avatarScrambledId} opened a ${frameScrambledId} tool at ${timestamp} ${additionalDescription}`;
+ aiPrompt += `\n${newInfo}`;
+ }
+
+ function onClose(envelope, avatarId = 'Anonymous id') {
+ const object = objects[envelope.object];
+ if (!object) {
+ return;
+ }
+ const frame = object.frames[envelope.frame];
+ if (!frame) {
+ return;
+ }
+
+ let timestamp = getFormattedTime();
+ let frameId = frame.uuid;
+ let frameType = frame.src;
+ let avatarName = realityEditor.avatar.getAvatarNameFromObjectKey(avatarId);
+ let avatarScrambledId = realityEditor.ai.crc.generateChecksum(avatarId);
+ let frameScrambledId = realityEditor.ai.crc.generateChecksum(frameId);
+ map.addToMap(avatarId, avatarName, avatarScrambledId);
+ map.addToMap(frameId, frameType, frameScrambledId);
+ let newInfo = `User ${avatarScrambledId} closed a ${frameScrambledId} tool at ${timestamp}`;
+ aiPrompt += `\n${newInfo}`;
+ }
+
+ function onBlur(envelope, avatarId = 'Anonymous id') {
+ const object = objects[envelope.object];
+ if (!object) {
+ return;
+ }
+ const frame = object.frames[envelope.frame];
+ if (!frame) {
+ return;
+ }
+
+ let timestamp = getFormattedTime();
+ let frameId = frame.uuid;
+ let frameType = frame.src;
+ let avatarName = realityEditor.avatar.getAvatarNameFromObjectKey(avatarId);
+ let avatarScrambledId = realityEditor.ai.crc.generateChecksum(avatarId);
+ let frameScrambledId = realityEditor.ai.crc.generateChecksum(frameId);
+ map.addToMap(avatarId, avatarName, avatarScrambledId);
+ map.addToMap(frameId, frameType, frameScrambledId);
+ let newInfo = `User ${avatarScrambledId} minimized a ${frameScrambledId} tool at ${timestamp}`;
+ // let newInfo = `The user ${avatarName} id:${avatarId} minimized a tool ${frameType} id:${frame.uuid} at ${timestamp}`;
+ aiPrompt += `\n${newInfo}`;
+ }
+
+ function onAvatarChangeName(oldName, _newName) {
+ // todo Steve: after switching from getavatarIdFromSessionId() to getAvatarObjectKeyFromSessionId(), this still stays the old way. Need to change later
+ let _timestamp = getFormattedTime();
+ if (oldName === null) {
+ // let newInfo = `User ${newName} joined the space at ${timestamp}`;
+ // aiPrompt += `\n${newInfo}`;
+ } else {
+ // let newInfo = `User ${oldName} has changed their name to ${newName}`;
+ // aiPrompt += `\n${newInfo}`;
+ }
+ }
+
+ function getFormattedTime() {
+ return new Date().toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false
+ });
+ }
+
+ let aiContainer;
+ let endpointArea, apiKeyArea;
+ let searchTextArea;
+ let dialogueContainer;
+
+ let keyPressed = {
+ 'Shift': false,
+ 'Enter': false,
+ };
+ let PAST_MESSAGES_INCLUDED = 20;
+ let map;
+
+ function initService() {
+ aiContainer = document.getElementById('ai-chat-tool-container');
+ endpointArea = document.getElementById('ai-endpoint-text-area');
+ apiKeyArea = document.getElementById('ai-api-key-text-area');
+ searchTextArea = document.getElementById('searchTextArea');
+ searchTextArea.style.display = 'none'; // initially, before inputting endpoint and api key, hide the search text area
+ dialogueContainer = document.getElementById('ai-chat-tool-dialogue-container');
+
+ map = realityEditor.ai.mapping;
+
+ scrollToBottom();
+ initTextAreaSize();
+ adjustTextAreaSize();
+ setupEventListeners();
+ setupSystemEventListeners();
+ hideEndpointApiKeyAndShowSearchTextArea();
+ }
+
+ function authorSetToString(authorSet) {
+ let authorSetArr = Array.from(authorSet);
+ let authorSetString = '';
+ if (authorSetArr.length === 1) {
+ authorSetString = `${authorSetArr[0]}`;
+ return authorSetString;
+ }
+ for (let i = 0; i < authorSetArr.length; i++) {
+ if (i === 0) {
+ authorSetString += authorSetArr[0];
+ } else if (i === authorSetArr.length - 1) {
+ authorSetString += `, and ${authorSetArr[authorSetArr.length - 1]}`;
+ } else {
+ authorSetString += `, ${authorSetArr[i]}`;
+ }
+ }
+ return authorSetString;
+ }
+
+ function askQuestion() {
+ let authorAll = [];
+ let chatAll = [];
+ // todo Steve: for the MS recording
+ let frames = realityEditor.worldObjects.getBestWorldObject().frames;
+ for (const frameId in frames) {
+ const frame = frames[frameId];
+ if (frame.src === 'communication') {
+ const storage = Object.values(frame.nodes)[0];
+ const messages = storage.publicData.messages;
+ if (messages === undefined) continue;
+ let authorSet = new Set();
+ let chat = '';
+ for (const message of messages) {
+ authorSet.add(message.author);
+ chat += message.messageText;
+ chat += '. ';
+ }
+ authorAll.push(authorSetToString(authorSet));
+ chatAll.push(chat);
+ } else if (frame.src === 'spatialAnalytics') {
+ console.log(frame);
+ let hpos = realityEditor.humanPose.returnHumanPoseObjects();
+ console.log(hpos);
+
+ const storage = Object.values(frame.nodes)[0];
+ const regionCards = storage.publicData.analyticsData.regionCards;
+ for (let i = 0; i < regionCards.length; i++) {
+ let _label = regionCards[i].label;
+ let startTime = regionCards[i].startTime;
+ let endTime = regionCards[i].endTime;
+ let motionStudy = realityEditor.motionStudy.getMotionStudyByFrame(frameId);
+ if (motionStudy === undefined) continue;
+ let _cloneDataStart = motionStudy.humanPoseAnalyzer.getClonesByTimestamp(startTime);
+ let _cloneDataEnd = motionStudy.humanPoseAnalyzer.getClonesByTimestamp(endTime);
+ }
+ }
+ }
+ // return;
+
+ let dialogueLengthTotal = dialogueContainer.children.length;
+ let maxDialogueLength = Math.min(PAST_MESSAGES_INCLUDED, dialogueLengthTotal);
+ let firstDialogueIndex = dialogueLengthTotal - maxDialogueLength - (PAST_MESSAGES_INCLUDED >= dialogueLengthTotal ? 0 : 1);
+ let lastDialogueIndex = firstDialogueIndex + maxDialogueLength + (PAST_MESSAGES_INCLUDED >= dialogueLengthTotal ? 0 : 1);
+ let conversation = {};
+ for (let i = firstDialogueIndex; i < lastDialogueIndex; i++) {
+ let child = dialogueContainer.children[i];
+ let conversationObjectIndex = i;
+ if (child.classList.contains('ai-chat-tool-dialogue-my')) {
+ if (i === lastDialogueIndex - 1) { // last dialogue, need to include the categorize question here
+ conversation[conversationObjectIndex] = { role: "user",
+ content: `${aiPrompt}\n${map.preprocess(child.innerHTML)}`,
+ extra: `${categorize_prompt}`,
+ communicationToolInfo: {
+ authorAll,
+ chatAll
+ }
+ };
+ } else {
+ conversation[conversationObjectIndex] = {role: "user", content: `${map.preprocess(child.innerHTML)}`};
+ }
+ } else if (child.classList.contains('ai-chat-tool-dialogue-ai')) {
+ conversation[conversationObjectIndex] = { role: "assistant", content: `${map.preprocess(child.innerHTML)}` };
+ }
+ }
+ // todo Steve: include extra information here to provide to ai
+ let extra = {
+ worldObjectId: realityEditor.worldObjects.getBestWorldObject().objectId,
+ }
+ console.log(conversation);
+ realityEditor.network.postQuestionToAI(conversation, extra);
+ }
+
+ function getAnswer(category, answer) {
+ console.log(`%c This question is of category ${category}`, 'color: blue');
+
+ // todo Steve new: preprocess the answer in map.js, and then send out the processed answer to the dialogue
+ // but since the processed answer got fed straight into the ai prompt, not sure if this is a good idea, or the names need to be converted to ids again, and fed back to the ai
+ let html = map.postprocess(answer);
+ pushAIDialogue(html);
+ }
+
+ function getToolAnswer(category, tools) {
+ console.log(`%c This question is of category ${category}`, 'color: blue');
+
+ let result = tools.split('\n');
+ console.log(result);
+ let bestWorldObject = realityEditor.worldObjects.getBestWorldObject();
+ let frames = [];
+ for (let frame of Object.values(bestWorldObject.frames)) {
+ if (!result.includes(frame.src)) continue;
+ frames.push(frame);
+ }
+ pushToolDialogue(frames, result);
+ }
+
+ function showDialogue() {
+ aiContainer.style.animation = `slideToRight 0.2s ease-in forwards`;
+ }
+
+ function hideDialogue() {
+ aiContainer.style.animation = `slideToLeft 0.2s ease-in forwards`;
+ }
+
+ function hideEndpointApiKeyAndShowSearchTextArea() {
+ endpointArea.style.display = 'none';
+ apiKeyArea.style.display = 'none';
+ searchTextArea.style.display = 'block';
+ adjustTextAreaSize();
+ }
+
+ function setupEventListeners() {
+ endpointArea.addEventListener('keydown', (e) => {
+ e.stopPropagation();
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (endpointArea.value === '' || apiKeyArea.value === '') return;
+ realityEditor.network.postAiApiKeys(endpointArea.value, apiKeyArea.value, true);
+ }
+ })
+ apiKeyArea.addEventListener('keydown', (e) => {
+ e.stopPropagation();
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (endpointArea.value === '' || apiKeyArea.value === '') return;
+ realityEditor.network.postAiApiKeys(endpointArea.value, apiKeyArea.value, true);
+ }
+ })
+
+ searchTextArea.addEventListener('input', function() {
+ // adjustTextAreaSize();
+ });
+
+ searchTextArea.addEventListener('pointerdown', (e) => {e.stopPropagation();});
+ searchTextArea.addEventListener('pointerup', (e) => {e.stopPropagation();});
+ searchTextArea.addEventListener('pointermove', (e) => {e.stopPropagation();});
+ searchTextArea.addEventListener('contextmenu', (e) => {e.stopPropagation();});
+
+ searchTextArea.addEventListener('keydown', (e) => {
+ e.stopPropagation();
+ adjustTextAreaSize();
+
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ keyPressed['Enter'] = true;
+
+ if (keyPressed['Shift'] === true) {
+ searchTextArea.value += '\n';
+ adjustTextAreaSize();
+ return;
+ }
+
+ pushMyDialogue(searchTextArea.value);
+ clearMyDialogue();
+ adjustTextAreaSize();
+ } else if (e.key === 'Shift') {
+ e.preventDefault();
+ keyPressed['Shift'] = true;
+
+ if (keyPressed['Enter'] === true) {
+ searchTextArea.value += '\n';
+ adjustTextAreaSize();
+ }
+ }
+ });
+
+ searchTextArea.addEventListener('keyup', (e) => {
+ e.stopPropagation();
+
+ if (e.key === 'Enter') {
+ keyPressed['Enter'] = false;
+ } else if (e.key === 'Shift') {
+ keyPressed['Shift'] = false;
+ }
+ });
+
+ window.addEventListener('blur', () => {
+ keyPressed['Enter'] = false;
+ keyPressed['Shift'] = false;
+ });
+
+ dialogueContainer.addEventListener('wheel', (e) => {
+ e.stopPropagation();
+ });
+
+ window.addEventListener('resize', () => {
+ adjustTextAreaSize();
+ });
+ }
+
+ let originalHeight = null;
+ function initTextAreaSize() {
+ originalHeight = searchTextArea.scrollHeight;
+ }
+
+ function adjustTextAreaSize() {
+ // searchTextArea.style.flexShrink = '1';
+ // searchTextArea.style.height = 'auto';
+ if (searchTextArea.scrollHeight > window.innerHeight / 4) {
+ searchTextArea.style.height = (window.innerHeight / 4) + 'px';
+ } else {
+ searchTextArea.style.height = (searchTextArea.scrollHeight) + 'px';
+ // todo Steve: this function is buggy, doesn't return the smallest scroll height of the text box
+ }
+ // searchTextArea.style.flexShrink = '0';
+ }
+
+ function resetTextAreaSize() {
+ if (originalHeight === null) {
+ originalHeight = searchTextArea.scrollHeight;
+ searchTextArea.style.height = originalHeight + 'px';
+ } else {
+ searchTextArea.style.height = originalHeight + 'px';
+ }
+ }
+
+ function pushToolDialogue(frames, result) {
+ let d = document.createElement('div');
+ d.classList.add('ai-chat-tool-dialogue', 'ai-chat-tool-dialogue-ai', 'ai-chat-tool-dialogue-tools');
+ d.innerText = `Here are all the ${result} tools:`;
+
+ for (let frame of frames) {
+ let b = document.createElement('button');
+ let frameKey = frame.uuid;
+ b.innerText = `${frame.src} tool`;
+ b.addEventListener('click', () => {
+ focusOnFrame(frameKey);
+ });
+ d.appendChild(b);
+ }
+
+ dialogueContainer.append(d);
+ scrollToBottom();
+ }
+
+ function pushMyDialogue(text) {
+ if (!text.trim()) {
+ console.log('error');
+ return;
+ }
+ let d = document.createElement('div');
+ d.classList.add('ai-chat-tool-dialogue', 'ai-chat-tool-dialogue-my');
+ d.innerText = text;
+ dialogueContainer.append(d);
+ realityEditor.avatar.network.sendAiDialogue(realityEditor.avatar.getMyAvatarNodeInfo(), d.outerHTML);
+ scrollToBottom();
+
+ askQuestion();
+ }
+
+ function pushAIDialogue(html) {
+ dialogueContainer.append(html);
+ realityEditor.avatar.network.sendAiDialogue(realityEditor.avatar.getMyAvatarNodeInfo(), html.outerHTML);
+ scrollToBottom();
+ }
+
+ function pushDialogueFromOtherUser(html) {
+ dialogueContainer.insertAdjacentHTML('beforeend', html);
+ }
+
+ function scrollToBottom() {
+ dialogueContainer.scrollTop = dialogueContainer.scrollHeight;
+ }
+
+ function clearMyDialogue() {
+ searchTextArea.value = '';
+ resetTextAreaSize();
+ }
+
+ exports.initService = initService;
+ exports.registerCallback = registerCallback;
+ exports.askQuestion = askQuestion;
+ exports.getAnswer = getAnswer;
+ exports.getToolAnswer = getToolAnswer;
+ exports.pushDialogueFromOtherUser = pushDialogueFromOtherUser;
+ exports.onOpen = onOpen;
+ exports.onClose = onClose;
+ exports.onBlur = onBlur;
+ exports.onFrameAdded = onFrameAdded;
+ exports.onFrameRepositioned = onFrameRepositioned;
+ exports.onFrameDeleted = onFrameDeleted;
+ exports.onAvatarChangeName = onAvatarChangeName;
+ exports.showDialogue = showDialogue;
+ exports.hideDialogue = hideDialogue;
+ exports.hideEndpointApiKeyAndShowSearchTextArea = hideEndpointApiKeyAndShowSearchTextArea;
+ exports.focusOnFrame = focusOnFrame;
+
+}(realityEditor.ai));
diff --git a/src/ai/mapping.js b/src/ai/mapping.js
new file mode 100644
index 000000000..d31aca18d
--- /dev/null
+++ b/src/ai/mapping.js
@@ -0,0 +1,139 @@
+createNameSpace("realityEditor.ai.mapping");
+
+/**
+ * @fileOverview realityEditor.ai.mapping
+ * When a new "spatial action" (eg: add/reposition/delete/open/minimize/close a tool, avatar name change, etc) takes place,
+ * add a mapping of the corresponding {id, name, scrambled id (crc.js encoded id)} as a reference.
+ * These mappings will be used later to convert between ids and human-readable names when prompting & getting answer from the AI chatbot.
+ */
+
+(function(exports) {
+
+ let bestWorldObjectId = '_WORLD_';
+ let toolRegex = new RegExp(bestWorldObjectId);
+ let avatarRegex = new RegExp('_AVATAR_');
+ // let toolRegex = new RegExp("\\b[a-zA-Z0-9]{6}\\b");
+ // let avatarRegex = new RegExp("\\b[a-zA-Z0-9]{6}\\b");
+ let animations = {};
+
+ // {id, scrambled id}, {scrambled id, id}, and {id, name} maps, with id as the unique identifier field, similar to traditional databases
+ class ThreeMap {
+ constructor() {
+ this.idToScrambledId = new Map();
+ this.scrambledIdToId = new Map();
+ this.idToName = new Map();
+ }
+
+ set(key, value, scrambledKey) {
+ if (this.idToScrambledId.has(key)) return;
+ this.idToScrambledId.set(key, scrambledKey);
+ this.scrambledIdToId.set(scrambledKey, key);
+ this.idToName.set(key, value);
+ }
+ }
+
+ let threeMap = new ThreeMap();
+
+ function addToMap(key, value, scrambledKey) {
+ threeMap.set(key, value, scrambledKey);
+ }
+
+ function printMap() {
+ console.log(threeMap);
+ }
+
+ // preprocess the historical message that got fed into ai prompt, to replace actual names with id names
+ function preprocess(html) {
+ // take the inner html, and convert id's back to scrambled id's, and replace name's with scrambled id's
+ let resultHTML = new DOMParser().parseFromString(html, 'text/html');
+ threeMap.idToScrambledId.forEach((value, key) => {
+ let spans = [...resultHTML.querySelectorAll(`span[data-id="${key}"]`)];
+ spans.forEach(span => {
+ span.textContent = value;
+ });
+ })
+ return resultHTML.body.innerText;
+ }
+
+ // postprocess ai answer, to replace id names with actual names, and set links to trigger highlights
+ function postprocess(text) {
+ if (!text.trim()) {
+ console.log('error: post processing but no text');
+ return;
+ }
+ let html = text.replace(/\n/g, ' ');
+
+ threeMap.scrambledIdToId.forEach((value, key) => {
+ let regex = new RegExp(`${key}`, 'g');
+ if (html.match(regex)) {
+ html = html.replace(regex, `${threeMap.idToName.get(value)} `); // convert name back to actual id, for mouse click --> line animation
+ }
+ });
+
+ let d = document.createElement('div');
+ d.classList.add('ai-chat-tool-dialogue', 'ai-chat-tool-dialogue-ai');
+ d.innerHTML = html;
+
+ return d;
+ }
+
+ function setupEventListeners() {
+ let currentDiv = null;
+
+ function onMouseDown() {
+ realityEditor.ai.focusOnFrame(currentDiv.dataset.id);
+ }
+
+ function onMouseLeave() {
+ setFrameHighlight(currentDiv.dataset.id, false);
+ currentDiv.removeEventListener('mousedown', onMouseDown);
+ currentDiv.removeEventListener('mouseleave', onMouseLeave);
+ }
+
+ let dialogueContainer = document.getElementById('ai-chat-tool-dialogue-container');
+ dialogueContainer.addEventListener('mouseover', (e) => {
+ currentDiv = e.target;
+ if (currentDiv.classList.contains('ai-highlight')) {
+ if (currentDiv.dataset.id.match(avatarRegex)) {
+ // todo Steve: make a line link to the corresponding avatar icon? Or turn camera to the avatar cube?
+ } else if (currentDiv.dataset.id.match(toolRegex)) {
+ let rect = currentDiv.getBoundingClientRect();
+ setFrameHighlight(currentDiv.dataset.id, true, {x: rect.x + rect.width / 2, y: rect.y + rect.height / 2});
+
+ currentDiv.addEventListener('mousedown', onMouseDown);
+
+ currentDiv.addEventListener('mouseleave', onMouseLeave);
+ }
+ }
+ })
+ }
+
+ function setFrameHighlight(frameId, isHighlighted, startPos) {
+ let animation = animations[frameId];
+ if (!isHighlighted) {
+ if (!animation) {
+ return;
+ }
+ animation.hoveredFrameId = null;
+ // if (animation.hoverAnimationPercent <= 0) {
+ realityEditor.gui.recentlyUsedBar.removeAnimation(animation);
+ delete animations[frameId];
+ // }
+ return;
+ }
+
+ if (!animation) {
+ animation = realityEditor.gui.recentlyUsedBar.createAnimation(frameId, false, true, startPos);
+ animations[frameId] = animation;
+ } else {
+ animation.hoveredFrameId = frameId;
+ }
+ }
+
+ exports.setupEventListeners = setupEventListeners;
+ exports.addToMap = addToMap;
+ exports.printMap = printMap;
+ exports.preprocess = preprocess;
+ exports.postprocess = postprocess;
+
+}(realityEditor.ai.mapping));
diff --git a/src/app/callbacks.js b/src/app/callbacks.js
new file mode 100644
index 000000000..7b8459db6
--- /dev/null
+++ b/src/app/callbacks.js
@@ -0,0 +1,541 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Ben Reynolds on 7/17/18.
+ */
+
+createNameSpace('realityEditor.app.callbacks');
+
+/**
+ * @fileOverview realityEditor.app.callbacks.js
+ * The central location where all functions triggered from within the native iOS code should reside.
+ * Includes processing detected matrices from the Vuforia Engine, and processing UDP messages.
+ * These can just be simple routing functions that trigger the appropriate function in other files,
+ * but this acts to organize all API calls in a single place.
+ * Note: callbacks related to target downloading are located in the targetDownloader module.
+ */
+
+(function(exports) {
+
+ // save this matrix in a local scope for faster retrieval
+ realityEditor.app.callbacks.rotationXMatrix = rotationXMatrix;
+
+ let hasActiveGroundPlaneStream = false;
+
+ // other modules can subscribe to what's happening here
+ let subscriptions = {
+ onPoseReceived: []
+ }
+
+ /**
+ * Callback for realityEditor.app.getVuforiaReady
+ * Triggered when Vuforia Engine finishes initializing.
+ * Retrieves the projection matrix and starts streaming the model matrices, camera matrix, and groundplane matrix.
+ * Also starts the object discovery and download process.
+ */
+ function vuforiaIsReady(success) {
+ if (typeof success !== 'undefined' && !success) {
+
+ while (listeners.onVuforiaInitFailure.length > 0) { // dismiss the intializing pop-up that was waiting
+ let callback = listeners.onVuforiaInitFailure.pop();
+ callback();
+ }
+
+ let headerText = 'Needs camera and microphone access';
+ let descriptionText = `Please enable camera and microphone access in your device's Settings app, and try again.`;
+
+ let notification = realityEditor.gui.modal.showSimpleNotification(
+ headerText, descriptionText, function () {}, realityEditor.device.environment.variables.layoutUIForPortrait);
+ notification.domElements.fade.style.backgroundColor = 'rgba(0,0,0,0.5)';
+ notification.domElements.container.classList.add('loaderContainerPortraitTall');
+ return;
+ }
+ // projection matrix only needs to be retrieved once
+ realityEditor.app.getProjectionMatrix('realityEditor.app.callbacks.receivedProjectionMatrix');
+
+ // subscribe to the model matrices from each recognized image or object target
+ realityEditor.app.getMatrixStream('realityEditor.app.callbacks.receiveMatricesFromAR');
+
+ // subscribe to the camera matrix from the positional device tracker
+ realityEditor.app.getCameraMatrixStream('realityEditor.app.callbacks.receiveCameraMatricesFromAR');
+
+ // Subscribe to poses if available
+ realityEditor.app.getPosesStream('realityEditor.app.callbacks.receivePoses');
+
+ // add heartbeat listener for UDP object discovery
+ realityEditor.app.getUDPMessages('realityEditor.app.callbacks.receivedUDPMessage');
+
+ // send three action UDP pings to start object discovery
+ for (var i = 0; i < 3; i++) {
+ setTimeout(function () {
+ realityEditor.app.sendUDPMessage({action: 'ping'});
+ }, 500 * i); // space out each message by 500ms
+ }
+
+ // in case engine was started for the second time, add any targets back to engine from the first instance
+ realityEditor.app.targetDownloader.reinstatePreviouslyAddedTargets();
+ }
+
+ /**
+ * Subscribe to the ground plane matrix stream that starts returning results when it has been detected and an
+ * anchor gets added to the ground. This only starts the tracker long enough to place an anchor on the ground -
+ * after that the tracker stops for performance optimization.
+ */
+ function startGroundPlaneTrackerIfNeeded() {
+ if (hasActiveGroundPlaneStream) { return; } // don't do this unnecessarily because it takes a lot of resources
+ if (!globalStates.useGroundPlane) { return; }
+
+ realityEditor.app.getGroundPlaneMatrixStream('realityEditor.app.callbacks.receiveGroundPlaneMatricesFromAR');
+ hasActiveGroundPlaneStream = true;
+
+ // automatically stop after 1 second
+ setTimeout(function() {
+ realityEditor.app.acceptGroundPlaneAndStop();
+
+ // prevent subsequent ground plane resets if the ground plane is snapped to a world object
+ let worldObject = realityEditor.worldObjects.getBestWorldObject();
+ hasActiveGroundPlaneStream = (worldObject && worldObject.uuid !== realityEditor.worldObjects.getLocalWorldId());
+ }, 1000);
+ }
+
+ /**
+ * Callback for realityEditor.app.getProjectionMatrix
+ * Sets the projection matrix once using the value from the AR engine
+ * @param {Array.} matrix
+ */
+ function receivedProjectionMatrix(matrix) {
+ if (realityEditor.device.modeTransition.isARMode()) {
+ realityEditor.gui.ar.setProjectionMatrix(matrix);
+ }
+ }
+
+ exports.acceptUDPBeats = true;
+
+ /**
+ * Callback for realityEditor.app.getUDPMessages
+ * Handles any UDP messages received by the app.
+ * Currently supports object discovery messages ("ip"/"id" pairs) and state synchronization ("action") messages
+ * Additional UDP messages can be listened for by using realityEditor.network.addUDPMessageHandler
+ * @param {string|object} message
+ */
+ function receivedUDPMessage(message) {
+ if (!exports.acceptUDPBeats && !message.network) {
+ return;
+ }
+
+ if (typeof message !== 'object') {
+ try {
+ message = JSON.parse(message);
+ } catch (e) {
+ // string doesn't need to be parsed... continue executing the function
+ }
+ }
+
+ // upon a new object discovery message, add the object and download its target files
+ if (typeof message.id !== 'undefined' &&
+ typeof message.ip !== 'undefined') {
+
+ realityEditor.network.discovery.processHeartbeat(message);
+
+ // forward the action message to the network module, to synchronize state across multiple clients
+ } else if (typeof message.ip !== 'undefined' &&
+ typeof message.services !== 'undefined') {
+
+ realityEditor.network.discovery.processServerBeat(message);
+
+ } else if (typeof message.action !== 'undefined') {
+ realityEditor.network.onAction(message.action);
+ }
+
+ // forward the message to a generic message handler that various modules use to subscribe to different messages
+ realityEditor.network.onUDPMessage(message);
+ }
+
+ // callback will trigger with array of joints {x,y,z} when a pose is detected
+ exports.subscribeToPoses = function(callback) {
+ subscriptions.onPoseReceived.push(callback);
+ }
+
+ /**
+ * Callback for realityEditor.app.getPosesStream
+ * @param {Array< {x: number, y: number, z: number, confidence: number} >} pose - joints (in world CS, in mm units)
+ * @param { {timestamp: number, imageSize: [number], focalLength: [number], principalPoint: [number], transformW2C: [number]} } frameData - frame data associated with the pose
+ * (timestamp in miliseconds, but floating point number with nanosecond precision); image size which the pose was computed from; camera intrinsics and extrinsics
+ */
+ function receivePoses(pose, frameData) {
+
+ let poseInWorld = [];
+
+ for (let point of pose) {
+ poseInWorld.push({
+ x: point.x,
+ y: point.y,
+ z: point.z,
+ confidence: point.score,
+ });
+ }
+
+ realityEditor.humanPose.draw.draw2DPoses(pose, frameData.imageSize);
+
+ // NOTE: if no pose detected, still send empty pose with a timestamp to notify other servers/clients that body tracking is 'lost'.
+ subscriptions.onPoseReceived.forEach(cb => cb(poseInWorld, frameData));
+ }
+
+ /**
+ * Callback for realityEditor.app.getMatrixStream
+ * Gets triggered ~60FPS when the AR SDK sends us a new set of modelView matrices for currently visible objects
+ * Stores those matrices in the draw module to be rendered in the next draw frame
+ * @param {Object.} visibleTargets.current - (App versions starting 2024)
+ * - Each key is a unique target ID, used internally by Vuforia. The value is an object with:
+ * - `matrix`: Length-16 transformation matrix representing the target relative to Vuforia's (0,0,0)
+ * - `targetName`: The name of the target. In old versions, this is the objectId. In current versions it can be anything.
+ * @param {Object.} visibleTargets.deprecated - (Deprecated app versions - prior to 2024)
+ * - Each key is the target name, not the target ID, and the value is the matrix (array of 16 numbers)
+ * - In this version, the target must be generated using the corresponding objectId as its target name
+ *
+ * Note: The deprecated format is maintained for backward compatibility.
+ */
+ function receiveMatricesFromAR(visibleTargets) {
+ if (!realityEditor.worldObjects) {
+ return;
+ } // prevents tons of error messages while app is loading but Vuforia has started
+
+ // If viewing the VR map instead of the AR view, don't update objects/tools based on Vuforia
+ if (!realityEditor.device.modeTransition.isARMode()) return;
+
+ // determines which format (current or deprecated) is used for visibleTargets, and extracts the correct info
+ let visibleObjects = {};
+ for (let key in visibleTargets) {
+ if (Array.isArray(visibleTargets[key])) {
+ // backwards compatible with old app versions -> the key is the objectId, and it directly holds the matrix
+ visibleObjects[key] = visibleTargets[key];
+ } else {
+ // find the object whose targetId matches the key, and store the .matrix under the objectId key
+ let matchingObjectId = Object.keys(objects).find(objectKey => {
+ return objects[objectKey].targetId === key;
+ });
+ if (matchingObjectId) {
+ visibleObjects[matchingObjectId] = visibleTargets[key].matrix;
+ } else {
+ // for old objects that weren't generated with a targetId, try to find them by targetName
+ visibleObjects[visibleTargets[key].targetName] = visibleTargets[key].matrix;
+ }
+ }
+ }
+
+ // we still need to ignore this default object in case the app provides it, to be backwards compatible with older app versions
+ if (visibleObjects.hasOwnProperty('WorldReferenceXXXXXXXXXXXX')) {
+ delete visibleObjects['WorldReferenceXXXXXXXXXXXX'];
+ }
+
+ // easiest way to implement freeze button is just to not use the new matrices when we render
+ if (globalStates.freezeButtonState) {
+ realityEditor.gui.ar.draw.update(realityEditor.gui.ar.draw.visibleObjectsCopy);
+ return;
+ }
+
+ // don't render origin objects as themselves
+ let originObjects = realityEditor.worldObjects.getOriginObjects();
+ let detectedOrigins = {};
+ Object.keys(originObjects).forEach(function(originKey) {
+ if (visibleObjects.hasOwnProperty(originKey)) {
+
+ // if (worldObject.isJpgTarget) {
+ let rotatedOriginMatrix = [];
+ realityEditor.gui.ar.utilities.multiplyMatrix(rotationXMatrix, visibleObjects[originKey], rotatedOriginMatrix);
+ // }
+
+ // detectedOrigins[originKey] = realityEditor.gui.ar.utilities.copyMatrix(visibleObjects[originKey]);
+ detectedOrigins[originKey] = realityEditor.gui.ar.utilities.copyMatrix(rotatedOriginMatrix);
+
+ // this part is just to enable the the SceneGraph/network.js to know when the origin moves enough to upload the originOffset
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(originKey);
+ if (sceneNode) {
+ sceneNode.setLocalMatrix(visibleObjects[originKey]);
+ }
+
+ delete visibleObjects[originKey];
+ }
+ });
+
+ // this next section adjusts each world origin to be centered on their image target if it ever gets recognized
+ realityEditor.worldObjects.getWorldObjectKeys().forEach(function (worldObjectKey) {
+ if (visibleObjects.hasOwnProperty(worldObjectKey)) {
+ let matchingOrigin = realityEditor.worldObjects.getMatchingOriginObject(worldObjectKey);
+ let worldObject = realityEditor.getObject(worldObjectKey);
+
+ let worldOriginMatrix = [];
+ let hasMatchingOrigin = !!matchingOrigin;
+ let isMatchingOriginVisible = (matchingOrigin && typeof detectedOrigins[matchingOrigin.uuid] !== 'undefined');
+ let hasOriginOffset = typeof worldObject.originOffset !== 'undefined';
+
+ if (!hasMatchingOrigin) {
+ worldOriginMatrix = realityEditor.gui.ar.utilities.copyMatrix(visibleObjects[worldObjectKey]);
+ } else {
+ if (!isMatchingOriginVisible) {
+ if (!hasOriginOffset) {
+ worldOriginMatrix = realityEditor.gui.ar.utilities.copyMatrix(visibleObjects[worldObjectKey]);
+ } else {
+ // calculate origin matrix using originOffset and visibleObjects[worldObjectKey]
+
+ // inverseWorld * originMatrix = relative;
+ // therefore:
+ // originMatrix = world * relative
+
+ realityEditor.gui.ar.utilities.multiplyMatrix(visibleObjects[worldObjectKey], worldObject.originOffset, worldOriginMatrix);
+ }
+ } else {
+ if (!hasOriginOffset) {
+ realityEditor.app.tap(); // haptic feedback the first time it localizes against origin
+ }
+ let relative = [];
+ let inverseWorld = realityEditor.gui.ar.utilities.invertMatrix(visibleObjects[worldObjectKey]);
+ realityEditor.gui.ar.utilities.multiplyMatrix(inverseWorld, detectedOrigins[matchingOrigin.uuid], relative);
+ worldObject.originOffset = relative;
+ worldOriginMatrix = realityEditor.gui.ar.utilities.copyMatrix(detectedOrigins[matchingOrigin.uuid]);
+ }
+ }
+
+ realityEditor.worldObjects.setOrigin(worldObjectKey, worldOriginMatrix);
+
+ if (worldObjectKey !== realityEditor.worldObjects.getLocalWorldId()) {
+ let bestWorldObject = realityEditor.worldObjects.getBestWorldObject();
+ if (!bestWorldObject || worldObjectKey === bestWorldObject.uuid) {
+
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(worldObjectKey);
+ if (sceneNode) {
+ sceneNode.setLocalMatrix(worldOriginMatrix);
+
+ // also relocalize the groundplane if it's already been detected / in use
+ if (globalStates.useGroundPlane) {
+ // let rotated = [];
+ // realityEditor.gui.ar.utilities.multiplyMatrix(this.rotationXMatrix, worldOriginMatrix, rotated);
+ let offset = [];
+ let floorOffset = 0;
+ try {
+ let navmesh = JSON.parse(window.localStorage.getItem(`realityEditor.navmesh.${worldObject.uuid}`));
+ floorOffset = navmesh.floorOffset * 1000;
+ } catch (e) {
+ console.warn('No navmesh', worldObject, e);
+ }
+ let buffer = 100;
+ floorOffset += buffer;
+ let groundPlaneOffsetMatrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, floorOffset, 0, 1
+ ];
+ let worldObjectSceneNode = realityEditor.sceneGraph.getSceneNodeById(worldObject.uuid);
+ realityEditor.gui.ar.utilities.multiplyMatrix(groundPlaneOffsetMatrix, worldObjectSceneNode.localMatrix, offset);
+ realityEditor.sceneGraph.setGroundPlanePosition(offset);
+ }
+ }
+ }
+ }
+
+ delete visibleObjects[worldObjectKey];
+ }
+ });
+
+ // this next section populates the visibleObjects matrices based on the model and view (camera) matrices
+
+ // visibleObjects contains the raw modelMatrices -> send them to the scene graph
+ for (let objectKey in visibleObjects) {
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(objectKey);
+ if (sceneNode) {
+ sceneNode.setLocalMatrix(visibleObjects[objectKey]);
+
+ let dontBroadcast = false;
+ if (!dontBroadcast && realityEditor.device.environment.isSourceOfObjectPositions()) {
+ // if it's an object, post object position relative to a world object
+ let worldObjectId = realityEditor.sceneGraph.getWorldId();
+ if (worldObjectId) {
+ let worldNode = realityEditor.sceneGraph.getSceneNodeById(worldObjectId);
+ sceneNode.updateWorldMatrix();
+ let relativeMatrix = sceneNode.getMatrixRelativeTo(worldNode);
+ realityEditor.network.realtime.broadcastUpdate(objectKey, null, null, 'matrix', relativeMatrix);
+ }
+ }
+ }
+ }
+
+ // currently the origin matrix here isn't actually used, the sceneGraph matrix is used instead
+ // but this still importantly adds all localized world objects (non-null origin) to the visibleObjects list
+ realityEditor.worldObjects.getWorldObjectKeys().forEach(function (worldObjectKey) {
+ var origin = realityEditor.worldObjects.getOrigin(worldObjectKey);
+ if (origin) {
+ visibleObjects[worldObjectKey] = origin; // always add all worldObjects that have been localized
+ }
+ });
+
+ realityEditor.gui.ar.draw.visibleObjectsCopy = visibleObjects;
+
+ // finally, render the objects/frames/nodes. I have tested doing this based on a requestAnimationFrame loop instead
+ // of being driven by the vuforia framerate, and have mixed results as to which is smoother/faster
+
+ realityEditor.gui.ar.draw.update(realityEditor.gui.ar.draw.visibleObjectsCopy);
+ }
+
+ /**
+ * Callback for realityEditor.app.getCameraMatrixStream
+ * Gets triggered ~60FPS when the AR SDK sends us a new cameraMatrix based on the device's world coordinates
+ * @param {*} cameraInfo
+ */
+ function receiveCameraMatricesFromAR(cameraInfo) {
+ realityEditor.sceneGraph.setDevicePosition(cameraInfo.matrix);
+
+ // easiest way to implement freeze button is just to not update the new matrices
+ if (!globalStates.freezeButtonState) {
+ // when viewing VR map, sceneGraph camera will get set based on virtual camera,
+ // but we can still access the device's true position through the deviceNode
+ if (!realityEditor.device.modeTransition.isARMode()) {
+ realityEditor.device.modeTransition.setDeviceCameraPosition(cameraInfo.matrix);
+ return;
+ }
+
+ realityEditor.worldObjects.checkIfFirstLocalization();
+
+ let cameraMatrix = cameraInfo.matrix;
+ let trackingStatus = cameraInfo.status;
+ let trackingStatusInfo = cameraInfo.statusInfo;
+
+ listeners.onDeviceTrackingStatus.forEach(function(callback) {
+ callback(trackingStatus, trackingStatusInfo);
+ });
+
+ realityEditor.sceneGraph.setCameraPosition(cameraMatrix);
+
+ while (listeners.onTrackingStarted.length > 0) {
+ let callback = listeners.onTrackingStarted.pop();
+ callback();
+ }
+ }
+ }
+
+ /**
+ * Callback for realityEditor.app.getGroundPlaneMatrixStream
+ * Gets triggered ~60FPS when the AR SDK sends us a new cameraMatrix based on the device's world coordinates
+ * @param {Array.} groundPlaneMatrix
+ */
+ function receiveGroundPlaneMatricesFromAR(groundPlaneMatrix) {
+ // only update groundPlane if unfrozen and at least one thing is has requested groundPlane usage
+ if (globalStates.useGroundPlane && !globalStates.freezeButtonState && realityEditor.device.modeTransition.isARMode()) {
+
+ let worldObject = realityEditor.worldObjects.getBestWorldObject();
+
+ // snap groundPlane to world origin, if available
+ if (worldObject && worldObject.uuid !== realityEditor.worldObjects.getLocalWorldId()) {
+ let worldObjectSceneNode = realityEditor.sceneGraph.getSceneNodeById(worldObject.uuid);
+ if (worldObjectSceneNode) {
+ // note: if sceneGraph hierarchy gets more complicated (if ground plane and world objects have
+ // different parents in the scene graph), remember to switch worldObjectSceneNode.localMatrix
+ // for a matrix computed to preserve worldObject's worldMatrix
+ if (worldObject.isJpgTarget) {
+ // let rotated = [];
+ // realityEditor.gui.ar.utilities.multiplyMatrix(this.rotationXMatrix, worldObjectSceneNode.localMatrix, rotated);
+ realityEditor.sceneGraph.setGroundPlanePosition(worldObjectSceneNode.localMatrix);
+ } else {
+ let offset = [];
+ let floorOffset = 0;
+ try {
+ let navmesh = JSON.parse(window.localStorage.getItem(`realityEditor.navmesh.${worldObject.uuid}`));
+ floorOffset = navmesh.floorOffset * 1000;
+ } catch (e) {
+ console.warn('No navmesh', worldObject, e);
+ }
+ let buffer = 100;
+ floorOffset += buffer;
+ let groundPlaneOffsetMatrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, floorOffset, 0, 1
+ ];
+ realityEditor.gui.ar.utilities.multiplyMatrix(groundPlaneOffsetMatrix, worldObjectSceneNode.localMatrix, offset);
+ realityEditor.sceneGraph.setGroundPlanePosition(offset);
+ // realityEditor.sceneGraph.setGroundPlanePosition(JSON.parse(JSON.stringify(worldObjectSceneNode.localMatrix)));
+ }
+ return;
+ }
+ }
+
+ // only set to groundPlane from vuforia if it isn't set to a world object's matrix
+ realityEditor.sceneGraph.setGroundPlanePosition(groundPlaneMatrix);
+ }
+ }
+
+ let listeners = {
+ onVuforiaInitFailure: [], // triggers when vuforia is first initialized
+ onTrackingStarted: [], // triggers when we first get a device position (again each time we lose and regain tracking)
+ onDeviceTrackingStatus: [] // constantly receive the camera's tracking status and statusInfo
+ }
+
+ /**
+ * Adds a callback that will trigger one time when tracking resumes (when the camera reports a new position)
+ * The callback will be discarded afterwards.
+ * @param {function} callback
+ */
+ exports.onTrackingInitialized = function(callback) {
+ listeners.onTrackingStarted.push(callback);
+ }
+
+ /**
+ * Adds an event handler which will constantly receive the camera's tracking status and statusInfo
+ * @param {function} callback
+ */
+ exports.handleDeviceTrackingStatus = function(callback) {
+ listeners.onDeviceTrackingStatus.push(callback);
+ }
+
+ /**
+ * @param {function} callback
+ */
+ exports.onVuforiaInitFailure = function(callback) {
+ listeners.onVuforiaInitFailure.push(callback);
+ }
+
+ // public methods (anything triggered by a native app callback needs to be public
+ exports.vuforiaIsReady = vuforiaIsReady;
+ exports.receivedProjectionMatrix = receivedProjectionMatrix;
+ exports.receivedUDPMessage = receivedUDPMessage;
+ exports.receiveGroundPlaneMatricesFromAR = receiveGroundPlaneMatricesFromAR;
+ exports.receiveMatricesFromAR = receiveMatricesFromAR;
+ exports.receivePoses = receivePoses;
+ exports.receiveCameraMatricesFromAR = receiveCameraMatricesFromAR;
+
+ exports.startGroundPlaneTrackerIfNeeded = startGroundPlaneTrackerIfNeeded;
+
+})(realityEditor.app.callbacks);
diff --git a/src/app/index.js b/src/app/index.js
new file mode 100644
index 000000000..4efbd8a64
--- /dev/null
+++ b/src/app/index.js
@@ -0,0 +1,615 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/25/17.
+ */
+
+createNameSpace("realityEditor.app");
+
+/**
+ * @fileOverview realityEditor.app.index.js
+ * Defines the API to communicate with the native iOS application.
+ * Calling realityEditor.app.{functionName} will trigger {functionName} in realityEditor.mm in the native iOS app.
+ * Note that as of 6/8/18, many of these are placeholders that lead to function stubs
+ */
+
+/**
+ * @typedef {string|function} FunctionName
+ * @desc The name of a function, in string form, with a path that can be reached from this file,
+ * e.g. "realityEditor.app.callbacks.vuforiaIsReady"
+ * Optional: if the function signature doesn't have any parameters, the entire function can be used instead of a string,
+ * e.g. function(){console.log("pong")})
+ */
+
+/**
+ * Response with a callback that indicates the device name.
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getDeviceReady = function(callBack) {
+ this.appFunctionCall('getDeviceReady', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ * Response with a callback that indicates the base URL for the manager and cloud services.
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getManagerBaseURL = function(callBack) {
+ this.appFunctionCall('getManagerBaseURL', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ * Response with true/false depending on whether app has "Local Network" permissions (required to discover edge servers)
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.didGrantNetworkPermissions = function(callBack) {
+ this.appFunctionCall('didGrantNetworkPermissions', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ **************Vuforia****************
+ **/
+
+/**
+ * Starts the AR engine. Fires a callback once it is ready.
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getVuforiaReady = function(callBack){
+ this.appFunctionCall('getVuforiaReady', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ * Checks if the device has a depth sensor, e.g. LiDAR, and thus supports Area Target Scanning
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.doesDeviceHaveDepthSensor = function(callBack) {
+ this.appFunctionCall('doesDeviceHaveDepthSensor', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ * Adds a new target and fires a callback with error or success
+ * and the targetName for reference
+ * @param {string} targetName
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.addNewTarget = function(targetName, callBack) {
+ this.appFunctionCall('addNewTarget', {targetName: targetName}, 'realityEditor.app.callBack('+callBack+', [__ARG1__, __ARG2__])');
+};
+
+/**
+ * Adds a new target using a JPG image and fires a callback with error or success
+ * and the targetName for reference
+ * @param {string} targetName
+ * @param {string} objectID
+ * @param {number} targetWidthMeters
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.addNewTargetJPG = function(targetName, objectID, targetWidthMeters, callBack) {
+ this.appFunctionCall('addNewTargetJPG', {targetName: targetName, objectID: objectID, targetWidthMeters: targetWidthMeters}, 'realityEditor.app.callBack('+callBack+', [__ARG1__, __ARG2__])');
+};
+
+/**
+ * Gets the projection matrix.
+ * Callback will have the matrix as a length-16 array as a parameter.
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getProjectionMatrix = function(callBack) {
+ this.appFunctionCall('getProjectionMatrix', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ * Sets up a callback for the model matrices of all targets that are found, that will get called every frame.
+ * Callback will have a set of objectId mapped to matrix for each visibleObjects.
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getMatrixStream = function(callBack) {
+ this.appFunctionCall('getMatrixStream', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ * Sets up a callback for the coordinates of any poses the phone finds
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getPosesStream = function(callBack) {
+ this.appFunctionCall('getPosesStream', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__, __ARG2__])');
+};
+
+/**
+ * Sets up a callback for the positional device tracker, reporting the pose of the camera at every frame.
+ * Callback will have the cameraMatrix (which is the inverse of the view matrix) as a parameter.
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getCameraMatrixStream = function(callBack) {
+ this.appFunctionCall('getCameraMatrixStream', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ * Sets up a callback for the ground plane matrix, which will start reporting a matrix each frame after one is detected.
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getGroundPlaneMatrixStream = function(callBack) {
+ this.appFunctionCall('getGroundPlaneMatrixStream', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ * Call this some time after getGroundPlaneMatrixStream to stop actively searching for new ground planes, and use
+ * the most recently detected ground plane location as a static anchor that will represent ground plane (until
+ * getGroundPlaneMatrixStream is called again)
+ */
+realityEditor.app.acceptGroundPlaneAndStop = function() {
+ this.appFunctionCall('acceptGroundPlaneAndStop', null, null);
+};
+
+/**
+ * Gets a screenshot image of the camera background.
+ * The callback will have a screenshot with base64. Size can be S,M,L
+ * @param {string} size - 'S' (25%), 'M' (50%), or 'L' (full size)
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getSnapshot = function(size, callBack) {
+ this.appFunctionCall('getSnapshot', {size: size}, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+/**
+ * @deprecated alias for getSnapshot
+ */
+realityEditor.app.getScreenshot = realityEditor.app.getSnapshot;
+
+/**
+ * Debug method that gets the camera background, decodes it, and passes the blob url to a callback.
+ * note - not used anywhere right now
+ * @return {string} screenshotBlobUrl
+ */
+realityEditor.app.getScreenshotAsJpg = function(callback) {
+ this.getSnapshot("L", function(base64String) {
+ var screenshotBlobUrl = realityEditor.device.utilities.decodeBase64JpgToBlobUrl(base64String);
+ callback(screenshotBlobUrl);
+ // to show the screenshot, you would:
+ // document.querySelector('#screenshotHolder').src = blobUrl;
+ // document.querySelector('#screenshotHolder').style.display ='inline';
+ });
+};
+
+/**
+ * Gets the background RGB texture as a base64 encoded string, and the depth texture if available as an RVL encoded byte array
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.get3dSnapshot = function(callBack) {
+ this.appFunctionCall('get3dSnapshot', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__, __ARG2__])');
+}
+
+/**
+ * Pauses the tracker (freezes the background)
+ * @param {FunctionName|null} callBack - optional, returns success when done pausing
+ */
+realityEditor.app.setPause = function(callBack = null) {
+ if (callBack) {
+ this.appFunctionCall('setPause', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+ } else {
+ this.appFunctionCall('setPause', null, null);
+ }
+};
+
+/**
+ * Resumes the tracker (unfreezes the background)
+ * @param {FunctionName|null} callBack - optional, returns success when done resuming
+ */
+realityEditor.app.setResume = function(callBack = null) {
+ if (callBack) {
+ this.appFunctionCall('setResume', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+ } else {
+ this.appFunctionCall('setResume', null, null);
+ }
+};
+
+/**
+ * Triggers a haptic feedback vibration.
+ */
+realityEditor.app.tap = function() {
+ this.appFunctionCall('tap', null, null);
+};
+
+/**
+ * Enable mode for stationary device. At the time of this call, a pose of device in the world is frozen.
+ */
+realityEditor.app.enableStationaryDevice = function () {
+ this.appFunctionCall('enableStationaryDevice', null, null);
+};
+
+/**
+ * Disable mode for stationary device. A pose of device in the world updates continuously as usual.
+ */
+realityEditor.app.disableStationaryDevice = function () {
+ this.appFunctionCall('disableStationaryDevice', null, null);
+};
+
+/**
+ **************UDP****************
+ **/
+
+/**
+ * Every time there is a new UDP message the callback is called. The Reality Editor listens to UDP messages on port 52316
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getUDPMessages = function(callBack) {
+ this.appFunctionCall('getUDPMessages', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ * Sends out a message over UDP broadcast (255.255.255.255 on port 52316)
+ * @param {Object} message - must be a JSON object
+ */
+realityEditor.app.sendUDPMessage = function(message) {
+ if (realityEditor.network.state.proxyNetwork) {
+ if (realityEditor.cloud.socket && message.action) {
+ realityEditor.cloud.socket.action('udp/action', message);
+ }
+ } else if (realityEditor.device.environment.isDesktop()) {
+ realityEditor.network.realtime.sendMessageToSocketSet(
+ 'realityServers',
+ 'udp/action',
+ message
+ );
+ } else {
+ this.appFunctionCall('sendUDPMessage', {message: JSON.stringify(message)}, null);
+ }
+};
+
+/**
+ **************File****************
+ **/
+
+/**
+ * Boolean response if a file exists in the local filesystem.
+ * You can pass in the same fileName as from where you downloaded the file
+ * (e.g. datAddress = 'http(s)://' + objectHeartbeat.ip + ':' + httpPort + '/obj/' + objectName + '/target/target.dat')
+ * It will automatically convert that to the location on the local filesystem where that download would end up.
+ * @param {string} fileName
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getFileExists = function(fileName, callBack) {
+ this.appFunctionCall('getFileExists', {fileName: fileName}, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ * Downloads a file. The callback is an error or success, and the filename for reference.
+ * The filename url is converted into a temp file path (works as a black box), so that file can be located again using the original filename url
+ * @param {string} fileName - the url that you are downloading, e.g. "http(s)://10.0.0.225:8080/obj/stonesScreen/target/target.xml"
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.downloadFile = function(fileName, callBack) {
+ this.appFunctionCall('downloadFile', {fileName: fileName}, 'realityEditor.app.callBack('+callBack+', [__ARG1__, __ARG2__])');
+};
+
+/**
+ * Boolean response if all files exists. fileNameArray should contain at least one filename. (similar to getFileExists)
+ * @param {Array.} fileNameArray
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getFilesExist = function (fileNameArray, callBack) {
+ this.appFunctionCall('getFilesExist', {fileNameArray: fileNameArray}, 'realityEditor.app.callBack('+callBack+', [__ARG1__, __ARG2__])');
+};
+
+/**
+ * Returns the checksum of a group of files. fileNameArray should contain at least one filename.
+ * @param {Array.} fileNameArray
+ * @param {FunctionName} callBack
+ * @todo implement within XCode - currently does nothing (returns fileNameArray.count as a placeholder)
+ */
+realityEditor.app.getChecksum = function (fileNameArray, callBack) {
+ this.appFunctionCall('getChecksum', {fileNameArray: fileNameArray}, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ **************Store Content****************
+ **/
+
+/**
+ * Store a message on the app level for persistence.
+ * @param {string} storageID - e.g. 'SETUP:DEVELOPER'
+ * @param {string} message
+ */
+realityEditor.app.setStorage = function (storageID, message) {
+ this.appFunctionCall('setStorage', {storageID: storageID, message: JSON.stringify(message)}, null);
+};
+
+/**
+ * Recall the message that may have been saved in a previous session.
+ * Note: currently not used because we are using window.localStorage API instead, but there is still a reason for this
+ * to exist, which is that this data is saved even if you load the userinterface from different locations, whereas the
+ * window.localStorage is dependent on the window href. For example, saving the external interface URL makes sense to
+ * do with this API.
+ * @param {string} storageID
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getStorage = function (storageID, callBack) {
+ this.appFunctionCall('getStorage', {storageID: storageID}, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+ /**
+ **************Speech****************
+ **/
+
+/**
+ * Starts the native speech recognition engine.
+ * While active, this engine will send received words to any callbacks registered by realityEditor.app.addSpeechListener
+ */
+realityEditor.app.startSpeechRecording = function () {
+ console.log("startSpeechRecording");
+ this.appFunctionCall('startSpeechRecording', null, null);
+};
+
+/**
+ * Stops the speech engine.
+ */
+realityEditor.app.stopSpeechRecording = function () {
+ console.log("stopSpeechRecording");
+ this.appFunctionCall('stopSpeechRecording', null, null);
+};
+
+/**
+ * Sends every individual word that was found one by one to the callback.
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.addSpeechListener = function (callBack) {
+ console.log("addSpeechListener");
+ this.appFunctionCall('addSpeechListener', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+
+/**
+ **************Video****************
+ **/
+
+/**
+ * Starts the screen recording of the camera background.
+ * Need to pass in an object id/ip pair so it can upload the resulting video to that object's server
+ * @param {string} objectKey
+ * @param {string} objectIP
+ * @param {number} objectPort
+ */
+realityEditor.app.startVideoRecording = function (objectKey, objectIP, objectPort) {
+ console.log("startVideoRecording");
+ this.appFunctionCall('startVideoRecording', {objectKey: objectKey, objectIP: objectIP, objectPort: objectPort}, null);
+};
+
+/**
+ * Stops the screen recording of the camera background and uploads to the object specified when startVideoRecording was called.
+ * @param {string} videoId - the name to save it as (without .mp4), e.g. a random string uuid
+ */
+realityEditor.app.stopVideoRecording = function (videoId) {
+ console.log("stopVideoRecording");
+ this.appFunctionCall('stopVideoRecording', {videoId: videoId}, null);
+};
+
+/**
+ * Enable human tracking, telling the app to submit frames to the human
+ * tracking MediaPipe graph.
+ */
+realityEditor.app.enableHumanTracking = function () {
+ this.appFunctionCall('enableHumanTracking', null, null);
+};
+
+/**
+ * Disable human tracking, some frames may already be in pipeline and show up
+ * shortly after this call
+ */
+realityEditor.app.disableHumanTracking = function () {
+ this.appFunctionCall('disableHumanTracking', null, null);
+ realityEditor.humanPose.deleteLocalHumanObjects();
+};
+
+/**
+ * Makes objects visible even when they move out of the camera view.
+ * @deprecated - was implemented in native app, but negatively impacts performance if we want it to be
+ * backwards compatible, because of changes to the Vuforia SDK. It is intentionally internally disabled for now.
+ * @param {boolean} _newState
+ */
+realityEditor.app.enableExtendedTracking = function (_newState) {
+ console.warn("TODO: implement enableExtendedTracking. currently has no effect.");
+ // this.appFunctionCall('enableExtendedTracking', {state: newState}, null);
+};
+
+/**
+ * Tells the native app to rotate the webview when the device rotates between landscape left and right.
+ * Triggers the callback whenever the device orientation changes, so that content can adapt if needed (e.g. matrices)
+ * The callback has a single string argument of: "landscapeLeft", "landscapeRight", "portrait", "portraitUpsideDown", or "unknown"
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.enableOrientationChanges = function (callBack) {
+ this.appFunctionCall('enableOrientationChanges', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ * Triggers the callback whenever the app moves to background or foreground
+ * The callback has a single string argument of:
+ * "appDidBecomeActive", "appWillResignActive", "appDidEnterBackground", "appWillEnterForeground", or "appWillTerminate"
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.subscribeToAppLifeCycleEvents = function (callBack) {
+ this.appFunctionCall('subscribeToAppLifeCycleEvents', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+/**
+ * Causes the positional device tracker (used for camera matrices to deinit and initialize again.
+ * This will cause the coordinate system origin to reset to the phone's current position, but will
+ * fix the AR tracking if the device is stuck in a re-localizing limited tracking mode
+ */
+realityEditor.app.restartDeviceTracker = function() {
+ console.log('restartDeviceTracker');
+ this.appFunctionCall('restartDeviceTracker', null, null);
+};
+
+/**
+ * Param should be "landscapeLeft", "landscapeRight", "portrait", or "portraitUpsideDown"
+ * @param orientationString
+ */
+realityEditor.app.setOrientation = function(orientationString, callBack) {
+ this.appFunctionCall('setOrientation', {orientationString: orientationString}, 'realityEditor.app.callBack('+callBack+')');
+};
+
+/**
+ * Triggers the callback whenever the app moves receives a high memory usage event
+ // * The callback has a single string argument of: "report_memory" or a warning, and an integer argument of bytesUsed
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.subscribeToAppMemoryEvents = function(callBack) {
+ this.appFunctionCall('subscribeToAppMemoryEvents', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__, __ARG2__, __ARG3__])');
+}
+
+/**
+ **************Debugging****************
+ **/
+
+/**
+ * Force clears the iOS WebView cache and force reloads the interface.
+ */
+realityEditor.app.clearCache = function () {
+ this.appFunctionCall('clearCache', null, null);
+ console.log('clearing cache and force reloading...');
+ setTimeout(function() {
+ location.reload(true);
+ console.log('NOW');
+ }, 1000);
+};
+
+/**
+ * Triggers a setFocusMode(Vuforia::CameraDevice::FOCUS_MODE_TRIGGERAUTO)
+ * note - not currently used
+ * @todo: the native implementation should revert back to auto mode after a certain amount of time
+ */
+realityEditor.app.focusCamera = function() {
+ this.appFunctionCall('focusCamera', null, null);
+};
+
+/**
+ ************** SAVE DATA TO DISK ****************
+ */
+
+/**
+ * Save the persistent setting to disk for the IP address to load the external userinterface from.
+ * @param {string} newExternalText
+ */
+realityEditor.app.saveExternalText = function(newExternalText) {
+ this.setStorage('SETUP:EXTERNAL', newExternalText);
+};
+
+/**
+ ************** SECURITY ****************
+ */
+
+/**
+ * Trigger the fingerprint authentication prompt to appear
+ * @todo: not working anymore, not even set up in the iOS app
+ */
+realityEditor.app.authenticateTouch = function() {
+ realityEditor.app.appFunctionCall("authenticateTouch", null, null);
+};
+
+realityEditor.app.setAspectRatio = function(ratio) {
+ realityEditor.app.appFunctionCall("setAspectRatio", {ratio});
+}
+
+/**
+ ************** AREA TARGET CAPTURE API ****************
+ */
+
+realityEditor.app.areaTargetCaptureStart = function (objectId, callBack) {
+ realityEditor.app.appFunctionCall("areaTargetCaptureStart", {objectId: objectId}, 'realityEditor.app.callBack('+callBack+', [__ARG1__, __ARG2__])');
+}
+
+realityEditor.app.areaTargetCaptureStop = function (callBack) {
+ realityEditor.app.appFunctionCall("areaTargetCaptureStop", null, 'realityEditor.app.callBack('+callBack+', [__ARG1__, __ARG2__])');
+}
+
+realityEditor.app.areaTargetCaptureGenerate = function (targetUploadURL) {
+ realityEditor.app.appFunctionCall("areaTargetCaptureGenerate", {targetUploadURL: targetUploadURL}, null);
+}
+
+realityEditor.app.onAreaTargetGenerateProgress = function (callBack) {
+ realityEditor.app.appFunctionCall("onAreaTargetGenerateProgress", null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+}
+
+/**
+ * Response with a callback that indicates the device provider id.
+ * @param {FunctionName} callBack
+ */
+realityEditor.app.getProviderId = function(callBack) {
+ this.appFunctionCall('getProviderId', null, 'realityEditor.app.callBack('+callBack+', [__ARG1__])');
+};
+
+
+/**
+ **************UTILITIES****************
+ **/
+
+/**
+ * Encodes a javascript function call to be sent to the native app via the webkit message interface.
+ * @param {string} functionName - the function to trigger in realityEditor.mm
+ * @param {Object|null} functionArguments - object with a key matching the name of each target function parameter,
+ * and the value of each key is the value to pass into that parameter
+ * @param {FunctionName} callbackString - 'realityEditor.app.callBack('+callBack+')'
+ */
+realityEditor.app.appFunctionCall = function(functionName, functionArguments, callbackString) {
+ var messageBody = {
+ functionName: functionName
+ };
+
+ if (functionArguments) {
+ messageBody.arguments = functionArguments;
+ }
+
+ if (callbackString) {
+ messageBody.callback = callbackString;
+ }
+
+ try {
+ window.webkit.messageHandlers.realityEditor.postMessage(messageBody);
+ } catch (e) {
+ console.warn('appFunctionCall error', e, messageBody);
+ }
+};
+
+/**
+ * Wrapper function for callbacks called by the native iOS application, applying any arguments as needed.
+ * @param {FunctionName} callBack
+ * @param {Array.<*>} callbackArguments
+ */
+realityEditor.app.callBack = function(callBack, callbackArguments){
+
+ if (callbackArguments) {
+ callBack.apply(null, callbackArguments);
+ } else {
+ callBack();
+ }
+};
diff --git a/src/app/navmeshWorker.js b/src/app/navmeshWorker.js
new file mode 100644
index 000000000..d75288aff
--- /dev/null
+++ b/src/app/navmeshWorker.js
@@ -0,0 +1,506 @@
+/* eslint-env worker */
+/* global globalThis */
+
+// ***** On Using Web Workers *****
+// Web Workers allow scripts to execute code in a background thread. In this
+// case, we use one for loading the GLBs for the area targets and generating
+// the corresponding navmeshes, as this is a computationally intensive process
+// that would otherwise block the main app.
+//
+// The onmessage function is called whenever the script that spawned the worker
+// (src/app/targetDownloader.js) sends a message to it.
+// The postMessage function allows the worker to send messages back to the
+// script that spawned the worker.
+// Workers can use the importScripts function to load in scripts from other
+// files.
+
+// This file receives a URL for a GLB file as well as the corresponding objectID
+// through the Web Worker messaging interface and returns the resulting
+// navmesh through the same interface
+
+import {Cache, LoadingManager, Ray, Texture, Vector3} from '../../thirdPartyCode/three/three.module.js';
+import {GLTFLoader} from '../../thirdPartyCode/three/GLTFLoader.module.js';
+import {mergeBufferGeometries} from '../../thirdPartyCode/three/BufferGeometryUtils.module.js';
+
+Cache.enabled = true;
+// Sets up a fake cache entry pointing to a blank texture since we can't load
+// textures in this environment
+const fakeCacheKey = 'fake';
+Cache.add(fakeCacheKey, new Texture());
+
+const manager = new LoadingManager();
+manager.resolveURL = function(url) {
+ if (url.endsWith('.glb')) {
+ return url;
+ }
+ // Resolve to fake cache key (empty texture)
+ return fakeCacheKey;
+};
+
+const gltfLoader = new GLTFLoader(manager);
+
+globalThis.document = {
+ createElementNS: function(ns, tag) {
+ console.warn('createElementNS called for', tag, new Error().stack);
+ }
+};
+
+const heatmapResolution = 20; // number of pixels per meter
+
+onmessage = function(evt) {
+ const fileName = evt.data.fileName;
+ const objectID = evt.data.objectID;
+ createNavmeshFromFile(fileName).then(navmesh => {
+ postMessage({navmesh, objectID, fileName, heatmapResolution});
+ }).catch(error => {
+ console.error(error);
+ });
+}
+
+const createNavmeshFromFile = (fileName) => {
+ return new Promise(resolve => {
+ gltfLoader.load(fileName, (gltf) => {
+ const geometries = [];
+ gltf.scene.traverse(obj => {
+ if (obj.geometry) {
+ obj.geometry.computeVertexNormals(); // todo Steve: figure out why compute vertex normals here?
+ obj.geometry.deleteAttribute('uv'); // Messes with merge if present in some geometries but not others
+ obj.geometry.deleteAttribute('uv2'); // Messes with merge if present in some geometries but not others
+ geometries.push(obj.geometry);
+ }
+ });
+ if (geometries.length === 1) {
+ resolve(createNavmesh(geometries[0], heatmapResolution));
+ } else {
+ const mergedGeometry = mergeBufferGeometries(geometries);
+ resolve(createNavmesh(mergedGeometry, heatmapResolution));
+ }
+ });
+ });
+}
+
+// Rasterization algorithm from http://www.sunshine2k.de/coding/java/TriangleRasterization/TriangleRasterization.html
+const addLine = (array, startX, endX, z, value, ignoreValue, faceY, steepness) => {
+ for (let x = Math.floor(startX); x <= Math.ceil(endX); x++) {
+ if (array[x] === undefined) {
+ continue;
+ }
+ if (array[x][z] === undefined) {
+ continue;
+ }
+ if (ignoreValue) {
+ array[x][z][2] = 1;
+ } else {
+ array[x][z][0] += value; // total weight of faces
+ array[x][z][1] += 1; // # of times faces have been added to the. Later can do a weighted sum
+ array[x][z][2] = 1; // whether or not this pixel is within the mesh
+ array[x][z][3] += faceY;
+ array[x][z][4] += steepness;
+ }
+ }
+}
+
+// v1 must be smallest z vertex, v3 must be the largest
+const addBottomFlatTriangle = (array, v1, v2, v3, value, ignoreValue, faceY, steepness) => {
+ const invslope1 = (v2.z-v1.z) === 0 ? 0 : (v2.x - v1.x) / (v2.z - v1.z);
+ const invslope2 = (v3.z-v1.z) === 0 ? 0 : (v3.x - v1.x) / (v3.z - v1.z);
+
+ let startX = v1.x;
+ let endX = v1.x;
+
+ for (let scanlineZ = Math.floor(v1.z); scanlineZ <= Math.ceil(v2.z); scanlineZ++) {
+ addLine(array, startX, endX, scanlineZ, value, ignoreValue, faceY, steepness);
+ startX += invslope1;
+ endX += invslope2;
+ }
+}
+
+// v1 must be smallest z vertex, v3 must be the largest
+const addTopFlatTriangle = (array, v1, v2, v3, value, ignoreValue, faceY, steepness) => {
+ const invslope1 = (v3.z-v1.z) === 0 ? 0 : (v3.x - v1.x) / (v3.z - v1.z);
+ const invslope2 = (v3.z-v2.z) === 0 ? 0 : (v3.x - v2.x) / (v3.z - v2.z);
+
+ let startX = v3.x;
+ let endX = v3.x;
+
+ for (let scanlineZ = Math.ceil(v3.z); scanlineZ >= Math.floor(v1.z); scanlineZ--) {
+ addLine(array, startX, endX, scanlineZ, value, ignoreValue, faceY, steepness);
+ startX -= invslope1;
+ endX -= invslope2;
+ }
+}
+
+const splitVertex = new Vector3();
+const addTriangle = (array, v1, v2, v3, value, ignoreValue, faceY, steepness) => {
+ const minZVertex = [v2,v3].reduce((min, current) => current.z < min.z ? current : min, v1);
+ const maxZVertex = [v1,v2,v3].filter(vertex => vertex != minZVertex).reduce((max, current) => current.z > max.z ? current : max, [v1,v2,v3].filter(vertex => vertex != minZVertex)[0]);
+ const midZVertex = [v1,v2,v3].filter(vertex => vertex != minZVertex && vertex != maxZVertex)[0];
+ if (midZVertex.z === maxZVertex.z) {
+ addBottomFlatTriangle(array, minZVertex, midZVertex, maxZVertex, value, ignoreValue, faceY, steepness);
+ } else if (midZVertex.z === minZVertex.z) {
+ addTopFlatTriangle(array, minZVertex, midZVertex, maxZVertex, value, ignoreValue, faceY, steepness);
+ } else {
+ splitVertex.x = minZVertex.x + (midZVertex.z - minZVertex.z) / (maxZVertex.z - minZVertex.z) * (maxZVertex.x - minZVertex.x);
+ splitVertex.z = midZVertex.z;
+ addBottomFlatTriangle(array, minZVertex, midZVertex, splitVertex, value, ignoreValue, faceY, steepness);
+ addTopFlatTriangle(array, midZVertex, splitVertex, maxZVertex, value, ignoreValue, faceY, steepness);
+ }
+}
+
+// Utility function for applying functions to values within a grid
+const mapGrid = (grid, mapping) => {
+ grid.forEach((row,i) => {
+ row.forEach((value, j) => {
+ row[j] = mapping(value,i,j);
+ })
+ });
+}
+
+const createNavmesh = (geometry, resolution) => { // resolution = number of pixels per meter
+ // geometry.computeVertexNormals();
+ geometry.computeBoundingBox();
+
+ const minX = geometry.boundingBox.min.x;
+ const maxX = geometry.boundingBox.max.x;
+ const minY = geometry.boundingBox.min.y;
+ const maxY = geometry.boundingBox.max.y;
+ const minZ = geometry.boundingBox.min.z;
+ const maxZ = geometry.boundingBox.max.z;
+
+ const xLength = Math.ceil((maxX - minX) * resolution); // Navmesh size
+ const zLength = Math.ceil((maxZ - minZ) * resolution); // Navmesh size
+ const faceData = []; // Stores data about normal directions
+ const outerHoles = []; // Stores data about areas that are part of the mesh
+ const expandedWallMap = []; // Stores data about where the walls are, expanded to prevent pathfinding along walls
+ const regionMap = []; // Stores data about isolated floor sections (to eliminate tables, countertops, etc.)
+ for (let x = 0; x < xLength; x++) {
+ const faceDataZArray = [];
+ const outerHolesZArray = [];
+ const expandedWallZArray = [];
+ const regionMapZArray = [];
+ for (let z = 0; z < zLength; z++) {
+ faceDataZArray.push([0,0,0,0,0]); // [totalWeight, count, withinMesh, total height, total Angle]
+ outerHolesZArray.push(0);
+ expandedWallZArray.push(0);
+ regionMapZArray.push(0);
+ }
+ faceData.push(faceDataZArray);
+ outerHoles.push(outerHolesZArray);
+ expandedWallMap.push(expandedWallZArray);
+ regionMap.push(regionMapZArray);
+ }
+
+ const indexedFaceAttribute = geometry.index;
+ const positionAttribute = geometry.attributes.position;
+ const normalAttribute = geometry.attributes.normal;
+
+ // Re-use vector objects for efficiency
+ const indexVector = new Vector3();
+ const vertexVector1 = new Vector3();
+ const vertexVector2 = new Vector3();
+ const vertexVector3 = new Vector3();
+
+ const vertexNormal1 = new Vector3();
+ const vertexNormal2 = new Vector3();
+ const vertexNormal3 = new Vector3();
+
+ let vertexAngle1, vertexAngle2, vertexAngle3;
+
+ let vertexIndex = 0;
+ const loadVertices = (v1, v2, v3, n1, n2, n3) => {
+ if (geometry.index) { // Have to handle indexed vertices differently from sequential vertices
+ if (vertexIndex >= indexedFaceAttribute.count) {
+ return false;
+ }
+ indexVector.fromBufferAttribute(indexedFaceAttribute, vertexIndex); // Gets indices of face vertices, not grouped by attribute so indexVector collects 3 at a time
+ v1.fromBufferAttribute(positionAttribute, indexVector.x);
+ v2.fromBufferAttribute(positionAttribute, indexVector.y);
+ v3.fromBufferAttribute(positionAttribute, indexVector.z);
+ n1.fromBufferAttribute(normalAttribute, indexVector.x);
+ n2.fromBufferAttribute(normalAttribute, indexVector.y);
+ n3.fromBufferAttribute(normalAttribute, indexVector.z);
+ } else {
+ if (vertexIndex >= positionAttribute.count) {
+ return false;
+ }
+ v1.fromBufferAttribute(positionAttribute, vertexIndex);
+ v2.fromBufferAttribute(positionAttribute, vertexIndex+1);
+ v3.fromBufferAttribute(positionAttribute, vertexIndex+2);
+ n1.fromBufferAttribute(normalAttribute, vertexIndex);
+ n2.fromBufferAttribute(normalAttribute, vertexIndex+1);
+ n3.fromBufferAttribute(normalAttribute, vertexIndex+2);
+ }
+ vertexIndex += 3;
+ return true;
+ }
+
+ // We're looking for walkable space, so any faces in this range are obstacles
+ const lowIgnoreHeight = 0.5; // 50cm ~= Knee height for tall people (like me)
+ const highIgnoreHeight = 2; // 2m ~= slightly under door height
+ // const lowIgnoreHeight = -20; // very low dummy value, for maps which we want all the terrains to be walkable
+ // const highIgnoreHeight = 20; // very high dummy value, for maps which we want all the terrains to be walkable
+
+ // The floor offset will be set by looking down from the origin first, and if nothing is found, looking up
+ let floorOffsetDown = 1; // Junk positive offset that will get replaced if there is a floor beneath the origin point
+ let floorOffsetUp = -1; // Junk negative offset that will get replaced if there is a floor above the origin point
+ const floorDetectionRayDown = new Ray(new Vector3(0,0,0), new Vector3(0,-1,0));
+ const floorDetectionResultDown = new Vector3();
+ const floorDetectionRayUp = new Ray(new Vector3(0,0,0), new Vector3(0,1,0));
+ const floorDetectionResultUp = new Vector3();
+
+ const abs = (a) => {return Math.abs(a)};
+ const dot = (a, b) => {return a.clone().dot(b)};
+ const normalize = (a) => {return a.normalize()};
+ const degrees = (a) => {return a * 180 / Math.PI};
+ const acos = (a) => {return Math.acos(a)};
+ const upVector = new Vector3(0, 1, 0);
+
+ const normalToSteepness = (v) => {
+ let steepness = abs(dot(normalize(v), upVector)); // Range [0., 1.]. 0. ~ very steep; 1. ~ very flat
+ let angle = degrees(acos(steepness));
+ return angle;
+ }
+
+ // Load the next face into our vertex vectors and evaluate until out of faces
+ while(loadVertices(vertexVector1, vertexVector2, vertexVector3, vertexNormal1, vertexNormal2, vertexNormal3)) {
+
+ // Use the average height of the vertices to determine the height of the face
+ const faceY = (vertexVector1.y + vertexVector2.y + vertexVector3.y)/3;
+
+ if (floorDetectionRayDown.intersectTriangle(vertexVector1, vertexVector2, vertexVector3, false, floorDetectionResultDown)) {
+ if (faceY > floorOffsetDown || floorOffsetDown > 0) {
+ floorOffsetDown = faceY; // Find the highest face below the origin to set as the floor height
+ }
+ }
+
+ if (floorDetectionRayUp.intersectTriangle(vertexVector1, vertexVector2, vertexVector3, false, floorDetectionResultUp)) {
+ if (faceY < floorOffsetUp || floorOffsetUp < 0) {
+ floorOffsetUp = faceY; // Find the lowest face above the origin to set as the floor height
+ }
+ }
+
+ // If something is out of the vertical range for obstacles, we don't want
+ // to have it contribute to the weight of that point, but we do want the
+ // pixels covered by that face to be considered walkable if no other
+ // obstacles are found there, so we want addTriangle to mark it as occupied
+ let ignoreWeight = false;
+ if (vertexVector1.y - minY < lowIgnoreHeight && vertexVector2.y - minY < lowIgnoreHeight && vertexVector3.y - minY < lowIgnoreHeight) {
+ ignoreWeight = true;
+ }
+ if (vertexVector1.y - minY > highIgnoreHeight && vertexVector2.y - minY > highIgnoreHeight && vertexVector3.y - minY > highIgnoreHeight) {
+ ignoreWeight = true;
+ }
+
+ // calculate steepness based on v1, v2, v3
+ vertexAngle1 = normalToSteepness(vertexNormal1);
+ vertexAngle2 = normalToSteepness(vertexNormal2);
+ vertexAngle3 = normalToSteepness(vertexNormal3);
+ let steepness = (vertexAngle1 + vertexAngle2 + vertexAngle3) / 3;
+
+ // Converting positions to navmesh coordinates to allow for rasterization of face
+ [vertexVector1, vertexVector2, vertexVector3].forEach(vertex => {
+ vertex.x = Math.floor((vertex.x - minX) / (maxX - minX) * xLength);
+ vertex.z = Math.floor((vertex.z - minZ) / (maxZ - minZ) * zLength); // Flip z-coordinate to ensure top-down view (rather than bottom-up)
+ });
+ const weight = 1;
+
+ // Rasterize face data onto navmesh
+ addTriangle(faceData, vertexVector1, vertexVector2, vertexVector3, weight, ignoreWeight, faceY, steepness);
+ }
+
+ const floorOffset = minY; // set the floorOffset to the lowest part of the scanned mesh
+
+ const makeArray = (a, b, cb) => {
+ var arr = [];
+ for(let i = 0; i < a; i++) {
+ arr[i] = [];
+ for(let j = 0; j < b; j++) {
+ arr[i][j] = cb(i, j);
+ }
+ }
+ return arr;
+ }
+
+ let steepnessMap = makeArray(faceData.length, faceData[0].length, () => {return 0});
+
+ // calculate the average steepness of each grid cell to a 2d array
+ for (let i = 0; i < faceData.length; i++) {
+ for (let j = 0; j < faceData[0].length; j++) {
+ steepnessMap[i][j] = faceData[i][j][1] === 0 ? 0 : faceData[i][j][4] / faceData[i][j][1]; // 0 --- not computed area in the navmesh, un-walkable area
+ }
+ }
+
+ let heightMap = makeArray(faceData.length, faceData[0].length, (i, j) => {
+ return faceData[i][j][1] === 0 ? 0 : faceData[i][j][3] / faceData[i][j][1];
+ })
+
+ let countMap = makeArray(faceData.length, faceData[0].length, (i, j) => {
+ return faceData[i][j][1];
+ });
+
+ // Calculate average weight of faces within pixels
+ mapGrid(faceData, value => [value[1] === 0 ? 0 : value[0] / value[1], value[2], 0]); // total weight / count
+ // similar to above, I build the final grid based on steepness value
+
+ // with in / out mesh, whether if the obstacle is within / outside of the mesh
+ // now value -- [average weight, within mesh, 0]
+ // Pixels without obstacles but within the mesh are considered walkable, other pixels are not
+ const normalCutoff = 0.1;
+ mapGrid(faceData, value => value[0] < normalCutoff ? [0, value[1], 0] : [value[0], 0, 0]);
+
+ // now value -- [0 -- not walkable / average weight -- walkable, within mesh -- not walkable / 0 -- walkable, 0]
+
+ // Filling outer holes (non-mesh pixels), defined as non-walkable pixels reachable from edge of grid
+ const outerHolesStack = [];
+ const isHole = (x,z) => {
+ return faceData[x][z][0] === 0 && faceData[x][z][1] === 0 && outerHoles[x][z] === 0;
+ }
+
+ // Expands search outwards
+ const pushAdjacent = (x,z,stack) => {
+ stack.push([x-1,z]);
+ stack.push([x+1,z]);
+ stack.push([x,z-1]);
+ stack.push([x,z+1]);
+ }
+
+ // Initializing search at borders
+ for (let x = 0; x < xLength; x++) {
+ if (isHole(x,0)) {
+ outerHoles[x][0] = 1;
+ pushAdjacent(x,0,outerHolesStack);
+ }
+ if (isHole(x,zLength-1)) {
+ outerHoles[x][zLength-1] = 1;
+ pushAdjacent(x,zLength-1,outerHolesStack);
+ }
+ }
+
+ // Initializing search at borders
+ for (let z = 0; z < zLength; z++) {
+ if (isHole(0,z)) {
+ outerHoles[0][z] = 1;
+ pushAdjacent(0,z,outerHolesStack);
+ }
+ if (isHole(xLength-1,z)) {
+ outerHoles[xLength-1][z] = 1;
+ pushAdjacent(xLength-1,z,outerHolesStack);
+ }
+ }
+
+ // Breadth-first spread
+ while (outerHolesStack.length != 0) {
+ const coords = outerHolesStack.pop();
+ const x = coords[0];
+ const z = coords[1];
+
+ // Skips out-of-bounds and visited pixels
+ if (x < 0 || x >= xLength || z < 0 || z >= zLength || !isHole(x,z)) {
+ continue;
+ }
+ outerHoles[x][z] = 1;
+ pushAdjacent(x,z,outerHolesStack);
+ }
+
+ // Fills holes in grid
+ for (let x = 0; x < xLength; x++) {
+ for (let z = 0; z < zLength; z++) {
+ if (isHole(x,z)) {
+ faceData[x][z][1] = 1;
+ } else {
+ faceData[x][z][0] = 0;
+ }
+ }
+ }
+
+ faceData.forEach((xRow, x) => {
+ xRow.forEach((value, z) => {
+ if (x-1 >= 0 && faceData[x-1][z][1] === 0) {
+ expandedWallMap[x][z] = 1;
+ return;
+ }
+ if (z-1 >= 0 && faceData[x][z-1][1] === 0) {
+ expandedWallMap[x][z] = 1;
+ return;
+ }
+ if (x+1 < faceData.length && faceData[x+1][z][1] === 0) {
+ expandedWallMap[x][z] = 1;
+ return;
+ }
+ if (z+1 < faceData[x].length && faceData[x][z+1][1] === 0) {
+ expandedWallMap[x][z] = 1;
+ return;
+ }
+ if (x-1 >= 0 && z-1 >= 0 && faceData[x-1][z-1][1] === 0) {
+ expandedWallMap[x][z] = 1;
+ return;
+ }
+ if (x+1 < faceData.length && z-1 >= 0 && faceData[x+1][z-1][1] === 0) {
+ expandedWallMap[x][z] = 1;
+ return;
+ }
+ if (x+1 < faceData.length && z+1 < faceData[x].length && faceData[x+1][z+1][1] === 0) {
+ expandedWallMap[x][z] = 1;
+ return;
+ }
+ if (x-1 >= 0 && z+1 < faceData[x].length && faceData[x-1][z+1][1] === 0) {
+ expandedWallMap[x][z] = 1;
+ return;
+ }
+ })
+ });
+
+ // Finding largest contiguous region for floor
+ let regionNumber = 1; // Number of current region
+ let maxRegionNumber = 0; // Number of largest region
+ let maxRegionCount = 0; // Number of pixels in largest region
+ const isMapped = (x,z) => {
+ return regionMap[x][z] != 0 || expandedWallMap[x][z] === 1;
+ }
+ for (let x = 0; x < xLength; x++) {
+ for (let z = 0; z < zLength; z++) {
+ if (!isMapped(x,z)) {
+ regionMap[x][z] = regionNumber;
+ let regionCount = 1;
+ const regionFillStack = [];
+ pushAdjacent(x,z,regionFillStack);
+ // Breadth-first spread again, this time navigating along adjacent walkable pixels to determine pixels in same region
+ while (regionFillStack.length != 0) {
+ const coords = regionFillStack.pop();
+ const x = coords[0];
+ const z = coords[1];
+ if (x < 0 || x >= xLength || z < 0 || z >= zLength || isMapped(x,z)) {
+ continue;
+ }
+ regionMap[x][z] = regionNumber;
+ regionCount++;
+ pushAdjacent(x,z,regionFillStack);
+ }
+ if (regionCount > maxRegionCount) {
+ maxRegionNumber = regionNumber;
+ maxRegionCount = regionCount;
+ }
+ regionNumber++;
+ }
+ }
+ }
+
+ // Replace regionMap with only those pixels belonging to the largest region
+ // This is our walkable space for navigation
+ mapGrid(regionMap, rNum => rNum === maxRegionNumber ? 1 : 0);
+
+ // Share bounding box positions so we can scale real-world positions to grid properly
+ return {
+ map: regionMap,
+ countMap: countMap,
+ steepnessMap: steepnessMap,
+ heightMap: heightMap,
+ minX: minX,
+ maxX: maxX,
+ minY: minY,
+ maxY: maxY,
+ minZ: minZ,
+ maxZ: maxZ,
+ floorOffset: floorOffset
+ }
+}
diff --git a/src/app/pathfinding.js b/src/app/pathfinding.js
new file mode 100644
index 000000000..069cf3163
--- /dev/null
+++ b/src/app/pathfinding.js
@@ -0,0 +1,444 @@
+createNameSpace("realityEditor.app.pathfinding");
+
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+import { MeshLine, MeshLineMaterial } from "../../thirdPartyCode/three/THREE.MeshLine.js";
+
+(function(exports) {
+
+ let mapData, steepnessMapData, heightMapData;
+ let MIN_STEEPNESS = 0, MAX_STEEPNESS = 25; // slope / tangent of mesh vertex, in degrees
+
+ function initService(map, steepnessMap, heightMap) {
+ size = 1 / realityEditor.app.targetDownloader.getNavmeshResolution();
+
+ mapData = map;
+ steepnessMapData = steepnessMap;
+ heightMapData = heightMap;
+
+ setupEventListener();
+
+ return buildMeshFromMapData();
+ }
+
+ function setupEventListener() {
+ realityEditor.network.addPostMessageHandler('measureAppSetPathPoint', (evt) => {
+ if (evt.point === undefined) return;
+ if (evt.type === 'start') {
+ resetStartAndEndIndices();
+ worldPosToNavmeshIndex(new THREE.Vector3(evt.point[0], evt.point[1], evt.point[2]));
+ } else if (evt.type === 'end') {
+ worldPosToNavmeshIndex(new THREE.Vector3(evt.point[0], evt.point[1], evt.point[2]));
+ findPath();
+ // findPath().then((result) => {
+ // buildPath(result);
+ // }).catch((error) => {
+ // console.log(`%c ${error}`, 'color: red');
+ // });
+ }
+ });
+ }
+
+ const vertexShader = `
+ attribute vec3 color;
+ varying vec3 vColor;
+
+ void main() {
+ vColor = color;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);
+ }
+ `;
+
+ const fragmentShader = `
+ varying vec3 vColor;
+
+ void main() {
+ gl_FragColor = vec4(vColor, 1.);
+ }
+ `;
+
+ const CYAN = new THREE.Vector3(0, 1, 1); // map START quad color
+ const ORANGE = new THREE.Vector3(1, 0.5, 0); // map END quad color
+ const BLACK = new THREE.Vector3(0, 0, 0);
+ const WHITE = new THREE.Vector3(1, 1, 1);
+
+ let start = {}, end = {};
+
+ let size = null; // 1 meter / (# of pixels per meter, set in navmeshWorker.js)
+ let index = 0;
+ let posArr = [];
+ let indices = [];
+ let colArr = [];
+ let posAttri, colAttri;
+
+ function ijToIndex(i, j) {
+ return (i + j * mapData[0].length) * 4;
+ }
+
+ function _indexToIJ(index) {
+ index = Math.floor(index / 4);
+ let j = Math.floor(index / mapData[0].length);
+ let i = index - j * mapData[0].length;
+ return {i, j};
+ }
+
+ function buildMeshFromMapData() {
+ // populate the posArr
+ for (let j = 0; j < mapData.length; j++) { // j --- quads in a column
+ for (let i = 0; i < mapData[0].length; i++) { // i --- quads in a row
+ // buildQuad(i * size, 0, j * size, size);
+ if (mapData[j][i] !== 0) { // walkable area
+ // if (steepnessMapData[j][i] > 0 && steepnessMapData[j][i] < 25) { // walkable area
+ buildQuad(j * size, 0, i * size, size, index, true);
+ } else { // un-walkable area
+ buildQuad(j * size, 0, i * size, size, index, false);
+ }
+ index += 4; // increment 4 to the next set of indices
+ }
+ }
+
+ // build the geometry -- walkable area
+ const geometry = new THREE.BufferGeometry();
+ geometry.setIndex(indices);
+ posAttri = new THREE.BufferAttribute( new Float32Array(posArr), 3 );
+ colAttri = new THREE.BufferAttribute( new Float32Array(colArr), 3 );
+ geometry.setAttribute( 'position', posAttri );
+ geometry.setAttribute( 'color', colAttri );
+ // geometry.translate(-mapData[0].length * size / 2, 0, -mapData.length * size / 2);
+ // geometry.translate(-mapData.length * size / 2, 0, -mapData[0].length * size / 2);
+ // todo Steve: find the origin point of the area target mesh point
+ const material = new THREE.ShaderMaterial({
+ vertexShader,
+ fragmentShader
+ });
+ const mesh = new THREE.Mesh( geometry, material );
+ // mesh.position.set(-mapData[0].length * size / 2, 0, -mapData.length * size / 2);
+ return mesh;
+ }
+
+ function buildQuad(x, y, z, size, index, isWalkable) { // (x, y, z) --- top left corner of the quad, size --- side length of the quad
+ posArr.push(x, y, z);
+ posArr.push(x, y, z + size);
+ posArr.push(x + size, y, z + size);
+ posArr.push(x + size, y, z);
+
+ if (isWalkable) {
+ colArr.push(WHITE.x, WHITE.y, WHITE.z);
+ colArr.push(WHITE.x, WHITE.y, WHITE.z);
+ colArr.push(WHITE.x, WHITE.y, WHITE.z);
+ colArr.push(WHITE.x, WHITE.y, WHITE.z);
+ } else {
+ colArr.push(BLACK.x, BLACK.y, BLACK.z);
+ colArr.push(BLACK.x, BLACK.y, BLACK.z);
+ colArr.push(BLACK.x, BLACK.y, BLACK.z);
+ colArr.push(BLACK.x, BLACK.y, BLACK.z);
+ }
+
+ indices.push(index, index + 1, index + 2, index, index + 2, index + 3);
+ }
+
+ function worldPosToNavmeshIndex(pos) { // converts threejsScene.js threeJsContainerObj coords to navmesh index (i, j)
+ let ij = threejsContainerObjPositionToIJ(pos);
+ let i = ij.i;
+ let j = ij.j;
+ // console.log(i, j)
+
+ // index in buffer attribute indices array, to change the corresponding mesh color
+ let index = ijToIndex(j, i);
+ // console.log(index);
+
+ setMapStartOrEndIndices(j, i, index); // j -- z pos, i -- x pos
+ }
+
+ function resetStartAndEndIndices() {
+ start = {};
+ end = {};
+ isSettingMapStart = true;
+ isSettingMapLocked = false;
+ indexCount = 0;
+ }
+
+ let isSettingMapStart = true; // whether should set map start indices OR end indices
+ let isSettingMapLocked = false; // lock the setting map start / end points
+ let indexCount = 0;
+ function setMapStartOrEndIndices(i, j, index) {
+ // console.log(i, j, index);
+ if (isSettingMapLocked) return;
+ // if (mapData[j][i] === 0 || steepnessMapData[j][i] < MIN_STEEPNESS || steepnessMapData[j][i] > MAX_STEEPNESS) {
+ if (steepnessMapData[j][i] < MIN_STEEPNESS || steepnessMapData[j][i] > MAX_STEEPNESS) { // todo Steve: changed for 9/18 demo, only consider steepness map
+ console.error('This point is within the un-walkable area. Try to set another point.');
+ return;
+ }
+
+ indexCount++;
+ if (indexCount <= 2) { // the first 2 points are strictly start & end points
+ if (indexCount === 1) { // set start point
+ start.i = i;
+ start.j = j;
+ start.index = index;
+ start.height = heightMapData[j][i];
+ changeMeshColorFromIndex(start.index, CYAN);
+ // console.log(`Setting start node: (${start.i}, ${start.j})`);
+ // console.log(`This node has height: ${heightMapData[start.j][start.i]}`);
+ } else if (indexCount === 2) { // set end point
+ end.i = i;
+ end.j = j;
+ end.index = index;
+ end.height = heightMapData[j][i];
+ changeMeshColorFromIndex(end.index, ORANGE);
+ // console.log(`Setting end node: (${end.i}, ${end.j})`);
+ // console.log(`This node has height: ${heightMapData[end.j][end.i]}`);
+ }
+ } else { // press button to switch from setting start / end points
+ if (isSettingMapStart) {
+ changeMeshColorFromIndex(start.index, WHITE); // todo Steve: this is assuming we ONLY click on walkable area. Fixed by checking & error when clicking on un-walkable area
+ start.i = i;
+ start.j = j;
+ start.index = index;
+ start.height = heightMapData[j][i];
+ changeMeshColorFromIndex(start.index, CYAN);
+ // console.log(`Setting start node: (${start.i}, ${start.j})`);
+ // console.log(`This node has height: ${heightMapData[start.j][start.i]}`);
+ } else {
+ changeMeshColorFromIndex(end.index, WHITE);
+ end.i = i;
+ end.j = j;
+ end.index = index;
+ end.height = heightMapData[j][i];
+ changeMeshColorFromIndex(end.index, ORANGE);
+ // console.log(`Setting end node: (${end.i}, ${end.j})`);
+ // console.log(`This node has height: ${heightMapData[end.j][end.i]}`);
+ }
+ }
+ }
+
+ function changeMeshColorFromIndex(index, color) {
+ colAttri.setXYZ(index, color.x, color.y, color.z);
+ colAttri.setXYZ(index + 1, color.x, color.y, color.z);
+ colAttri.setXYZ(index + 2, color.x, color.y, color.z);
+ colAttri.setXYZ(index + 3, color.x, color.y, color.z);
+ colAttri.needsUpdate = true;
+ }
+
+ function _ijToThreejsContainerObjPosition(i, j) {
+ let pos = new THREE.Vector3(j * size, 0, i * size);
+ pos.multiplyScalar(1000);
+ let gltfBoundingBox = realityEditor.gui.threejsScene.getGltfBoundingBox();
+ pos.add(new THREE.Vector3(gltfBoundingBox.min.x * 1000, 0, gltfBoundingBox.min.z * 1000));
+ pos.y += 50;
+ return pos;
+ }
+
+ function threejsContainerObjPositionToIJ(pos) {
+ let gltfBoundingBox = realityEditor.gui.threejsScene.getGltfBoundingBox();
+ pos.sub(new THREE.Vector3(gltfBoundingBox.min.x * 1000, 0, gltfBoundingBox.min.z * 1000));
+ pos.divideScalar(1000);
+ return {i: Math.floor(pos.x / size), j: Math.floor(pos.z / size)};
+ }
+
+ /* <------------------- anything from here is concerned with pathfinding -----------------------> */
+ function computeDistance(a, b) { // if we can take on diagonals, use Chebyshev distance; otherwise, use Manhattan distance instead
+ let di = Math.abs(a.i - b.i);
+ let dj = Math.abs(a.j - b.j);
+ return 14 * Math.min(di, dj) + 10 * Math.abs(di - dj);
+ }
+
+ function isEqual(a, b) {
+ return a.i === b.i && a.j === b.j && a.index === b.index;
+ }
+
+ function isDiagonal(parent, current) { // check if current node is the diagonal node of parent
+ return (current.i === parent.i - 1 && current.j === parent.j - 1) || (current.i === parent.i + 1 && current.j === parent.j - 1) || (current.i === parent.i - 1 && current.j === parent.j + 1) || (current.i === parent.i + 1 && current.j === parent.j + 1);
+ }
+
+ function isNodeInArray(n2, arr) {
+ const found = arr.find((n1) => isEqual(n1, n2));
+ return found !== undefined;
+ }
+
+ function findNodeInArray(n2, arr) {
+ return arr.find((n1) => isEqual(n1, n2));
+ }
+
+ function computeGCost(parent, current) { // d (current - start)
+ if (isDiagonal(parent, current)) current.gCost = parent.gCost + 14;
+ else current.gCost = parent.gCost + 10;
+ return current.gCost;
+ }
+
+ function computeHCost(n) { // d (end - current)
+ n.hCost = computeDistance(n, end);
+ return n.hCost;
+ }
+
+ function computeFCost(parent, current) { // G cost + H cost, compute current node's g & f cost based on parent node's g cost
+ current.fCost = computeGCost(parent, current) * 0.6 + computeHCost(current) * 0.4;
+ return current.fCost;
+ }
+
+ function sortNodeArray(arr) {
+ // for performance, open[] array should sort F Cost from low to high (if F Cost the same, then sort H Cost from low to high)
+ // b/c in the while loop, later we push nodes with higher F Costs to the end of array
+ // sorting from low to high keeps the array pretty much the same, with minimal entries shifting around
+ const compareFn = (a, b) => {
+ if (a.fCost === b.fCost) {
+ if (a.hCost === b.hCost) return 0;
+ else if (a.hCost > b.hCost) return 1;
+ return -1;
+ }
+ else if (a.fCost > b.fCost) return 1;
+ else return -1;
+ }
+ arr.sort(compareFn);
+ }
+
+ function updateNewPathCost(n, open) {
+ // if (!isNodeInArray(n, open)) return false;
+ let nOriginal = findNodeInArray(n, open);
+ if (n.fCost < nOriginal.fCost) {
+ nOriginal.gCost = n.gCost;
+ nOriginal.fCost = n.fCost;
+ }
+ }
+
+ function findNeighbor(n) {
+ let neighbors = [];
+ for (let j = n.j - 1; j <= n.j + 1; j++) {
+ for (let i = n.i - 1; i <= n.i + 1; i++) {
+ if (i === n.i && j === n.j) continue;
+ // console.log(`For node (${i},${j}), steepness: ${steepnessMapData[j][i]}, height: ${heightMapData[j][i]}`);
+ neighbors.push({
+ i: i,
+ j: j,
+ index: ijToIndex(i, j),
+ height: heightMapData[j][i],
+ });
+ }
+ }
+ neighbors = neighbors.filter(neighbor => neighbor.i >= 0 && neighbor.i < mapData[0].length && neighbor.j >= 0 && neighbor.j < mapData.length);
+ // compute the f/g/h costs for each neighbor
+ neighbors.forEach((x) => {
+ computeFCost(n, x);
+ });
+ return neighbors;
+ }
+
+ function buildPath(current) {
+ console.log('%c Found a path!', 'color: green');
+ pathNodeArr = [];
+ pathPosArr = [];
+ addNodeToPath(current);
+
+ computePathLength();
+ // addPathMeshToScene();
+ sendPathToMeasureTool();
+ }
+
+ let pathNodeArr = [];
+ let pathPosArr = [];
+ let pathLength = null;
+ function addNodeToPath(n) {
+ if (n.parent !== undefined) {
+ pathNodeArr.push(n.parent);
+ // pathPosArr.push(ijToThreejsContainerObjPosition(n.parent.i, n.parent.j));
+ // changeMeshColorFromIndex(ijToIndex(n.parent.i, n.parent.j), GREEN);
+ addNodeToPath(n.parent);
+ }
+ }
+
+ function _addPathMeshToScene() {
+ const line = new MeshLine();
+ line.setPoints(pathPosArr);
+ const material = new MeshLineMaterial({color: new THREE.Color(0xffff00),lineWidth: 15});
+ const mesh = new THREE.Mesh(line, material);
+ realityEditor.gui.threejsScene.addToScene(mesh);
+ }
+
+ function sendPathToMeasureTool() {
+ let focusedEnvelopes = realityEditor.envelopeManager.getFocusedEnvelopes();
+ let objectkey = focusedEnvelopes[0].object;
+ let framekey = focusedEnvelopes[0].frame;
+ let gltfBoundingBox = realityEditor.gui.threejsScene.getGltfBoundingBox();
+
+ let groundPlaneMatrix = realityEditor.sceneGraph.getGroundPlaneNode().worldMatrix;
+ let inverseGroundPlaneMatrix = new THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(inverseGroundPlaneMatrix, groundPlaneMatrix);
+ inverseGroundPlaneMatrix.invert();
+
+ if (realityEditor.envelopeManager.getFrameTypeFromKey(objectkey, framekey) === 'spatialMeasure') {
+ let iframe = document.getElementById('iframe' + framekey);
+ iframe.contentWindow.postMessage(JSON.stringify({
+ cellSize: size,
+ pathArr: pathNodeArr,
+ pathLength: pathLength,
+ offset: {
+ inverseGroundPlaneMatrix: inverseGroundPlaneMatrix,
+ minX: gltfBoundingBox.min.x * 1000,
+ minZ: gltfBoundingBox.min.z * 1000,
+ }
+ }), '*');
+ }
+ }
+
+ function computePathLength() {
+ pathLength = pathNodeArr[pathNodeArr.length - 1].fCost / 10 * size;
+ console.log(`Length of the path is: ${pathLength}`);
+ }
+
+ function findPath() { // start & end are objects, containing following fields: start.i, start.j, start.index
+ if (Object.keys(start).length === 0 || Object.keys(end).length === 0) {
+ console.warn('Start / end point missing. Need to define both to find path');
+ return;
+ }
+ let open = [];
+ let closed = [];
+
+ // manually compute start node's f/g/h costs, b/c we have to manually set start's gCost to 0
+ start.gCost = 0;
+ start.hCost = computeHCost(start);
+ start.fCost = start.gCost + start.hCost;
+
+ open.push(start);
+
+ while(open.length !== 0) {
+ sortNodeArray(open); // sort the open[] array, compute & store the corresponding F/G/H cost in nodes
+ let current = open.shift();
+ closed.push(current);
+ // color closed to red
+
+ if (isEqual(current, end)) {
+ buildPath(current);
+ return;
+ // resolve(current);
+ }
+
+ let neighbors = findNeighbor(current);
+ for (let i = 0; i < neighbors.length; i++) {
+ let n = neighbors[i];
+ // console.log(steepnessMapData[n.j][n.i]);
+ // if (mapData[n.j][n.i] === 0 || steepnessMapData[n.j][n.i] < MIN_STEEPNESS || steepnessMapData[n.j][n.i] > MAX_STEEPNESS || isNodeInArray(n, closed)) continue;
+ if (steepnessMapData[n.j][n.i] < MIN_STEEPNESS || steepnessMapData[n.j][n.i] > MAX_STEEPNESS || isNodeInArray(n, closed)) continue; // todo Steve: changed for 9/18 demo, only consider steepness map
+
+ if (!isNodeInArray(n, open)) { // if neighbor not in open[], push it to open[]
+ n.parent = current;
+ open.push(n);
+ } else { // if neighbor is already in open[], but has a lower f cost than what's originally in open[], update f cost
+ n.parent = current;
+ updateNewPathCost(n, open);
+ }
+ }
+ }
+ console.log(`%c Cannot find a path.`, 'color: red');
+ }
+
+ function updateSteepnessRange(min, max) {
+ MIN_STEEPNESS = min;
+ MAX_STEEPNESS = max;
+ }
+
+ exports.initService = initService;
+ exports.buildMeshFromMapData = buildMeshFromMapData;
+ exports.resetStartAndEndIndices = resetStartAndEndIndices;
+ exports.worldPosToNavmeshIndex = worldPosToNavmeshIndex;
+ exports.updateSteepnessRange = updateSteepnessRange;
+
+}(realityEditor.app.pathfinding));
diff --git a/src/app/promises.js b/src/app/promises.js
new file mode 100644
index 000000000..5ed71d599
--- /dev/null
+++ b/src/app/promises.js
@@ -0,0 +1,90 @@
+createNameSpace("realityEditor.app.promises");
+
+/**
+ * @fileOverview
+ * Provides a simpler interface to some APIs defined in app/index.js, by wrapping them in a Promise
+ * APIs that return a single value vs those that return multiple values should be accessed like:
+ * getDeviceReady().then(deviceName => {})
+ * addNewTarget('target.xml').then(({success, fileName}) => {})
+ * APIs for subscriptions, such as the matrix stream, should still be accessed directly using app/index.js
+ */
+(function(exports) {
+ const app = realityEditor.app;
+
+ // resolves to deviceName: string
+ exports.getDeviceReady = makeAPI(app.getDeviceReady.bind(app));
+ // resolves to success: boolean
+ exports.didGrantNetworkPermissions = makeAPI(app.didGrantNetworkPermissions.bind(app));
+ // resolves to success: boolean
+ exports.getVuforiaReady = makeAPI(app.getVuforiaReady.bind(app));
+ // resolves to success: boolean
+ exports.doesDeviceHaveDepthSensor = makeAPI(app.doesDeviceHaveDepthSensor.bind(app));
+ //resolves to baseURL: string
+ exports.getManagerBaseURL = makeAPI(app.getManagerBaseURL.bind(app));
+
+ // params: [targetName], resolves to: {success: boolean, fileName: string]}
+ exports.addNewTarget = makeAPI(app.addNewTarget.bind(app), ['success', 'fileName']);
+ // params: [targetName, objectID, targetWidthMeters], resolves to: {success: boolean, fileName: string]}
+ exports.addNewTargetJPG = makeAPI(app.addNewTargetJPG.bind(app), ['success', 'fileName']);
+
+ // resolves to success: boolean
+ exports.setPause = makeAPI(app.setPause.bind(app));
+ // resolves to success: boolean
+ exports.setResume = makeAPI(app.setResume.bind(app));
+
+ // resolves to providerId: string
+ exports.getProviderId = makeAPI(app.getProviderId.bind(app));
+
+ // resolves to {texture: string, textureDepth: string}
+ exports.get3dSnapshot = makeAPI(app.get3dSnapshot.bind(app), ['texture', 'textureDepth']);
+
+ // adapted from: https://stackoverflow.com/a/34637436
+ class Deferred {
+ constructor(onFinally) {
+ this.promise = new Promise((resolve, reject) => {
+ this.reject = reject;
+ this.resolve = resolve;
+ });
+ this.promise.finally(onFinally); // use this to clean up state after it's done
+ }
+ }
+
+ // exposes randomly generated public function signatures to resolve the deferred promises when the native code returns
+ exports._callbackProxies = {};
+
+ // Helper function to wrap the appFunctionCall in a deferred promise that will resolve when the native code finishes
+ // The name of each resolve param should be included iff the native API returns multiple values
+ function makeAPI(appFunctionCall, resolveParams) {
+ return function() {
+ const functionUuid = '_proxy_' + realityEditor.device.utilities.uuidTime();
+
+ // when the API is called, create a new Promise
+ let deferred = new Deferred(() => {
+ delete realityEditor.app.promises._callbackProxies[functionUuid];
+ });
+
+ // create a new function to be used as the callback that is passed to the native code
+ realityEditor.app.promises._callbackProxies[functionUuid] = function() {
+ // when the callback is triggered, resolve the promise
+ if (Array.from(arguments).length < 2) {
+ deferred.resolve.apply(null, arguments);
+ return;
+ }
+
+ // if the native code returned multiple arguments, pack them into an object and resolve
+ let argMap = {};
+ Array.from(arguments).forEach((arg, i) => {
+ argMap[resolveParams[i]] = arg;
+ });
+ deferred.resolve(argMap);
+ };
+
+ // the APIs in app/index.js expect the callback signature as the final argument
+ const argumentsPlusCallback = Array.from(arguments);
+ argumentsPlusCallback.push('realityEditor.app.promises._callbackProxies.' + functionUuid);
+ appFunctionCall.apply(null, argumentsPlusCallback);
+ return deferred.promise;
+ }
+ }
+
+})(realityEditor.app.promises);
diff --git a/src/app/targetDownloader.js b/src/app/targetDownloader.js
new file mode 100644
index 000000000..d13352668
--- /dev/null
+++ b/src/app/targetDownloader.js
@@ -0,0 +1,863 @@
+createNameSpace("realityEditor.app.targetDownloader");
+
+/**
+ * @fileOverview realityEditor.app.targetDownloader.js
+ * Compartmentalizes the functions related to downloading JPG, DAT, and XML data for each object,
+ * and using that data to initialize Vuforia targets.
+ */
+
+(function(exports) {
+
+ /**
+ * Used to pass module path to native app to trigger callbacks here
+ * @type {string}
+ */
+ const moduleName = 'realityEditor.app.targetDownloader';
+
+ /**
+ * @typedef {Readonly<{NOT_STARTED: number, STARTED: number, FAILED: number, SUCCEEDED: number}>} DownloadState
+ * @description used to keep track of the download status of a certain resource (e.g. DAT and XML files of each object)
+ */
+
+ /**
+ * @type {Object.}
+ * Maps object names to the download states of their XML and DAT files, and whether the tracking engine has added the resulting target
+ */
+ var targetDownloadStates = {};
+
+ /**
+ * Temporarily caches objectIDs with their heartbeat checksum, which later on gets stored
+ * to localStorage so that next time the app opens we don't re-download unmodified target data
+ * @type {Object.}
+ */
+ var temporaryChecksumMap = {};
+
+ /**
+ * Temporarily caches objectIDs with their full heartbeat entry so that it can be accessed in multiple download functions
+ * @type {Object.}
+ */
+ var temporaryHeartbeatMap = {};
+
+ /**
+ * We will attempt to download up to this many times if it keeps failing
+ * @type {number}
+ */
+ const MAXIMUM_RETRY_ATTEMPTS = 10;
+
+ /**
+ * Wait this much time between each failed re-download attempt
+ * @type {number}
+ */
+ const MIN_MILLISECONDS_BETWEEN_ATTEMPTS = 10000;
+
+ /**
+ * Keeps track of how many download attempts we've tried for each objectId
+ * Also stores the checksum that last failed, so we can reset number of attempts if checksum changes
+ * And stores timestamp of last download attempt so we can wait enough time in between
+ *{Object.}
+ */
+ let retryMap = {};
+
+ /**
+ * Flag to keep track of whether we've scheduled a re-download ping, so we don't spam
+ * @type {boolean}
+ */
+ let isPingPending = false;
+
+ /**
+ * @type DownloadState
+ * enum defining whether a particular download has started, failed, or succeeded
+ */
+ var DownloadState = Object.freeze(
+ {
+ NOT_STARTED: 0,
+ STARTED: 1,
+ FAILED: 2,
+ SUCCEEDED: 3
+ });
+
+ let callbacks = {
+ onCreateNavmesh: [],
+ onTargetAdded: [],
+ onTargetState: []
+ }
+
+ let navmeshResolution = null;
+ let navmeshReference = null;
+
+ /**
+ * Worker that generates navmeshes from upload area target meshes
+ * @type {Worker}
+ */
+ const navmeshWorker = new Worker(new URL('./navmeshWorker.js', import.meta.url), {
+ type: 'module',
+ })
+ navmeshWorker.onmessage = function(evt) {
+ const navmesh = evt.data.navmesh;
+ const objectID = evt.data.objectID;
+ navmeshResolution = evt.data.heatmapResolution;
+ for (let i = 0; i < window.localStorage.length; i++) {
+ const key = window.localStorage.key(i);
+ if (key.includes("realityEditor.navmesh.") && !key.includes(`${objectID}`)) {
+ window.localStorage.removeItem(key);
+ i--;
+ }
+ }
+ window.localStorage.setItem(`realityEditor.navmesh.${objectID}`, JSON.stringify(navmesh));
+
+ if (realityEditor.device.environment.variables.addOcclusionGltf) {
+ let object = realityEditor.getObject(objectID);
+ let gltfPath = realityEditor.network.getURL(object.ip, realityEditor.network.getPort(object), '/obj/' + object.name + '/target/target.glb');
+ realityEditor.gui.threejsScene.addOcclusionGltf(gltfPath, objectID);
+ }
+
+ // realityEditor.gui.threejsScene.addGltfToScene(gltfPath);
+ // let floorOffset = -1.55 * 1000;
+ // realityEditor.gui.threejsScene.addGltfToScene(gltfPath, {x: -600, y: -floorOffset, z: -3300}, {x: 0, y: 2.661627109291353, z: 0});
+
+ navmeshReference = navmesh;
+
+ callbacks.onCreateNavmesh.forEach(cb => cb(navmesh));
+ }
+ navmeshWorker.onerror = function(error) {
+ console.error(`navmeshWorker: '${error.message}' on line ${error.lineno}`);
+ }
+
+ /**
+ * Downloads the JPG files, and adds the AR target to the tracking engine, when a new UDP object heartbeat is detected
+ * @param {{id: string, ip: string, vn: number, tcs: string, zone: string}} objectHeartbeat
+ * id: the objectId
+ * ip: the IP address of the server hosting this object
+ * vn: the object's version number, e.g. 300 for version 3.0.0
+ * tcs: the checksum which can be used to tell if anything has changed since last loading this object
+ * zone: the name of the zone this object is in, so we can ignore objects outside this editor's zone if we have previously specified one
+ */
+ function downloadAvailableTargetFiles(objectHeartbeat) {
+ if (!shouldStartDownloadingFiles(objectHeartbeat)) {
+ if (realityEditor.gui.ar.anchors.isAnchorHeartbeat(objectHeartbeat)) {
+ realityEditor.gui.ar.anchors.createAnchorFromHeartbeat(objectHeartbeat);
+ } else {
+ onDownloadFailed(); // reschedule this attempt for later
+ }
+ return;
+ }
+
+ var objectID = objectHeartbeat.id;
+ var objectName = getObjectNameFromId(objectHeartbeat.id);
+ temporaryHeartbeatMap[objectHeartbeat.id] = objectHeartbeat;
+
+ var newChecksum = objectHeartbeat.tcs;
+ if (newChecksum === 'null') { newChecksum = null; }
+ temporaryChecksumMap[objectHeartbeat.id] = newChecksum;
+
+ // store info about this download attempt
+ if (typeof retryMap[objectHeartbeat.id] === 'undefined' ||
+ newChecksum !== retryMap[objectHeartbeat.id].previousChecksum) {
+ retryMap[objectHeartbeat.id] = {
+ previousChecksum: newChecksum,
+ attemptsLeft: MAXIMUM_RETRY_ATTEMPTS
+ };
+ } else {
+ // count down the number of re-download attempts
+ retryMap[objectHeartbeat.id].attemptsLeft -= 1;
+ }
+ retryMap[objectHeartbeat.id].previousTimestamp = Date.now();
+
+ // mark all downloads as not started
+ // first we will download the XML
+ // then we will download DAT, but resort to JPG if no DAT available
+ // lastly we will try to add the downloaded data to Vuforia
+
+ targetDownloadStates[objectID] = {
+ XML: DownloadState.NOT_STARTED,
+ DAT: DownloadState.NOT_STARTED,
+ JPG: DownloadState.NOT_STARTED,
+ GLB: DownloadState.NOT_STARTED,
+ TARGET_ADDED: DownloadState.NOT_STARTED
+ };
+ var xmlAddress = realityEditor.network.getURL(objectHeartbeat.ip, realityEditor.network.getPort(objectHeartbeat), '/obj/' + objectName + '/target/target.xml');
+
+ // regardless of previous conditions, don't proceed with any downloads if this is an anchor object
+ if (realityEditor.gui.ar.anchors.isAnchorHeartbeat(objectHeartbeat)) {
+ return;
+ }
+
+ // don't download XML again if already stored the same checksum version - effectively a way to cache the targets
+ if (isAlreadyDownloaded(objectID, 'XML')) {
+ onTargetXMLDownloaded(true, xmlAddress); // just directly trigger onTargetXMLDownloaded
+ return;
+ }
+
+ // downloads the vuforia target.xml file if it doesn't have it yet
+ realityEditor.app.downloadFile(xmlAddress, moduleName + '.onTargetXMLDownloaded');
+ targetDownloadStates[objectID].XML = DownloadState.STARTED;
+ }
+
+ function getObjectNameFromId(objectId) {
+ let objectName = objectId.slice(0,-12); // get objectName from objectId
+ if (objectName.length === 0) { objectName = objectId; } // use objectId as a backup (e.g. for _WORLD_local)
+ return objectName;
+ }
+
+ /**
+ * Prevents re-downloading if this object already in the middle of a download or was too recently attempted
+ * @param {{id: string, ip: string, vn: number, tcs: string, zone: string}} objectHeartbeat
+ * @return {boolean}
+ */
+ function shouldStartDownloadingFiles(objectHeartbeat) {
+ var objectID = objectHeartbeat.id;
+
+ // first ensure that this object isn't already mid-download
+ if (typeof targetDownloadStates[objectID] !== 'undefined') {
+ if (targetDownloadStates[objectID].XML === DownloadState.STARTED ||
+ targetDownloadStates[objectID].DAT === DownloadState.STARTED ||
+ targetDownloadStates[objectID].JPG === DownloadState.STARTED ||
+ targetDownloadStates[objectID].GLB === DownloadState.STARTED ||
+ targetDownloadStates[objectID].TARGET_ADDED === DownloadState.STARTED) {
+ return false;
+ }
+ }
+
+ // next ensure enough time has passed since the failed attempt
+ if (typeof retryMap[objectID] !== 'undefined' &&
+ objectHeartbeat.tcs === retryMap[objectID].previousChecksum) {
+ let timeSinceLastAttempt = Date.now() - retryMap[objectID].previousTimestamp;
+ if (timeSinceLastAttempt < MIN_MILLISECONDS_BETWEEN_ATTEMPTS) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * If successfully downloads target JPG, tries to add a new target to Vuforia
+ * @param {boolean} success
+ * @param {string} fileName
+ */
+ function onTargetXMLDownloaded(success, fileName) {
+ // we don't have the objectID but luckily it can be extracted from the fileName
+ var objectID = getObjectIDFromFilename(fileName);
+ if (!objectID) {
+ console.warn('ignoring unknown object target: ' + fileName);
+ return;
+ }
+
+ if (success) {
+ var object = realityEditor.getObject(objectID);
+ targetDownloadStates[objectID].XML = DownloadState.SUCCEEDED;
+ triggerDownloadStateCallbacks(objectID);
+
+ var datAddress = realityEditor.network.getURL(object.ip, realityEditor.network.getPort(object), '/obj/' + object.name + '/target/target.dat');
+
+ // don't download again if already stored the same checksum version
+ if (isAlreadyDownloaded(objectID, 'DAT')) {
+ onTargetDATDownloaded(true, datAddress); // just directly trigger onTargetXMLDownloaded
+ return;
+ }
+
+ // try to download DAT
+ realityEditor.app.downloadFile(datAddress, moduleName + '.onTargetDATDownloaded');
+ targetDownloadStates[objectID].DAT = DownloadState.STARTED;
+
+ } else {
+ console.error('failed to download XML file: ' + fileName);
+ targetDownloadStates[objectID].XML = DownloadState.FAILED;
+ triggerDownloadStateCallbacks(objectID);
+ onDownloadFailed(objectID);
+ }
+ }
+
+ /**
+ * If successfully downloads target JPG, tries to add a new target to Vuforia
+ * @param {boolean} success
+ * @param {string} fileName
+ */
+ function onTargetDATDownloaded(success, fileName) {
+ // we don't have the objectID but luckily it can be extracted from the fileName
+ var objectID = getObjectIDFromFilename(fileName);
+ var object = realityEditor.getObject(objectID);
+
+ const jpgAddress = realityEditor.network.getURL(object.ip, realityEditor.network.getPort(object), '/obj/' + object.name + '/target/target.jpg');
+
+ if (success) {
+ targetDownloadStates[objectID].DAT = DownloadState.SUCCEEDED;
+ triggerDownloadStateCallbacks(objectID);
+
+ var xmlFileName = realityEditor.network.getURL(object.ip, realityEditor.network.getPort(object), '/obj/' + object.name + '/target/target.xml');
+ realityEditor.app.promises.addNewTarget(xmlFileName).then(({success, fileName}) => {
+ onTargetAdded(success, fileName);
+ });
+ targetDownloadStates[objectID].TARGET_ADDED = DownloadState.STARTED;
+ targetDownloadStates[objectID].FILENAME = fileName;
+ realityEditor.getObject(objectID).isJpgTarget = false;
+
+ if (realityEditor.getObject(objectID).isWorldObject) {
+ var glbAddress = realityEditor.network.getURL(object.ip, realityEditor.network.getPort(object), '/obj/' + object.name + '/target/target.glb');
+
+ // don't download again if already stored the same checksum version
+ if (isAlreadyDownloaded(objectID, 'GLB')) {
+ onTargetGLBDownloaded(true, glbAddress); // just directly trigger onTargetGLBDownloaded
+ return;
+ }
+
+ // try to download GLB
+ realityEditor.app.downloadFile(glbAddress, moduleName + '.onTargetGLBDownloaded');
+ targetDownloadStates[objectID].GLB = DownloadState.STARTED;
+ }
+
+ } else {
+ console.error('failed to download DAT file: ' + fileName);
+ targetDownloadStates[objectID].DAT = DownloadState.FAILED;
+ triggerDownloadStateCallbacks(objectID);
+
+ if (isAlreadyDownloaded(objectID, 'JPG')) {
+ onTargetJPGDownloaded(true, jpgAddress); // just directly trigger onTargetXMLDownloaded
+ return;
+ }
+
+ // try to download JPG, marking XML as incomplete until we get the
+ // extra information from the JPG
+ realityEditor.app.downloadFile(jpgAddress, moduleName + '.onTargetJPGDownloaded');
+ targetDownloadStates[objectID].JPG = DownloadState.STARTED;
+ }
+ }
+
+ /**
+ * If successfully downloads target GLB, tries to set up navigation map
+ * @param {boolean} success
+ * @param {string} fileName
+ */
+ function onTargetGLBDownloaded(success, fileName) {
+ // we don't have the objectID but luckily it can be extracted from the fileName
+ var objectID = getObjectIDFromFilename(fileName);
+
+ if (success) {
+ targetDownloadStates[objectID].GLB = DownloadState.SUCCEEDED;
+ } else {
+ console.error('failed to download GLB file: ' + fileName);
+ targetDownloadStates[objectID].GLB = DownloadState.FAILED;
+ onDownloadFailed(objectID);
+ }
+ createNavmesh(fileName, objectID);
+
+ triggerDownloadStateCallbacks(objectID);
+ }
+
+ /**
+ * @param {string} fileName - Full URL of GLB file
+ * @param {string} objectID
+ * @param {Function?} callback
+ */
+ function createNavmesh(fileName, objectID, callback) {
+ if (callback) {
+ callbacks.onCreateNavmesh.push(callback);
+ }
+ navmeshWorker.postMessage({fileName, objectID});
+ }
+
+ function onNavmeshCreated(callback) {
+ if (!callback) {
+ return;
+ }
+ if (navmeshReference) {
+ callback(navmeshReference);
+ } else {
+ callbacks.onCreateNavmesh.push(callback);
+ }
+ }
+ exports.onNavmeshCreated = onNavmeshCreated;
+
+ /**
+ * If successfully downloads target JPG, tries to add a new target to Vuforia
+ * @param {boolean} success
+ * @param {string} fileName
+ */
+ function onTargetJPGDownloaded(success, fileName) {
+ // we don't have the objectID but luckily it can be extracted from the fileName
+ var objectID = getObjectIDFromFilename(fileName);
+
+ if (success) {
+ targetDownloadStates[objectID].JPG = DownloadState.SUCCEEDED;
+ let targetWidth = realityEditor.gui.utilities.getTargetSize(objectID).width;
+ realityEditor.app.promises.addNewTargetJPG(fileName, objectID, targetWidth).then(({success, fileName}) => {
+ onTargetAdded(success, fileName);
+ });
+ targetDownloadStates[objectID].TARGET_ADDED = DownloadState.STARTED;
+ targetDownloadStates[objectID].FILENAME = fileName;
+ realityEditor.getObject(objectID).isJpgTarget = true;
+ } else {
+ console.error('failed to download JPG file: ' + fileName);
+ targetDownloadStates[objectID].JPG = DownloadState.FAILED;
+ onDownloadFailed(objectID);
+ }
+
+ triggerDownloadStateCallbacks(objectID);
+ }
+
+ /**
+ * Callback for realityEditor.app.addNewTarget
+ * Updates the download state for that object to mark it as fully initialized in the AR engine
+ * Marks the object as SUCCEEDED only if its target is added, so we can later provide visual feedback
+ * @param {boolean} success
+ * @param {string} fileName
+ */
+ function onTargetAdded(success, fileName) {
+ var objectID = getObjectIDFromFilename(fileName);
+
+ if (success) {
+ targetDownloadStates[objectID].TARGET_ADDED = DownloadState.SUCCEEDED;
+ saveDownloadInfo(objectID); // only caches the target images after we confirm that they work
+ } else {
+ console.error('failed to add target: ' + fileName);
+ targetDownloadStates[objectID].TARGET_ADDED = DownloadState.FAILED;
+ onDownloadFailed(objectID);
+ }
+
+ triggerDownloadStateCallbacks(objectID);
+
+ callbacks.onTargetAdded.forEach(listener => {
+ if (listener.objectId === objectID) {
+ listener.callback(success, targetDownloadStates[objectID]);
+ }
+ });
+ }
+
+ /**
+ * Respond to a failed download by trying to re-download after a delay
+ * Only schedules one at a time because a single ping has the potential
+ * to re-download every object that still needs a target.
+ * Also clears the cache for the failed object so it freshly downloads next time
+ * @param {string?} objectId
+ */
+ function onDownloadFailed(objectId) {
+ if (objectId) {
+ window.localStorage.removeItem('realityEditor.previousDownloadInfo.' + objectId);
+ }
+
+ if (!isPingPending) {
+ setTimeout(function () {
+ realityEditor.app.sendUDPMessage({action: 'ping'});
+ isPingPending = false;
+ }, MIN_MILLISECONDS_BETWEEN_ATTEMPTS);
+ isPingPending = true;
+ }
+ }
+
+ /**
+ * Public function for determining whether an object's Vuforia target was successfully downloaded and initialized.
+ * @param {string} objectID
+ * @return {boolean}
+ */
+ function isObjectTargetInitialized(objectID) {
+ return targetDownloadStates[objectID] && targetDownloadStates[objectID].TARGET_ADDED === DownloadState.SUCCEEDED;
+ }
+
+ /**
+ * True if the target failed to add given a successful download,
+ * or the XML failed to download, or both the JPG and the DAT failed.
+ * @param {string} objectID
+ * @param {string} beatChecksum
+ * @return {boolean}
+ */
+ function isObjectReadyToRetryDownload(objectID, beatChecksum) {
+ if (!retryMap[objectID]) { return false; }
+
+ // if we ran out of attempts for this checksum, don't retry download
+ let hasAttemptsLeft = retryMap[objectID].attemptsLeft > 0;
+ let isNewChecksum = beatChecksum && beatChecksum !== retryMap[objectID].previousChecksum;
+
+ // if xml or target adding failed, or (jpg AND dat) failed, don't rery download
+ let didTargetAddFail = targetDownloadStates[objectID].TARGET_ADDED === DownloadState.FAILED;
+ let didXmlFail = targetDownloadStates[objectID].XML === DownloadState.FAILED;
+ let didDatFail = targetDownloadStates[objectID].DAT === DownloadState.FAILED ||
+ targetDownloadStates[objectID].DAT === DownloadState.NOT_STARTED; // dat isn't guaranteed to start
+ let didJpgFail = targetDownloadStates[objectID].JPG === DownloadState.FAILED ||
+ targetDownloadStates[objectID].JPG === DownloadState.NOT_STARTED; // jpg isn't guaranteed to start
+
+ return (hasAttemptsLeft || isNewChecksum) && (didTargetAddFail || didXmlFail || (didDatFail && didJpgFail));
+ }
+
+ /**
+ * Checks if the provided file was previously downloaded for this object
+ * If found, it verifies that the checksum from the previous download matches
+ * the current object checksum, so it doesn't cache stale data
+ * @param {string} objectID
+ * @param {string} fileType - (XML, DAT, or JPG)
+ * @return {boolean}
+ */
+ function isAlreadyDownloaded(objectID, fileType) {
+ var previousDownloadInfo = getPreviousDownloadInfo(objectID);
+ let xmlPreviouslyDownloaded = false;
+ let jpgPreviouslyDownloaded = false;
+ let datPreviouslyDownloaded = false;
+ let glbPreviouslyDownloaded = false;
+ let previousChecksum = null;
+ if (previousDownloadInfo) {
+ try {
+ let parsed = JSON.parse(previousDownloadInfo);
+ xmlPreviouslyDownloaded = parsed.xmlDownloaded === DownloadState.SUCCEEDED;
+ jpgPreviouslyDownloaded = parsed.jpgDownloaded === DownloadState.SUCCEEDED;
+ datPreviouslyDownloaded = parsed.datDownloaded === DownloadState.SUCCEEDED;
+ glbPreviouslyDownloaded = parsed.glbDownloaded === DownloadState.SUCCEEDED;
+ previousChecksum = parsed.checksum;
+ } catch (e) {
+ console.warn('error parsing previousDownloadInfo');
+ }
+ }
+
+ // check if the specified fileType successfully downloaded to the cache
+ if (fileType === 'XML' && !xmlPreviouslyDownloaded) {
+ return false;
+ } else if (fileType === 'DAT' && !datPreviouslyDownloaded) {
+ return false;
+ } else if (fileType === 'JPG' && !jpgPreviouslyDownloaded) {
+ return false;
+ } else if (fileType === 'GLB' && !glbPreviouslyDownloaded) {
+ return false;
+ }
+
+ // if the file succeeded, also check that the checksum hasn't changed so we don't use stale data
+ var newChecksum = temporaryChecksumMap[objectID];
+ return previousChecksum && (previousChecksum === newChecksum);
+ }
+
+ /**
+ * Stores the checksum of the XML + DAT + JPG files in localStorage, so that we can skip re-downloading them if we already have them.
+ * @param {string} objectID
+ * @return {string} - the checksum at time of downloading. null if never downloaded before.
+ */
+ function getPreviousDownloadInfo(objectID) {
+ return window.localStorage.getItem('realityEditor.previousDownloadInfo.' + objectID);
+ }
+
+ /**
+ * Store the object's checksum into persistent localStorage.
+ * Also stores the success/fail state of the xml, dat, and jpg downloads individually
+ * @param {string} objectID
+ */
+ function saveDownloadInfo(objectID) {
+ if (temporaryChecksumMap[objectID]) {
+ window.localStorage.setItem('realityEditor.previousDownloadInfo.' + objectID, JSON.stringify({
+ checksum: temporaryChecksumMap[objectID],
+ xmlDownloaded: targetDownloadStates[objectID].XML,
+ datDownloaded: targetDownloadStates[objectID].DAT,
+ jpgDownloaded: targetDownloadStates[objectID].JPG,
+ glbDownloaded: targetDownloadStates[objectID].GLB
+ }));
+ }
+ }
+
+ /**
+ * Removes all download info from localStorage, so that the app re-downloads
+ * all targets instead of using a cached version
+ */
+ function resetTargetDownloadCache() {
+ Object.keys(window.localStorage).filter(function(key) {
+ return key.includes('realityEditor.previousDownloadInfo');
+ }).forEach(function(key) {
+ window.localStorage.removeItem(key);
+ });
+ }
+
+ /**
+ * @deprecated - use downloadAvailableTargetFiles instead, if the device can add objects based on DAT or JPG, not just DAT
+ * @todo - evaluate if this is necessary at all or if it can be completely removed (github issue #14)
+ * Downloads the XML and DAT files, and adds the AR target to the tracking engine, when a new UDP object heartbeat is detected
+ * @param {{id: string, ip: string, vn: number, tcs: string, zone: string}} objectHeartbeat
+ * id: the objectId
+ * ip: the IP address of the server hosting this object
+ * vn: the object's version number, e.g. 300 for version 3.0.0
+ * tcs: the checksum which can be used to tell if anything has changed since last loading this object
+ * zone: the name of the zone this object is in, so we can ignore objects outside this editor's zone if we have previously specified one
+ */
+ function downloadTargetFilesForDiscoveredObject(objectHeartbeat) {
+
+ var objectID = objectHeartbeat.id;
+ var objectName = getObjectNameFromId(objectHeartbeat.id);
+
+ temporaryHeartbeatMap[objectHeartbeat.id] = objectHeartbeat;
+
+ var newChecksum = objectHeartbeat.tcs;
+ if (newChecksum === 'null') { newChecksum = null; }
+ temporaryChecksumMap[objectHeartbeat.id] = newChecksum;
+
+ var needsXML = true;
+ var needsDAT = true;
+
+ if (typeof targetDownloadStates[objectID] !== 'undefined') {
+ if (targetDownloadStates[objectID].XML === DownloadState.STARTED ||
+ targetDownloadStates[objectID].XML === DownloadState.SUCCEEDED) {
+ needsXML = false;
+ }
+ if (targetDownloadStates[objectID].DAT === DownloadState.STARTED ||
+ targetDownloadStates[objectID].DAT === DownloadState.SUCCEEDED) {
+ needsDAT = false;
+ }
+
+ } else {
+ targetDownloadStates[objectID] = {
+ XML: DownloadState.NOT_STARTED,
+ DAT: DownloadState.NOT_STARTED,
+ TARGET_ADDED: DownloadState.NOT_STARTED
+ };
+ }
+
+ // don't download again if already stored the same checksum version
+ var storedChecksum = window.localStorage.getItem('realityEditor.objectChecksums.'+objectID);
+ if (storedChecksum) {
+ if (newChecksum === storedChecksum) {
+ // check that the files still exist in the app's temporary storage
+ var xmlFileName = realityEditor.network.getURL(objectHeartbeat.ip, realityEditor.network.getPort(objectHeartbeat), '/obj/' + objectName + '/target/target.xml');
+ var datFileName = realityEditor.network.getURL(objectHeartbeat.ip, realityEditor.network.getPort(objectHeartbeat), '/obj/' + objectName + '/target/target.dat');
+
+ realityEditor.app.getFilesExist([xmlFileName, datFileName], moduleName + '.doTargetFilesExist');
+ return;
+ }
+ }
+
+ // no matching checksum. download fresh target files.
+ continueDownload(objectID, objectHeartbeat, needsXML, needsDAT);
+ }
+
+ /**
+ * Downloads the XML and/or the DAT for the object target depending on which are still needed
+ * @param {string} objectID
+ * @param {{id: string, ip: string, vn: number, tcs: string, zone: string}} objectHeartbeat
+ * @param {boolean} needsXML
+ * @param {boolean} needsDAT
+ */
+ function continueDownload(objectID, objectHeartbeat, needsXML, needsDAT) {
+ if (!needsXML && !needsDAT) {
+ return;
+ }
+
+ var objectName = getObjectNameFromId(objectHeartbeat.id);
+
+ // downloads the vuforia target.xml file if it doesn't have it yet
+ if (needsXML) {
+ var xmlAddress = realityEditor.network.getURL(objectHeartbeat.ip, realityEditor.network.getPort(objectHeartbeat), '/obj/' + objectName + '/target/target.xml');
+ realityEditor.app.downloadFile(xmlAddress, moduleName + '.onTargetFileDownloaded');
+ targetDownloadStates[objectID].XML = DownloadState.STARTED;
+ }
+
+ // downloads the vuforia target.dat file it it doesn't have it yet
+ if (needsDAT) {
+ var datAddress = realityEditor.network.getURL(objectHeartbeat.ip, realityEditor.network.getPort(objectHeartbeat), '/obj/' + objectName + '/target/target.dat');
+ realityEditor.app.downloadFile(datAddress, moduleName + '.onTargetFileDownloaded');
+ targetDownloadStates[objectID].DAT = DownloadState.STARTED;
+ }
+ }
+
+ /**
+ *
+ * @param {boolean} success
+ * @param {Array.} fileNameArray
+ */
+ function doTargetFilesExist(success, fileNameArray) {
+ if (fileNameArray.length > 0) {
+ var objectID = getObjectIDFromFilename(fileNameArray[0]);
+ var heartbeat = temporaryHeartbeatMap[objectID];
+
+ if (success) {
+
+ // if the checksums match and we verified that the files exist, proceed without downloading
+ targetDownloadStates[objectID].XML = DownloadState.SUCCEEDED;
+ targetDownloadStates[objectID].DAT = DownloadState.SUCCEEDED;
+
+ var xmlFileName = fileNameArray.filter(function(fileName) {
+ return fileName.indexOf('xml') > -1;
+ })[0];
+
+ realityEditor.app.promises.addNewTarget(xmlFileName).then(({success, fileName}) => {
+ onTargetAdded(success, fileName);
+ });
+ targetDownloadStates[objectID].TARGET_ADDED = DownloadState.STARTED;
+ targetDownloadStates[objectID].FILENAME = xmlFileName;
+
+ } else {
+
+ var needsXML = !(targetDownloadStates[objectID].XML === DownloadState.STARTED ||
+ targetDownloadStates[objectID].XML === DownloadState.SUCCEEDED);
+
+ var needsDAT = !(targetDownloadStates[objectID].DAT === DownloadState.STARTED ||
+ targetDownloadStates[objectID].DAT === DownloadState.SUCCEEDED);
+
+ continueDownload(objectID, heartbeat, needsXML, needsDAT);
+ }
+
+ }
+
+ }
+
+ // let schema = {
+ // "type": "object",
+ // "items": {
+ // "properties": {
+ // "obj": {"type": "string", "minLength": 1, "maxLength": 50, "pattern": "^[A-Za-z0-9_]*$"},
+ // "server" : {"type": "string", "minLength": 0, "maxLength": 2000, "pattern": "^[A-Za-z0-9~!@$%^&*()-_=+|;:,.]"},
+ // },
+ // "required": ["server", "obj"],
+ // "expected": ["server", "obj"],
+ // }
+ // }
+
+ const schema = new ToolSocket.Schema([
+ new ToolSocket.Schema.StringValidator('obj', {minLength: 1, maxLength: 50, pattern: /^[A-Za-z0-9_]*$/, required: true, expected: true}),
+ new ToolSocket.Schema.StringValidator('server', {minLength: 0, maxLength: 2000, pattern: /^[A-Za-z0-9~!@$%^&*()-_=+|;:,.]/, required: true, expected: true})
+ ])
+
+ /**
+ * Uses a combination of IP address and object name to locate the ID.
+ * e.g. "http(s)://10.10.10.108:8080/obj/monitorScreen/target/target.xml" -> ("10.10.10.108", "monitorScreen") -> object named monitor screen with that IP
+ * @param {string} fileName
+ */
+ function getObjectIDFromFilename(fileName) {
+ let fileUrl;
+ try {
+ fileUrl = new URL(fileName);
+ } catch (e) {
+ console.error(`Cannot create URL from file name: ${fileName}`, e);
+ return;
+ }
+ let parsedUrl = schema.parseUrl(fileUrl);
+ if (!parsedUrl) {
+ console.warn('schema.parseUrl failed. this may cause targets not to download', fileName);
+ return;
+ }
+ const ip = parsedUrl.server;
+ const objectName = parsedUrl.obj;
+
+
+ for (var objectKey in objects) {
+ if (!objects.hasOwnProperty(objectKey)) continue;
+ const object = realityEditor.getObject(objectKey);
+ const ipMatches = object.ip === ip || object.ip === 'localhost' || ip === 'localhost';
+ if (ipMatches && object.name === objectName) {
+ return objectKey;
+ }
+ }
+
+ console.warn('tried to download a file that couldn\'t locate a matching object', fileName);
+ }
+
+ /**
+ * Callback for realityEditor.app.downloadFile for either target.xml or target.dat
+ * Updates the corresponding object's targetDownloadState,
+ * and if both the XML and DAT are finished downloading, adds the resulting target to the AR engine
+ * @param {boolean} success
+ * @param {string} fileName
+ */
+ function onTargetFileDownloaded(success, fileName) {
+
+ var isXML = fileName.split('/')[fileName.split('/').length-1].indexOf('xml') > -1;
+ var fileTypeString = isXML ? 'XML' : 'DAT';
+
+ // we don't have the objectID but luckily it can be extracted from the fileName
+ // var objectID = getObjectIDFromName(objectName, DownloadState.STARTED, fileTypeString);
+ var objectID = getObjectIDFromFilename(fileName);
+
+ if (success) {
+ targetDownloadStates[objectID][fileTypeString] = DownloadState.SUCCEEDED;
+ } else {
+ console.error('failed to download file: ' + fileName);
+ targetDownloadStates[objectID][fileTypeString] = DownloadState.FAILED;
+ }
+
+ var hasXML = targetDownloadStates[objectID].XML === DownloadState.SUCCEEDED;
+ var hasDAT = targetDownloadStates[objectID].DAT === DownloadState.SUCCEEDED;
+ var targetNotAdded = (targetDownloadStates[objectID].TARGET_ADDED === DownloadState.NOT_STARTED ||
+ targetDownloadStates[objectID].TARGET_ADDED === DownloadState.FAILED);
+
+ // synchronizes the two async download calls to add the target when both tasks have completed
+ var xmlFileName = isXML ? fileName : fileName.slice(0, -3) + 'xml';
+ if (hasXML && hasDAT && targetNotAdded) {
+ realityEditor.app.promises.addNewTarget(xmlFileName).then(({success, fileName}) => {
+ onTargetAdded(success, fileName);
+ });
+ targetDownloadStates[objectID].TARGET_ADDED = DownloadState.STARTED;
+ targetDownloadStates[objectID].FILENAME = fileName;
+
+ if (temporaryChecksumMap[objectID]) {
+ window.localStorage.setItem('realityEditor.objectChecksums.'+objectID, temporaryChecksumMap[objectID]);
+ }
+ }
+ }
+
+ // if the vuforia engine gets hard-restarted during the session, we can use this to add the observers back to engine
+ function reinstatePreviouslyAddedTargets() {
+ Object.keys(targetDownloadStates).forEach(function(objectID) {
+ let states = targetDownloadStates[objectID];
+ if (states && states.TARGET_ADDED === DownloadState.SUCCEEDED && targetDownloadStates[objectID].FILENAME) {
+ if (states.JPG === DownloadState.SUCCEEDED && states.DAT !== DownloadState.SUCCEEDED) {
+ let targetWidth = realityEditor.gui.utilities.getTargetSize(objectID).width;
+ realityEditor.app.promises.addNewTargetJPG(targetDownloadStates[objectID].FILENAME, objectID, targetWidth).then(({success, fileName}) => {
+ onTargetAdded(success, fileName);
+ });
+ targetDownloadStates[objectID].TARGET_ADDED = DownloadState.STARTED;
+ } else if (states.DAT === DownloadState.SUCCEEDED) {
+ realityEditor.app.promises.addNewTarget(targetDownloadStates[objectID].FILENAME).then(({success, fileName}) => {
+ onTargetAdded(success, fileName);
+ });
+ }
+ }
+ });
+ }
+
+ exports.addTargetAddedCallback = function(objectId, callback) {
+ callbacks.onTargetAdded.push({
+ objectId: objectId,
+ callback: callback
+ });
+
+ if (typeof targetDownloadStates[objectId] !== 'undefined') {
+ if (targetDownloadStates[objectId].TARGET_ADDED === DownloadState.SUCCEEDED) {
+ // process any previously added targets in case we added the listener too late
+ callback(true, targetDownloadStates[objectId]);
+ }
+ }
+ }
+
+ exports.addTargetStateCallback = function(objectId, callback) {
+ callbacks.onTargetState.push({
+ objectId: objectId,
+ callback: callback
+ });
+
+ if (typeof targetDownloadStates[objectId] !== 'undefined') {
+ // process any previously added targets in case we added the listener too late
+ callback(targetDownloadStates[objectId]);
+ }
+ }
+
+ function triggerDownloadStateCallbacks(objectID) {
+ callbacks.onTargetState.forEach(listener => {
+ if (listener.objectId === objectID) {
+ listener.callback(targetDownloadStates[objectID]);
+ }
+ });
+ }
+
+ exports.getNavmeshResolution = function() {
+ return navmeshResolution;
+ }
+
+ // These functions are the public API that should be called by other modules
+ exports.downloadAvailableTargetFiles = downloadAvailableTargetFiles;
+ exports.downloadTargetFilesForDiscoveredObject = downloadTargetFilesForDiscoveredObject;
+ exports.isObjectTargetInitialized = isObjectTargetInitialized;
+ exports.isObjectReadyToRetryDownload = isObjectReadyToRetryDownload;
+ exports.resetTargetDownloadCache = resetTargetDownloadCache;
+ exports.reinstatePreviouslyAddedTargets = reinstatePreviouslyAddedTargets;
+ exports.DownloadState = DownloadState;
+
+ // These functions are public only because they need to be triggered by native app callbacks
+ exports.onTargetXMLDownloaded = onTargetXMLDownloaded;
+ exports.onTargetDATDownloaded = onTargetDATDownloaded;
+ exports.onTargetJPGDownloaded = onTargetJPGDownloaded;
+ exports.onTargetGLBDownloaded = onTargetGLBDownloaded;
+ exports.createNavmesh = createNavmesh;
+ exports.doTargetFilesExist = doTargetFilesExist;
+ exports.onTargetFileDownloaded = onTargetFileDownloaded;
+
+})(realityEditor.app.targetDownloader);
diff --git a/src/assistant/assistant.js b/src/assistant/assistant.js
new file mode 100644
index 000000000..c74d90b28
--- /dev/null
+++ b/src/assistant/assistant.js
@@ -0,0 +1,232 @@
+import {getFrameText} from '../gui/search.js';
+import {apiKey} from './config.js';
+
+const SYSTEM_PROMPT = `Act like an industry expert. You will be provided with information about an area delimited by triple quotes. You will then receive a question from a local technician working in this area. You should attempt to answer the technician's question using the provided area information. First, if you need additional information or context, you should use function calls. Second, answer concisely in one to two sentences.`;
+const SYSTEM_PROMPT_IMAGE = `Describe what you can see in the image, and generate the description so it is suitable to be used for alt-text preserving as much unique information as possible.`;
+
+const tools = [{
+ 'type': 'function',
+ 'function': {
+ name: 'search_tools',
+ description: 'Search for text within nearby tools. Returns an array of descriptions of any matching tools.',
+ parameters: {
+ type: 'object',
+ properties: {
+ text: {
+ type: 'string',
+ description: 'The text to search for'
+ },
+ },
+ required: ['text'],
+ },
+ },
+}];
+/*, {
+ 'type': 'function',
+ 'function': {
+ name: 'take_picture',
+ description: 'Take a picture of the current space',
+ },
+}]; */
+
+async function takePictureAndSummarize() {
+ if (!apiKey) {
+ return 'missing api key';
+ }
+
+ const snapshot = await realityEditor.app.promises.get3dSnapshot();
+ const messages = [{
+ role: 'system',
+ content: SYSTEM_PROMPT_IMAGE,
+ }, {
+ role: 'user',
+ content: [{
+ type: 'image',
+ image_url: {
+ url: snapshot.texture,
+ }
+ }],
+ }];
+
+ const body = {
+ model: 'gpt-4-vision-preview',
+ max_tokens: 4096,
+ temperature: 0,
+ messages,
+ };
+
+ try {
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+ const data = await response.json();
+ console.log('got openai', data);
+ return data.choices[0].message.content;
+ } catch (error) {
+ console.error('not openai', error);
+ }
+}
+
+function searchTools(query) {
+ if (query.length === 0) {
+ return [];
+ }
+ // `There is one pdf document in the space with the text "${args.text}"`; // Could not find any tools for query "${args.text}"`;
+ let matches = [];
+ let frames = realityEditor.worldObjects.getBestWorldObject().frames;
+ for (const frameId in frames) {
+ const frame = frames[frameId];
+ let envText = getFrameText(frame);
+ if (envText.toLowerCase().includes(query)) {
+ matches.push(envText);
+ }
+ }
+ return JSON.stringify(matches);
+}
+
+async function doToolCalls(toolCalls) {
+ let results = [];
+ for (let call of toolCalls) {
+ let content = '';
+ let fn = call['function'];
+ try {
+ let args = JSON.parse(fn['arguments']);
+ switch (fn.name) {
+ case 'take_picture':
+ content = await takePictureAndSummarize();
+ break;
+ case 'search_tools':
+ content = await searchTools(args.text);
+ break;
+ }
+ } catch (e) {
+ console.warn('Unable to call function', e);
+ content = 'Error: unable to call function';
+ }
+
+ results.push({
+ tool_call_id: call.id,
+ role: 'tool',
+ name: fn.name,
+ content,
+ });
+ }
+
+ return results;
+}
+
+function getMotionStudyInformation() {
+ let hpa = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!hpa) {
+ return 'No person seen.';
+ }
+ let desc = [];
+ for (const id of Object.keys(hpa.poseRenderInstances)) {
+ if (!id.startsWith('_HUMAN')) {
+ continue;
+ }
+ const poseRenderInstance = hpa.poseRenderInstances[id];
+ const pose = poseRenderInstance.pose;
+ desc.push('There is a person in the space.');
+ let anyBad = false;
+ for (const jointName of Object.keys(pose.joints)) {
+ let jointInfo = pose.joints[jointName];
+ if (jointName.split('_').length > 2) {
+ continue;
+ }
+ if (jointInfo.rebaScore > 3) {
+ anyBad = true;
+ desc.push(`The person's ${jointName.replace(/_/g, ' ')} is under strain.`);
+ }
+ }
+ if (!anyBad) {
+ desc.push('The person has good posture for working.');
+ }
+ }
+ if (desc.length === 0) {
+ return 'No person seen.';
+ }
+ return desc.join(' ');
+}
+
+// There is a chat tool with a conversation about tape. There is a person working in the space. This person's arms are under strain. There is a chat tool with a conversation about wrenches.
+function getAreaInformation() {
+ let frames = realityEditor.worldObjects.getBestWorldObject().frames;
+ let tools = {};
+ for (const frameId in frames) {
+ const frame = frames[frameId];
+ if (!tools[frame.src]) {
+ tools[frame.src] = 0;
+ }
+ tools[frame.src] += 1;
+ }
+ let toolsText = Object.entries(tools).map(([toolSrc, count]) => {
+ if (count === 1) {
+ return `There is a ${toolSrc} tool.`;
+ }
+ return `There are ${count} ${toolSrc} tools.`;
+ }).join(' ');
+
+ let hpaText = getMotionStudyInformation();
+ return toolsText + '\n' + hpaText;
+}
+
+export async function answerQuestion(question, toolCallMessages) {
+ if (!apiKey) {
+ throw new Error('Missing apiKey');
+ }
+
+ const messages = [{
+ role: 'system',
+ content: SYSTEM_PROMPT,
+ }, {
+ role: 'user',
+ content: `Area information: """${getAreaInformation()}"""
+
+Question: ${question}`,
+ }];
+
+ if (toolCallMessages) {
+ messages.push(...toolCallMessages);
+ }
+
+ const body = {
+ // model: 'gpt-4-vision-preview',
+ model: 'gpt-4-turbo-preview',
+ max_tokens: 2048,
+ temperature: 0,
+ messages,
+ tools,
+ };
+
+ try {
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+ const data = await response.json();
+ const message = data.choices[0].message;
+ let description = message.content;
+ console.log('got openai', description, data);
+ if (data.choices[0].finish_reason === 'tool_calls') {
+ delete message.content;
+ toolCallMessages = [message];
+ toolCallMessages.push(...await doToolCalls(message.tool_calls));
+ return await answerQuestion(question, toolCallMessages);
+ } else {
+ console.log('stop openai', description, data);
+ return description;
+ }
+ } catch (error) {
+ console.error('not openai', error);
+ }
+}
diff --git a/src/assistant/config.js b/src/assistant/config.js
new file mode 100644
index 000000000..5134fc04b
--- /dev/null
+++ b/src/assistant/config.js
@@ -0,0 +1,2 @@
+export const apiKey = 'your openai api key here';
+export const apiKey11 = 'your eleven api key here';
diff --git a/src/assistant/hearing.js b/src/assistant/hearing.js
new file mode 100644
index 000000000..f7e3ee481
--- /dev/null
+++ b/src/assistant/hearing.js
@@ -0,0 +1,92 @@
+/* global webkitSpeechRecognition */
+
+import {speak} from './speech.js';
+import {answerQuestion} from './assistant.js';
+
+const recognition = new webkitSpeechRecognition();
+recognition.continuous = true;
+recognition.interimResults = true;
+recognition.lang = 'en-US';
+
+let isRecognitionRunning = false;
+
+recognition.onstart = () => {
+ isRecognitionRunning = true;
+};
+
+recognition.onend = () => {
+ isRecognitionRunning = false;
+};
+
+recognition.onerror = error => {
+ console.log('Recognition error', error);
+ // Check if the error is 'no-speech'
+ if (error.error === 'no-speech') {
+ console.log('No speech detected, restarting recognition...');
+ setTimeout(() => {
+ recognition.start();
+ }, 0);
+ } else {
+ console.error('Fatal recognition error', error);
+ isRecognitionRunning = false;
+ }
+};
+
+let answerTimeout = null;
+let silenceMs = 1500;
+
+let answering = false;
+
+/**
+ * Need to do a rolling submission window or something
+ * because you won't get isFinal
+ */
+recognition.onresult = event => {
+ if (answering) {
+ return;
+ }
+ const newMessages = [];
+ console.info('onresult', event);
+ Array.from(event.results).forEach(result => {
+ newMessages.push(result[0].transcript);
+ });
+
+ if (newMessages.length > 0) {
+ const transcription = newMessages.join(' ').trim();
+ if (answerTimeout) {
+ clearTimeout(answerTimeout);
+ answerTimeout = null;
+ }
+ answerTimeout = setTimeout(() => {
+ onNewTranscription(transcription);
+ }, silenceMs);
+ }
+};
+
+window.startAssistantRecognition = function() {
+ if (!isRecognitionRunning) {
+ recognition.start();
+ }
+}
+
+async function onNewTranscription(transcription) {
+ answering = true;
+ recognition.stop();
+ if (transcription.toLowerCase().includes('mercury')) {
+ let question = transcription.split('ercury').at(-1);
+ try {
+ const answer = await answerQuestion(question);
+ await speak(answer);
+ } catch (e) {
+ console.error('unable to answer question', e);
+ }
+ }
+ const elt = document.createElement('p');
+ elt.textContent = transcription;
+ document.body.appendChild(elt);
+
+ setTimeout(() => {
+ answering = false;
+ recognition.start();
+ }, 2000);
+}
diff --git a/src/assistant/speech.js b/src/assistant/speech.js
new file mode 100644
index 000000000..a166ea3ba
--- /dev/null
+++ b/src/assistant/speech.js
@@ -0,0 +1,111 @@
+import {apiKey11} from './config.js';
+
+const voiceId = 'CYw3kZ02Hs0563khs1Fj'; // Dave
+const model = 'eleven_monolingual_v1';
+const wsUrl = `wss://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream-input?model_id=${model}`;
+
+export function speak(text) {
+ return new Promise(resolve => {
+ if (!sourceBuffer) {
+ console.warn('media source unavailable');
+ resolve();
+ return;
+ }
+
+ if (!apiKey11) {
+ console.error('missing api key');
+ resolve();
+ return;
+ }
+
+ // TODO find out what prevents websocket reuse
+ const socket = new WebSocket(wsUrl);
+
+ socket.onopen = function onopen() {
+ const bosMessage = {
+ text: ' ',
+ voice_settings: {
+ 'stability': 0.5,
+ 'similarity_boost': 0.8
+ },
+ xi_api_key: apiKey11,
+ };
+
+ socket.send(JSON.stringify(bosMessage));
+
+ const textMessage = {
+ text,
+ try_trigger_generation: true,
+ };
+ socket.send(JSON.stringify(textMessage));
+
+ // 4. Send the EOS message with an empty string
+ const eosMessage = {
+ text: ''
+ };
+
+ socket.send(JSON.stringify(eosMessage));
+ }
+
+ // 5. Handle server responses
+ socket.onmessage = async function (event) {
+ const response = JSON.parse(event.data);
+
+ if (response.audio) {
+ // decode and handle the audio data (e.g., play it)
+ const audioChunk = atob(response.audio); // decode base64
+ const audioBuf = Uint8Array.from(audioChunk, c => c.charCodeAt(0))
+ buffersToAppend.push(audioBuf);
+ } else {
+ console.log('No audio data in the response');
+ }
+
+ if (response.isFinal) {
+ // the generation is complete
+ }
+
+ if (response.normalizedAlignment) {
+ // use the alignment info if needed
+ }
+ };
+
+ // Handle errors
+ socket.onerror = function (error) {
+ console.error(`WebSocket Error: ${error}`);
+ };
+
+ // Handle socket closing
+ socket.onclose = function (event) {
+ if (event.wasClean) {
+ console.info(`Connection closed cleanly, code=${event.code}, reason=${event.reason}`);
+ } else {
+ console.warn('Connection died');
+ }
+ resolve();
+ };
+ });
+}
+
+const audio = document.createElement('audio');
+const mediaSource = new MediaSource();
+let sourceBuffer;
+let buffersToAppend = [];
+mediaSource.addEventListener('sourceopen', function() {
+ sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
+ setInterval(() => {
+ if (buffersToAppend.length === 0) {
+ return;
+ }
+ if (sourceBuffer.updating) {
+ return;
+ }
+
+ sourceBuffer.appendBuffer(buffersToAppend.shift());
+
+ if (audio.paused) {
+ audio.play();
+ }
+ }, 50);
+});
+
+audio.src = URL.createObjectURL(mediaSource);
diff --git a/src/avatar/draw.js b/src/avatar/draw.js
new file mode 100644
index 000000000..abab6c708
--- /dev/null
+++ b/src/avatar/draw.js
@@ -0,0 +1,521 @@
+createNameSpace("realityEditor.avatar.draw");
+
+/**
+ * @fileOverview realityEditor.avatar.draw
+ * Contains a variety of helper functions for avatar/index.js to render all visuals related to avatars
+ */
+
+(function(exports) {
+ const RENDER_DEVICE_CUBE = false; // turn on to show a cube at each of the avatar positions, in addition to the beams
+ const SMOOTH_AVATAR_POSITIONS = false; // try to animate the positions of the avatars โ doesn't work too well yet
+
+ // main data structure that stores the various visual elements for each avatar objectKey (beam, pointer, textLabel)
+ let avatarMeshes = {};
+ let linkObjects = {}; // contains the animation properties for the lines drawn from avatars
+
+ // 2D UI for keeping track of the connection status
+ let debugUI = null;
+ let statusUI = null;
+ let hasConnectionFeedbackBeenShown = false; // ensures we only show the "Connected!" UI one time
+
+ // main rendering loop โ trigger this at 60fps to render all the visual feedback for the avatars (e.g. laser pointers)
+ function renderOtherAvatars(avatarTouchStates, avatarNames, avatarCursorStates) {
+ try {
+ for (const [objectKey, avatarTouchState] of Object.entries(avatarTouchStates)) {
+ renderAvatar(objectKey, avatarTouchState, avatarNames[objectKey]);
+ }
+ for (const [objectKey, avatarCursorState] of Object.entries(avatarCursorStates)) {
+ realityEditor.spatialCursor.renderOtherSpatialCursor(objectKey,
+ avatarCursorState.matrix, avatarCursorState.colorHSL, avatarCursorState.isColored, avatarCursorState.worldId);
+ }
+ } catch (e) {
+ console.warn('error rendering other avatars', e);
+ }
+ }
+
+ function renderMyAvatar(myAvatarObject, myAvatarTouchState) {
+ if (!myAvatarObject) return;
+ if (!myAvatarTouchState) return;
+
+ realityEditor.avatar.setLinkCanvasNeedsClear(true);
+
+ try {
+ // if that device isn't touching down, hide its laser beam and ignore the rest
+ if (!myAvatarTouchState.isPointerDown) {
+ return;
+ }
+
+ // it only makes sense to draw the laser beam on our own screen if at least one other user is connected
+ let numConnectedAvatars = Object.keys(realityEditor.avatar.getConnectedAvatarList()).length;
+ if (numConnectedAvatars < 1) return;
+
+ drawLaserBeam(myAvatarObject.objectId, null, realityEditor.avatar.utils.getColor(myAvatarObject), realityEditor.avatar.utils.getColorLighter(myAvatarObject), myAvatarTouchState.screenX, myAvatarTouchState.screenY);
+ } catch (e) {
+ console.warn(e);
+ }
+ }
+
+ // return a quadratic function with a lower & upper bound
+ // when input below threshold, the output maintains at outputLimit
+ // but when input above threshold, the output quadratically shrinks
+ function quadraticRemap(x, lowIn, highIn, lowOut, highOut) {
+ if (x < lowIn) return highOut;
+ else if (x > highIn) return lowOut;
+ else return ((highOut - lowOut) / Math.pow((highIn - lowIn), 2)) * Math.pow((highIn - x), 2);
+ }
+
+ // main rendering function for a single avatar โ creates a beam, a sphere at the endpoint, and a text label if a name is provided
+ function renderAvatar(objectKey, touchState, avatarName) {
+ if (!touchState) { return; }
+
+ realityEditor.avatar.setLinkCanvasNeedsClear(true);
+
+ // if that device isn't touching down, hide its laser beam and ignore the rest
+ if (!touchState.isPointerDown) {
+ if (avatarMeshes[objectKey]) {
+ avatarMeshes[objectKey].pointer.visible = false;
+ avatarMeshes[objectKey].beam.visible = false;
+ avatarMeshes[objectKey].textLabel.style.display = 'none';
+ realityEditor.gui.spatialArrow.deleteLaserBeamIndicator(objectKey);
+ }
+ return;
+ }
+
+ const THREE = realityEditor.gui.threejsScene.THREE;
+ const color = realityEditor.avatar.utils.getColor(realityEditor.getObject(objectKey)) || 'hsl(60, 100%, 50%)';
+
+ // lazy-create the meshes and text label if they don't exist yet
+ if (typeof avatarMeshes[objectKey] === 'undefined') {
+
+ let pointerGroup = new THREE.Group();
+ let pointerSphere = sphereMesh(color, objectKey + 'pointer', 50);
+ pointerGroup.add(pointerSphere);
+
+ let initials = null;
+ if (avatarName) {
+ initials = realityEditor.avatar.utils.getInitialsFromName(avatarName);
+ }
+
+ avatarMeshes[objectKey] = {
+ pointer: pointerGroup,
+ beam: cylinderMesh(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1, 0, 0), new THREE.Vector3(1, 0, 0), color),
+ textLabel: createTextLabel(objectKey, initials)
+ }
+ if (RENDER_DEVICE_CUBE) { // debug option to show where the avatars are located
+ avatarMeshes[objectKey].device = boxMesh(color, objectKey + 'device')
+ avatarMeshes[objectKey].device.matrixAutoUpdate = false;
+ realityEditor.gui.threejsScene.addToScene(avatarMeshes[objectKey].device);
+ }
+ avatarMeshes[objectKey].beam.name = objectKey + 'beam';
+ realityEditor.gui.threejsScene.addToScene(avatarMeshes[objectKey].pointer);
+ realityEditor.gui.threejsScene.addToScene(avatarMeshes[objectKey].beam);
+ }
+
+ // get the scene position of the avatar by multiplying the avatar matrix (which is relative to world) by the world origin matrix
+ let thatAvatarSceneNode = realityEditor.sceneGraph.getSceneNodeById(objectKey);
+ let worldSceneNode = realityEditor.sceneGraph.getSceneNodeById(realityEditor.sceneGraph.getWorldId());
+ let worldMatrixThree = new THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(worldMatrixThree, worldSceneNode.worldMatrix);
+ let avatarObjectMatrixThree = new THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(avatarObjectMatrixThree, thatAvatarSceneNode.worldMatrix);
+ avatarObjectMatrixThree.premultiply(worldMatrixThree);
+
+ // then transform the final avatar position into groundplane coordinates since the threejsScene is relative to groundplane
+ let groundPlaneSceneNode = realityEditor.sceneGraph.getGroundPlaneNode();
+ let groundPlaneMatrix = new THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(groundPlaneMatrix, groundPlaneSceneNode.worldMatrix);
+ avatarObjectMatrixThree.premultiply(groundPlaneMatrix.invert());
+
+ // show all the meshes, etc, for this avatar
+ avatarMeshes[objectKey].pointer.visible = true;
+ let wasBeamVisible = avatarMeshes[objectKey].beam.visible; // animate differently if just made visible
+ avatarMeshes[objectKey].beam.visible = true;
+ if (RENDER_DEVICE_CUBE) {
+ avatarMeshes[objectKey].device.visible = true;
+ avatarMeshes[objectKey].device.matrixAutoUpdate = false
+ avatarMeshes[objectKey].device.matrix.copy(avatarObjectMatrixThree);
+ }
+
+ // we either draw an "infinite" ray in the specified direction, or draw a line to the specified point
+ if (!touchState.worldIntersectPoint && !touchState.rayDirection) return;
+
+ let convertedEndPosition = new THREE.Vector3();
+
+ if (touchState.worldIntersectPoint) {
+ // worldIntersectPoint was converted to world coordinates. need to convert back to groundPlane coordinates in this system
+ let groundPlaneRelativeToWorldToolbox = worldSceneNode.getMatrixRelativeTo(groundPlaneSceneNode);
+ let groundPlaneRelativeToWorldThree = new realityEditor.gui.threejsScene.THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(groundPlaneRelativeToWorldThree, groundPlaneRelativeToWorldToolbox);
+ // convertedEndPosition = new THREE.Vector3(touchState.worldIntersectPoint.x, touchState.worldIntersectPoint.y, touchState.worldIntersectPoint.z);
+ convertedEndPosition.set(touchState.worldIntersectPoint.x, touchState.worldIntersectPoint.y, touchState.worldIntersectPoint.z);
+ convertedEndPosition.applyMatrix4(groundPlaneRelativeToWorldThree);
+ // move the pointer sphere to the raycast intersect position
+
+ avatarMeshes[objectKey].pointer.visible = true;
+ avatarMeshes[objectKey].pointer.position.set(convertedEndPosition.x, convertedEndPosition.y, convertedEndPosition.z);
+
+ // get the 2D screen coordinates of the pointer, and render a text bubble centered on it with the name of the sender
+ let pointerWorldPosition = new THREE.Vector3();
+ avatarMeshes[objectKey].pointer.getWorldPosition(pointerWorldPosition);
+ let screenCoords = realityEditor.gui.threejsScene.getScreenXY(pointerWorldPosition);
+ if (avatarName) {
+ avatarMeshes[objectKey].textLabel.style.display = 'inline';
+ }
+ // scale the name textLabel based on distance from convertedEndPosition to camera
+ let camPos = realityEditor.sceneGraph.getWorldPosition('CAMERA');
+ let delta = {
+ x: camPos.x - convertedEndPosition.x,
+ y: camPos.y - convertedEndPosition.y,
+ z: camPos.z - convertedEndPosition.z
+ };
+ let distanceToCamera = Math.max(0.001, Math.sqrt(delta.x * delta.x + delta.y * delta.y + delta.z * delta.z));
+ let scale = Math.max(0.5, Math.min(2, 2000 / distanceToCamera)); // biggest when <1m, smallest when >4m
+ avatarMeshes[objectKey].textLabel.style.transform = 'translateX(-50%) translateY(-50%) translateZ(3000px) scale(' + scale + ')';
+ avatarMeshes[objectKey].textLabel.style.left = screenCoords.x + 'px'; // position it centered on the pointer sphere
+ avatarMeshes[objectKey].textLabel.style.top = screenCoords.y + 'px';
+ } else {
+ // hide the pointer and just compute a point along the rayDirection, so we can render the beam
+ avatarMeshes[objectKey].pointer.visible = false;
+ avatarMeshes[objectKey].textLabel.style.display = 'none';
+
+ // rayDirection is relative to world object โ convert to relative to groundPlane
+ let rayDirectionRelativeToWorldObject = touchState.rayDirection;
+ const RAY_LENGTH_MM = 100 * 1000; // render it 100 meters long
+ let arUtils = realityEditor.gui.ar.utilities;
+ let rayOriginRelativeToWorldObject = realityEditor.sceneGraph.convertToNewCoordSystem([0, 0, 0], thatAvatarSceneNode, worldSceneNode);
+ let endRelativeToWorldObject = arUtils.add(rayOriginRelativeToWorldObject, arUtils.scalarMultiply(rayDirectionRelativeToWorldObject, RAY_LENGTH_MM));
+ let endRelativeToGroundPlane = realityEditor.sceneGraph.convertToNewCoordSystem(endRelativeToWorldObject, worldSceneNode, groundPlaneSceneNode);
+ convertedEndPosition.set(endRelativeToGroundPlane[0], endRelativeToGroundPlane[1], endRelativeToGroundPlane[2]);
+ }
+
+ // the position of the avatar in space
+ let startPosition = new THREE.Vector3(avatarObjectMatrixThree.elements[12], avatarObjectMatrixThree.elements[13], avatarObjectMatrixThree.elements[14]);
+ // the position of the destination of the laser pointer (where that clicked on the environment)
+ let endPosition = new THREE.Vector3(convertedEndPosition.x, convertedEndPosition.y, convertedEndPosition.z);
+
+ if (SMOOTH_AVATAR_POSITIONS && wasBeamVisible) { // animate start position if already visible
+ let currentStartPosition = [
+ avatarMeshes[objectKey].beam.position.x,
+ avatarMeshes[objectKey].beam.position.y,
+ avatarMeshes[objectKey].beam.position.z
+ ];
+ let newStartPosition = [
+ avatarObjectMatrixThree.elements[12],
+ avatarObjectMatrixThree.elements[13],
+ avatarObjectMatrixThree.elements[14]
+ ];
+ // animation option 1: move the cursor faster the further away it is from the new position, so it eases out
+ // let animatedStartPosition = realityEditor.gui.ar.utilities.tweenMatrix(currentStartPosition, newStartPosition, 0.05);
+ // animation option 2: move the cursor linearly at 30*[FPS] millimeters per second
+ let animatedStartPosition = realityEditor.gui.ar.utilities.animationVectorLinear(currentStartPosition, newStartPosition, 30);
+ startPosition = new THREE.Vector3(animatedStartPosition[0], animatedStartPosition[1], animatedStartPosition[2]);
+ }
+
+ // replace the old laser beam cylinder with a new one that goes from the avatar position to the beam destination
+ avatarMeshes[objectKey].beam = updateCylinderMesh(avatarMeshes[objectKey].beam, startPosition, endPosition, color);
+ avatarMeshes[objectKey].beam.name = objectKey + 'beam';
+ // realityEditor.gui.threejsScene.addToScene(avatarMeshes[objectKey].beam);
+ // if laser beam is off screen, add an arrow pointing to the laser beam destination position
+ let lightColor = realityEditor.avatar.utils.getColorLighter(realityEditor.getObject(objectKey)) || 'hsl(60, 100%, 50%)';
+ // get the world position of the laser pointer sphere, and draw arrow to it if off screen
+ let endWorldPosition = new THREE.Vector3();
+ if (avatarMeshes[objectKey].pointer.visible) {
+ avatarMeshes[objectKey].pointer.getWorldPosition(endWorldPosition);
+ } else {
+ let endWorldPositionArray = [endPosition.x, endPosition.y, endPosition.z];
+ endWorldPositionArray = realityEditor.sceneGraph.convertToNewCoordSystem(endWorldPositionArray, groundPlaneSceneNode, worldSceneNode);
+ endWorldPosition.set(endWorldPositionArray[0], endWorldPositionArray[1], endWorldPositionArray[2]);
+ }
+ drawLaserBeam(objectKey, endWorldPosition, color, lightColor);
+ }
+
+ function drawLaserBeam(objectKey, endWorldPosition, color, lightColor, screenX, screenY) {
+ const THREE = realityEditor.gui.threejsScene.THREE;
+ // realityEditor.gui.spatialArrow.drawArrowBasedOnWorldPosition(endWorldPosition, color, lightColor);
+ realityEditor.gui.spatialArrow.addLaserBeamIndicator(objectKey, endWorldPosition, color, lightColor);
+ // todo Steve: draw a fake 3d line from avatar icon to the position of the laser pointer sphere (endWorldPosition)
+ let linkCanvasInfo = realityEditor.avatar.getLinkCanvasInfo();
+ let avatarIconElement = document.getElementById('avatarIcon' + objectKey);
+ let avatarIconElementRect;
+ // try to draw the line coming from the avatar icon that corresponds with who sent the line
+ if (avatarIconElement) {
+ avatarIconElementRect = avatarIconElement.getBoundingClientRect();
+ } else {
+ // if we can't find the icon for the avatar, then try to get the last icon in the container (the "+N" one)
+ let avatarIconContainer = document.getElementById('avatarIconContainer');
+ let iconList = avatarIconContainer.querySelectorAll('.avatarListIcon');
+ let lastIcon = iconList[iconList.length - 1];
+ if (lastIcon) {
+ avatarIconElementRect = lastIcon.getBoundingClientRect();
+ } else {
+ // default it to the top-center of the screen if all else fails
+ avatarIconElementRect = avatarIconContainer.getBoundingClientRect();
+ }
+ }
+ let linkStartPos = [avatarIconElementRect.x + avatarIconElementRect.width / 2, avatarIconElementRect.y + avatarIconElementRect.height / 2];
+
+ // for laser beams coming from other devices, draw to the worldPosition
+ // for laser beams from this device, draw to the (screenX, screenY) where the user touches
+ let endScreenXY = null;
+ let lineEndThicknessRatio = 1;
+ if (endWorldPosition) {
+ let camWorldPos = new THREE.Vector3();
+ realityEditor.gui.threejsScene.getInternals().getCamera().getWorldPosition(camWorldPos);
+ let linkDistance = camWorldPos.sub(endWorldPosition).length();
+ lineEndThicknessRatio = quadraticRemap(linkDistance, 0, 20000, 0.1, 1);
+ endScreenXY = realityEditor.gui.threejsScene.getScreenXY(endWorldPosition);
+ } else if (screenX && screenY) {
+ endScreenXY = {
+ x: screenX,
+ y: screenY
+ };
+ let linkDistance = Math.sqrt(Math.pow((screenX - linkStartPos[0]), 2) + Math.pow((screenY - linkStartPos[1]), 2));
+ lineEndThicknessRatio = quadraticRemap(linkDistance, 0, 10000, 0.1, 1);
+ } else {
+ return;
+ }
+
+ let linkEndPos = [endScreenXY.x, endScreenXY.y];
+ let colorArr = HSLStrToRGBArr(color);
+ let lightColorArr = HSLStrToRGBArr(lightColor);
+
+ if (typeof linkObjects[objectKey] === 'undefined') {
+ linkObjects[objectKey] = { ballAnimationCount: 0 };
+ }
+
+ // for unknown reason, using the default line width looks much thinner in AR mode and needs to be compensated for
+ let arModeScaleFactor = 5.0;
+ let lineWeight = 1.5 * (realityEditor.device.environment.isARMode() ? arModeScaleFactor : 1.0);
+ // thinner lines outside of AR mode are a little overwhelming if they're too fast
+ let lineSpeed = realityEditor.device.environment.isARMode() ? 1.0 : 0.5;
+ realityEditor.gui.ar.lines.drawLine(linkCanvasInfo.ctx, linkStartPos, linkEndPos, lineWeight, lineWeight * lineEndThicknessRatio, linkObjects[objectKey], timeCorrection, lightColorArr, colorArr, lineSpeed, 1, 1);
+ }
+
+ function HSLStrToRGBArr(hslStr) {
+ let hslObj = parseHSLStr(hslStr);
+ return HSLToRGB(hslObj.h, hslObj.s, hslObj.l);
+ }
+
+ // https://www.30secondsofcode.org/js/s/to-hsl-object/
+ function parseHSLStr(hslStr) {
+ const regex = /-?\d+(?:\.\d+)?/g;
+ const [h, s, l] = hslStr.match(regex).map(Number);
+ return {h, s, l};
+ }
+
+ function HSLToRGB (h, s, l) {
+ s /= 100;
+ l /= 100;
+ const k = n => (n + h / 30) % 12;
+ const a = s * Math.min(l, 1 - l);
+ const f = n =>
+ l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
+ return [255 * f(0), 255 * f(8), 255 * f(4)];
+ }
+
+ // helper to create a box mesh
+ function boxMesh(color, name) {
+ const THREE = realityEditor.gui.threejsScene.THREE;
+ const geo = new THREE.BoxGeometry(100, 100, 100);
+ const mat = new THREE.MeshBasicMaterial({color: color});
+ const box = new THREE.Mesh(geo, mat);
+ box.name = name;
+ return box;
+ }
+
+ // helper to create a sphere mesh
+ function sphereMesh(color, name, radius) {
+ const THREE = realityEditor.gui.threejsScene.THREE;
+ const geo = new THREE.SphereGeometry((radius || 50), 8, 6, 0, 2 * Math.PI, 0, Math.PI);
+ const mat = new THREE.MeshBasicMaterial({ color: color });
+ const sphere = new THREE.Mesh(geo, mat);
+ sphere.name = name;
+ return sphere;
+ }
+
+ // helper to create a thin laser beam cylinder from start to end
+ function cylinderMesh(startPoint, endPoint, color) {
+ const THREE = realityEditor.gui.threejsScene.THREE;
+ let length = 0;
+ if (startPoint && endPoint) {
+ let direction = new THREE.Vector3().subVectors(endPoint, startPoint);
+ length = direction.length();
+ }
+ const material = getBeamMaterial(color);
+ let geometry = new THREE.CylinderGeometry(6, 6, length, 6, 2, false);
+ // shift it so one end rests on the origin
+ geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, length / 2, 0));
+ // rotate it the right way for lookAt to work
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); // 90 degrees
+ let mesh = new THREE.Mesh(geometry, material);
+ if (startPoint) {
+ mesh.position.copy(startPoint);
+ }
+ if (endPoint) {
+ mesh.lookAt(endPoint);
+ }
+ return mesh;
+ }
+
+ // TODO: make this return a material using a custom shader to fade out the opacity
+ // ideally the opacity will be close to 1 where the beam hits the area target,
+ // and fades out to 0 or 0.1 after a meter or two, so that it just indicates the direction without being too intense
+ function getBeamMaterial(color) {
+ const THREE = realityEditor.gui.threejsScene.THREE;
+ return new THREE.MeshBasicMaterial({color: color, transparent: true, opacity: 0.5});
+ }
+
+ // replace the existing cylinderMesh object with a new cylinderMesh with updated start and end points
+ function updateCylinderMesh(obj, startPoint, endPoint, color) {
+ obj.geometry.dispose();
+ obj.material.dispose();
+
+ realityEditor.gui.threejsScene.removeFromScene(obj);
+ return cylinderMesh(startPoint, endPoint, color);
+ }
+
+ // adds a circular label with enough space for two initials, e.g. "BR" (but hides it if no initials provided)
+ function createTextLabel(objectKey, initials) {
+ let labelContainer = document.createElement('div');
+ labelContainer.id = 'avatarBeamLabelContainer_' + objectKey;
+ labelContainer.classList.add('avatarBeamLabel');
+ document.body.appendChild(labelContainer);
+
+ let label = document.createElement('div');
+ label.id = 'avatarBeamLabel_' + objectKey;
+ labelContainer.appendChild(label);
+
+ if (initials) {
+ label.innerText = initials;
+ labelContainer.classList.remove('displayNone');
+ } else {
+ label.innerText = initials;
+ labelContainer.classList.add('displayNone');
+ }
+
+ return labelContainer;
+ }
+
+ // update the laser beam text label with this name's initials
+ function updateAvatarName(objectKey, name) {
+ let matchingTextLabel = document.getElementById('avatarBeamLabel_' + objectKey);
+ if (matchingTextLabel) {
+ let initials = realityEditor.avatar.utils.getInitialsFromName(name);
+ if (initials) {
+ matchingTextLabel.innerText = initials;
+ matchingTextLabel.parentElement.classList.remove('displayNone');
+ } else {
+ matchingTextLabel.innerText = '';
+ matchingTextLabel.parentElement.classList.add('displayNone');
+ }
+ }
+ }
+
+ // when sending a beam, highlight your cursor
+ function renderCursorOverlay(isVisible, screenX, screenY, color) {
+ let overlay = document.getElementById('beamOverlay');
+ if (!overlay) {
+ overlay = document.createElement('div');
+ overlay.id = 'beamOverlay';
+ overlay.style.position = 'absolute';
+ overlay.style.left = '-10px';
+ overlay.style.top = '-10px';
+ overlay.style.pointerEvents = 'none';
+ overlay.style.width = '20px';
+ overlay.style.height = '20px';
+ overlay.style.borderRadius = '10px';
+ overlay.style.backgroundColor = color;
+ overlay.style.opacity = '0.5';
+ document.body.appendChild(overlay);
+ }
+ overlay.style.transform = 'translate3d(' + screenX + 'px, ' + screenY + 'px, 1201px)';
+ overlay.style.display = isVisible ? 'inline' : 'none';
+ }
+
+ // Shows an "Establishing Connection..." --> "Connected!" label in the top left
+ function renderConnectionFeedback(isConnected, didFail = false) {
+ if (!statusUI) {
+ statusUI = document.createElement('div');
+ statusUI.id = 'avatarStatus';
+ statusUI.classList.add('topLeftInfoText');
+ statusUI.style.opacity = '0.5';
+ statusUI.style.left = '5px';
+ statusUI.style.top = (realityEditor.device.environment.variables.screenTopOffset + 5) + 'px';
+ document.body.appendChild(statusUI);
+ }
+ if (hasConnectionFeedbackBeenShown) { return; }
+ if (isConnected) {
+ hasConnectionFeedbackBeenShown = true;
+ statusUI.innerText = '';
+ setTimeout(() => {
+ statusUI.innerText = 'Avatar Connected!';
+ setTimeout(() => {
+ statusUI.innerText = '';
+ statusUI.style.display = 'none';
+ }, 2000);
+ }, 300);
+ } else {
+ if (didFail) {
+ statusUI.innerText = ''; // hide the "Establishing" message on fail or timeout
+ } else {
+ statusUI.innerText = 'Establishing Avatar Connection...';
+ }
+ }
+ }
+
+ // show some debug text fields in the top left corner of the screen to track data connections and transmission
+ function renderConnectionDebugInfo(connectionStatus, debugConnectionStatus, myId, debugMode) {
+ if (!debugMode) {
+ if (debugUI) { debugUI.style.display = 'none'; }
+ return;
+ }
+
+ if (!debugUI) {
+ debugUI = document.createElement('div');
+ debugUI.id = 'avatarConnectionStatus';
+ debugUI.classList.add('topLeftInfoText');
+ debugUI.style.top = realityEditor.device.environment.variables.screenTopOffset + 'px';
+ document.body.appendChild(debugUI);
+ }
+ let sendText = debugConnectionStatus.didSendAnything && debugConnectionStatus.didRecentlySend ? 'TRUE' : debugConnectionStatus.didSendAnything ? 'true' : 'false';
+ let receiveText = debugConnectionStatus.didReceiveAnything && debugConnectionStatus.didRecentlyReceive ? 'TRUE' : debugConnectionStatus.didReceiveAnything ? 'true' : 'false';
+
+ debugUI.style.display = '';
+ debugUI.innerHTML = 'Localized? (' + connectionStatus.isLocalized +'). ' +
+ 'Created? (' + connectionStatus.isMyAvatarCreated + ').' +
+ ' ' +
+ 'Verified? (' + connectionStatus.isMyAvatarInitialized + '). ' +
+ 'Occlusion? (' + connectionStatus.isWorldOcclusionObjectAdded + ').' +
+ ' ' +
+ 'Subscribed? (' + debugConnectionStatus.subscribedToHowMany + '). ' +
+ ' ' +
+ 'Did Send? (' + sendText + '). ' +
+ 'Did Receive? (' + receiveText + ')' +
+ ' ' +
+ 'Did Fail? (' + debugConnectionStatus.didCreationFail + ')' +
+ ' ' +
+ 'My ID: ' + (myId ? myId : 'null');
+ }
+
+ function deleteAvatarMeshes(objectKey) {
+ if (avatarMeshes[objectKey]) {
+ Object.values(avatarMeshes[objectKey]).forEach(elt => {
+ if (typeof elt.isObject3D !== 'undefined' && elt.isObject3D && typeof elt.removeFromParent !== 'undefined') {
+ elt.removeFromParent();
+ } else if (elt.tagName !== 'undefined' && typeof elt.parentElement !== 'undefined') {
+ elt.parentElement.removeChild(elt);
+ }
+ });
+ }
+ delete avatarMeshes[objectKey];
+ }
+
+ exports.renderOtherAvatars = renderOtherAvatars;
+ exports.renderMyAvatar = renderMyAvatar;
+ exports.updateAvatarName = updateAvatarName;
+ exports.renderCursorOverlay = renderCursorOverlay;
+ exports.renderConnectionFeedback = renderConnectionFeedback;
+ exports.renderConnectionDebugInfo = renderConnectionDebugInfo;
+ exports.deleteAvatarMeshes = deleteAvatarMeshes;
+
+}(realityEditor.avatar.draw));
diff --git a/src/avatar/iconMenu.js b/src/avatar/iconMenu.js
new file mode 100644
index 000000000..e71292e4c
--- /dev/null
+++ b/src/avatar/iconMenu.js
@@ -0,0 +1,374 @@
+createNameSpace("realityEditor.avatar.iconMenu");
+
+/**
+ * @fileOverview realityEditor.avatar.iconMenu
+ * Renders the interactable UI component with the list of avatars connected to the scene
+ * Show their initials, and clicking on them allows you to rename yourself or follow other users' views
+ */
+
+(function(exports) {
+ // Note: MAX_ICONS isn't set here, it is set to realityEditor.device.environment.variables.maxAvatarIcons
+ // If more than MAX_ICONS avatars are connected, the icons for extras will be hidden/combined in the ellipsis icon
+
+ const ADDITIONAL_NAMES = 2; // list out this many extra names with commas when hovering over the ellipsis
+ const ICON_WIDTH = 30; // layout information for circular icons
+ const ICON_GAP = 10;
+
+ let callbacks = {
+ onMenuItemClicked: [],
+ }
+
+ // Enum of the menu item labels, which other modules can also check against to listen to specific menu items
+ const MENU_ITEMS = Object.freeze({
+ EditName: 'Edit Name',
+ AllFollowMe: 'All Follow Me',
+ FollowThem: 'Follow',
+ FollowMe: 'Follow Me'
+ });
+
+ function initService() {
+ // the iconMenu itself provides the implementation of the Edit Name menu item.
+ // Other menu items can be implemented by their relevant modules, e.g. desktopCamera can handle Follow actions
+ onAvatarIconMenuItemSelected((params) => {
+ if (!(params.isMyIcon && params.buttonText === realityEditor.avatar.iconMenu.MENU_ITEMS.EditName)) return;
+ // show a modal that lets you type in a name
+ realityEditor.gui.modal.openInputModal({
+ headerText: 'Edit Avatar Name',
+ descriptionText: 'Specify the name that other users will see.',
+ inputPlaceholderText: 'Your username here',
+ onSubmitCallback: (e, userName) => {
+ if (userName && typeof userName === 'string') {
+ userName = userName.trim();
+ if (userName.length === 0) {
+ userName = 'Anonymous';
+ }
+ realityEditor.avatar.setMyUsername(userName);
+ realityEditor.avatar.writeUsername(userName);
+ // write to window.localStorage and use instead of anonymous in the future in this browser
+ window.localStorage.setItem('manuallyEnteredUsername', userName);
+ }
+ }
+ });
+ });
+ }
+ /**
+ * Show a list of circular icons, one per avatar, with the (random) color and (chosen) initials of that user.
+ * If too many avatars, combines the overflow into a final icon with (+N) in it.
+ * This uses very simple render logic, which entirely clears the container and rebuilds it each time it renders.
+ * @param {Object.} connectedAvatars
+ */
+ function renderAvatarIconList(connectedAvatars) {
+ let iconContainer = document.getElementById('avatarIconContainer');
+ if (!iconContainer) {
+ iconContainer = createIconContainer();
+ }
+
+ // reset the container, so we can rebuild it from scratch. simple, inefficient, but reliable.
+ while (iconContainer.hasChildNodes()) {
+ iconContainer.removeChild(iconContainer.lastChild);
+ }
+
+ if (Object.keys(connectedAvatars).length < 1) {
+ return; // don't show unless there is at least one avatar
+ }
+
+ // moves you to the front // TODO: sort by recent activity, or who's following you, etc
+ let sortedKeys = realityEditor.avatar.utils.sortAvatarList(connectedAvatars);
+
+ // if too many collaborators, show a "+N..." at the end (I'm calling this the ellipsis) and limit how many icons
+ const MAX_ICONS = realityEditor.device.environment.variables.maxAvatarIcons;
+
+ // build and add each of the icons to the container, and attach pointer event listeners to them
+ sortedKeys.forEach((objectKey, index) => {
+ if (index >= MAX_ICONS) { return; } // after the ellipsis, we ignore the rest
+ let isEllipsis = index === (MAX_ICONS - 1) && sortedKeys.length > MAX_ICONS; // last one turns into "+2", "+3", etc
+ let numTooMany = sortedKeys.length - (MAX_ICONS - 1);
+
+ let info = connectedAvatars[objectKey];
+ let initials = realityEditor.avatar.utils.getInitialsFromName(info.name) || '';
+ if (isEllipsis) {
+ initials = '+' + numTooMany;
+ }
+
+ let usersFollowingMe = realityEditor.avatar.utils.getUsersFollowingUser(objectKey, connectedAvatars);
+ let isMyIcon = objectKey.includes(realityEditor.avatar.utils.getAvatarName());
+ let iconDiv = createAvatarIcon(iconContainer, objectKey, initials, index, isMyIcon, isEllipsis);
+
+ // TODO: show more details on who you are following, and who is following you
+ if (usersFollowingMe.length > 0) {
+ // currently just adds a notification bubble showing the number of users following me
+ let bubble = document.createElement('div');
+ bubble.classList.add('avatarListIconFollowingBubble');
+ bubble.textContent = `${usersFollowingMe.length}`;
+ iconDiv.appendChild(bubble);
+ }
+
+ // show full name when hovering over the icon
+ let tooltipText = info.name;
+ // or put all the extra names into the tooltip text
+ if (isEllipsis) {
+ let remainingKeys = sortedKeys.slice(-1 * numTooMany);
+ let names = remainingKeys.map(key => connectedAvatars[key].name).filter(name => !!name);
+ names = names.slice(0, ADDITIONAL_NAMES); // limit number of comma-separated names
+ tooltipText = names.join(', ');
+
+ let additional = numTooMany - names.length; // number of anonymous and beyond-additional
+ if (additional > 0) {
+ tooltipText += ' and ' + additional + ' more';
+ }
+ }
+
+ // hovering/clicking on icon image triggers tooltip or dropdown
+ let iconImageDiv = iconDiv.querySelector('.avatarListIconImage');
+ iconImageDiv.addEventListener('pointerover', () => {
+ showFullNameTooltip(iconImageDiv, tooltipText, isMyIcon, isEllipsis);
+ });
+ ['pointerout', 'pointercancel', 'pointerup'].forEach((eventName) => {
+ iconImageDiv.addEventListener(eventName, hideFullNameTooltip);
+ });
+ iconImageDiv.addEventListener('pointerup', () => {
+ if (isEllipsis) { // don't add follow menu to ellipsis
+ return; // TODO: what should happen when you click on the "+N" icon? show all names?
+ }
+ toggleDropdown(objectKey, info, initials, isMyIcon);
+ });
+ });
+
+ let iconsWidth = Math.min(MAX_ICONS, sortedKeys.length) * (ICON_WIDTH + ICON_GAP) + ICON_GAP;
+ iconContainer.style.width = iconsWidth + 'px';
+ }
+ /**
+ * Shows or hides the dropdown corresponding to the avatar icon for the given avatar objectId
+ * @param {string} objectId
+ * @param {UserProfile} userProfile
+ * @param {string|null} userInitials
+ * @param {boolean} isMyIcon
+ */
+ function toggleDropdown(objectId, userProfile, userInitials, isMyIcon) {
+ let iconDropdown = document.getElementById('avatarIconDropdown' + objectId);
+ if (!iconDropdown) {
+ iconDropdown = createAvatarIconDropdown(objectId, userProfile, userInitials, isMyIcon);
+ }
+
+ // show or hide the clicked menu depending on previous state
+ let newIsShown = iconDropdown.classList.contains('hiddenDropdown'); // show if it was hidden
+ // if we're going to show this one, hide all other dropdown menus
+ if (newIsShown) {
+ showDropdown(iconDropdown);
+ } else {
+ hideDropdown(iconDropdown);
+ }
+ }
+ /**
+ * Show the dropdown menu, and hide other dropdowns and tooltips
+ * @param {HTMLElement} iconDropdown
+ */
+ function showDropdown(iconDropdown) {
+ Array.from(document.querySelectorAll('.avatarListIconDropdown')).forEach(dropdown => {
+ hideDropdown(dropdown);
+ });
+ iconDropdown.classList.remove('hiddenDropdown');
+ hideFullNameTooltip();
+ }
+ /**
+ * Hides a particular dropdown menu
+ * @param {HTMLElement} iconDropdown
+ */
+ function hideDropdown(iconDropdown) {
+ iconDropdown.classList.add('hiddenDropdown');
+ }
+ /**
+ * Constructs a dropdown menu with the correct menu items for the specified avatar.
+ * E.g. your own avatar has Edit Name and All Follow Me, while others have Follow and Follow Me
+ * @param {string} objectId
+ * @param {UserProfile} userProfile
+ * @param {string|null} userInitials
+ * @param {boolean} isMyIcon
+ */
+ function createAvatarIconDropdown(objectId, userProfile, userInitials, isMyIcon) {
+ let parent = document.getElementById('avatarIcon' + objectId);
+ if (!parent) {
+ console.warn('cant create avatar icon dropdown because parent doesnt exist');
+ return;
+ }
+ let container = document.createElement('div');
+ container.id = 'avatarIconDropdown' + objectId;
+ container.classList.add('avatarListIconDropdown', 'hiddenDropdown'); // hide, because toggle happens right after creation
+ if (isMyIcon) {
+ addMenuItemToDropdown(container, MENU_ITEMS.EditName, objectId, userProfile, userInitials, isMyIcon);
+ addMenuItemToDropdown(container, MENU_ITEMS.AllFollowMe, objectId, userProfile, userInitials, isMyIcon);
+ } else {
+ addMenuItemToDropdown(container, MENU_ITEMS.FollowThem, objectId, userProfile, userInitials, isMyIcon);
+ addMenuItemToDropdown(container, MENU_ITEMS.FollowMe, objectId, userProfile, userInitials, isMyIcon);
+ }
+ parent.appendChild(container);
+ return container;
+ }
+ /**
+ * Creates a div for the menu item and attaches a pointerup event that other modules can subscribe to
+ * @param {HTMLElement} parentDiv
+ * @param {string} textContent
+ * @param {string} objectId
+ * @param {UserProfile} userProfile
+ * @param {string|null} userInitials
+ * @param {boolean} isMyIcon
+ */
+ function addMenuItemToDropdown(parentDiv, textContent, objectId, userProfile, userInitials, isMyIcon) {
+ let item = document.createElement('div');
+ item.classList.add('avatarListIconDropdownItem');
+ item.textContent = textContent;
+ parentDiv.appendChild(item);
+
+ item.addEventListener('pointerup', (e) => {
+ callbacks.onMenuItemClicked.forEach((cb) => {
+ cb({
+ buttonText: textContent,
+ avatarObjectId: objectId,
+ avatarProfile: userProfile,
+ userInitials: userInitials,
+ isMyIcon: isMyIcon,
+ pointerEvent: e
+ });
+ });
+ hideDropdown(parentDiv);
+ });
+ }
+ /**
+ * Other modules can use this to detect when any avatar icon menu item was selected
+ * @param {function} callback
+ */
+ function onAvatarIconMenuItemSelected(callback) {
+ callbacks.onMenuItemClicked.push(callback);
+ }
+ /**
+ * Create the container that all the avatar icon list elements will get added to
+ * @returns {HTMLDivElement}
+ */
+ function createIconContainer() {
+ let iconContainer = document.createElement('div');
+ iconContainer.id = 'avatarIconContainer';
+ iconContainer.classList.add('avatarIconContainerScaleAdjustment')
+ document.body.appendChild(iconContainer)
+ return iconContainer;
+ }
+ /**
+ * Create an icon for this avatar, and add hover event listeners to show tooltip with full name
+ * @param {HTMLElement} parent
+ * @param {string} objectKey
+ * @param {string|null} initials
+ * @param {number} index
+ * @param {boolean} isMyIcon
+ * @param {boolean} isEllipsis
+ * @returns {HTMLDivElement}
+ */
+ function createAvatarIcon(parent, objectKey, initials, index, isMyIcon, isEllipsis) {
+ let iconDiv = document.createElement('div');
+ iconDiv.id = 'avatarIcon' + objectKey;
+ iconDiv.classList.add('avatarListIcon', 'avatarListIconVerticalAdjustment');
+ iconDiv.style.left = (ICON_GAP + (ICON_WIDTH + ICON_GAP) * index) + 'px';
+ parent.appendChild(iconDiv);
+
+ let iconImg = document.createElement('img');
+ iconImg.classList.add('avatarListIconImage');
+ iconDiv.appendChild(iconImg);
+
+ // your icon has a different visual style (and different default image if no username/initials set)
+ if (initials) {
+ iconImg.src = 'svg/avatar-initials-background-dark.svg';
+ let iconInitials = document.createElement('div');
+ iconInitials.classList.add('avatarListIconInitials');
+ iconInitials.innerText = initials;
+ iconDiv.appendChild(iconInitials);
+ } else {
+ if (isMyIcon) {
+ iconImg.src = 'svg/avatar-placeholder-icon.svg';
+ } else {
+ iconImg.src = 'svg/avatar-placeholder-icon-dark.svg';
+ }
+ }
+
+ // color the avatar icon to match the avatar color (the same color used by cursor, pointer, etc)
+ let color = realityEditor.avatar.utils.getColor(realityEditor.getObject(objectKey));
+ let lightColor = realityEditor.avatar.utils.getColorLighter(realityEditor.getObject(objectKey));
+ if (isMyIcon && color) {
+ iconImg.style.border = '2px solid white';
+ iconImg.style.backgroundColor = color;
+ } else if (!isEllipsis && lightColor) {
+ iconImg.style.border = '2px solid ' + lightColor;
+ iconImg.style.backgroundColor = lightColor;
+ } else {
+ iconImg.style.border = '2px solid black';
+ iconImg.style.backgroundColor = 'rgb(95, 95, 95)';
+ }
+ iconImg.style.borderRadius = '20px';
+
+ return iconDiv;
+ }
+ /**
+ * Helper function to detect if any menus are visible
+ * @returns {boolean}
+ */
+ function areAnyDropdownsShown() {
+ return Array.from(document.querySelectorAll('.avatarListIconDropdown')).some(dropdown => {
+ return dropdown && dropdown.classList && !dropdown.classList.contains('hiddenDropdown');
+ });
+ }
+ /**
+ * shows a tooltip that either says the name, or "You" or "Anonymous", or a list of extra names
+ * @param {HTMLElement} element
+ * @param {string|null} name
+ * @param {boolean} isMyAvatar
+ * @param {boolean} isEllipsis
+ */
+ function showFullNameTooltip(element, name, isMyAvatar, isEllipsis) {
+ // only show it if there aren't any dropdown menus shown
+ if (areAnyDropdownsShown()) {
+ return;
+ }
+
+ let container = document.getElementById('avatarListHoverName');
+ if (!container) {
+ container = document.createElement('div');
+ container.id = 'avatarListHoverName';
+ }
+ element.parentElement.appendChild(container);
+
+ let nameDiv = document.getElementById('avatarListHoverNameText');
+ if (!nameDiv) {
+ nameDiv = document.createElement('div');
+ nameDiv.id = 'avatarListHoverNameText';
+ container.appendChild(nameDiv);
+ }
+
+ let tooltipArrow = document.getElementById('avatarListTooltipArrow');
+ if (!tooltipArrow) {
+ let tooltipArrow = document.createElement('img');
+ tooltipArrow.id = 'avatarListTooltipArrow';
+ tooltipArrow.src = 'svg/tooltip-arrow-up.svg';
+ container.appendChild(tooltipArrow);
+ }
+
+ const clickActionText = isEllipsis ? '' : ' (click for options)';
+ const nameText = isMyAvatar ? (name ? `${name} (You)` : 'You') : (name || 'Anonymous');
+ nameDiv.innerText = `${nameText}${clickActionText}`;
+ let clickActionTextWidth = 8 * clickActionText.length;
+ let width = Math.max(120, ((nameDiv.innerText.length - clickActionText.length)) * 12 + clickActionTextWidth);
+ nameDiv.style.width = width + 'px';
+ container.style.display = '';
+ }
+ /**
+ * Hides the tooltip, if shown
+ */
+ function hideFullNameTooltip() {
+ let nameDiv = document.getElementById('avatarListHoverName');
+ if (nameDiv) {
+ nameDiv.style.display = 'none';
+ }
+ }
+
+ exports.initService = initService;
+ exports.renderAvatarIconList = renderAvatarIconList;
+ exports.onAvatarIconMenuItemSelected = onAvatarIconMenuItemSelected;
+ exports.MENU_ITEMS = MENU_ITEMS;
+
+}(realityEditor.avatar.iconMenu));
diff --git a/src/avatar/index.js b/src/avatar/index.js
new file mode 100644
index 000000000..a96c2c170
--- /dev/null
+++ b/src/avatar/index.js
@@ -0,0 +1,808 @@
+createNameSpace("realityEditor.avatar");
+
+/**
+ * @fileOverview realityEditor.avatar
+ * When the app successfully localizes within a world, checks if this device has a "avatar" representation saved on that
+ * world object's server. If not, create one. Continuously updates this object's position in the scene graph to match
+ * the camera position, and broadcasts that position over the realtime sockets. On click-and-drag, sends this avatar's
+ * touchState to other clients via the avatar's node's publicData, and renders laser beams coming from other avatars.
+ */
+
+(function(exports) {
+
+ let network, draw, iconMenu, utils; // shortcuts to access realityEditor.avatar._____
+
+ const KEEP_ALIVE_HEARTBEAT_INTERVAL = 3 * 1000; // should be a small fraction of the keep-alive timeout on the server (currently 15 seconds)
+ const AVATAR_CREATION_TIMEOUT_LENGTH = 10 * 1000; // handle if avatar takes longer than 10 seconds to load
+ const RAYCAST_AGAINST_GROUNDPLANE = true;
+
+ let linkCanvas = null, linkCanvasCtx = null;
+ let linkCanvasNeedsClear = true;
+
+ let myAvatarId = null;
+ let myAvatarObject = null;
+ let avatarObjects = {}; // avatar objects are stored here, so that we know which ones we've discovered/initialized
+ let avatarTouchStates = {}; // data received from avatars' touchState property in their storage node
+ let myAvatarTouchState = null;
+ let avatarCursorStates = {}; // data received from avatars' cursorState property in their storage node
+ let avatarNames = {}; // names received from avatars' userProfile property in their storage node
+ let connectedAvatarUserProfiles = {}; // similar to avatarObjects, but maps objectKey -> user profile or undefined
+ let isPointerDown = false;
+ let lastPointerState = {
+ position: null,
+ timestamp: Date.now(),
+ viewMatrixChecksum: null
+ };
+ let lastBeamOnTimestamp = null;
+
+ // if you set your name, and other clients will see your initials near the endpoint of your laser beam
+ let myUsername = window.localStorage.getItem('manuallyEnteredUsername') || null;
+ let myProviderId = '';
+
+ // these are used for raycasting against the environment when sending laser beams
+ let cachedWorldObject = null;
+ let cachedOcclusionObject = null;
+
+ // these are used to establish a connection and create the avatar object
+ let connectionStatus = {
+ isLocalized: false,
+ isMyAvatarCreated: false,
+ isMyAvatarInitialized: false,
+ isWorldOcclusionObjectAdded: false,
+ didCreationFail: false,
+ isConnectionAttemptInProgress: false
+ };
+
+ // these are just used for debugging purposes
+ let DEBUG_MODE = false; // can be toggled from remote operator's Develop menu
+ let debugSendTimeout = null;
+ let debugReceiveTimeout = null;
+ let debugConnectionStatus = {
+ subscribedToHowMany: 0,
+ didReceiveAnything: false,
+ didRecentlyReceive: false,
+ didSendAnything: false,
+ didRecentlySend: false
+ };
+
+ let callbacks = {
+ onMyAvatarInitialized: []
+ };
+
+ let isDesktop = false;
+
+ function initService() {
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'g' || e.key === 'G') {
+ console.log(connectedAvatarUserProfiles);
+ }
+ })
+ network = realityEditor.avatar.network;
+ draw = realityEditor.avatar.draw;
+ iconMenu = realityEditor.avatar.iconMenu;
+ utils = realityEditor.avatar.utils;
+
+ iconMenu.initService();
+
+ // begin creating our own avatar object when we localize within a world object
+ realityEditor.worldObjects.onLocalizedWithinWorld(function(worldObjectKey) {
+ if (worldObjectKey === realityEditor.worldObjects.getLocalWorldId()) { return; }
+
+ // todo: for now, we don't create a new avatar object for each world we see, but in future we may want to
+ // migrate our existing avatar to the server hosting the current world object that we're looking at
+ if (myAvatarObject || myAvatarId) { return; }
+
+ connectionStatus.isLocalized = true;
+ refreshStatusUI();
+ network.processPendingAvatarInitializations(connectionStatus, cachedWorldObject, onOtherAvatarInitialized);
+
+ attemptToCreateAvatarOnServer(worldObjectKey);
+
+ setInterval(() => {
+ try {
+ reestablishAvatarIfNeeded();
+ } catch (e) {
+ console.warn('error trying to reestablish avatar', e);
+ }
+ }, 1000);
+
+ // if it takes longer than 10 seconds to load the avatar, hide the "loading" UI - todo: retry if timeout
+ setTimeout(() => {
+ if (myAvatarId) return;
+ connectionStatus.didCreationFail = true;
+ refreshStatusUI();
+ }, AVATAR_CREATION_TIMEOUT_LENGTH);
+ });
+
+ if (document.getElementsByClassName('link-canvas-container')[0] === undefined) {
+ isDesktop = realityEditor.device.environment.isDesktop();
+ addLinkCanvas();
+ resizeLinkCanvas();
+ window.addEventListener('resize', () => {
+ realityEditor.avatar.setLinkCanvasNeedsClear(true);
+ resizeLinkCanvas();
+ });
+ }
+
+ network.onAvatarDiscovered((object, objectKey) => {
+ handleDiscoveredObject(object, objectKey);
+ iconMenu.renderAvatarIconList(connectedAvatarUserProfiles);
+ });
+
+ network.onAvatarDeleted((objectKey) => {
+ delete avatarObjects[objectKey];
+ delete connectedAvatarUserProfiles[objectKey];
+ delete avatarTouchStates[objectKey];
+ delete avatarCursorStates[objectKey];
+ delete avatarNames[objectKey];
+ draw.deleteAvatarMeshes(objectKey);
+ iconMenu.renderAvatarIconList(connectedAvatarUserProfiles);
+ realityEditor.avatar.setLinkCanvasNeedsClear(true);
+ realityEditor.spatialCursor.deleteOtherSpatialCursor(objectKey);
+
+ if (objectKey === myAvatarId) {
+ myAvatarId = null;
+ myAvatarObject = null;
+ }
+ });
+
+ realityEditor.gui.ar.draw.addUpdateListener(() => {
+ if (linkCanvasNeedsClear) {
+ clearLinkCanvas();
+ }
+
+ draw.renderOtherAvatars(avatarTouchStates, avatarNames, avatarCursorStates);
+ draw.renderMyAvatar(myAvatarObject, myAvatarTouchState);
+
+ if (!myAvatarObject || globalStates.freezeButtonState) { return; }
+
+ try {
+ updateMyAvatar();
+
+ sendMySpatialCursorPosition();
+
+ // send updated ray even if the touch doesn't move, because the camera might have moved
+ // Limit to 10 FPS because this is a bit CPU-intensive
+ if (isPointerDown) {
+ let needsUpdate = lastPointerState.position &&
+ Date.now() - lastPointerState.timestamp > 100 &&
+ Date.now() - lastBeamOnTimestamp > 100;
+ if (!needsUpdate) return;
+ // this is a quick way to check for changes to the camera - in very rare instances this can be incorrect
+ // but because this is just for performance optimizations that is an ok tradeoff
+ let checksum = realityEditor.sceneGraph.getCameraNode().worldMatrix.reduce((sum, a) => sum + a, 0);
+ needsUpdate = lastPointerState.viewMatrixChecksum !== checksum;
+ if (!needsUpdate) return;
+ setBeamOn(lastPointerState.position.x, lastPointerState.position.y);
+ lastPointerState.viewMatrixChecksum = checksum;
+ }
+ } catch (e) {
+ console.warn('error updating my avatar', e);
+ }
+ });
+
+ //full path is used here as network variable may not be initialised before this function runs
+ realityEditor.avatar.network.onLoadOcclusionObject((worldObject, occlusionObject) => {
+ cachedWorldObject = worldObject;
+ cachedOcclusionObject = occlusionObject;
+
+ connectionStatus.isWorldOcclusionObjectAdded = true;
+ refreshStatusUI();
+
+ // we have a cachedWorldObject here, so it's also a good point to check pending subscriptions for that world
+ network.processPendingAvatarInitializations(connectionStatus, cachedWorldObject, onOtherAvatarInitialized);
+ });
+
+ setInterval(() => {
+ if (myAvatarId && myAvatarObject) {
+ network.keepObjectAlive(myAvatarId);
+ }
+ }, KEEP_ALIVE_HEARTBEAT_INTERVAL);
+
+ realityEditor.app.promises.getProviderId().then(providerId => {
+ myProviderId = providerId;
+ // write user name will also persist providerId
+ writeUsername(myUsername);
+ });
+
+ realityEditor.network.addPostMessageHandler('getUserDetails', (_, fullMessageData) => {
+ realityEditor.network.postMessageIntoFrame(fullMessageData.frame, {
+ userDetails: {
+ name: myUsername,
+ providerId: myProviderId,
+ sessionId: globalStates.tempUuid
+ }
+ });
+ });
+ }
+
+ // todo Steve: a function that subscribes to different users, so that whenever me / another user perform some actions, the user info should be included as part of the info in the action message,
+ // eg: when added a frame, realityEditor.gui.pocket.callbackHandler.triggerCallbacks('frameAdded', callback) should include who added the frame in the callback parameter.
+ // very similar to the function above 'getUserDetails'
+
+ // todo Steve: object.json, last editor
+
+ function addLinkCanvas() {
+ let linkCanvasContainer = document.createElement('div');
+ linkCanvasContainer.className = 'link-canvas-container';
+ linkCanvasContainer.style.position = 'absolute';
+ linkCanvasContainer.style.top = '0';
+ linkCanvasContainer.style.left = '0';
+ linkCanvasContainer.style.pointerEvents = 'none';
+ document.body.appendChild(linkCanvasContainer);
+
+ linkCanvas = document.createElement('canvas');
+ linkCanvas.className = 'link-canvas';
+ linkCanvas.style.position = 'absolute';
+ linkCanvas.style.top = '0';
+ linkCanvas.style.left = '0';
+ linkCanvas.style.zIndex = '3001';
+ linkCanvasContainer.appendChild(linkCanvas);
+
+ linkCanvasCtx = linkCanvas.getContext("2d");
+ }
+
+ function resizeLinkCanvas() {
+ if (linkCanvas !== undefined) {
+ linkCanvas.width = window.innerWidth;
+ linkCanvas.height = window.innerHeight;
+ }
+ }
+
+ function clearLinkCanvas() {
+ linkCanvasCtx.clearRect(0, 0, window.innerWidth, window.innerHeight);
+ linkCanvasNeedsClear = false;
+ }
+
+ function reestablishAvatarIfNeeded() {
+ if (myAvatarId || myAvatarObject) return;
+ if (connectionStatus.isConnectionAttemptInProgress) return;
+ let worldObject = realityEditor.worldObjects.getBestWorldObject();
+ if (!worldObject || worldObject.objectId === realityEditor.worldObjects.getLocalWorldId()) return;
+
+ attemptToCreateAvatarOnServer(worldObject.objectId);
+ }
+
+ function attemptToCreateAvatarOnServer(worldObjectKey) {
+ if (!worldObjectKey) return;
+
+ // in theory there shouldn't be an avatar object for this device on the server yet, but verify that before creating a new one
+ let thisAvatarName = utils.getAvatarName();
+ let worldObject = realityEditor.getObject(worldObjectKey);
+
+ if (!worldObject) return;
+
+ connectionStatus.isConnectionAttemptInProgress = true;
+
+ // cachedWorldObject = worldObject;
+ realityEditor.network.utilities.verifyObjectNameNotOnWorldServer(worldObject, thisAvatarName, () => {
+ network.addAvatarObject(worldObjectKey, thisAvatarName, (data) => {
+ myAvatarId = data.id;
+ connectionStatus.isMyAvatarCreated = true;
+ connectionStatus.isConnectionAttemptInProgress = false;
+ refreshStatusUI();
+
+ // ping the server to discover the object more quickly
+ for (let i = 0; i < 3; i++) {
+ setTimeout(() => realityEditor.app.sendUDPMessage({action: 'ping'}), 300 * i * i);
+ }
+ }, (err) => {
+ console.warn('unable to add avatar object to server', err);
+ connectionStatus.didCreationFail = true;
+ connectionStatus.isConnectionAttemptInProgress = false;
+ refreshStatusUI();
+ });
+ }, () => {
+ console.warn('avatar already exists on server');
+ connectionStatus.didCreationFail = true;
+ connectionStatus.isConnectionAttemptInProgress = false;
+ refreshStatusUI();
+ });
+ }
+
+ // initialize the avatar object representing my own device, and those representing other devices
+ function handleDiscoveredObject(object, objectKey) {
+ if (!utils.isAvatarObject(object)) { return; }
+
+ // ignore objects from other worlds if we have a primaryWorld set
+ let primaryWorldInfo = realityEditor.network.discovery.getPrimaryWorldInfo();
+ if (primaryWorldInfo && primaryWorldInfo.id &&
+ object.worldId && object.worldId !== primaryWorldInfo.id) {
+ return;
+ }
+
+ if (typeof avatarObjects[objectKey] !== 'undefined') { return; }
+ avatarObjects[objectKey] = object; // keep track of which avatar objects we've processed so far
+ connectedAvatarUserProfiles[objectKey] = new utils.UserProfile(null, '', null, globalStates.tempUuid);
+
+ function finalizeAvatar() {
+ // There is a race between object discovery here and object
+ // discovery as a result of creation which sets myAvatarId
+ if (!myAvatarId) {
+ setTimeout(finalizeAvatar, 500);
+ }
+
+ if (objectKey === myAvatarId) {
+ myAvatarObject = object;
+ onMyAvatarInitialized();
+ } else {
+ onOtherAvatarInitialized(object);
+ }
+ }
+ finalizeAvatar();
+ }
+
+ // update the avatar object to match the camera position each frame (if it exists), and realtime broadcast to others
+ function updateMyAvatar() {
+ let avatarSceneNode = realityEditor.sceneGraph.getSceneNodeById(myAvatarId);
+ let cameraNode = realityEditor.sceneGraph.getSceneNodeById(realityEditor.sceneGraph.NAMES.CAMERA);
+ if (!avatarSceneNode || !cameraNode) { return; }
+
+ // my avatar should always be positioned exactly at the camera
+ avatarSceneNode.setPositionRelativeTo(cameraNode, realityEditor.gui.ar.utilities.newIdentityMatrix());
+ avatarSceneNode.updateWorldMatrix(); // immediately process instead of waiting for next frame
+
+ let worldObjectId = realityEditor.sceneGraph.getWorldId();
+ let worldNode = realityEditor.sceneGraph.getSceneNodeById(worldObjectId);
+ let relativeMatrix = avatarSceneNode.getMatrixRelativeTo(worldNode);
+
+ network.realtimeSendAvatarPosition(myAvatarObject, relativeMatrix);
+ }
+
+ function sendMySpatialCursorPosition() {
+ if (!myAvatarObject) return;
+
+ let avatarSceneNode = realityEditor.sceneGraph.getSceneNodeById(myAvatarId);
+ let cameraNode = realityEditor.sceneGraph.getSceneNodeById(realityEditor.sceneGraph.NAMES.CAMERA);
+ if (!avatarSceneNode || !cameraNode) { return; }
+
+ let spatialCursorMatrix = realityEditor.spatialCursor.getCursorRelativeToWorldObject();
+ let worldId = realityEditor.sceneGraph.getWorldId();
+
+ if (!spatialCursorMatrix || !worldId || worldId === realityEditor.worldObjects.getLocalWorldId()) return;
+
+ let cursorState = {
+ matrix: spatialCursorMatrix,
+ colorHSL: utils.getColor(myAvatarObject),
+ isColored: realityEditor.spatialCursor.isSpatialCursorOnGroundPlane(),
+ worldId: worldId
+ }
+
+ let info = utils.getAvatarNodeInfo(myAvatarObject);
+ if (info) {
+ network.sendSpatialCursorState(info, cursorState, { limitToFps: true });
+ }
+
+ debugDataSent();
+ }
+
+ // subscribe to the node's public data of a newly discovered avatar
+ function onOtherAvatarInitialized(thatAvatarObject) {
+ if (!connectionStatus.isLocalized || !cachedWorldObject) {
+ network.addPendingAvatarInitialization(thatAvatarObject.worldId, thatAvatarObject.objectId);
+ return;
+ }
+
+ let subscriptionCallbacks = {};
+
+ subscriptionCallbacks[utils.PUBLIC_DATA_KEYS.touchState] = (msgContent) => {
+ avatarTouchStates[msgContent.object] = msgContent.publicData.touchState;
+ debugDataReceived();
+ };
+
+ subscriptionCallbacks[utils.PUBLIC_DATA_KEYS.cursorState] = (msgContent) => {
+ avatarCursorStates[msgContent.object] = msgContent.publicData.cursorState;
+ debugDataReceived();
+ };
+
+ subscriptionCallbacks[utils.PUBLIC_DATA_KEYS.userProfile] = (msgContent) => {
+ const userProfile = msgContent.publicData.userProfile;
+ if (avatarNames[msgContent.object] !== userProfile.name) {
+ realityEditor.ai.onAvatarChangeName(avatarNames[msgContent.object], userProfile.name);
+ }
+ avatarNames[msgContent.object] = userProfile.name;
+ if (!connectedAvatarUserProfiles[msgContent.object]) {
+ connectedAvatarUserProfiles[msgContent.object] = new utils.UserProfile(null, '', null);
+ }
+
+ // Copy over any present keys
+ Object.assign(connectedAvatarUserProfiles[msgContent.object], userProfile);
+
+ draw.updateAvatarName(msgContent.object, userProfile.name);
+ iconMenu.renderAvatarIconList(connectedAvatarUserProfiles);
+ debugDataReceived();
+ };
+
+ subscriptionCallbacks[utils.PUBLIC_DATA_KEYS.aiDialogue] = (msgContent) => {
+ // console.log(msgContent.publicData.aiDialogue);
+ console.log("push other's ai dialogue");
+ realityEditor.ai.pushDialogueFromOtherUser(msgContent.publicData.aiDialogue);
+ }
+
+ subscriptionCallbacks[utils.PUBLIC_DATA_KEYS.aiApiKeys] = (msgContent) => {
+ let endpoint = msgContent.publicData.aiApiKeys.endpoint;
+ let azureApiKey = msgContent.publicData.aiApiKeys.azureApiKey;
+ realityEditor.network.postAiApiKeys(endpoint, azureApiKey, false);
+ }
+
+ network.subscribeToAvatarPublicData(thatAvatarObject, subscriptionCallbacks);
+
+ debugConnectionStatus.subscribedToHowMany += 1;
+ refreshStatusUI();
+ }
+
+ // return a vector relative to the world object
+ function getRayDirection(screenX, screenY) {
+ if (!realityEditor.sceneGraph.getWorldId()) return null;
+
+ let cameraNode = realityEditor.sceneGraph.getCameraNode();
+ let worldObjectNode = realityEditor.sceneGraph.getSceneNodeById(realityEditor.sceneGraph.getWorldId());
+ const SEGMENT_LENGTH = 1000; // arbitrary, just need to calculate one point so we can solve parametric equation
+ let testPoint = realityEditor.sceneGraph.getPointAtDistanceFromCamera(screenX, screenY, SEGMENT_LENGTH, worldObjectNode);
+ let cameraRelativeToWorldObject = realityEditor.sceneGraph.convertToNewCoordSystem({x: 0, y: 0, z: 9}, cameraNode, worldObjectNode);
+ let rayOrigin = [cameraRelativeToWorldObject.x, cameraRelativeToWorldObject.y, cameraRelativeToWorldObject.z];
+ let arUtils = realityEditor.gui.ar.utilities;
+ return arUtils.normalize(arUtils.subtract([testPoint.x, testPoint.y, testPoint.z], rayOrigin));
+ }
+
+ // checks where the click intersects with the area target, or the groundplane, and returns {x,y,z} relative to the world object origin
+ function getRaycastCoordinates(screenX, screenY) {
+ let worldIntersectPoint = null;
+
+ let objectsToCheck = [];
+ if (cachedOcclusionObject) {
+ objectsToCheck.push(cachedOcclusionObject);
+ }
+
+ if (cachedWorldObject && objectsToCheck.length > 0) {
+ // by default, three.js raycast returns coordinates in the top-level scene coordinate system
+ let raycastIntersects = realityEditor.gui.threejsScene.getRaycastIntersects(screenX, screenY, objectsToCheck);
+ if (raycastIntersects.length > 0) {
+ worldIntersectPoint = raycastIntersects[0].scenePoint;
+
+ // if we don't hit against the area target mesh, try colliding with the ground plane (if mode is enabled)
+ } else if (RAYCAST_AGAINST_GROUNDPLANE) {
+ let groundPlane = realityEditor.gui.threejsScene.getGroundPlaneCollider();
+ raycastIntersects = realityEditor.gui.threejsScene.getRaycastIntersects(screenX, screenY, [groundPlane.getInternalObject()]);
+ groundPlane.updateWorldMatrix(true, false);
+ if (raycastIntersects.length > 0) {
+ worldIntersectPoint = raycastIntersects[0].scenePoint;
+ }
+ }
+
+ if (worldIntersectPoint) {
+ // multiplying the point by the inverse world matrix seems to get it in the right coordinate system
+ let worldSceneNode = realityEditor.sceneGraph.getSceneNodeById(realityEditor.sceneGraph.getWorldId());
+ let matrix = new realityEditor.gui.threejsScene.THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(matrix, worldSceneNode.worldMatrix);
+ matrix.invert();
+ worldIntersectPoint.applyMatrix4(matrix);
+ }
+ }
+
+ return worldIntersectPoint; // these are relative to the world object
+ }
+
+ // add pointer events to turn on and off my own avatar's laser beam (therefore sending my touchState to other users)
+ function onMyAvatarInitialized() {
+ connectionStatus.isMyAvatarInitialized = true;
+ refreshStatusUI();
+
+ writeUsername(myUsername);
+
+ document.body.addEventListener('pointerdown', (e) => {
+ if (realityEditor.device.isMouseEventCameraControl(e)) { return; }
+ if (realityEditor.device.utilities.isEventHittingBackground(e)) {
+ setBeamOn(e.pageX, e.pageY);
+ lastPointerState.position = {
+ x: e.pageX,
+ y: e.pageY
+ };
+ lastPointerState.timestamp = Date.now();
+ }
+ });
+
+ ['pointerup', 'pointercancel', 'pointerleave'].forEach(eventName => {
+ document.body.addEventListener(eventName, (e) => {
+ if (realityEditor.device.isMouseEventCameraControl(e)) { return; }
+ setBeamOff();
+ lastPointerState.position = null;
+ });
+ });
+
+ document.body.addEventListener('pointermove', (e) => {
+ if (!isPointerDown || realityEditor.device.isMouseEventCameraControl(e)) { return; }
+ if (network.isTouchStateFpsLimited()) {
+ return;
+ }
+ // update the beam position even if not hitting background, as long as we started on the background
+ setBeamOn(e.pageX, e.pageY);
+
+ lastPointerState.position = {
+ x: e.pageX,
+ y: e.pageY
+ };
+ lastPointerState.timestamp = Date.now();
+ });
+
+ callbacks.onMyAvatarInitialized.forEach(cb => {
+ cb(myAvatarObject);
+ });
+ }
+ /**
+ * Sets my username โ you can set this even before the avatar has been created
+ * @param {string} name
+ */
+ function setMyUsername(name) {
+ myUsername = name;
+ }
+ /**
+ * Note: Avatar has to exist before calling this (call setMyUsername before it exists, or both for safety)
+ * Stores the avatar's name as one property within the avatar node's userProfile public data
+ * @param name
+ */
+ function writeUsername(name) {
+ if (!myAvatarObject) { return; }
+ realityEditor.ai.onAvatarChangeName(connectedAvatarUserProfiles[myAvatarId].name, name);
+ connectedAvatarUserProfiles[myAvatarId].name = name;
+ let sessionId = globalStates.tempUuid;
+ connectedAvatarUserProfiles[myAvatarId].sessionId = sessionId;
+ draw.updateAvatarName(myAvatarId, name);
+ iconMenu.renderAvatarIconList(connectedAvatarUserProfiles);
+
+ let info = utils.getAvatarNodeInfo(myAvatarObject);
+ if (info) {
+ network.sendUserProfile(info, connectedAvatarUserProfiles[myAvatarId]); // name, myProviderId);
+ }
+ }
+ /**
+ * Stores who myAvatar is following in publicData.userProfile.lockOnMode, and sends that data to all other clients
+ * @param {string} objectId
+ */
+ function writeMyLockOnMode(objectId) {
+ if (!myAvatarObject) { return; }
+ writeLockOnMode(myAvatarId, objectId);
+ }
+ /**
+ * Sends a message to otherAvatarId telling them that they are now following myAvatarId (via lockOnMode in publicData)
+ * @param {string} otherAvatarId
+ */
+ function writeLockOnToMe(otherAvatarId) {
+ if (!myAvatarId) { return; }
+ writeLockOnMode(otherAvatarId, myAvatarId);
+ }
+
+ /**
+ * Helper function used by writeMyLockOnMode and writeLockOnToMe, to actually write the data and refresh the UI
+ * @param {string} avatarId - the "follower" - whose userProfile to modify
+ * @param {string} targetAvatarId - the "leader" - the avatar that will be stored in that userProfile
+ */
+ function writeLockOnMode(avatarId, targetAvatarId) {
+ try {
+ let object = realityEditor.getObject(avatarId);
+ if (!object) { return; }
+ connectedAvatarUserProfiles[avatarId].lockOnMode = targetAvatarId;
+ iconMenu.renderAvatarIconList(connectedAvatarUserProfiles); // refresh the UI
+ let info = utils.getAvatarNodeInfo(object);
+ if (info) {
+ network.sendUserProfile(info, connectedAvatarUserProfiles[avatarId]);
+ }
+ } catch (e) {
+ console.warn('error writing lockOnMode to avatar', e);
+ }
+ }
+
+ // send touch intersect to other users via the public data node, and show visual feedback on your cursor
+ function setBeamOn(screenX, screenY) {
+ isPointerDown = true;
+
+ let touchState = {
+ isPointerDown: isPointerDown,
+ screenX: screenX,
+ screenY: screenY,
+ worldIntersectPoint: getRaycastCoordinates(screenX, screenY),
+ rayDirection: getRayDirection(screenX, screenY),
+ timestamp: Date.now()
+ }
+
+ lastBeamOnTimestamp = Date.now();
+
+ if (touchState.isPointerDown && !(touchState.worldIntersectPoint || touchState.rayDirection)) { return; } // don't send if click on nothing
+
+ let info = utils.getAvatarNodeInfo(myAvatarObject);
+ if (info) {
+ draw.renderCursorOverlay(true, screenX, screenY, utils.getColor(myAvatarObject));
+ network.sendTouchState(info, touchState, { limitToFps: true });
+
+ // show your own beam, so you can tell what you're pointing at
+ myAvatarTouchState = touchState;
+ }
+
+ // snaps the spatial cursor to the beam endpoint on all devices until you stop the beam
+ realityEditor.spatialCursor.updatePointerSnapMode(true);
+
+ debugDataSent();
+ }
+
+ // send touchState: {isPointerDown: false} to other users, so they'll stop showing this avatar's laser beam
+ function setBeamOff(screenX, screenY) {
+ isPointerDown = false;
+
+ let touchState = {
+ isPointerDown: isPointerDown,
+ screenX: screenX,
+ screenY: screenY,
+ worldIntersectPoint: null,
+ rayDirection: null,
+ timestamp: Date.now()
+ }
+
+ let info = utils.getAvatarNodeInfo(myAvatarObject);
+ if (info) {
+ draw.renderCursorOverlay(false, screenX, screenY, utils.getColor(myAvatarObject));
+ network.sendTouchState(info, touchState);
+
+ // stop showing your own beam
+ myAvatarTouchState = touchState;
+ }
+
+ // ensure that on non-desktop devices, the spatial cursor position resets to center of view
+ realityEditor.spatialCursor.updatePointerSnapMode(false);
+
+ debugDataSent();
+ }
+
+ // settings menu can toggle this if desired
+ function toggleDebugMode(showDebug) {
+ DEBUG_MODE = showDebug;
+ refreshStatusUI();
+ }
+
+ // highlight the debugText for 1 second upon receiving data
+ function debugDataReceived() {
+ if (!debugConnectionStatus.didReceiveAnything) {
+ debugConnectionStatus.didReceiveAnything = true;
+ refreshStatusUI();
+ }
+ if (!debugConnectionStatus.didRecentlyReceive && !debugReceiveTimeout) {
+ debugConnectionStatus.didRecentlyReceive = true;
+ refreshStatusUI();
+
+ debugReceiveTimeout = setTimeout(() => {
+ debugConnectionStatus.didRecentlyReceive = false;
+ clearTimeout(debugReceiveTimeout);
+ debugReceiveTimeout = null;
+ refreshStatusUI();
+ }, 1000);
+ }
+ }
+
+ // highlight the debugText for 1 second upon sending data
+ function debugDataSent() {
+ if (!debugConnectionStatus.didSendAnything) {
+ debugConnectionStatus.didSendAnything = true;
+ refreshStatusUI();
+ }
+ if (!debugConnectionStatus.didRecentlySend && !debugSendTimeout) {
+ debugConnectionStatus.didRecentlySend = true;
+ refreshStatusUI();
+
+ debugSendTimeout = setTimeout(() => {
+ debugConnectionStatus.didRecentlySend = false;
+ clearTimeout(debugSendTimeout);
+ debugSendTimeout = null;
+ refreshStatusUI();
+ }, 1000);
+ }
+ }
+
+ // update the simple UI that shows "connecting..." --> "connected!" (and update debug text if DEBUG_MODE is true)
+ function refreshStatusUI() {
+ draw.renderConnectionDebugInfo(connectionStatus, debugConnectionStatus, myAvatarId, DEBUG_MODE);
+
+ // render a simple UI to show while we establish the avatar (only show after we've connected to a world)
+ if (connectionStatus.isLocalized) {
+ let isConnectionReady = connectionStatus.isLocalized &&
+ connectionStatus.isMyAvatarCreated &&
+ connectionStatus.isMyAvatarInitialized && myAvatarId;
+ // && connectionStatus.isWorldOcclusionObjectAdded;
+ draw.renderConnectionFeedback(isConnectionReady);
+ }
+
+ if (connectionStatus.didCreationFail) {
+ draw.renderConnectionFeedback(false, connectionStatus.didCreationFail);
+ }
+ }
+
+ function getMyAvatarColor() {
+ return new Promise((resolve) => {
+ let id = setInterval(() => {
+ if (myAvatarObject !== null) {
+ clearInterval(id);
+ resolve({
+ color: utils.getColor(myAvatarObject),
+ colorLighter: utils.getColorLighter(myAvatarObject)
+ });
+ }
+ }, 100);
+ });
+ }
+
+ /**
+ * @param {string} providerId
+ * @return {string?} color
+ */
+ function getAvatarColorFromProviderId(providerId) {
+ for (let objectKey in connectedAvatarUserProfiles) {
+ if (!connectedAvatarUserProfiles[objectKey]) {
+ return;
+ }
+ let userProfile = connectedAvatarUserProfiles[objectKey];
+ if (userProfile.providerId !== providerId) {
+ continue;
+ }
+ return utils.getColor(realityEditor.getObject(objectKey));
+ }
+ }
+
+ function getAvatarObjectKeyFromSessionId(sessionId) {
+ for (let objectKey in connectedAvatarUserProfiles) {
+ if (!connectedAvatarUserProfiles[objectKey]) {
+ return;
+ }
+ let userProfile = connectedAvatarUserProfiles[objectKey];
+ if (userProfile.sessionId !== sessionId) {
+ continue;
+ }
+ return objectKey;
+ }
+ }
+
+ function getAvatarNameFromObjectKey(objectKey) {
+ if (!connectedAvatarUserProfiles[objectKey]) {
+ return;
+ }
+ return connectedAvatarUserProfiles[objectKey].name;
+ }
+
+ function getMyAvatarNodeInfo() {
+ return utils.getAvatarNodeInfo(myAvatarObject);
+ }
+
+ function getLinkCanvasInfo() {
+ return {
+ canvas: linkCanvas,
+ ctx: linkCanvasCtx,
+ // linkObject: linkObject
+ };
+ }
+
+ function registerOnMyAvatarInitializedCallback(callback) {
+ callbacks.onMyAvatarInitialized.push(callback);
+ if (myAvatarObject) {
+ callback(myAvatarObject);
+ }
+ }
+
+ exports.initService = initService;
+ exports.registerOnMyAvatarInitializedCallback = registerOnMyAvatarInitializedCallback;
+ exports.setBeamOn = setBeamOn;
+ exports.setBeamOff = setBeamOff;
+ exports.toggleDebugMode = toggleDebugMode;
+ exports.getMyAvatarColor = getMyAvatarColor;
+ exports.getAvatarColorFromProviderId = getAvatarColorFromProviderId;
+ exports.getAvatarObjectKeyFromSessionId = getAvatarObjectKeyFromSessionId;
+ exports.getAvatarNameFromObjectKey = getAvatarNameFromObjectKey;
+ exports.setMyUsername = setMyUsername; // this sets it preemptively if it doesn't exist yet
+ exports.writeUsername = writeUsername; // this propagates the data if it already exists
+ exports.writeMyLockOnMode = writeMyLockOnMode;
+ exports.writeLockOnToMe = writeLockOnToMe;
+ exports.clearLinkCanvas = clearLinkCanvas;
+ exports.getLinkCanvasInfo = getLinkCanvasInfo;
+ exports.isDesktop = function() {return isDesktop};
+ exports.getConnectedAvatarList = () => { return connectedAvatarUserProfiles; };
+ exports.setLinkCanvasNeedsClear = (value) => { linkCanvasNeedsClear = value; };
+ exports.getMyAvatarId = () => { return myAvatarId; };
+ exports.getMyAvatarNodeInfo = getMyAvatarNodeInfo;
+
+}(realityEditor.avatar));
diff --git a/src/avatar/network.js b/src/avatar/network.js
new file mode 100644
index 000000000..541fc2ab8
--- /dev/null
+++ b/src/avatar/network.js
@@ -0,0 +1,270 @@
+createNameSpace("realityEditor.avatar.network");
+
+/**
+ * @fileOverview realityEditor.avatar.network
+ * Contains a variety of helper functions for avatar/index.js to create and discover avatar objects,
+ * realtime broadcast my avatar's state, and subscribe to the state of other avatars
+ */
+
+(function(exports) {
+ const DATA_SEND_FPS_LIMIT = 30;
+ let occlusionDownloadInterval = null;
+ let cachedOcclusionObject = null;
+ let cachedWorldObject = null;
+ let lastBroadcastPositionTimestamp = Date.now();
+ let lastWritePublicDataTimestamp = Date.now();
+ let lastWriteSpatialCursorTimestamp = Date.now();
+ let pendingAvatarInitializations = {};
+ let lastSentCursorState = null;
+
+ let callbacks = {
+ onLoadOcclusionObject: [],
+ };
+
+ // Tell the server (corresponding to this world object) to create a new avatar object with the specified ID
+ function addAvatarObject(worldId, clientId, onSuccess, onError) {
+ let worldObject = realityEditor.getObject(worldId);
+ if (!worldObject) {
+ console.warn('Unable to add avatar object because no world with ID: ' + worldId);
+ return;
+ }
+
+ let postUrl = realityEditor.network.getURL(worldObject.ip, realityEditor.network.getPort(worldObject), '/');
+ let params = new URLSearchParams({action: 'new', name: clientId, isAvatar: true, worldId: worldId});
+ fetch(postUrl, {
+ method: 'POST',
+ body: params
+ }).then(response => response.json())
+ .then(data => {
+ onSuccess(data);
+ }).catch(err => {
+ onError(err);
+ });
+ }
+
+ // helper function that will trigger the callback for each avatar object previously or in-future discovered
+ function onAvatarDiscovered(callback) {
+ // first check if any previously discovered objects are avatars
+ for (let [objectKey, object] of Object.entries(objects)) {
+ if (realityEditor.avatar.utils.isAvatarObject(object)) {
+ callback(object, objectKey);
+ }
+ }
+
+ // next, listen to newly discovered objects
+ realityEditor.network.addObjectDiscoveredCallback(function(object, objectKey) {
+ if (realityEditor.avatar.utils.isAvatarObject(object)) {
+ callback(object, objectKey);
+ }
+ });
+ }
+
+ function onAvatarDeleted(callback) {
+ realityEditor.network.registerCallback('objectDeleted', (params) => {
+ callback(params.objectKey);
+ });
+ }
+
+ // polls the three.js scene every 1 second to see if the gltf for the world object has finished loading
+ function onLoadOcclusionObject(callback) {
+ // if cachedWorld and occlusionDownloadInterval, call callback asap
+ if (cachedWorldObject && cachedOcclusionObject) {
+ callback(cachedWorldObject, cachedOcclusionObject);
+ return;
+ }
+ //if !cachedWorld and occlusionDownloadInterval then add callback to list of callback to be called
+ callbacks.onLoadOcclusionObject.push(callback);
+ if (occlusionDownloadInterval) {
+ return;
+ }
+ //if !cached world and !occlusionDownloadInterval, instantiate occlusionDownloadInterval
+ occlusionDownloadInterval = setInterval(() => {
+ if (!cachedWorldObject) {
+ cachedWorldObject = realityEditor.worldObjects.getBestWorldObject();
+ }
+ if (!cachedWorldObject) {
+ return;
+ }
+ if (cachedWorldObject.objectId === realityEditor.worldObjects.getLocalWorldId()) {
+ cachedWorldObject = null; // don't accept the local world object
+ }
+ if (cachedWorldObject && !cachedOcclusionObject) {
+ cachedOcclusionObject = realityEditor.gui.threejsScene.getObjectForWorldRaycasts(cachedWorldObject.objectId);
+ if (cachedOcclusionObject) {
+ // trigger the callback and clear the interval
+ callbacks.onLoadOcclusionObject.forEach(cb => cb(cachedWorldObject, cachedOcclusionObject));
+ clearInterval(occlusionDownloadInterval);
+ occlusionDownloadInterval = null;
+ }
+ }
+ }, 1000);
+ }
+
+ function hasCursorStateChanged(currentState) {
+ if (lastSentCursorState === null) {
+ return currentState !== null;
+ }
+ if (currentState === null) {
+ return true;
+ }
+ const epsilon = 0.00001;
+ for (let i = 0; i < 16; i++) {
+ if (Math.abs(lastSentCursorState.matrix.elements[i] - currentState.matrix.elements[i]) > epsilon) {
+ // Matrix mismatch, have to check like this instead of matrix.equals(other) due to floating point errors
+ return true;
+ }
+ }
+ if (lastSentCursorState.colorHSL !== currentState.colorHSL) {
+ return true;
+ }
+ if (lastSentCursorState.isColored !== currentState.isColored) {
+ return true;
+ }
+ if (lastSentCursorState.worldId !== currentState.worldId) {
+ return true;
+ }
+ return false;
+ }
+
+ // if the object has moved at all, and enough time has passed (FPS_LIMIT), realtime broadcast the new avatar matrix
+ function realtimeSendAvatarPosition(avatarObject, matrix) {
+ // only send a data update if the matrix has changed since last time
+ if (avatarObject.matrix.length !== 16) { avatarObject.matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; }
+ let totalDifference = realityEditor.avatar.utils.sumOfElementDifferences(avatarObject.matrix, matrix);
+ if (totalDifference < 0.00001) {
+ return;
+ }
+
+ // already gets uploaded to server but isn't set locally yet
+ avatarObject.matrix = matrix;
+
+ // sceneGraph uploads object position to server every 1 second via REST, but we can stream updates in realtime here
+ if (Date.now() - lastBroadcastPositionTimestamp < (1000 / DATA_SEND_FPS_LIMIT)) {
+ return;
+ }
+ realityEditor.network.realtime.broadcastUpdate(avatarObject.objectId, null, null, 'matrix', matrix);
+ lastBroadcastPositionTimestamp = Date.now();
+ }
+
+ // write the touchState into the avatar object's storage node (internally limits data rate to FPS_LIMIT)
+ function sendTouchState(keys, touchState, options) {
+ let sendData = !(options && options.limitToFps) || !isTouchStateFpsLimited();
+ if (sendData) {
+ realityEditor.network.realtime.writePublicData(keys.objectKey, keys.frameKey, keys.nodeKey, realityEditor.avatar.utils.PUBLIC_DATA_KEYS.touchState, touchState);
+ lastWritePublicDataTimestamp = Date.now();
+ }
+ }
+
+ // write the cursorState to the avatar object's storage node
+ function sendSpatialCursorState(keys, cursorState, options) {
+ let sendData = (!(options && options.limitToFps) || !isCursorStateFpsLimited()) && hasCursorStateChanged(cursorState);
+ if (sendData) {
+ realityEditor.network.realtime.writePublicData(keys.objectKey, keys.frameKey, keys.nodeKey, realityEditor.avatar.utils.PUBLIC_DATA_KEYS.cursorState, cursorState);
+ lastWriteSpatialCursorTimestamp = Date.now();
+ lastSentCursorState = cursorState;
+ }
+ }
+
+ function sendAiDialogue(keys, aiDialogueHTML) {
+ realityEditor.network.realtime.writePublicData(keys.objectKey, keys.frameKey, keys.nodeKey, realityEditor.avatar.utils.PUBLIC_DATA_KEYS.aiDialogue, aiDialogueHTML);
+ }
+
+ function sendAiApiKeys(keys, aiApiKeys) {
+ realityEditor.network.realtime.writePublicData(keys.objectKey, keys.frameKey, keys.nodeKey, realityEditor.avatar.utils.PUBLIC_DATA_KEYS.aiApiKeys, aiApiKeys);
+ }
+
+ /**
+ * Helper function to provide insight into the fps limiter
+ * @return {boolean}
+ */
+ function isTouchStateFpsLimited() {
+ return Date.now() - lastWritePublicDataTimestamp < (1000 / DATA_SEND_FPS_LIMIT);
+ }
+
+ // same as isTouchStateFpsLimited, but to limit the FPS of the spatial cursor data sending
+ function isCursorStateFpsLimited() {
+ return Date.now() - lastWriteSpatialCursorTimestamp < (1000 / DATA_SEND_FPS_LIMIT);
+ }
+
+ /**
+ * write the user profile into the avatar object's storage node
+ * @param {Object} keys - where to store avatar's data
+ * @param {Object} userProfile - contains name, providerId, lockOnMode, sessionId, etc.
+ */
+ function sendUserProfile(keys, userProfile) {
+ realityEditor.network.realtime.writePublicData(keys.objectKey, keys.frameKey, keys.nodeKey, realityEditor.avatar.utils.PUBLIC_DATA_KEYS.userProfile, {
+ name: userProfile.name,
+ providerId: userProfile.providerId,
+ lockOnMode: userProfile.lockOnMode,
+ sessionId: userProfile.sessionId
+ });
+ }
+
+ // if we discover other avatar objects before we're localized in a world, queue them up to be initialized later
+ function processPendingAvatarInitializations(connectionStatus, cachedWorldObject, callback) {
+ if (!connectionStatus.isLocalized || !cachedWorldObject) {
+ return; // don't process until we're properly localized
+ }
+
+ let objectIdList = pendingAvatarInitializations[cachedWorldObject.objectId];
+ if (!(objectIdList && objectIdList.length > 0)) { return; }
+
+ while (objectIdList.length > 0) {
+ let thatAvatarObject = realityEditor.getObject(objectIdList.pop());
+ if (thatAvatarObject) {
+ callback(thatAvatarObject); // callback can be used to initialize and subscribe to the publicData of the avatar
+ }
+ }
+ }
+
+ // remember this object so that if we localize against this world in the future, we can subscribe to its node's public data
+ function addPendingAvatarInitialization(worldId, objectId) {
+ if (typeof pendingAvatarInitializations[worldId] === 'undefined') {
+ pendingAvatarInitializations[worldId] = [];
+ }
+ pendingAvatarInitializations[worldId].push(objectId);
+ }
+
+ // given a data structure of { PUBLIC_DATA_KEYS: callbacks }, adds the callbacks to the provided avatarObject's avatar node,
+ // so that the corresponding callback will be triggered iff the corresponding data key is changed by another user
+ function subscribeToAvatarPublicData(avatarObject, subscriptionCallbacks) {
+ let avatarObjectKey = avatarObject.objectId;
+ let avatarFrameKey = Object.keys(avatarObject.frames).find(name => name.includes(realityEditor.avatar.utils.TOOL_NAME));
+ let thatAvatarTool = realityEditor.getFrame(avatarObjectKey, avatarFrameKey);
+ if (!thatAvatarTool) {
+ console.warn('cannot find Avatar tool on Avatar object named ' + avatarObjectKey);
+ return;
+ }
+ let avatarNodeKey = Object.keys(thatAvatarTool.nodes).find(name => name.includes(realityEditor.avatar.utils.NODE_NAME));
+
+ Object.keys(subscriptionCallbacks).forEach((publicDataKey) => {
+ let callback = subscriptionCallbacks[publicDataKey];
+
+ realityEditor.network.realtime.subscribeToPublicData(avatarObjectKey, avatarFrameKey, avatarNodeKey, publicDataKey, (msg) => {
+ callback(JSON.parse(msg));
+ });
+ });
+ }
+
+ // signal the server that this avatar object is still active and shouldn't be deleted
+ function keepObjectAlive(objectKey) {
+ realityEditor.app.sendUDPMessage({action: {type: 'keepObjectAlive', objectKey: objectKey}});
+ }
+
+ exports.addAvatarObject = addAvatarObject;
+ exports.onAvatarDiscovered = onAvatarDiscovered;
+ exports.onAvatarDeleted = onAvatarDeleted;
+ exports.onLoadOcclusionObject = onLoadOcclusionObject;
+ exports.realtimeSendAvatarPosition = realtimeSendAvatarPosition;
+ exports.isTouchStateFpsLimited = isTouchStateFpsLimited;
+ exports.sendTouchState = sendTouchState;
+ exports.sendSpatialCursorState = sendSpatialCursorState;
+ exports.sendUserProfile = sendUserProfile;
+ exports.sendAiDialogue = sendAiDialogue;
+ exports.sendAiApiKeys = sendAiApiKeys;
+ exports.processPendingAvatarInitializations = processPendingAvatarInitializations;
+ exports.addPendingAvatarInitialization = addPendingAvatarInitialization;
+ exports.subscribeToAvatarPublicData = subscribeToAvatarPublicData;
+ exports.keepObjectAlive = keepObjectAlive;
+
+}(realityEditor.avatar.network));
diff --git a/src/avatar/utils.js b/src/avatar/utils.js
new file mode 100644
index 000000000..c1f9c6f32
--- /dev/null
+++ b/src/avatar/utils.js
@@ -0,0 +1,128 @@
+createNameSpace("realityEditor.avatar.utils");
+
+/**
+ * @fileOverview realityEditor.avatar.utils
+ * Miscellaneous helper functions for avatars
+ */
+
+(function(exports) {
+ exports.AVATAR_ID_PREFIX = '_AVATAR_';
+ exports.TOOL_NAME = 'Avatar'; // these need to match the way the server intializes the tool and node
+ exports.NODE_NAME = 'storage';
+ exports.PUBLIC_DATA_KEYS = {
+ touchState: 'touchState',
+ cursorState: 'cursorState',
+ userProfile: 'userProfile',
+ aiDialogue: 'aiDialogue',
+ aiApiKeys: 'aiApiKeys'
+ };
+
+ // other modules in the project can use this to reliably check whether an object is an avatar
+ exports.isAvatarObject = function(object) {
+ if (!object) { return false; }
+ return object.type === 'avatar' || object.objectId.indexOf('_AVATAR_') === 0;
+ }
+
+ // returns a random but consistent color for a provided avatar object's editorId
+ exports.getColor = function(avatarObject) {
+ if (!this.isAvatarObject(avatarObject)) { return null; }
+ let editorId = avatarObject.objectId.split('_AVATAR_')[1].split('_')[0];
+ let id = Math.abs(this.hashCode(editorId));
+ return `hsl(${(id % Math.PI) * 360 / Math.PI}, 100%, 50%)`;
+ }
+
+ exports.getColorLighter = function(avatarObject) {
+ let defaultColor = this.getColor(avatarObject);
+ if (defaultColor) {
+ return defaultColor.replace('50%', '70%'); // increase the HSL lightness to 70%
+ }
+ return null;
+ }
+
+ // helper function to generate an integer hash from a string (https://stackoverflow.com/a/15710692)
+ exports.hashCode = function(s) {
+ return s.split("").reduce(function(a,b){a=((a<<5)-a)+b.charCodeAt(0);return a&a},0);
+ }
+
+ // helper function returns first and last capitalized initials from name (https://stackoverflow.com/a/63763497)
+ exports.getInitialsFromName = function(name) {
+ if (!name) { return null; }
+ return name.match(/(\b\S)?/g).join("").match(/(^\S|\S$)?/g).join("").toUpperCase();
+ }
+
+ // helper to calculate if the matrices are identical by returning a simple sum of how different they are
+ exports.sumOfElementDifferences = function(M1, M2) {
+ // assumes M1 and M2 are of equal length
+ let sum = 0;
+ for (let i = 0; i < M1.length; i++) {
+ sum += Math.abs(M1[i] - M2[i]);
+ }
+ return sum;
+ }
+
+ // generates a unique id for this avatar, based on this client's editorId (aka. tempUuid)
+ exports.getAvatarName = function() {
+ // TODO: we may need to use different criteria in the future to categorize devices, although this is only to help with debugging for now
+ const deviceSuffix = realityEditor.device.environment.variables.supportsAreaTargetCapture ? '_iOS' : '_desktop';
+ return this.AVATAR_ID_PREFIX + globalStates.tempUuid + deviceSuffix;
+ }
+
+ // returns the {objectKey, frameKey, nodeKey} address of the avatar storeData node on this avatar object
+ exports.getAvatarNodeInfo = function (avatarObject) {
+ if (!avatarObject) { return null; }
+
+ let avatarObjectKey = avatarObject.objectId;
+ let avatarFrameKey = Object.keys(avatarObject.frames).find(name => name.includes(this.TOOL_NAME));
+ let myAvatarTool = realityEditor.getFrame(avatarObjectKey, avatarFrameKey);
+ if (!myAvatarTool) { return null; }
+
+ let avatarNodeKey = Object.keys(myAvatarTool.nodes).find(name => name.includes(this.NODE_NAME));
+ if (!avatarNodeKey) { return null; }
+
+ return {
+ objectKey: avatarObjectKey,
+ frameKey: avatarFrameKey,
+ nodeKey: avatarNodeKey
+ }
+ }
+
+ // sort the list of connected avatars. currently moves yourself to the front.
+ // in future could also sort by join time or recent activity
+ exports.sortAvatarList = function(connectedAvatars) {
+ let keys = Object.keys(connectedAvatars);
+ let first = this.getAvatarName(); // move yourself to the font of the list
+ keys.sort(function(x,y){ return x.includes(first) ? -1 : y.includes(first) ? 1 : 0; });
+ return keys;
+ }
+
+ /**
+ * Get the avatarIds of any avatar whose userProfile lists the specified avatarObjectId as their lockOnMode
+ * @param {string} avatarObjectId
+ * @param {Object.} connectedAvatars
+ * @returns {string[]}
+ */
+ exports.getUsersFollowingUser = function (avatarObjectId, connectedAvatars) {
+ return Object.keys(connectedAvatars).filter(objectId => {
+ return connectedAvatars[objectId].lockOnMode === avatarObjectId;
+ });
+ }
+ /**
+ * The avatar object's node's publicData stores a userProfile value, which is an instance of this class
+ */
+ class UserProfile {
+ /**
+ * @param {string|null} name - the username (in theory, first name + last name... or however they identify)
+ * @param {string} providerId - string id of the virtualizer (point cloud provider) if it's an AR client, '' if not
+ * @param {string|null} lockOnMode - the objectId of an avatar they are following, if they're lock on to someone's view
+ * @param {string} sessionId - optional associated session id that is unique for every user on every browser session
+ */
+ constructor(name, providerId, lockOnMode, sessionId) {
+ this.name = name; // username
+ this.providerId = providerId; // id of the phone virtualizer ('' for remote operators)
+ this.lockOnMode = lockOnMode; // id of which other avatar this avatar's perspective is locked onto (null if not locked on)
+ this.sessionId = sessionId;
+ }
+ }
+ exports.UserProfile = UserProfile;
+
+}(realityEditor.avatar.utils));
diff --git a/src/cloud/hrqrWorker.js b/src/cloud/hrqrWorker.js
new file mode 100644
index 000000000..92bb3087f
--- /dev/null
+++ b/src/cloud/hrqrWorker.js
@@ -0,0 +1,26 @@
+/* global importScripts, HRQR, cv */
+
+importScripts("../../thirdPartyCode/opencv.js");
+importScripts("../../thirdPartyCode/HRQRDecoder.js");
+
+let hrqr = new HRQR();
+//let hrqr = new MEMORYTEST();
+
+cv["onRuntimeInitialized"] = () => {
+ hrqr.init();
+ postMessage({"mode":"ready"});
+};
+
+onmessage = function(msg) {
+
+ // console.log("worker",msg.data.image);
+ // console.log(msg.data);
+
+ let message = hrqr.render(msg.data.image)
+
+ // console.log(msg.data[0].data);
+ if(message) {
+ postMessage({"mode": "msg", msg: message});
+ }
+};
+
diff --git a/src/cloud/index.js b/src/cloud/index.js
new file mode 100644
index 000000000..3ede0e4e9
--- /dev/null
+++ b/src/cloud/index.js
@@ -0,0 +1,137 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.cloud");
+realityEditor.cloud = {};
+
+realityEditor.cloud.state = {};
+realityEditor.cloud.socket = null;
+
+realityEditor.cloud.updateEdgeConnections = function(connections) {
+ globalStates.network.edgeServer = connections;
+};
+
+realityEditor.cloud.connectToCloud = function (){
+ const isSecure = realityEditor.network.state.proxyProtocol.includes('https');
+ const wsProtocol = isSecure ? 'wss://' : 'ws://';
+ const wsURL = wsProtocol + realityEditor.network.state.proxyHost;
+
+ if(realityEditor.cloud.socket) realityEditor.cloud.socket.close();
+
+ this.socket = new ToolSocket(wsURL, realityEditor.network.state.proxyNetwork , "web");
+
+ this.socket.on('beat', function (route, body) {
+ // todo validate for heartbeat
+ // realityEditor.network.addHeartbeatObject(body);
+ body.network = realityEditor.network.state.proxyNetwork;
+ realityEditor.app.callbacks.receivedUDPMessage(body)
+ // console.log(route, body);
+ });
+
+ this.socket.on('action', function (route, body) {
+ // todo validate for heartbeat
+ body.network = realityEditor.network.state.proxyNetwork;
+ realityEditor.app.callbacks.receivedUDPMessage(body)
+ // realityEditor.network.addHeartbeatObject(body);
+ });
+ // globalStates.network.edgeServer = connections;
+}.bind(realityEditor.cloud);
+
+// load remote interface via dekstop interface
+let getDesktopLinkData = realityEditor.network.desktopURLSchema.parseRoute(window.location.pathname);
+if(getDesktopLinkData) {
+ if(getDesktopLinkData.n) {
+ realityEditor.network.state.proxyProtocol = window.location.protocol.slice(0, -1); // Need to remove the colon
+ realityEditor.network.state.proxyPort = window.location.port;
+ realityEditor.network.state.proxyUrl = window.location.host;
+ realityEditor.network.state.proxyHost = window.location.host;
+ realityEditor.network.state.proxyHostname = window.location.hostname;
+ if(getDesktopLinkData.n) realityEditor.network.state.proxyNetwork = getDesktopLinkData.n;
+ if(getDesktopLinkData.s) realityEditor.network.state.proxySecret = getDesktopLinkData.s;
+ realityEditor.cloud.connectToCloud();
+ } else {
+ /*
+ realityEditor.cloud.worker = new Worker("src/cloud/hrqrWorker.js");
+
+ realityEditor.cloud.worker.onmessage = function(event) {
+ let msg = event.data;
+ if(msg["mode"] === "msg") {
+ let getLinkData = io.parseUrl(msg["msg"][0].msg, realityEditor.network.qrSchema);
+
+ if(getLinkData.protocol === "spatialtoolbox") {
+ realityEditor.app.tap();
+ realityEditor.network.state.proxyProtocol = "https";
+ realityEditor.network.state.proxyPort = 443;
+ if(getLinkData.server) realityEditor.network.state.proxyUrl = getLinkData.server;
+ if(getLinkData.n) realityEditor.network.state.proxyNetwork = getLinkData.n;
+ if(getLinkData.s) realityEditor.network.state.proxySecret = getLinkData.s;
+ realityEditor.cloud.connectToCloud();
+ }
+ }
+ }
+ */
+ }
+}
+
+realityEditor.cloud.imageBuffer = new window.Image();
+// setInterval(function (){
+// // time = Date.now();
+// realityEditor.app.getScreenshot("MS", function(image){
+// let img = realityEditor.cloud.imageBuffer;
+// realityEditor.cloud.imageBuffer.onload = function() {
+// globalCanvas.canv23.width = img.width;
+// globalCanvas.canv23.height = img.height;
+// globalCanvas.ctx2333.drawImage(img, 0, 0,img.width,img.height);
+// let pixels = globalCanvas.ctx2333.getImageData(0, 0, img.width, img.height);
+// realityEditor.cloud.worker.postMessage({image: pixels}, [pixels.data.buffer]);
+// };
+// img.src = image;
+// //console.log("total main thread time: ", Date.now()-time);
+// });
+// },2000);
diff --git a/js/constructors.js b/src/constructors.js
similarity index 54%
rename from js/constructors.js
rename to src/constructors.js
index 4b66bd7e6..51b303903 100644
--- a/js/constructors.js
+++ b/src/constructors.js
@@ -1,5 +1,5 @@
/**
- * @preserve
+ *
*
* .,,,;;,'''..
* .'','... ..',,,.
@@ -28,14 +28,17 @@
* o.
* .,
*
- * โฆ โฆโฌ โฌโโ โฌโโโฌโโฌโ โโโโโ โฌโโโโโโโโฌโโโโ
- * โ โโฃโโฌโโโดโโโฌโโ โโ โ โโโดโ โโโค โ โ โโโ
- * โฉ โฉ โด โโโโดโโโดโโดโ โโโโโโโโโโโโโโ โด โโโ
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
*
*
* Created by Valentin on 10/22/14.
*
* Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
*
* All ascii characters above must be included in any redistribution.
*
@@ -44,6 +47,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
+/* exported Objects, Frame, Link, Node, Logic, BlockLink, Block, EdgeBlock */
+
/**
* @desc Constructor used to define every logic node generated in the Object. It does not need to contain its own ID
@@ -57,13 +62,18 @@
/**
* @desc This is the default constructor for the Hybrid Object.
* It contains information about how to render the UI and how to process the internal data.
- **/
-
+ * note - this constructor never gets used in the userinterface, just on the server
+ * @deprecated - not up to date with server's constructor, and not used recently in the code
+ * @constructor
+ */
function Objects() {
// The ID for the object will be broadcasted along with the IP. It consists of the name with a 12 letter UUID added.
this.objectId = null;
// The name for the object used for interfaces.
this.name = "";
+ // The UUID used internally by Vuforia for tracking
+ this.targetId = null;
+
// The IP address for the object is relevant to point the Reality Editor to the right server.
// It will be used for the UDP broadcasts.
this.ip = "localhost";
@@ -73,14 +83,76 @@ function Objects() {
this.protocol = "R1";
// The (t)arget (C)eck(S)um is a sum of the checksum values for the target files.
this.tcs = null;
- // Reality Editor: This is used to possition the UI element within its x axis in 3D Space. Relative to Marker origin.
- this.x = 0;
- // Reality Editor: This is used to possition the UI element within its y axis in 3D Space. Relative to Marker origin.
- this.y = 0;
- // Reality Editor: This is used to scale the UI element in 3D Space. Default scale is 1.
- this.scale = 1;
- // Unconstrained positioning in 3D space
- this.matrix = [];
+ // Used internally from the reality editor to indicate if an object should be rendered or not.
+ this.visible = false;
+ // Used internally from the reality editor to trigger the visibility of naming UI elements.
+ this.visibleText = false;
+ // Used internally from the reality editor to indicate the editing status.
+ this.visibleEditing = false;
+ // Intended future use is to keep a memory of the last matrix transformation when interacted.
+ // This data can be used for interacting with objects for when they are not visible.
+ this.memory = {}; // TODO use this to store UI interface for image later.
+ // Stores all the links that emerge from within the object. If a IOPoint has new data,
+ // the server looks through the Links to find if the data has influence on other IOPoints or Objects.
+ this.frames = {};
+ // which visualization mode it should use right now ("ar" or "screen")
+ this.visualization = "ar";
+
+ this.zone = "";
+
+ this.averageScale = 0.5;
+
+ this.matrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+ // taken from target.xml. necessary to make the screens work correctly.
+ this.targetSize = {
+ width: 0.3, // default size should always be overridden, but exists in case xml doesn't contain size
+ height: 0.3
+ }
+}
+
+/**
+ * Constructor for one UI frame that will be attached to an object. Each frame is associated with an HTML iframe and
+ * contains 3d position data, and optionally links, nodes, and metadata for how it should behave and be rendered.
+ * @constructor
+ * @todo - update to be consistent with server
+ */
+function Frame() {
+ // The ID for the object will be broadcasted along with the IP. It consists of the name with a 12 letter UUID added.
+ this.objectId = null;
+ // The name for the object used for interfaces.
+ this.name = "";
+ // which visualization mode it should use right now ("ar" or "screen")
+ this.visualization = "ar";
+ // position data for the ar visualization mode
+ this.ar = {
+ // Reality Editor: This is used to position the UI element within its x axis in 3D Space. Relative to Target origin.
+ x : 0,
+ // Reality Editor: This is used to position the UI element within its y axis in 3D Space. Relative to Target origin.
+ y : 0,
+ // Reality Editor: This is used to scale the UI element in 3D Space. Default scale is 1.
+ scale : 0.5,
+ // Unconstrained positioning in 3D space
+ matrix: [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ],
+ };
+ // position data for the screen visualization mode
+ this.screen = {
+ // Reality Editor: This is used to position the UI element within its x axis in 3D Space. Relative to Target origin.
+ x : 0,
+ // Reality Editor: This is used to position the UI element within its y axis in 3D Space. Relative to Target origin.
+ y : 0,
+ // Reality Editor: This is used to scale the UI element in 3D Space. Default scale is 1.
+ scale : 0.5
+ };
// Used internally from the reality editor to indicate if an object should be rendered or not.
this.visible = false;
// Used internally from the reality editor to trigger the visibility of naming UI elements.
@@ -97,22 +169,32 @@ function Objects() {
this.links = {};
// Stores all IOPoints. These points are used to keep the state of an object and process its data.
this.nodes = {};
- // The arrangement of nodes for crafting.
- this.logic = {};
+ // local or global. If local, node-name is exposed to hardware interface
+ this.location = "global";
+ // source
+ this.src = "editor";
+ // if true, cannot move the frame but copies are made from it when you pull into unconstrained
+ this.staticCopy = false;
+ // the maximum distance (in meters) to the camera within which it will be rendered
+ this.distanceScale = 1.0;
+ // Indicates what group the frame belongs to; null if none
+ this.groupID = null;
+ // "Pinned" frames are by default loaded and visible with the object they belong to. Unpinned must be asked for.
+ this.pinned = true;
}
/**
* @desc The Link constructor is used every time a new link is stored in the links object.
* The link does not need to keep its own ID since it is created with the link ID as Obejct name.
- **/
-
+ * @constructor
+ */
function Link() {
// The origin object from where the link is sending data from
this.objectA = null;
// The origin IOPoint from where the link is taking its data from
this.nodeA = null;
// if origin location is a Logic Node then set to Logic Node output location (which is a number between 0 and 3) otherwise null
- this.logicA = null;
+ this.logicA = false;
// Defines the type of the link origin. Currently this function is not in use.
this.namesA = ["",""];
// The destination object to where the origin object is sending data to.
@@ -122,7 +204,7 @@ function Link() {
// objectB and nodeB will be send with each data package.
this.nodeB = null;
// if destination location is a Logic Node then set to logic block input location (which is a number between 0 and 3) otherwise null
- this.logicB = null;
+ this.logicB = false;
// Defines the type of the link destination. Currently this function is not in use.
this.namesB = ["",""];
// check that there is no endless loop in the system
@@ -130,60 +212,88 @@ function Link() {
// Will be used to test if a link is still able to find its destination.
// It needs to be discussed what to do if a link is not able to find the destination and for what time span.
this.health = 0; // todo use this to test if link is still valid. If not able to send for some while, kill link.
+
+ this.lockPassword = null;
+ this.lockType = null;
}
/**
* @desc Constructor used to define every nodes generated in the Object. It does not need to contain its own ID
* since the object is created within the nodes with the ID as object name.
- **/
-
+ * @constructor
+ */
function Node() {
// the name of each link. It is used in the Reality Editor to show the IO name.
this.name = "";
+ // the ID of the containing object.
+ this.objectId = null;
+ // the ID of the containing frame.
+ this.frameId = null;
// the actual data of the node
- this.item = [new Data(), {}, {}, {}]; // todo maybe value
- // Reality Editor: This is used to possition the UI element within its x axis in 3D Space. Relative to Marker origin.
+ this.data = new Data(); // todo maybe value
+ // Reality Editor: This is used to position the UI element within its x axis in 3D Space. Relative to Target origin.
this.x = 0;
- // Reality Editor: This is used to possition the UI element within its y axis in 3D Space. Relative to Marker origin.
+ // Reality Editor: This is used to position the UI element within its y axis in 3D Space. Relative to Target origin.
this.y = 0;
// Reality Editor: This is used to scale the UI element in 3D Space. Default scale is 1.
- this.scale = 1;
+ this.scale = 0.5;
// Unconstrained positioning in 3D space
- this.matrix = [];
+ this.matrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
// defines the nodeInterface that is used to process data of this type. It also defines the visual representation
// in the Reality Editor. Such data points interfaces can be found in the nodeInterface folder.
- // todo appearance should be removed eventually as there is only one kind of appearance
- this.appearance = "logicNode";
+ this.type = "node";
+ // todo implement src
+ this.src = "";
// defines the origin Hardware interface of the IO Point. For example if this is arduinoYun the Server associates
- // this IO Point with the Arduino Yun hardware interface.
- //this.type = "arduinoYun"; // todo "arduinoYun", "virtual", "edison", ... make sure to define yours in your internal_module file
// indicates how much calls per second is happening on this node
this.stress = 0;
+ // objects for arbitrary persistent data storage. currently only publicData has been used/tested
+ this.privateData = {};
+ this.publicData = {};
+
+ this.lockPassword = null;
+ this.lockType = null;
}
/**
* @desc Constructor used to define every logic node generated in the Object. It does not need to contain its own ID
* since the object is created within the nodes with the ID as object name.
- **/
-
+ * @constructor
+ */
function Logic() {
this.name = "";
// data for logic blocks. depending on the blockSize which one is used.
- this.item = [new Data(), new Data(), new Data(), new Data()];
- // Reality Editor: This is used to possition the UI element within its x axis in 3D Space. Relative to Marker origin.
+ this.data = new Data();
+ // Reality Editor: This is used to position the UI element within its x axis in 3D Space. Relative to Target origin.
this.x = 0;
- // Reality Editor: This is used to possition the UI element within its y axis in 3D Space. Relative to Marker origin.
+ // Reality Editor: This is used to position the UI element within its y axis in 3D Space. Relative to Target origin.
this.y = 0;
// Reality Editor: This is used to scale the UI element in 3D Space. Default scale is 1.
- this.scale = 1;
+ this.scale = 0.5;
// Unconstrained positioning in 3D space
- this.matrix = [];
+ this.matrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+
+ // Used internally from the reality editor to indicate if an object should be rendered or not.
+ this.visible = false;
+ // Used internally from the reality editor to indicate the editing status.
+ this.visibleEditing = false;
+
// if showLastSettingFirst is true then lastSetting is the name of the last block that was moved or set.
this.lastSetting = false;
this.lastSettingBlock = "";
// the iconImage is in png or jpg format and will be stored within the logicBlock folder. A reference is placed here.
- this.iconImage = null;
+ this.iconImage = 'auto';
// nameInput are the names given for each IO.
this.nameInput = ["", "", "", ""];
// nameOutput are the names given for each IO
@@ -197,28 +307,78 @@ function Logic() {
[[null, 0], [null, 0], [null, 0], [null, 0]]
];*/
- this.appearance = "default";
+ this.type = "logic";
this.links = {};
this.blocks = {};
- this.tempLink = null;
+ this.guiState = new LogicGUIState();
+
+ this.lockPassword = null;
+ this.lockType = null;
+}
+
+/**
+ * @desc Constructor used to define temporary state for drawing the GUI of a Logic node's crafting board
+ * This state doesn't get saved to the server - it can be reconstructed at runtime based on the data from the server
+ * @constructor
+ */
+function LogicGUIState() {
+ // lookup table for all the current dom elements for the UI of the blocks
+ this.blockDomElements = {};
+ // block link currently being drawn
+ this.tempLink = null;
+ // keeps track of which block/item are currently being interacted with
+ this.tappedContents = null;
+ // keeps track of whether the background has been hidden to show nodes
+ this.isCraftingBackgroundShown = true;
+ // when moving a block, traces outlines of incoming links and re-adds them
+ this.tempIncomingLinks = [];
+ // when moving a block, traces outlines of outgoing links and re-adds them
+ this.tempOutgoingLinks = [];
+ // endpoints of line used to cut links
+ this.cutLine = {
+ start: null,
+ end: null
+ };
+ // endpoints of visual-feedback line showing you the new link you are drawing
+ this.tempLine = {
+ start: null,
+ end: null,
+ color: null
+ };
+ // which block you tapped on in the block menu
+ this.menuSelectedBlock = null;
+ // block to add to crafting board when menu closes
+ this.menuBlockToAdd = null;
+ // touch interaction state in the menu
+ this.menuIsPointerDown = false;
+ // which menu tab is open
+ this.menuSelectedTab = 0;
+ // dom elements for the menu tab buttons
+ this.menuTabDivs = [];
+ // menuBlockData[i] stores an array of json data describing each block in the ith menu tab
+ this.menuBlockData = [ [], [], [], [], [] ]; //defaultBlockData(); //TODO: load cached blocks instead of empty
+ // dom elements for blocks in menu
+ this.menuBlockDivs = [];
+ // keeps track of which colors have links drawn to them from the outside
+ this.connectedInputColors = [false, false, false, false]; // blue, green, yellow, red
+ this.connectedOutputColors = [false, false, false, false]; // stored in array for easy conversion to coordinates
}
/**
* @desc The Link constructor for Blocks is used every time a new logic Link is stored in the logic Node.
* The block link does not need to keep its own ID since it is created with the link ID as Object name.
- **/
-
+ */
function BlockLink() {
// origin block UUID
- this.blockA = null;
+ this.nodeA = null;
// item in that block
- this.itemA = 0;
+ this.logicA = 0;
// destination block UUID
- this.blockB = null;
+ this.nodeB = null;
// item in that block
- this.itemB = 0;
+ this.logicB = 0;
// check if the links are looped.
this.loop = false;
// Will be used to test if a link is still able to find its destination.
@@ -227,18 +387,17 @@ function BlockLink() {
// keeps track of the path from the start block to end block and how to draw it
this.route = null;
this.ballAnimationCount = 0;
+ this.globalId = null;
}
/**
* @desc Constructor used to define every block within the logicNode.
* The block does not need to keep its own ID since it is created with the link ID as Object name.
- **/
-
-
+ * @constructor
+ */
function Block() {
// name of the block
- this.name = "";
-
+ this.type = "";
this.x = null;
this.y = null;
// amount of elements the IO point is created of. Single IO nodes have the size 1.
@@ -248,12 +407,10 @@ function Block() {
// the checksum should be identical with the checksum for the persistent package files of the reference block design.
this.checksum = null; // checksum of the files for the program
// data for logic blocks. depending on the blockSize which one is used.
- this.item = [new Data(), new Data(), new Data(), new Data()];
- // experimental. This are objects for data storage. Maybe it makes sense to store data in the general object
- // this would allow the the packages to be persistent. // todo discuss usability with Ben.
+ this.data = [new Data(), new Data(), new Data(), new Data()];
+ // objects for arbitrary persistent data storage. currently only publicData has been used/tested
this.privateData = {};
this.publicData = {};
-
// IO for logic
// define how many inputs are active.
this.activeInputs = [true, false, false, false];
@@ -265,19 +422,34 @@ function Block() {
// A specific icon for the node, png or jpg.
this.iconImage = null;
// Text within the node, if no icon is available.
- this.text = "";
+ this.name = "";
// indicates how much calls per second is happening on this block
- this.stress = 0;
+ this.stress = 0; // todo: implement this
+ this.isTempBlock = false;
+ this.isPortBlock = false;
}
/**
- * @desc Definition for Values that are sent around.
+ * @desc Constructor used to define special blocks that are connecting the logic crafting with the outside system.
+ * @constructor
**/
+function EdgeBlock() {
+ // name of the block
+ this.name = "";
+ // data for logic blocks. depending on the blockSize which one is used.
+ this.data = [new Data(), new Data(), new Data(), new Data()];
+ // indicates how much calls per second is happening on this block
+ this.stress = 0;
+}
+/**
+ * @desc Definition for Values that are sent around.
+ * @constructor
+ */
function Data() {
// storing the numerical content send between nodes. Range is between 0 and 1.
- this.number = 0;
- // Defines the kind of data send. At this point we have 3 active data modes and one future possibility.
+ this.value = 0;
+ // Defines the type of data send. At this point we have 3 active data modes and one future possibility.
// (f) defines floating point values between 0 and 1. This is the default value.
// (d) defines a digital value exactly 0 or 1.
// (+) defines a positive step with a floating point value for compatibility.
@@ -288,4 +460,4 @@ function Data() {
// scale of the unit that is used. Usually the scale is between 0 and 1.
this.unitMin = 0;
this.unitMax = 1;
-}
\ No newline at end of file
+}
diff --git a/src/device/PinchGestureRecognizer.js b/src/device/PinchGestureRecognizer.js
new file mode 100644
index 000000000..e49ad09ad
--- /dev/null
+++ b/src/device/PinchGestureRecognizer.js
@@ -0,0 +1,89 @@
+export class PinchGestureRecognizer {
+ constructor() {
+ this.unprocessedScroll = 0;
+ this.callbacks = {
+ onPinchChange: [],
+ onPinchStart: [],
+ onPinchEnd: []
+ };
+ this.addMultitouchEvents();
+ }
+ onPinchChange(callback) {
+ this.callbacks.onPinchChange.push(callback);
+ }
+ onPinchStart(callback) {
+ this.callbacks.onPinchStart.push(callback);
+ }
+ onPinchEnd(callback) {
+ this.callbacks.onPinchEnd.push(callback);
+ }
+ addMultitouchEvents() {
+ let isMultitouchGestureActive = false;
+ let initialDistance = 0;
+ let lastDistance = 0;
+
+ // Prevent the default pinch gesture response (zooming) on mobile browsers
+ document.addEventListener('gesturestart', (event) => {
+ event.preventDefault();
+ });
+
+ // Handle pinch to zoom
+ const handlePinch = (event) => {
+ event.preventDefault();
+ if (event.touches.length === 2) {
+ const touch1 = event.touches[0];
+ const touch2 = event.touches[1];
+ const currentDistance = Math.hypot(touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY);
+
+ if (initialDistance === 0) { // indicates the start of the pinch gesture
+ initialDistance = currentDistance;
+ lastDistance = initialDistance;
+ this.callbacks.onPinchStart.forEach(callback => {
+ callback();
+ });
+ } else {
+ // Calculate the pinch scale based on the change in distance over time.
+ // 5 is empirically determined to feel natural. -= so bigger distance leads to closer zoom
+ this.unprocessedScroll -= 5 * (currentDistance - lastDistance);
+ lastDistance = currentDistance;
+ this.callbacks.onPinchChange.forEach(callback => {
+ callback(this.unprocessedScroll);
+ });
+ this.unprocessedScroll = 0;
+ }
+ }
+ }
+
+ // Add multitouch event listeners to the document
+ document.addEventListener('touchstart', (event) => {
+ if (!realityEditor.device.utilities.isEventHittingBackground(event)) return;
+
+ isMultitouchGestureActive = true;
+
+ if (event.touches.length === 2) {
+ initialDistance = 0; // Reset pinch distance
+ }
+ });
+ document.addEventListener('touchmove', (event) => {
+ if (!isMultitouchGestureActive) return;
+ event.preventDefault();
+
+ // Ensure regular zoom level
+ document.documentElement.style.zoom = '1';
+ // Ensure no page offset
+ window.scrollTo(0, 0);
+
+ if (event.touches.length === 2) {
+ // zooms based on changing distance between fingers
+ handlePinch(event);
+ }
+ });
+ document.addEventListener('touchend', (_event) => {
+ initialDistance = 0;
+ isMultitouchGestureActive = false;
+ this.callbacks.onPinchEnd.forEach(callback => {
+ callback();
+ });
+ });
+ }
+}
diff --git a/src/device/distanceScaling.js b/src/device/distanceScaling.js
new file mode 100644
index 000000000..91eeee598
--- /dev/null
+++ b/src/device/distanceScaling.js
@@ -0,0 +1,468 @@
+createNameSpace("realityEditor.device.distanceScaling");
+
+/**
+ * @fileOverview realityEditor.device.distanceScaling.js
+ */
+
+(function(exports) {
+
+ // maps frameKeys to div elements visualizing the distance
+ var allDistanceUIs = {};
+
+ // placeholder link object to pass into the line rendering function, to prevent animation
+ var linkObject = {
+ ballAnimationCount: 0
+ };
+
+ var defaultDistance = 2000 * 10;
+
+ var isScalingDistance = false;
+
+ var distanceScalingState = {
+ objectKey: null,
+ frameKey: null
+ };
+
+ var groundPlaneRotation = [];
+
+ /**
+ * @type {CallbackHandler}
+ */
+ var callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('device/distanceScaling');
+
+ /**
+ * Adds a callback function that will be invoked when the specified function is called
+ * @param {string} functionName
+ * @param {function} callback
+ */
+ function registerCallback(functionName, callback) {
+ if (!callbackHandler) {
+ callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('device/distanceScaling');
+ }
+ callbackHandler.registerCallback(functionName, callback);
+ }
+
+ function getDefaultDistance() {
+ return defaultDistance * realityEditor.device.environment.getDistanceScaleFactor();
+ }
+
+ function initService() {
+ realityEditor.gui.ar.draw.addUpdateListener(loop);
+ realityEditor.device.registerCallback('onDocumentMultiTouchStart', onDocumentMultiTouchStart);
+ realityEditor.device.registerCallback('onDocumentMultiTouchEnd', onDocumentMultiTouchEnd);
+
+ realityEditor.device.registerCallback('vehicleDeleted', onVehicleDeleted);
+ realityEditor.network.registerCallback('vehicleDeleted', onVehicleDeleted);
+
+ realityEditor.gui.buttons.registerCallbackForButton('distance', onDistanceEditingModeChanged);
+ realityEditor.gui.buttons.registerCallbackForButton('distanceGreen', onDistanceGreenPressed);
+ }
+
+ function loop() {
+
+ // render the UIs if in distance editing mode or actively scaling one of them
+ if (isScalingDistance || globalStates.distanceEditingMode) {
+ realityEditor.gui.ar.draw.forEachVisibleFrame( function(objectKey, frameKey) {
+ // if frame it is attached to no longer exists, remove it
+ // otherwise render it
+ transformDistanceUI(objectKey, frameKey);
+ });
+ }
+
+ // only update the distanceScale of a frame and draw the distance line if you are actively scaling it
+ if (isScalingDistance) {
+ scaleEditingFrameDistance();
+
+ globalCanvas.hasContent = true;
+ var frame = realityEditor.device.getEditingVehicle();
+ // noinspection JSSuspiciousNameCombination
+ var screenWidth = globalStates.height;
+ // noinspection JSSuspiciousNameCombination
+ var screenHeight = globalStates.width;
+ var startPoint = [screenWidth/2, screenHeight/2];
+ var startWeight = 30;
+ var colorCode = 4; // white
+ var widthFactor = 0.25;
+ linkObject.ballAnimationCount = 0; // prevent animation by resetting animation count each time
+ realityEditor.gui.ar.lines.drawLine(globalCanvas.context, startPoint, [frame.screenX, frame.screenY], startWeight * widthFactor, frame.screenLinearZ * widthFactor, linkObject, timeCorrection, colorCode, colorCode);
+ }
+
+ var groundplaneContainer = document.getElementById('groundplaneContainer');
+ if (!groundplaneContainer) {
+ groundplaneContainer = document.createElement('div');
+ groundplaneContainer.className = 'main';
+ groundplaneContainer.id = 'groundplaneContainer';
+ groundplaneContainer.style.position = 'absolute';
+ groundplaneContainer.style.left = 0;
+ groundplaneContainer.style.top = 0;
+ document.body.appendChild(groundplaneContainer);
+ }
+
+ var element = document.getElementById('distanceGroundplaneUI');
+ if (!element) {
+ element = document.createElement('div');
+ element.id = 'distanceGroundplaneUI';
+ element.className = 'main';
+ element.style.width = '736px';
+ element.style.height = '414px';
+ // element.style.visibility = 'visible';
+ element.style.backgroundColor = 'red';
+ groundplaneContainer.appendChild(element);
+ }
+
+ var DEBUG_DONT_SHOW_GROUNDPLANE_HALO = true;
+ if (DEBUG_DONT_SHOW_GROUNDPLANE_HALO) { return; }
+
+ if (realityEditor.gui.ar.draw.groundPlaneMatrix) {
+ var rotatedGroundPlaneMatrix = [];
+ var rotation3d = [
+ 1, 0, 0, 0,
+ 0, 0, 1, 0,
+ 0, 1, 0, 0,
+ 0, 0, 0, 1
+ ];
+ var finalMatrix = [];
+ realityEditor.gui.ar.utilities.multiplyMatrix(rotation3d, realityEditor.gui.ar.draw.groundPlaneMatrix, rotatedGroundPlaneMatrix);
+ realityEditor.gui.ar.utilities.multiplyMatrix(rotatedGroundPlaneMatrix, globalStates.projectionMatrix, finalMatrix);
+
+ groundPlaneRotation = realityEditor.gui.ar.utilities.copyMatrix(finalMatrix);
+ var perspectiveValue = groundPlaneRotation[15];
+ groundPlaneRotation[12] = perspectiveValue * globalStates.height/2;
+ groundPlaneRotation[13] = -1 * perspectiveValue * globalStates.width/2;
+ groundPlaneRotation[14] = 0;
+ // groundPlaneRotation[15] = 1;
+
+ // groundPlaneQuaternion = realityEditor.gui.ar.utilities.getQuaternionFromMatrix(groundPlaneRotation);
+
+ // element.style.transform = 'matrix3d(' + groundPlaneRotation.toString() + ')';
+
+ // var translatedGroundPlaneMatrix = [];
+ // utilities.multiplyMatrix(matrix.r3, rotatedGroundPlaneMatrix, translatedGroundPlaneMatrix);
+ // utilities.multiplyMatrix(translatedGroundPlaneMatrix, this.globalStates.projectionMatrix, finalMatrix);
+
+ realityEditor.gui.ar.draw.forEachVisibleFrame( function(objectKey, frameKey) {
+ // if frame it is attached to no longer exists, remove it
+ // otherwise render it
+ // transformDistanceUI(objectKey, frameKey);
+
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ if (frame) {
+
+ var frameMatrix = frame.mostRecentFinalMatrix;
+ // var normalizedFrameMatrix = realityEditor.gui.ar.utilities.normalizeMatrix(frameMatrix);
+ // var normalizedGroundplaneRotationMatrix = realityEditor.gui.ar.utilities.normalizeMatrix(groundPlaneRotation);
+ //
+ // normalizedGroundplaneRotationMatrix[12] = normalizedFrameMatrix[12];
+ // normalizedGroundplaneRotationMatrix[13] = normalizedFrameMatrix[13];
+ // normalizedGroundplaneRotationMatrix[14] = normalizedFrameMatrix[14];
+
+ /*
+ var rotated = [];
+ var r = realityEditor.gui.ar.utilities.getMatrixFromQuaternion(groundPlaneQuaternion);
+ realityEditor.gui.ar.utilities.multiplyMatrix(frameMatrix, r, rotated);
+ element.style.transform = 'matrix3d(' + rotated.toString() + ')';
+ */
+
+ if (!frameMatrix) return;
+
+ // element.style.transform = 'matrix3d(' + frameMatrix.toString() + ')';
+ element.style.visibility = 'visible';
+
+ // TODO: calculate position of "halo" element
+
+ // var frameQ = realityEditor.gui.ar.utilities.getQuaternionFromMatrix(frameMatrix);
+ // var invFrameQ = realityEditor.gui.ar.utilities.invertQuaternion(frameQ);
+
+ // var frameM = realityEditor.gui.ar.utilities.getMatrixFromQuaternion(frameQ);
+ // var invFrameM = realityEditor.gui.ar.utilities.getMatrixFromQuaternion(invFrameQ);
+
+ var frameM = realityEditor.gui.ar.utilities.extractRotation(frameMatrix);
+ var invFrameM = realityEditor.gui.ar.utilities.invertMatrix(frameM);
+
+ var rotated = [];
+
+ realityEditor.gui.ar.utilities.multiplyMatrix(invFrameM, frameMatrix, rotated);
+
+ // var frameQ2 = realityEditor.gui.ar.utilities.getQuaternionFromMatrix(rotated);
+
+
+ element.style.transform = 'matrix3d(' + rotated.toString() + ')';
+
+
+ }
+
+ });
+
+ }
+
+
+
+ }
+
+ /**
+ * Remove a distanceUI when its frame gets deleted, so it doesn't get stuck on the screen
+ * @param {{objectKey: string, frameKey: string, nodeKey: string|null}} params
+ */
+ function onVehicleDeleted(params) {
+ if (params.objectKey && params.frameKey && !params.nodeKey) {
+ hideDistanceUI(params.frameKey);
+ }
+ }
+
+ /**
+ * Start scaling distance when three finger touch starts
+ * @param {{event: object}} params
+ */
+ function onDocumentMultiTouchStart(params) {
+ // console.log(params.event);
+
+ if (params.event.touches.length === 3) {
+ var touchTargets = Array.from(params.event.touches).map(function(touch){return touch.target.id.replace(/^(svg)/,"")});
+ if (touchTargets.indexOf(realityEditor.device.editingState.frame) > -1) {
+ // console.log('change distance');
+ isScalingDistance = true;
+ distanceScalingState.objectKey = realityEditor.device.editingState.object;
+ distanceScalingState.frameKey = realityEditor.device.editingState.frame;
+ showDistanceUI(distanceScalingState.frameKey);
+ realityEditor.device.disableUnconstrained();
+ }
+ }
+ }
+
+ /**
+ * Stop scaling distance when three finger touch stops
+ * @param {{event: object}} _params (unused)
+ */
+ function onDocumentMultiTouchEnd(_params) {
+ // console.log(params.event);
+ // if (params.event.touches.length < 3) {
+ isScalingDistance = false;
+ // }
+
+ if (distanceScalingState.frameKey) {
+ // don't hide it if we're in permanent distance editing mode
+ if (!globalStates.distanceEditingMode) {
+ hideDistanceUI(distanceScalingState.frameKey);
+ }
+ distanceScalingState.objectKey = null;
+ distanceScalingState.frameKey = null;
+ }
+
+ realityEditor.device.enableUnconstrained();
+ realityEditor.device.enablePinchToScale(); // just in case we didn't touch up on the green button
+
+ }
+
+ /**
+ * Triggered when the distance editing mode button is pressed
+ * @param {{buttonName: string, newButtonState: string}} params
+ */
+ function onDistanceEditingModeChanged(params) {
+ console.log('registered in distanceScaling module', params.newButtonState, globalStates.distanceEditingMode);
+
+ // 'leave' happens after 'up' so the changes to distanceEditingMode in buttons.js will have taken place
+ if (params.newButtonState === 'leave') {
+ var frameKey;
+ if (globalStates.distanceEditingMode) {
+ console.log('show all distance editing UIs');
+
+ realityEditor.gui.ar.draw.forEachVisibleFrame( function(objectKey, frameKey) {
+ getDistanceUI(frameKey); // populates allDistanceUIs with new distanceUIs if they don't exist yet
+ });
+
+ for (frameKey in allDistanceUIs) {
+ if (!allDistanceUIs.hasOwnProperty(frameKey)) continue;
+ showDistanceUI(frameKey);
+ }
+
+ } else {
+ console.log('hide all distance editing UIs');
+
+ for (frameKey in allDistanceUIs) {
+ if (!allDistanceUIs.hasOwnProperty(frameKey)) continue;
+ hideDistanceUI(frameKey);
+ }
+ }
+
+ }
+ }
+
+ function onDistanceGreenPressed(params) {
+
+ if (params.newButtonState === 'down') {
+
+ isScalingDistance = true;
+ distanceScalingState.objectKey = realityEditor.device.editingState.object;
+ distanceScalingState.frameKey = realityEditor.device.editingState.frame;
+ showDistanceUI(distanceScalingState.frameKey);
+ scaleEditingFrameDistance();
+ realityEditor.device.disableUnconstrained();
+ realityEditor.device.disablePinchToScale();
+
+ } else if (params.newButtonState === 'up') {
+
+ isScalingDistance = false;
+ if (distanceScalingState.frameKey) {
+ // don't hide it if we're in permanent distance editing mode
+ if (!globalStates.distanceEditingMode) {
+ hideDistanceUI(distanceScalingState.frameKey);
+ }
+ distanceScalingState.objectKey = null;
+ distanceScalingState.frameKey = null;
+ }
+ realityEditor.device.enableUnconstrained();
+ realityEditor.device.enablePinchToScale();
+
+ }
+
+ }
+
+ // adds a semi-transparent circle/sphere that indicates the maximum distance you can be from the frame for it to be rendered
+ function createDistanceUI(frameKey) {
+ if (globalDOMCache['object' + frameKey]) {
+ var element = document.createElement('div');
+ element.id = 'distanceUI' + frameKey;
+ element.classList.add('main');
+ element.classList.add('distanceUI');
+
+ var diameterString = globalDOMCache['object' + frameKey].style.width; // when scale is at 1.0, should be the width of the frame // TODO: this might not be right anymore
+ element.style.width = diameterString;
+ element.style.height = diameterString;
+
+ document.body.appendChild(element);
+ return element;
+ }
+
+ return null;
+ }
+
+ /**
+ * Updates the CSS 3D matrix of the distanceUI element for the given frame.
+ * Matches the x,y,z position of the frame.
+ * Scales according to the frame's distance scale, ignores its regular scale.
+ * Doesn't rotate.
+ * @param {string} objectKey
+ * @param {string} frameKey
+ */
+ function transformDistanceUI(objectKey, frameKey) {
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ var editingVehicle = realityEditor.device.getEditingVehicle();
+ var shouldRenderDistance = ((editingVehicle === frame) || globalStates.distanceEditingMode) && globalDOMCache['object'+frameKey];
+
+ if (shouldRenderDistance) {
+ var m1 = realityEditor.gui.ar.utilities.getTransform(globalDOMCache['object'+frameKey]);
+
+ var framePositionData = realityEditor.gui.ar.positioning.getPositionData(frame); // inverse scale on circle
+ var frameScaleFactor = (framePositionData.scale / globalStates.defaultScale);
+
+ var distanceScale = frame.distanceScale || 1.0; // 1 is the default if it hasn't been set yet
+ var circleScaleConstant = 3.0 * (defaultDistance/2000); //5.0;
+
+ var scaleMatrix = realityEditor.gui.ar.utilities.newIdentityMatrix();
+
+ var scaleAvr = Math.sqrt(Math.pow(m1[0], 2) + Math.pow(m1[5], 2) + Math.pow(m1[10], 2));
+
+ scaleMatrix[0] = scaleAvr* circleScaleConstant * distanceScale / frameScaleFactor; // divide by frame scale so distanceUI doesn't get bigger when frame scales up
+ scaleMatrix[5] = scaleAvr * circleScaleConstant * distanceScale / frameScaleFactor; // use same scale (m[0]) for x and y to preserve circle shape
+ // scaleMatrix[10] = scaleAv * circleScaleConstant * distanceScale / frameScaleFactor;
+
+ // console.log( scaleMatrix[5],scaleMatrix[0] );
+ var translateMatrix = realityEditor.gui.ar.utilities.newIdentityMatrix();
+ translateMatrix[12] = m1[12];
+ var yTranslate = -125; // TODO: we scale the circle's height by m1[0] not m1[5], which makes it not centered...
+ translateMatrix[13] = m1[13] + (yTranslate * m1[15]); // TODO: -125 * m1[15] is a hack to move it up to center of object. find a mathematically correct solution
+ translateMatrix[14] = m1[14];
+ translateMatrix[15] = m1[15];
+
+ var transformationMatrix = [];
+ realityEditor.gui.ar.utilities.multiplyMatrix(scaleMatrix, translateMatrix, transformationMatrix);
+
+ var thisDistanceUI = getDistanceUI(frameKey);
+ if (thisDistanceUI) {
+ thisDistanceUI.style.transform = 'matrix3d(' + transformationMatrix.toString() + ')';
+ }
+ } /*else {
+ if (!globalDOMCache['object'+frameKey]) {
+ hideDistanceUI(frameKey);
+ }
+ }*/
+ }
+
+ /**
+ * Lazy instantiation of new distanceUIs for each frame
+ * @param {string} frameKey
+ * @return {HTMLElement}
+ */
+ function getDistanceUI(frameKey) {
+ if (!frameKey) return;
+ if (typeof allDistanceUIs[frameKey] === 'undefined') {
+ var newDistanceUI = createDistanceUI(frameKey);
+ if (newDistanceUI) {
+ allDistanceUIs[frameKey] = newDistanceUI;
+ // the distance UI starts out invisible until you make a 3-finger-pinch gesture
+ hideDistanceUI(frameKey);
+ }
+ }
+ return allDistanceUIs[frameKey];
+ }
+
+ /**
+ * Scales the visible distance threshold for this frame to match the current distance of the phone to the frame
+ */
+ function scaleEditingFrameDistance() {
+ var editingFrame = realityEditor.device.getEditingVehicle();
+ if (!editingFrame) return;
+
+ // defaultDistance = 2000 is the default size of the radius (1000 per meter)
+ // we divide by 0.85 since 1.0 is when it fades out entirely, 0.8 is visible entirely, so 0.85 is in between
+
+ editingFrame.distanceScale = (realityEditor.sceneGraph.getDistanceToCamera(editingFrame.uuid) / defaultDistance) / 0.85;
+
+ callbackHandler.triggerCallbacks('scaleEditingFrameDistance', {frame: editingFrame});
+ }
+
+ /**
+ * Shows the semi-transparent sphere UI and hides the green outline editing UI
+ * @param {string} frameKey
+ */
+ function showDistanceUI(frameKey) {
+ var thisDistanceUI = getDistanceUI(frameKey);
+ if (!thisDistanceUI) return;
+
+ thisDistanceUI.style.display = 'inline';
+
+ // don't show the green overlay at the same time as changing the distance
+ var svgOverlay = globalDOMCache['svg' + frameKey];
+ if (!svgOverlay) {
+ delete allDistanceUIs[frameKey]; // clean up frames that don't exist anymore
+ return;
+ }
+
+ svgOverlay.classList.add('hiddenForDistance');
+ }
+
+ /**
+ * Hides the semi-transparent sphere UI and re-shows the green outline editing UI
+ * @param {string} frameKey
+ */
+ function hideDistanceUI(frameKey) {
+ var thisDistanceUI = getDistanceUI(frameKey);
+ if (!thisDistanceUI) return;
+
+ thisDistanceUI.style.display = 'none';
+
+ // able to show the green overlay again
+ var svgOverlay = globalDOMCache['svg' + frameKey];
+ if (!svgOverlay) {
+ delete allDistanceUIs[frameKey]; // clean up frames that don't exist anymore
+ return;
+ }
+
+ svgOverlay.classList.remove('hiddenForDistance');
+ }
+
+ exports.initService = initService;
+ exports.getDefaultDistance = getDefaultDistance;
+ exports.registerCallback = registerCallback;
+
+})(realityEditor.device.distanceScaling);
diff --git a/src/device/environment.js b/src/device/environment.js
new file mode 100644
index 000000000..7d437d37c
--- /dev/null
+++ b/src/device/environment.js
@@ -0,0 +1,258 @@
+createNameSpace("realityEditor.device.environment");
+
+/**
+ * @fileOverview realityEditor.device.environment.js
+ * This provides an extensible location for defining environment variables that are used by
+ * the core application, but may be modified by add-ons to affect conditional behavior in the app.
+ *
+ * For example, an add-on can disable distance fading, if that is important for the add-on behavior,
+ * or it can change which event names the app responds to (mousedown vs touchdown), or it can scale
+ * certain UI constants, such as the link line width, by factors specific to the environment.
+ *
+ * Currently, if multiple add-ons try to set the same variable it will lead to inconsistent results
+ * based on which add-on is loaded first. There are plans to allow add-ons to register their
+ * individual requirements, which the getter functions would resolve at runtime.
+ */
+(function(exports) {
+
+ exports.initService = function() {
+ realityEditor.network.addPostMessageHandler('getEnvironmentVariables', (_, fullMessageData) => {
+ realityEditor.network.postMessageIntoFrame(fullMessageData.frame, {environmentVariables: variables});
+ });
+ };
+
+ // use this to distinguish between opening the remote operator in a mobile
+ // safari vs opening the userinterface in AR mode in the app
+ function isWithinToolboxApp() {
+ return navigator.userAgent.includes('iOS/VuforiaSpatialToolbox');
+ }
+
+ // rather than checking for "isDesktop", this gives a more reliable way to
+ // determine whether to run the AR interface or the remote operator interface
+ function isARMode() {
+ return isWithinToolboxApp() && !isDesktop() &&
+ realityEditor.device.modeTransition.isARMode();
+ }
+
+ function isDesktop() {
+ const userAgent = window.navigator.userAgent;
+ const isWebView = userAgent.includes('Mobile') && !userAgent.includes('Safari');
+ const isIpad = /Macintosh/i.test(navigator.userAgent) &&
+ navigator.maxTouchPoints &&
+ navigator.maxTouchPoints > 1;
+ const isIphone = /iPhone/i.test(navigator.userAgent) &&
+ navigator.maxTouchPoints &&
+ navigator.maxTouchPoints > 1;
+
+ return !isWebView && !isIpad && !isIphone;
+ }
+
+ // initialized with default variables for iPhone environment. add-ons can modify
+ let variables = {
+ // booleans
+ providesOwnUpdateLoop: false,
+ shouldBroadcastUpdateObjectMatrix: false,
+ doWorldObjectsRequireCameraTransform: false,
+ requiresMouseEvents: false,
+ supportsDistanceFading: true,
+ shouldCreateDesktopSocket: false,
+ alwaysEnableRealtime: true,
+ distanceRequiresCameraTransform: false,
+ ignoresFreezeButton: false,
+ shouldDisplayLogicMenuModally: false,
+ isSourceOfObjectPositions: true,
+ isCameraOrientationFlipped: false,
+ waitForARTracking: !isDesktop() && isWithinToolboxApp(), // set to false on remote operator
+ overrideMenusAndButtons: false,
+ listenForDeviceOrientationChanges: true,
+ enableViewFrustumCulling: true,
+ layoutUIForPortrait: false,
+ defaultShowGroundPlane: false,
+ supportsMemoryCreation: true,
+ hasLocalNetworkAccess: true, // set to false if iOS device permissions disabled
+ // numbers
+ lineWidthMultiplier: 1, // 5
+ distanceScaleFactor: 1, // 10
+ newFrameDistanceMultiplier: 1, // 10
+ transformControlsSize: 1, // on remote operator, we can scale down the gizmo size for moving groundplane anchors
+ localServerPort: 49369, // the port where a local vuforia-spatial-edge-server can be expected
+ screenTopOffset: 0, // if there's a menubar on the top, increase this
+ maxAvatarIcons: 7, // limits the number of circular icons depicting how many avatars are currently connected
+ // matrices
+ initialPocketToolRotation: null,
+ supportsAreaTargetCapture: true,
+ automaticallyPromptForAreaTargetCapture: true,
+ hideOriginCube: false, // explicitly don't show the 3d cubes at the world origin
+ addOcclusionGltf: true, // by default loads the occlusion mesh, but a VR viewer can disable this
+ suppressObjectDetections: false, // temporarily toggle on to stop UDP messages from triggered object download
+ suppressObjectRendering: false, // temporarily toggle on to stop rendering objects/tools/nodes
+ overrideAreaTargetScanningUI: false, // hide the default status textfield for the area target scanning
+ // colors
+ groundWireframeColor: 'rgb(0, 255, 255)',
+ };
+
+ // variables can be directly set by add-ons by using the public 'variables' property
+ exports.variables = variables;
+ // however, rather than reading these variables directly, it is preferred to use the getters:
+ // this is for compatibility with future plans which will add more logic to the variables
+
+ // using variables.suppressObjectRendering allows any module to overwrite any other module's preferences
+ // but a module can add a flag, and rendering will only re-enable when all flags are cleared
+ let suppressedRenderingFlags = {};
+
+ exports.addSuppressedObjectRenderingFlag = (flagName) => {
+ suppressedRenderingFlags[flagName] = true;
+ };
+
+ exports.clearSuppressedObjectRenderingFlag = (flagName) => {
+ delete suppressedRenderingFlags[flagName];
+ }
+
+ exports.isObjectRenderingSuppressed = () => {
+ return Object.keys(suppressedRenderingFlags).length > 0 || variables.suppressObjectRendering;
+ }
+
+ /**
+ * Whether the environment contains a service that will trigger gui.ar.draw.update
+ * If not, the editor will keep the update loop running while frozen to drive line animations.
+ * @return {boolean} - default false
+ */
+ exports.providesOwnUpdateLoop = function() {
+ return variables.providesOwnUpdateLoop;
+ };
+
+ /**
+ * If true, and there is a localized world object in sight, looking at new objects will
+ * continuously set their ar.matrix property on the server to store their position
+ * @return {boolean} - default false
+ */
+ exports.shouldBroadcastUpdateObjectMatrix = function() {
+ return variables.shouldBroadcastUpdateObjectMatrix;
+ };
+
+ /**
+ * If true, multiplies world origin by the camera matrix while rendering, rather than using
+ * the visibleObjects matrix for world objects un-altered.
+ * May be required based on the camera system being used.
+ * @return {boolean} - default false
+ */
+ exports.doWorldObjectsRequireCameraTransform = function() {
+ return variables.doWorldObjectsRequireCameraTransform;
+ };
+
+ /**
+ * If true, replaces touch events with mouse events.
+ * @return {boolean} - default false
+ */
+ exports.requiresMouseEvents = function() {
+ return variables.requiresMouseEvents;
+ };
+
+ /**
+ * Whether tools and nodes should become invisible as the camera moves further away
+ * @return {boolean} - default true
+ */
+ exports.supportsDistanceFading = function() {
+ return variables.supportsDistanceFading;
+ };
+
+ /**
+ * Whether the application should open a socket to directly receive /update/object,
+ * /update/frame, and /update/node realtime messages
+ * @return {boolean} - default false
+ */
+ exports.shouldCreateDesktopSocket = function() {
+ return variables.shouldCreateDesktopSocket;
+ };
+
+ /**
+ * Whether features such as unconstrained repositioning or distance scaling should continue even if the freeze
+ * button is activated.
+ * @return {boolean} - default false
+ */
+ exports.ignoresFreezeButton = function() {
+ return variables.ignoresFreezeButton;
+ };
+
+ /**
+ * Whether the logic block menu should be rendered as a popup along the right edge of the screen, rather than
+ * expanding to be fullscreen and centered.
+ * @return {boolean} - default false
+ */
+ exports.shouldDisplayLogicMenuModally = function() {
+ return variables.shouldDisplayLogicMenuModally;
+ };
+
+ /**
+ * Whether this client is allowed to modify/upload .matrix properties of objects.
+ * Should be true for AR clients, since they can observe the world and determine latest positions of things.
+ * @return {boolean} - default true
+ */
+ exports.isSourceOfObjectPositions = function() {
+ return variables.isSourceOfObjectPositions;
+ };
+
+ /**
+ * Set to true if calculating distance of visibleObjects matrix should implicitly multiply by camera position
+ * Necessary for some camera systems.
+ * @return {boolean} - default false
+ */
+ exports.distanceRequiresCameraTransform = function() {
+ return variables.distanceRequiresCameraTransform;
+ };
+
+ /**
+ * In some environments adding new tools (etc) at the camera position results in them appearing upside-down unless
+ * corrected with some matrix adjustments
+ * @return {boolean} - default false
+ */
+ exports.isCameraOrientationFlipped = function() {
+ return variables.isCameraOrientationFlipped;
+ };
+
+ /**
+ * How much bigger than usual each dot in a link should be rendered
+ * @return {number} - default 1
+ */
+ exports.getLineWidthMultiplier = function() {
+ return variables.lineWidthMultiplier;
+ };
+
+ /**
+ * How much further away than usual before a tool or node fades away
+ * @return {number} - default 1
+ */
+ exports.getDistanceScaleFactor = function() {
+ return variables.distanceScaleFactor;
+ };
+
+ /**
+ * The port where a local vuforia-spatial-edge-server can be expected
+ * This is where the toolbox tries to load the _WORLD_local
+ * @return {number} - default 49369
+ */
+ exports.getLocalServerPort = function() {
+ return variables.localServerPort;
+ };
+
+ /**
+ * True by default - whether this client needs to wait for the AR SDK to provide it with camera matrices
+ * @return {boolean}
+ */
+ exports.waitForARTracking = function() {
+ return variables.waitForARTracking;
+ };
+
+ /**
+ * Multiplies the original transform of tools dropped from the pocket by this
+ * @return {Array.|null}
+ */
+ exports.getInitialPocketToolRotation = function() {
+ return variables.initialPocketToolRotation;
+ };
+
+ exports.isDesktop = isDesktop;
+ exports.isWithinToolboxApp = isWithinToolboxApp;
+ exports.isARMode = isARMode;
+
+}(realityEditor.device.environment));
diff --git a/src/device/index.js b/src/device/index.js
new file mode 100644
index 000000000..45ba3e375
--- /dev/null
+++ b/src/device/index.js
@@ -0,0 +1,1795 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.device");
+
+/**
+ * @fileOverview realityEditor.device.index.js
+ * Implements the touch event handlers for all major AR user interactions,
+ * keeping track of the editingState and modifying state of Objects, Frames, and Nodes as necessary.
+ */
+
+/**
+ * @typedef {Object} TouchEditingTimer
+ * @desc All the necessary state to track a tap-and-hold gesture that triggers a timeout callback.
+ * @property {number} startX
+ * @property {number} startY
+ * @property {number} moveToleranceSquared
+ * @property {Function} timeoutFunction
+ */
+
+/**
+ * @type {TouchEditingTimer|null}
+ */
+realityEditor.device.touchEditingTimer = null;
+
+/**
+ * @type {number} How long in ms you need to tap and hold on a frame to start moving it.
+ */
+realityEditor.device.defaultMoveDelay = 400;
+
+/**
+ * @type {Array.} List of each current touch on the screen, using the id of the touch event target.
+ */
+realityEditor.device.currentScreenTouches = [];
+
+/**
+ * @type {THREE.Mesh} Area target GLTF to raycast against
+ */
+realityEditor.device.cachedOcclusionObject = null;
+
+/**
+ * @type {Object} cached result of getBestWorldObject(), corresponding to the cachedOcclusionObject
+ */
+realityEditor.device.cachedWorldObject = null;
+
+/**
+ * @typedef {Object} EditingState
+ * @desc All the necessary state about what's currently being repositioned. Everything else can be calculated from these.
+ * @property {string|null} object - objectId of the selected vehicle
+ * @property {string|null} frame - frameId of the selected vehicle
+ * @property {string|null} node - nodeIf of the selected node (null if vehicle is a frame, not a node)
+ * @property {{x: number, y: number, z: number}|null} touchOffset - relative position of the touch to the vehicle when you start repositioning
+ * @property {boolean} unconstrained - iff the current reposition is temporarily unconstrained (globalStates.unconstrainedEditing is used for permanent unconstrained repositioning)
+ * @property {number|null} initialCameraPosition - initial camera position used for calculating popping into unconstrained
+ * @property {Array.|null} startingMatrix - stores the previous vehicle matrix while unconstrained editing, so that it can be returned to its original position if dropped in an invalid location
+ * @property {Array.|null} startingTransform - stores the matrix encoding (x,y,scale) at time of startingMatrix
+ * @property {boolean} unconstrainedDisabled - iff unconstrained is temporarily disabled (e.g. if changing distance threshold)
+ * @property {boolean} preDisabledUnconstrained - the unconstrained state before we disabled, so that we can go back to that when we're done
+ * @property {boolean} pinchToScaleDisabled - iff pinch to scale is temporarily disabled (e.g. if changing distance threshold)
+ * @property {{startX: number, startY: number}} - if not null, drag gesture turns into pinch gesture with these start coordinates
+ */
+
+/**
+ * @type {EditingState}
+ */
+realityEditor.device.editingState = {
+ object: null,
+ frame: null,
+ node: null,
+ touchOffset: null,
+ unconstrained: false,
+ initialCameraPosition: null,
+ startingMatrix: null,
+ startingTransform: null,
+ unconstrainedDisabled: false,
+ preDisabledUnconstrained: undefined,
+ pinchToScaleDisabled: false,
+ syntheticPinchInfo: null
+};
+
+/**
+ * Used to prevent duplicate pointermove events from triggering if the touch position didn't actually change
+ * @type {{x: number, y: number}|null}
+ */
+realityEditor.device.previousPointerMove = null;
+
+/**
+ * @type {CallbackHandler}
+ */
+realityEditor.device.callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('device/index');
+
+/**
+ * Adds a callback function that will be invoked when the specified function is called
+ * @param {string} functionName
+ * @param {function} callback
+ */
+realityEditor.device.registerCallback = function(functionName, callback) {
+ if (!this.callbackHandler) {
+ this.callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('device/index');
+ }
+ this.callbackHandler.registerCallback(functionName, callback);
+};
+
+/**
+ * Initialize the device module by registering callbacks to other modules
+ */
+realityEditor.device.initService = function() {
+
+ realityEditor.gui.buttons.registerCallbackForButton('gui', resetEditingOnButtonUp);
+ realityEditor.gui.buttons.registerCallbackForButton('logic', resetEditingOnButtonUp);
+ realityEditor.gui.buttons.registerCallbackForButton('setting', resetEditingOnButtonUp);
+
+ function resetEditingOnButtonUp(params) {
+ if (params.newButtonState === 'up') {
+ realityEditor.device.resetEditingState();
+ }
+ }
+};
+
+/**
+ * Sets the global editingMode and updates the svg overlay visibility for frames and nodes.
+ * @param {boolean} newEditingMode
+ */
+realityEditor.device.setEditingMode = function(newEditingMode) {
+ globalStates.editingMode = newEditingMode;
+
+ // also turn off unconstrained
+ if (!newEditingMode) {
+ globalStates.unconstrainedPositioning = false;
+ }
+
+ // TODO: how will svg overlays update when toggle between frames and nodes?
+
+ // var newDisplay = newEditingMode ? 'inline' : 'none';
+ realityEditor.forEachFrameInAllObjects(function(objectKey, frameKey) {
+ var svg = document.getElementById('svg' + frameKey);
+ if (svg && globalStates.guiState === "ui") { // don't show green outline for frames if in node view
+ // svg.style.display = newDisplay;
+ if (newEditingMode) {
+ svg.classList.add('visibleEditingSVG');
+ globalDOMCache[frameKey].querySelector('.corners').style.visibility = 'visible';
+ } else {
+ svg.classList.remove('visibleEditingSVG');
+ globalDOMCache[frameKey].querySelector('.corners').style.visibility = 'hidden';
+ }
+ }
+ realityEditor.forEachNodeInFrame(objectKey, frameKey, function(objectKey, frameKey, nodeKey) {
+ svg = document.getElementById('svg' + nodeKey);
+ if (svg) {
+ // svg.style.display = newDisplay;
+ if (newEditingMode) {
+ svg.classList.add('visibleEditingSVG');
+ globalDOMCache[nodeKey].querySelector('.corners').style.visibility = 'visible';
+ } else {
+ svg.classList.remove('visibleEditingSVG');
+ globalDOMCache[nodeKey].querySelector('.corners').style.visibility = 'hidden';
+ }
+ }
+ });
+ });
+
+ this.callbackHandler.triggerCallbacks('setEditingMode', {newEditingMode: newEditingMode});
+
+};
+
+/**
+ * Returns the frame or node that is currently being edited, if one exists.
+ * @return {Frame|Node|null}
+ */
+realityEditor.device.getEditingVehicle = function() {
+ return realityEditor.getVehicle(this.editingState.object, this.editingState.frame, this.editingState.node);
+};
+
+/**
+ * Returns true iff the vehicle is the active editing vehicle, and being unconstrained edited
+ * @param {Frame|Node} vehicle
+ * @return {boolean}
+ */
+realityEditor.device.isEditingUnconstrained = function(vehicle) {
+ if (vehicle === this.getEditingVehicle() && (realityEditor.device.editingState.unconstrained || globalStates.unconstrainedPositioning) && !realityEditor.device.editingState.unconstrainedDisabled) {
+ // staticCopy frames cannot be unconstrained edited
+ if (typeof vehicle.staticCopy !== 'undefined') {
+ if (vehicle.staticCopy) {
+ return false;
+ }
+ }
+ // only frames and logic nodes can be unconstrained edited
+ return realityEditor.gui.ar.positioning.isVehicleUnconstrainedEditable(vehicle);
+ }
+ return false;
+};
+
+/**
+ * Finds the closest frame to the camera and moves the pocket node from the pocketItem storage to that frame.
+ * @param {Logic} pocketNode
+ */
+realityEditor.device.addPocketNodeToClosestFrame = function(pocketNode) {
+
+ // find the closest frame
+ var closestKeys = realityEditor.gui.ar.getClosestFrame();
+ var closestObjectKey = closestKeys[0];
+ var closestFrameKey = closestKeys[1];
+
+ // TODO: look up why it can't equal 2... it might not be correct anymore
+ if (closestFrameKey && pocketNode.screenZ && pocketNode.screenZ !== 2) {
+
+ // update the pocket node with values from its new parent frame
+ pocketNode.objectId = closestObjectKey;
+ pocketNode.frameId = closestFrameKey;
+
+ // set the name of the node by counting how many logic nodes the frame already has
+ var closestFrame = realityEditor.getFrame(closestObjectKey, closestFrameKey);
+ var logicCount = Object.values(closestFrame.nodes).filter(function (node) {
+ return node.type === 'logic'
+ }).length;
+ pocketNode.name = "LOGIC" + logicCount;
+
+ // make sure that logic nodes only stick to 2.0 server version
+ if (realityEditor.network.testVersion(closestObjectKey) > 165) {
+
+ // add the node to that frame
+ closestFrame.nodes[pocketItemId] = pocketNode;
+
+ // post the new object/frame/node keys into the existing iframe
+ var pocketNodeIframe = document.getElementById("iframe" + pocketItemId);
+ if (pocketNodeIframe && pocketNodeIframe.loaded) {
+ realityEditor.network.onElementLoad(closestObjectKey, closestFrameKey, pocketItemId);
+ }
+
+ globalDOMCache[pocketItemId].objectId = closestObjectKey;
+ globalDOMCache[pocketItemId].frameId = closestFrameKey;
+
+ globalDOMCache['iframe' + pocketItemId].setAttribute("data-object-key", closestObjectKey);
+ globalDOMCache['iframe' + pocketItemId].setAttribute("data-frame-key", closestFrameKey);
+ globalDOMCache['iframe' + pocketItemId].setAttribute("onload", 'realityEditor.network.onElementLoad("' + closestObjectKey + '","' + closestFrameKey + '","' + pocketItemId + '")');
+
+ // post new object, frame, node, name into the logicNode iframe
+ realityEditor.network.onElementLoad(closestObjectKey, closestFrameKey, pocketItemId);
+
+ // upload it to the server
+ realityEditor.network.postNewLogicNode(objects[closestObjectKey].ip, closestObjectKey, closestFrameKey, pocketItemId, pocketNode);
+
+ // realityEditor.network.postNewNodeName(objects[closestObjectKey].ip, closestObjectKey, closestFrameKey, pocketItemId, pocketNode.name);
+ }
+
+ }
+
+ realityEditor.gui.ar.draw.hideTransformed(pocketItemId, pocketNode, globalDOMCache, cout);
+ delete pocketItem["pocket"].frames["pocket"].nodes[pocketItemId];
+};
+
+/**
+ * Don't post touches into the iframe if any are true:
+ * 1. we're in editing mode
+ * 2. we're dragging the current vehicle around, or
+ * 3. we're waiting for the touchEditing timer to either finish or be cleared by moving/releasing
+ * @return {boolean}
+ */
+realityEditor.device.shouldPostEventsIntoIframe = function() {
+ var editingVehicle = this.getEditingVehicle();
+ return !(globalStates.editingMode || editingVehicle /*|| this.touchEditingTimer */); // TODO: pointerup never gets posted if this last isnt commented out... was it doing anything?
+};
+
+/**
+ * Post a fake PointerEvent into the provided frame or node's iframe.
+ * @param {PointerEvent} event
+ * @param {string} frameKey
+ * @param {string|undefined} nodeKey
+ */
+realityEditor.device.postEventIntoIframe = function(event, frameKey, nodeKey) {
+ var iframe = document.getElementById('iframe' + (nodeKey || frameKey));
+ var newCoords = webkitConvertPointFromPageToNode(iframe, new WebKitPoint(event.pageX, event.pageY));
+ if (!newCoords) { return }
+
+ let projectedZ;
+ let worldIntersectPoint;
+ let threejsIntersectPoint;
+
+ if (!this.cachedWorldObject) {
+ this.cachedWorldObject = realityEditor.worldObjects.getBestWorldObject();
+ }
+
+ if (this.cachedWorldObject && !this.cachedOcclusionObject) {
+ this.cachedOcclusionObject = realityEditor.gui.threejsScene.getObjectForWorldRaycasts(this.cachedWorldObject.objectId);
+ if (this.cachedOcclusionObject) {
+ this.cachedOcclusionObject.updateMatrixWorld();
+ }
+ }
+
+ // if there's a ground plane or an area target mesh, compute the projectedZ, worldIntersectPoint, and threejsIntersectPoint
+ if ((this.cachedWorldObject && this.cachedOcclusionObject) || realityEditor.gui.threejsScene.isGroundPlanePositionSet()) {
+ let objectsToCheck = [];
+ if (this.cachedOcclusionObject) {
+ objectsToCheck.push(this.cachedOcclusionObject);
+ }
+ // pass correct coordinate into tools even if there's no world mesh, if we raycast against the groundplane
+ if (realityEditor.gui.threejsScene.isGroundPlanePositionSet()) {
+ objectsToCheck.push(realityEditor.gui.threejsScene.getGroundPlaneCollider().getInternalObject());
+ }
+
+ let raycastIntersects = realityEditor.gui.threejsScene.getRaycastIntersects(event.pageX, event.pageY, objectsToCheck);
+ if (raycastIntersects.length > 0) {
+ projectedZ = raycastIntersects[0].distance;
+
+ // multiply intersect, which is in ROOT coordinates, by the relative world matrix (ground plane) to ROOT
+ let inverseGroundPlaneMatrix = new realityEditor.gui.threejsScene.THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(inverseGroundPlaneMatrix, realityEditor.sceneGraph.getGroundPlaneModelViewMatrix())
+ inverseGroundPlaneMatrix.invert();
+ let intersect1 = raycastIntersects[0].scenePoint.clone().applyMatrix4(inverseGroundPlaneMatrix);
+
+ // transpose of the inverse of the ground-plane model-view matrix
+ let trInvGroundPlaneMat = inverseGroundPlaneMatrix.clone().transpose();
+
+ worldIntersectPoint = {
+ x: intersect1.x,
+ y: intersect1.y,
+ z: intersect1.z,
+ // NOTE: to transform a normal, you must multiply by the transpose of the inverse of the model-view matrix
+ normalVector: raycastIntersects[0].face.normal.clone().applyMatrix4(trInvGroundPlaneMat).normalize(),
+ // the ray direction is just a vector, so we don't need the transpose matrix
+ rayDirection: raycastIntersects[0].rayDirection.clone().applyMatrix4(inverseGroundPlaneMatrix).normalize()
+ };
+
+ // compared to worldIntersectPoint, threejsSceneIntersectPoint returns the intersect point in three js container object coordinates
+ realityEditor.gui.threejsScene.setMatrixFromArray(inverseGroundPlaneMatrix, realityEditor.sceneGraph.getGroundPlaneNode().worldMatrix)
+ inverseGroundPlaneMatrix.invert();
+ let intersect2 = raycastIntersects[0].scenePoint.clone().applyMatrix4(inverseGroundPlaneMatrix);
+
+ threejsIntersectPoint = {
+ x: intersect2.x,
+ y: intersect2.y,
+ z: intersect2.z,
+ };
+ }
+ }
+ let eventData = {
+ type: event.type,
+ pointerId: event.pointerId,
+ pointerType: event.pointerType,
+ button: event.button,
+ x: newCoords.x,
+ y: newCoords.y
+ }
+ if (typeof projectedZ !== 'undefined') {
+ eventData.projectedZ = projectedZ;
+ }
+ if (typeof worldIntersectPoint !== 'undefined') {
+ eventData.worldIntersectPoint = worldIntersectPoint;
+ }
+ if (typeof threejsIntersectPoint !== 'undefined') {
+ eventData.threejsIntersectPoint = threejsIntersectPoint;
+ }
+ iframe.contentWindow.postMessage(JSON.stringify({
+ event: eventData
+ }), '*');
+};
+
+/**
+ * Stop and reset the touchEditingTimer if it's in progress.
+ */
+realityEditor.device.clearTouchTimer = function() {
+ if (this.touchEditingTimer) {
+ clearTimeout(this.touchEditingTimer.timeoutFunction);
+ this.touchEditingTimer = null;
+ }
+};
+
+/**
+ * Reset all state related to the link being created.
+ */
+realityEditor.device.resetGlobalProgram = function() {
+ globalProgram.objectA = false;
+ globalProgram.frameA = false;
+ globalProgram.nodeA = false;
+ globalProgram.logicA = false;
+ globalProgram.objectB = false;
+ globalProgram.frameB = false;
+ globalProgram.nodeB = false;
+ globalProgram.logicB = false;
+ globalProgram.logicSelector = 4;
+};
+
+/**
+ * Reset full editing state so that no object is set as being edited.
+ */
+realityEditor.device.resetEditingState = function() {
+ this.sendEditingStateToFrameContents(this.editingState.frame, false); // TODO: move to a callback
+
+ // gets triggered before state gets reset, so that subscribed modules can respond based on what is about to be reset
+ this.callbackHandler.triggerCallbacks('resetEditingState');
+
+ // properly write the vehicle position to the server if it's been moved relative to another parent
+ if (this.getEditingVehicle() && this.isEditingUnconstrained(this.getEditingVehicle())) {
+ let activeVehicle = this.getEditingVehicle();
+ let vehicleParentId = realityEditor.isVehicleAFrame(activeVehicle) ? activeVehicle.objectId : activeVehicle.frameId;
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid);
+ if (sceneNode.parent && sceneNode.parent.id !== vehicleParentId) {
+ let parentId = realityEditor.isVehicleAFrame(activeVehicle) ? activeVehicle.objectId : activeVehicle.frameId;
+ realityEditor.sceneGraph.changeParent(sceneNode, parentId, true);
+ realityEditor.gui.ar.positioning.setPositionDataMatrix(this.getEditingVehicle(), sceneNode.localMatrix, false);
+ sceneNode.needsUploadToServer = true;
+ }
+ }
+
+ this.editingState.object = null;
+ this.editingState.frame = null;
+ this.editingState.node = null;
+ this.editingState.touchOffset = null;
+ this.editingState.unconstrained = false;
+ this.editingState.initialCameraPosition = null;
+ this.editingState.startingMatrix = null;
+ this.editingState.startingTransform = null;
+ this.editingState.syntheticPinchInfo = null;
+
+ this.previousPointerMove = null;
+
+ globalStates.inTransitionObject = null;
+ globalStates.inTransitionFrame = null;
+ pocketFrame.vehicle = null;
+
+ realityEditor.gui.ar.positioning.stopRepositioning();
+};
+
+/**
+ * Sets up the PointerEvent and TouchEvent listeners for the entire document.
+ * (now includes events that used to take effect on the background canvas)
+ */
+realityEditor.device.addDocumentTouchListeners = function() {
+ document.addEventListener('pointerdown', this.onDocumentPointerDown.bind(this));
+ document.addEventListener('pointermove', this.onDocumentPointerMove.bind(this));
+ document.addEventListener('pointerup', this.onDocumentPointerUp.bind(this));
+
+ if (realityEditor.device.environment.requiresMouseEvents()) {
+ document.addEventListener('mousedown', this.onDocumentMultiTouchStart.bind(this));
+ document.addEventListener('mousemove', this.onDocumentMultiTouchMove.bind(this));
+ document.addEventListener('mouseup', this.onDocumentMultiTouchEnd.bind(this));
+ // document.addEventListener('touchcancel', this.onDocumentMultiTouchEnd.bind(this));
+ } else {
+ document.addEventListener('touchstart', this.onDocumentMultiTouchStart.bind(this));
+ document.addEventListener('touchmove', this.onDocumentMultiTouchMove.bind(this));
+ document.addEventListener('touchend', this.onDocumentMultiTouchEnd.bind(this));
+ document.addEventListener('touchcancel', this.onDocumentMultiTouchEnd.bind(this));
+ }
+};
+
+/**
+ * Sets up PointerEvent and TouchEvent listeners for the provided frame or node's DOM element.
+ * @param {HTMLElement} overlayDomElement
+ * @param {Frame|Node} activeVehicle
+ */
+realityEditor.device.addTouchListenersForElement = function(overlayDomElement, activeVehicle) {
+
+ // use PointerEvents for movement events except for dragging
+ overlayDomElement.addEventListener('pointerdown', this.onElementTouchDown.bind(this));
+ overlayDomElement.addEventListener('pointermove', this.onElementTouchMove.bind(this));
+ overlayDomElement.addEventListener('pointerup', this.onElementTouchUp.bind(this));
+ overlayDomElement.addEventListener('gotpointercapture', function(evt) {
+ evt.target.releasePointerCapture(evt.pointerId);
+ });
+
+ if (realityEditor.device.environment.requiresMouseEvents()) {
+ // use TouchEvents for dragging because it keeps its original target even if you leave the bounds of the target
+ overlayDomElement.addEventListener('mouseup', this.onElementMultiTouchEnd.bind(this));
+ // overlayDomElement.addEventListener('touchcancel', this.onElementMultiTouchEnd.bind(this));
+ } else {
+ // use TouchEvents for dragging because it keeps its original target even if you leave the bounds of the target
+ overlayDomElement.addEventListener('touchend', this.onElementMultiTouchEnd.bind(this));
+ overlayDomElement.addEventListener('touchcancel', this.onElementMultiTouchEnd.bind(this));
+ }
+
+ // give enter and leave events to nodes for when you draw links between them
+ if (activeVehicle.type !== 'ui') {
+ overlayDomElement.addEventListener('pointerenter', this.onElementTouchEnter.bind(this));
+ overlayDomElement.addEventListener('pointerout', this.onElementTouchOut.bind(this));
+ }
+};
+
+/**
+ * Set the specified frame or node as the editingMode target and update the UI.
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string|undefined} nodeKey
+ */
+realityEditor.device.beginTouchEditing = function(objectKey, frameKey, nodeKey) {
+
+ var activeVehicle = realityEditor.getVehicle(objectKey, frameKey, nodeKey);
+
+ // if you're already editing another object (or can't find this one) don't let you start editing this one
+ if (this.editingState.object || !activeVehicle) { return; }
+
+ this.editingState.object = objectKey;
+ this.editingState.frame = frameKey;
+
+ if (globalStates.guiState === "node") {
+ this.editingState.node = nodeKey;
+
+ // reset link creation state
+ this.resetGlobalProgram();
+
+ // show the trash and pocket
+ if (activeVehicle.type === "logic") {
+ realityEditor.gui.menus.switchToMenu("trashOrSave"); // TODO: use this to enable logic node pocket again
+ // realityEditor.gui.menus.switchToMenu("bigTrash");
+
+ }
+
+ } else if (globalStates.guiState === "ui") {
+
+ if (activeVehicle.location === "global") {
+ // show the trash if this is a reusable frame
+ realityEditor.gui.menus.switchToMenu("bigTrash");
+ }
+
+ }
+
+ var activeObject = realityEditor.getObject(this.editingState.object);
+ if (activeObject.isWorldObject) {
+ // check if only world objects are visible
+ // one way to do this is to get the closest object and see it it's a world object
+ var closestObject = realityEditor.getObject(realityEditor.gui.ar.getClosestObject()[0]);
+ if (closestObject.isWorldObject) {
+ globalStates.inTransitionObject = objectKey;
+ globalStates.inTransitionFrame = frameKey;
+ }
+ }
+
+ realityEditor.gui.ar.draw.matrix.copyStillFromMatrixSwitch = true;
+ // store this so we can undo the move if needed (e.g. image target disappears)
+ realityEditor.device.editingState.startingMatrix = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid).localMatrix;
+ realityEditor.device.editingState.startingTransform = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid).getTransformMatrix();
+
+ globalDOMCache[(nodeKey || frameKey)].querySelector('.corners').style.visibility = 'visible';
+
+ this.sendEditingStateToFrameContents(frameKey, true);
+
+ this.callbackHandler.triggerCallbacks('beginTouchEditing');
+};
+
+/**
+ * post beginTouchEditing and endTouchEditing event into frame so that 3d object can highlight to show that it's being moved
+ * @param frameKey
+ * @param frameIsMoving
+ */
+realityEditor.device.sendEditingStateToFrameContents = function(frameKey, frameIsMoving) {
+ if (!frameKey) return;
+ var iframe = document.getElementById('iframe' + frameKey);
+ if (!iframe) return;
+
+ iframe.contentWindow.postMessage(JSON.stringify({
+ frameIsMoving: frameIsMoving
+ }), '*');
+};
+
+/**
+ * Stop disabling unconstrained mode (gets disabled when you are changing the distance visibility threshold)
+ */
+realityEditor.device.enableUnconstrained = function() {
+
+ // only do this once, otherwise it will undo the effects of saving the previous value
+ if (this.editingState.unconstrainedDisabled) {
+ if (typeof this.editingState.preDisabledUnconstrained !== "undefined") {
+ this.editingState.unconstrained = this.editingState.preDisabledUnconstrained;
+ delete this.editingState.preDisabledUnconstrained; // get only works once per set
+ } else {
+ this.editingState.unconstrained = false;
+ }
+ }
+ this.editingState.unconstrainedDisabled = false;
+ this.editingState.initialCameraPosition = null;
+};
+
+/**
+ * Disable unconstrained editing mode so that the frame/node doesn't move when you pull the phone away from it
+ * (Useful when you want to adjust the distance visibility threshold of the frame by walking away from it)
+ */
+realityEditor.device.disableUnconstrained = function() {
+ this.editingState.unconstrainedDisabled = true;
+ this.editingState.preDisabledUnconstrained = this.editingState.unconstrained;
+ this.editingState.unconstrained = false;
+};
+
+/**
+ * Re-enable pinch to scale (gets disabled when you are changing the distance visibility threshold)
+ */
+realityEditor.device.enablePinchToScale = function() {
+ this.editingState.pinchToScaleDisabled = false;
+};
+
+/**
+ * Disable pinch to scale
+ * @todo: is this necessary anymore? This was added because we added a new 3-finger pinch gesture to adjust distance visibility threshold, but we removed that pinch gesture now so it might be ok for this to always be enabled?
+ */
+realityEditor.device.disablePinchToScale = function() {
+ this.editingState.pinchToScaleDisabled = true;
+};
+
+/**
+ * This system allows any number of modules to mark with a flag that they're
+ * currently claiming the pointer events for a camera control mode.
+ * @type {Set}
+ */
+realityEditor.device.manualCameraControlFlags = new Set();
+/**
+ * @param {string} flagName - the name of the module/reason that the pointer is claimed by
+ */
+realityEditor.device.setFlagForPointerOccupiedByCamera = function(flagName) {
+ realityEditor.device.manualCameraControlFlags.add(flagName);
+};
+/**
+ * @param {string} flagName - provide the same name used in setFlagForPointerOccupiedByCamera, to release the pointer
+ */
+realityEditor.device.clearFlagForPointerOccupiedByCamera = function(flagName) {
+ realityEditor.device.manualCameraControlFlags.delete(flagName);
+};
+/**
+ * @return {boolean}
+ */
+realityEditor.device.isPointerOccupiedByCameraControl = function() {
+ return realityEditor.device.manualCameraControlFlags.size > 0;
+};
+
+/**
+ * @return {boolean} If the event is intended to control the camera and not the
+ * AR elements, avatar pointer beams, or other pointer interactions
+ */
+realityEditor.device.isMouseEventCameraControl = function(event) {
+ // first check if anything is manually taking claim over the pointer events
+ if (realityEditor.device.isPointerOccupiedByCameraControl()) {
+ return true;
+ }
+ // If mouse events are enabled ignore right clicks and middle clicks
+ // otherwise the pointer is presumed to not be being used for camera controls
+ return realityEditor.device.environment.requiresMouseEvents() &&
+ (event.button === 2 || event.button === 1);
+};
+
+/**
+ * Begin the touchTimer to enable editing mode if the user doesn't move too much before it finishes.
+ * Also set point A of the globalProgram so we can start creating a link if this is a node.
+ * @param {PointerEvent} event
+ */
+realityEditor.device.onElementTouchDown = function(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) {
+ return;
+ }
+
+ var target = event.currentTarget;
+ var activeVehicle = realityEditor.getVehicle(target.objectId, target.frameId, target.nodeId);
+
+ // how long it takes to move the element:
+ // instant if editing mode on, 400ms if not (or touchMoveDelay if specially configured for that element)
+ var moveDelay = this.defaultMoveDelay;
+ // take a lot longer to move nodes, otherwise it's hard to draw links
+ if (globalStates.guiState === "node") {
+ moveDelay = this.defaultMoveDelay * 3;
+ }
+ if (globalStates.editingMode) {
+ moveDelay = 0;
+ } else if (activeVehicle.moveDelay) {
+ moveDelay = activeVehicle.moveDelay; // This gets set from the JavaScript API
+ }
+
+ // set point A of the link you are starting to create
+ if (globalStates.guiState === "node" && !globalProgram.objectA) {
+ globalProgram.objectA = target.objectId;
+ globalProgram.frameA = target.frameId;
+ globalProgram.nodeA = target.nodeId;
+ globalProgram.logicA = activeVehicle.type === "logic" ? 0 : false;
+ }
+
+ // Post event into iframe
+ if (this.shouldPostEventsIntoIframe()) {
+ this.postEventIntoIframe(event, target.frameId, target.nodeId);
+ }
+
+ // after a certain amount of time, start editing this element
+ if (moveDelay >= 0) {
+ var timeoutFunction = setTimeout(function () {
+
+ var touchPosition = realityEditor.gui.ar.positioning.getMostRecentTouchPosition();
+
+ // send a pointercancel event into the frame so it doesn't get stuck thinking you're clicking in it
+ var syntheticPointerCancelEvent = {
+ pageX: touchPosition.x || 0,
+ pageY: touchPosition.y || 0,
+ type: 'pointercancel',
+ pointerId: event.pointerId,
+ pointerType: event.pointerType
+ };
+ realityEditor.device.postEventIntoIframe(syntheticPointerCancelEvent, target.frameId, target.nodeId);
+
+ realityEditor.device.beginTouchEditing(target.objectId, target.frameId, target.nodeId);
+ }, moveDelay);
+ }
+
+ this.touchEditingTimer = {
+ startX: event.pageX,
+ startY: event.pageY,
+ moveToleranceSquared: (activeVehicle.type === "logic" ? 900 : 100), // make logic nodes easier to move
+ timeoutFunction: timeoutFunction
+ };
+
+ this.previousPointerMove = {x: event.pageX, y: event.pageY};
+
+ cout("onElementTouchDown");
+};
+
+// Tracks pointer move events with no buttons pressed to limit their frequency
+realityEditor.device.moveLiftedLast = 0;
+realityEditor.device.moveLiftedMsLimit = 100;
+
+/**
+ * When touch move that originated on a frame or node, do any of the following:
+ * 1. show visual feedback if you move over the trash
+ * 2. if move more than a certain threshold, cancel touchTimer
+ * @param {PointerEvent} event
+ */
+realityEditor.device.onElementTouchMove = function(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) {
+ return;
+ }
+ if (event.button === -1) {
+ if (Date.now() - realityEditor.device.moveLiftedLast < realityEditor.device.moveLiftedMsLimit) {
+ return;
+ }
+ realityEditor.device.moveLiftedLast = Date.now();
+ }
+
+ if (this.previousPointerMove && this.previousPointerMove.x === event.pageX && this.previousPointerMove.y === event.pageY) {
+ return; // ensure that we ignore supposed "move" events if position didn't change
+ }
+
+ var target = event.currentTarget;
+
+ // cancel the touch hold timer if you move more than a negligible amount
+ if (this.touchEditingTimer) {
+
+ var dx = event.pageX - this.touchEditingTimer.startX;
+ var dy = event.pageY - this.touchEditingTimer.startY;
+ if (dx * dx + dy * dy > this.touchEditingTimer.moveToleranceSquared) {
+ this.clearTouchTimer();
+ }
+
+ }
+
+ if (this.shouldPostEventsIntoIframe()) {
+ this.postEventIntoIframe(event, target.frameId, target.nodeId);
+ }
+
+ this.previousPointerMove = {x: event.pageX, y: event.pageY};
+
+ cout("onElementTouchMove");
+};
+
+
+/**
+ * When touch enters a node that didn't originate in it,
+ * Show visual feedback based on whether you are allowed to create a link to this new node
+ * @param {PointerEvent} event
+ */
+realityEditor.device.onElementTouchEnter = function(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) {
+ return;
+ }
+
+ var target = event.currentTarget;
+
+ // show visual feedback for nodes unless you are dragging something around
+ if (target.type !== "ui" && !this.getEditingVehicle()) {
+ var contentForFeedback;
+
+ // if exactly one of objectA and objectB is the localWorldObject of the phone, prevent the link from being made
+ var localWorldObjectKey = realityEditor.worldObjects.getLocalWorldId();
+ var isBetweenLocalWorldAndOtherServer = (globalProgram.objectA === localWorldObjectKey && target.objectId !== localWorldObjectKey) ||
+ (globalProgram.objectA !== localWorldObjectKey && target.objectId === localWorldObjectKey);
+
+ // when over the same node you started with
+ if (globalProgram.nodeA === target.nodeId || globalProgram.nodeA === false) {
+ contentForFeedback = 3; // TODO: replace ints with a human-readable enum/encoding
+ overlayDiv.classList.add('overlayAction');
+
+ } else if (realityEditor.network.checkForNetworkLoop(globalProgram.objectA, globalProgram.frameA, globalProgram.nodeA, globalProgram.logicA, target.objectId, target.frameId, target.nodeId, 0) && !isBetweenLocalWorldAndOtherServer) {
+ contentForFeedback = 2;
+ overlayDiv.classList.add('overlayPositive');
+
+ } else {
+ contentForFeedback = 0;
+ overlayDiv.classList.add('overlayNegative');
+ }
+
+ if (globalDOMCache["iframe" + target.nodeId]) {
+ globalDOMCache["iframe" + target.nodeId].contentWindow.postMessage(
+ JSON.stringify( { uiActionFeedback: contentForFeedback }) , "*");
+ }
+ }
+
+ cout("onElementTouchEnter");
+};
+
+/**
+ * When touch leaves a node,
+ * Stop the touchTimer and reset the visual feedback for that node
+ * @param {PointerEvent} event
+ */
+realityEditor.device.onElementTouchOut = function(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) {
+ return;
+ }
+
+ var target = event.currentTarget;
+ if (target.type !== "ui") {
+
+ // stop node hold timer // TODO: handle node move same as frame by calculating dist^2 > threshold
+ this.clearTouchTimer();
+
+ // if (this.editingState.node) {
+ // realityEditor.gui.menus.buttonOn([]); // endTrash // TODO: need a new method to end trash programmatically ???
+ // }
+
+ globalProgram.logicSelector = 4; // 4 means default link (not one of the colored ports)
+
+ // reset touch overlay
+ overlayDiv.classList.remove('overlayPositive');
+ overlayDiv.classList.remove('overlayNegative');
+ overlayDiv.classList.remove('overlayAction');
+
+ if (globalDOMCache["iframe" + target.nodeId]) {
+ globalDOMCache["iframe" + target.nodeId].contentWindow.postMessage(
+ JSON.stringify( { uiActionFeedback: 1 }) , "*");
+ }
+ }
+
+ cout("onElementTouchOut");
+};
+
+/**
+ * When touch up on a frame or node, do any of the following if necessary:
+ * 1. Open the crafting board
+ * 2. Create and upload a new link
+ * 3. Reset various editingMode state
+ * 4. Delete logic node dragged into trash
+ * 5. delete resuable frame dragged onto trash
+ * @param {PointerEvent} event
+ */
+realityEditor.device.onElementTouchUp = function(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) {
+ return;
+ }
+
+ const target = event.currentTarget;
+
+ if (this.shouldPostEventsIntoIframe()) {
+ this.postEventIntoIframe(event, target.frameId, target.nodeId);
+
+ if (!target.nodeId) {
+ this.toolInteractionCallbacks.forEach(function(callback) {
+ callback(target.objectId, target.frameId, 'touchUp');
+ });
+ }
+ }
+
+ // var didDisplayCrafting = false;
+ if (globalStates.guiState === "node") {
+
+ if (globalProgram.objectA) {
+
+ // open the crafting board if you tapped on a logic node
+ if (target.nodeId === globalProgram.nodeA && target.type === "logic" && !globalStates.editingMode && !this.getEditingVehicle()) {
+ realityEditor.gui.crafting.craftingBoardVisible(target.objectId, target.frameId, target.nodeId);
+ // didDisplayCrafting = true;
+ }
+
+ globalProgram.objectB = target.objectId;
+ globalProgram.frameB = target.frameId;
+ globalProgram.nodeB = target.nodeId;
+
+ if (target.type !== "logic") {
+ globalProgram.logicB = false;
+ }
+
+ realityEditor.network.postLinkToServer(globalProgram);
+
+ this.resetGlobalProgram();
+
+ }
+
+ }
+
+ // force the canvas to re-render
+ globalCanvas.hasContent = true;
+
+ cout("onElementTouchUp");
+};
+
+/**
+ * Once a frame has been decided to be deleted, this fully deletes it
+ * removing links to and from it, removing it from the DOM and objects data structure, and clearing related state
+ * @param {Frame} frameToDelete
+ * @param {string} objectKeyToDelete
+ * @param {string} frameKeyToDelete
+ */
+realityEditor.device.deleteFrame = function(frameToDelete, objectKeyToDelete, frameKeyToDelete) {
+
+ // delete links to and from the frame
+ realityEditor.forEachFrameInAllObjects(function(objectKey, frameKey) {
+ var thisFrame = realityEditor.getFrame(objectKey, frameKey);
+ Object.keys(thisFrame.links).forEach(function(linkKey) {
+ var thisLink = thisFrame.links[linkKey];
+ if (((thisLink.objectA === objectKeyToDelete) && (thisLink.frameA === frameKeyToDelete)) ||
+ ((thisLink.objectB === objectKeyToDelete) && (thisLink.frameB === frameKeyToDelete))) {
+ delete thisFrame.links[linkKey];
+ realityEditor.network.deleteLinkFromObject(objects[objectKey].ip, objectKey, frameKey, linkKey);
+ }
+ });
+ });
+
+ // remove it from the DOM
+ realityEditor.gui.ar.draw.killElement(frameKeyToDelete, frameToDelete, globalDOMCache);
+ // delete it from the server
+ realityEditor.network.deleteFrameFromObject(objects[objectKeyToDelete].ip, objectKeyToDelete, frameKeyToDelete);
+
+ globalStates.inTransitionObject = null;
+ globalStates.inTransitionFrame = null;
+
+ delete objects[objectKeyToDelete].frames[frameKeyToDelete];
+};
+
+/**
+ * 1. update the counter to keep track of how many touches are on the screen right now
+ * 2. upload new position data to server
+ * 3. drop inTransition frame onto closest object
+ * @param {TouchEvent} event
+ */
+realityEditor.device.onElementMultiTouchEnd = function(event) {
+
+ var activeVehicle = this.getEditingVehicle();
+
+ var isOverTrash = false;
+ if (this.isPointerInTrashZone(event.pageX, event.pageY)) {
+ if (globalStates.guiState === "ui" && activeVehicle && activeVehicle.location === "global") {
+ isOverTrash = true;
+ } else if (activeVehicle && activeVehicle.type === "logic") {
+ isOverTrash = true;
+ }
+ }
+
+ if (isOverTrash) return;
+
+ if (activeVehicle && !isOverTrash) {
+ var ignoreMatrix = !(this.editingState.unconstrained || globalStates.unconstrainedPositioning);
+ realityEditor.network.postVehiclePosition(activeVehicle, ignoreMatrix);
+ }
+
+ // drop frame onto closest object if we have pulled one away from a previous object
+ if (globalStates.inTransitionObject && globalStates.inTransitionFrame) {
+
+ // allow scaling with multiple fingers without dropping the frame in motion
+ var touchesOnActiveVehicle = this.currentScreenTouches.map(function(elt){ return elt.targetId; }).filter(function(touchTarget) {
+ return (touchTarget === this.editingState.frame || touchTarget === this.editingState.node || touchTarget === "pocket-element");
+ }.bind(this));
+ if (touchesOnActiveVehicle.length > 1) {
+ return;
+ }
+
+ var frameBeingMoved = realityEditor.getFrame(globalStates.inTransitionObject, globalStates.inTransitionFrame);
+
+ var closestObjectKey = realityEditor.network.availableFrames.getBestObjectInfoForFrame(frameBeingMoved.src);
+
+ // TODO: when moving a frame from an object to the world, that the world doesn't support... you shouldnt be able to do that... right now it breaks
+ // var closestObjectKey = realityEditor.gui.ar.getClosestObject()[0];
+
+ if (closestObjectKey) {
+
+ if (closestObjectKey !== globalStates.inTransitionObject) {
+ console.log('there is an object to drop this frame onto');
+
+ var newFrameKey = closestObjectKey + frameBeingMoved.name;
+
+ realityEditor.gui.ar.draw.moveTransitionFrameToObject(globalStates.inTransitionObject, globalStates.inTransitionFrame, closestObjectKey, newFrameKey);
+
+ var newFrame = realityEditor.getFrame(closestObjectKey, newFrameKey);
+ realityEditor.network.postVehiclePosition(newFrame);
+ }
+
+ } else {
+
+ console.log('there are no visible objects - return this frame to its previous object');
+ realityEditor.gui.ar.draw.returnTransitionFrameBackToSource();
+
+ }
+ }
+
+};
+
+/**
+ * Show the touch overlay, and start drawing the dot line to cut links (in node guiState)
+ * @param {PointerEvent} event
+ */
+realityEditor.device.onDocumentPointerDown = function(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) {
+ return;
+ }
+
+ globalStates.pointerPosition = [event.clientX, event.clientY];
+
+ if (realityEditor.device.utilities.isEventHittingBackground(event)) {
+
+ if (globalStates.guiState === "node" && !globalStates.editingMode) {
+
+ if (!globalProgram.objectA) {
+ globalStates.drawDotLine = true;
+ globalStates.drawDotLineX = event.clientX;
+ globalStates.drawDotLineY = event.clientY;
+ }
+ }
+
+ }
+
+ cout("onDocumentPointerDown");
+};
+
+// TODO: add in functionality from onMultiTouchCanvasMove to onDocumentPointerMove
+// TODO: 1. reposition frame that was just pulled out of a screen
+
+// TODO: position the pocket nodes the same way that we position pocket frames?
+/**
+ * Move the touch overlay and move the pocket node if one is being dragged in.
+ * @param {PointerEvent} event
+ */
+realityEditor.device.onDocumentPointerMove = function(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) {
+ return;
+ }
+
+ event.preventDefault(); //TODO: why is this here but not in other document events?
+
+ globalStates.pointerPosition = [event.clientX, event.clientY];
+
+ // if we are dragging a node in using the pocket, moves that element to this position
+ realityEditor.gui.pocket.setPocketPosition(event);
+
+ cout("onDocumentPointerMove");
+};
+
+/**
+ * When touch up anywhere, do any of the following if necessary:
+ * 1. Add the pocket node to the closest frame
+ * 2. Stop drawing link
+ * 3. Delete links crossed by dot line
+ * 4. Hide touch overlay, reset menu, and clear memory
+ * @param {PointerEvent} event
+ */
+realityEditor.device.onDocumentPointerUp = function(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) {
+ return;
+ }
+
+ // add the pocket node to the closest frame
+ if (realityEditor.gui.buttons.getButtonState('pocket') === 'down') {
+
+ // hide the pocket node
+ realityEditor.gui.ar.draw.setObjectVisible(pocketItem["pocket"], false);
+
+ var pocketNode = pocketItem["pocket"].frames["pocket"].nodes[pocketItemId];
+ if (pocketNode) {
+ this.addPocketNodeToClosestFrame(pocketNode);
+ }
+ }
+
+ if (globalStates.guiState === "node") {
+
+ // stop drawing current link
+ this.resetGlobalProgram();
+
+ // delete links
+ if (globalStates.drawDotLine) {
+ realityEditor.gui.ar.lines.deleteLines(globalStates.drawDotLineX, globalStates.drawDotLineY, event.clientX, event.clientY);
+ globalStates.drawDotLine = false;
+ }
+ }
+
+ // if over the trash icon we need to delete it, but this is handled in onElementTouchUp
+ // which wont naturally trigger if we just added the element from the pocket
+ if (pocketFrame.vehicle) {
+ var syntheticPointerEvent = {
+ pageX: event.pageX || 0,
+ pageY: event.pageY || 0,
+ type: 'pointerup',
+ pointerId: event.pointerId,
+ pointerType: event.pointerType,
+ currentTarget: globalDOMCache[pocketFrame.vehicle.uuid]
+ };
+ realityEditor.device.onElementTouchUp(syntheticPointerEvent);
+ }
+
+ // delete the tool if you are over a defined trash zone
+ if (this.editingState.frame && this.isPointerInTrashZone(event.pageX, event.pageY)) {
+ this.tryToDeleteSelectedVehicle();
+ }
+
+ // clear state that may have been set during a touchdown or touchmove event
+ this.clearTouchTimer();
+ realityEditor.gui.ar.positioning.initialScaleData = null;
+
+ // force redraw the background canvas to remove links
+ globalCanvas.hasContent = true;
+
+ // hide and reset the overlay divs
+ [overlayDiv, overlayDiv2].forEach(overlay => {
+ overlay.style.display = "none";
+ overlay.classList.remove('overlayMemory');
+ overlay.classList.remove('overlayLogicNode');
+ overlay.classList.remove('overlayAction');
+ overlay.classList.remove('overlayPositive');
+ overlay.classList.remove('overlayNegative');
+ overlay.classList.remove('overlayScreenFrame');
+ overlay.innerHTML = '';
+ });
+
+ // if not in crafting board, reset menu back to main
+ if (globalStates.guiState !== "logic" && this.currentScreenTouches.length === 1) {
+ var didDisplayGroundplane = realityEditor.gui.settings.toggleStates.visualizeGroundPlane;
+ if (didDisplayGroundplane) {
+ realityEditor.gui.menus.switchToMenu('groundPlane');
+ } else {
+ realityEditor.gui.menus.switchToMenu('main');
+ }
+ }
+
+ // clear the memory being saved in the touch overlay
+ if (overlayDiv.style.backgroundImage !== '' && overlayDiv.style.backgroundImage !== 'none') {
+ overlayDiv.style.backgroundImage = 'none';
+ realityEditor.app.appFunctionCall("clearMemory");
+ }
+
+ cout("onDocumentPointerUp");
+};
+
+realityEditor.device.isPointerInTrashZone = function(x, y) {
+ let customTrashZone = realityEditor.device.layout.getCustomTrashZone();
+ if (customTrashZone) {
+ return (x > customTrashZone.x && x < (customTrashZone.x + customTrashZone.width) &&
+ y > customTrashZone.y && y < (customTrashZone.y + customTrashZone.height));
+ } else {
+ return x > realityEditor.device.layout.getTrashThresholdX(); // by default, just uses right edge of screen
+ }
+};
+
+/**
+ * By default, we can exclude the specifiedVehicle and it will try to delete the editingVehicle,
+ * but you can pass in a specific vehicle if you want to delete that one
+ * @param {Frame|Node} specifiedVehicle
+ */
+realityEditor.device.tryToDeleteSelectedVehicle = function(specifiedVehicle) {
+ let activeVehicle = specifiedVehicle || this.getEditingVehicle();
+ if (!activeVehicle) return;
+
+ const isFrame = realityEditor.isVehicleAFrame(activeVehicle);
+ const additionalInfo = isFrame ? { frameType: activeVehicle.src } : {};
+ const objectId = activeVehicle.objectId;
+ const frameId = isFrame ? activeVehicle.uuid : activeVehicle.frameId;
+ const nodeId = (isFrame) ? null : activeVehicle.uuid;
+ let didDelete = false;
+
+ if (isFrame && activeVehicle.location === 'global') {
+ // delete frame after a slight delay so that DOM changes don't mess with touch event propagation
+ setTimeout(function() {
+ realityEditor.device.deleteFrame(activeVehicle, objectId, frameId);
+ }, 10);
+ didDelete = true;
+ }
+
+ if (nodeId && activeVehicle.type === 'logic') {
+ // delete links to and from the node
+ realityEditor.forEachFrameInAllObjects(function(objectKey, frameKey) {
+ let thisFrame = realityEditor.getFrame(objectKey, frameKey);
+ Object.keys(thisFrame.links).forEach(linkKey => {
+ let thisLink = thisFrame.links[linkKey];
+ if (((thisLink.objectA === objectId) && (thisLink.frameA === frameId) && (thisLink.nodeA === nodeId)) ||
+ ((thisLink.objectB === objectId) && (thisLink.frameB === frameId) && (thisLink.nodeB === nodeId))) {
+ delete thisFrame.links[linkKey];
+ realityEditor.network.deleteLinkFromObject(objects[objectKey].ip, objectKey, frameKey, linkKey);
+ }
+ });
+ });
+ // delete node after a slight delay so DOM changes don't mess with touch event propagation
+ setTimeout(() => {
+ realityEditor.gui.ar.draw.deleteNode(objectId, frameId, nodeId);
+ realityEditor.network.deleteNodeFromObject(objects[objectId].ip, objectId, frameId, nodeId);
+ }, 10);
+ didDelete = true;
+ }
+
+ if (!didDelete) return;
+
+ this.resetEditingState();
+ this.callbackHandler.triggerCallbacks('vehicleDeleted', {
+ objectKey: objectId,
+ frameKey: frameId,
+ nodeKey: nodeId,
+ additionalInfo: additionalInfo
+ });
+};
+
+/**
+ * Converts MouseEvents from a desktop screen to one touch in a multi-touch data structure (TouchEvents),
+ * so that they can be handled by the same functions that expect multi-touch
+ * @param {MouseEvent} event
+ */
+function modifyTouchEventIfDesktop(event) {
+ if (realityEditor.device.environment.requiresMouseEvents()) {
+ event.touches = [];
+ event.touches[0] = {
+ altitudeAngle: 0,
+ azimuthAngle: 0,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ force: 0,
+ identifier: event.timeStamp,
+ pageX: event.pageX,
+ pageY: event.pageY,
+ radiusX: 20,
+ radiusY: 20,
+ rotationAngle: 0,
+ screenX: event.screenX,
+ screenY: event.screenY,
+ target: event.target,
+ touchType: 'direct'
+ };
+ }
+}
+
+/**
+ * Exposes all touchstart events to the touchInputs module for additional functionality (e.g. screens).
+ * Also keeps track of how many touches are down on the screen right now.
+ * if its down on the background create a memory (in ui guiState)
+ * @param {TouchEvent} event
+ */
+realityEditor.device.onDocumentMultiTouchStart = function (event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) {
+ return;
+ }
+
+ if (typeof event.touches !== 'undefined') {
+ if (event.touches.length === 1) {
+ overlayDiv.style.display = 'inline';
+ overlayDiv.style.transform = `translate3d(${event.touches[0].clientX}px, ${event.touches[0].clientY}px, 1200px)`;
+ } else if (event.touches.length === 2) {
+ overlayDiv2.style.display = 'inline';
+ overlayDiv2.style.transform = `translate3d(${event.touches[1].clientX}px, ${event.touches[1].clientY}px, 1200px)`;
+ }
+ } else {
+ overlayDiv.style.display = 'inline';
+ overlayDiv.style.transform = `translate3d(${event.clientX}px, ${event.clientY}px, 1200px)`;
+ }
+
+ modifyTouchEventIfDesktop(event);
+
+ realityEditor.device.touchEventObject(event, "touchstart", realityEditor.device.touchInputs.screenTouchStart);
+ cout("onDocumentMultiTouchStart");
+
+ Array.from(event.touches).forEach(function(touch) {
+ if (realityEditor.device.currentScreenTouches.map(function(elt) { return elt.identifier; }).indexOf(touch.identifier) === -1) {
+ realityEditor.device.currentScreenTouches.push({
+ targetId: realityEditor.device.utilities.getVehicleIdFromTargetId(touch.target.id), //touch.target.id.replace(/^(svg)/,""),
+ identifier: touch.identifier,
+ position: {
+ x: touch.pageX,
+ y: touch.pageY
+ }
+ });
+ }
+ });
+
+ // If the event is hitting the background and it isn't the multi-touch to scale an object
+ if (realityEditor.device.utilities.isEventHittingBackground(event)) {
+ if (event.touches.length < 2) {
+ var didTouchScreen = this.checkIfTouchWithinScreenBounds(event.pageX, event.pageY);
+
+ if (!didTouchScreen && realityEditor.gui.memory.memoryCanCreate()) { // && window.innerWidth - event.clientX > 65) {
+
+ if (!realityEditor.gui.settings.toggleStates.groupingEnabled) {
+
+ // try only doing it for double taps now....
+ if (!this.isDoubleTap) { // on first tap
+ this.isDoubleTap = true;
+ // if no follow up tap within time reset
+ setTimeout(function() {
+ this.isDoubleTap = false;
+ }.bind(this), 300);
+ } else { // registered double tap and create memory
+ if (realityEditor.device.environment.variables.supportsMemoryCreation) {
+ realityEditor.gui.menus.switchToMenu("bigPocket");
+ realityEditor.gui.memory.createMemory();
+ }
+ }
+
+ }
+
+ }
+ }
+ }
+
+ this.callbackHandler.triggerCallbacks('onDocumentMultiTouchStart', {event: event});
+};
+
+/**
+ * 1. Exposes all touchmove events to the touchInputs module for additional functionality (e.g. screens).
+ * 2. If there is an active editingMode target, drag it when one finger moves on canvas, or scale when two fingers.
+ * @param {TouchEvent} event
+ */
+realityEditor.device.onDocumentMultiTouchMove = function (event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) {
+ return;
+ }
+ modifyTouchEventIfDesktop(event);
+
+ // if it's a mouse event, move the first touch overlay div
+ if (typeof event.touches === 'undefined') {
+ overlayDiv.style.transform = 'translate3d(' + event.pageX + 'px,' + event.pageY + 'px, 1200px)';
+ }
+
+ realityEditor.device.touchEventObject(event, "touchmove", realityEditor.device.touchInputs.screenTouchMove);
+ cout("onDocumentMultiTouchMove");
+
+ Array.from(event.touches).forEach(function(touch, index) {
+ realityEditor.device.currentScreenTouches.filter(function(currentScreenTouch) {
+ return touch.identifier === currentScreenTouch.identifier;
+ }).forEach(function(currentScreenTouch) {
+ currentScreenTouch.position.x = touch.pageX;
+ currentScreenTouch.position.y = touch.pageY;
+ });
+
+ // if it's a touch event, move the touch overlay div for the corresponding finger
+ if (index === 0) {
+ overlayDiv.style.transform = 'translate3d(' + touch.pageX + 'px,' + touch.pageY + 'px, 1200px)';
+ } else if (index === 1) {
+ overlayDiv2.style.transform = 'translate3d(' + touch.pageX + 'px,' + touch.pageY + 'px, 1200px)';
+ }
+ });
+
+ var activeVehicle = this.getEditingVehicle();
+
+ if (activeVehicle) {
+
+ let syntheticPinch = realityEditor.device.editingState.syntheticPinchInfo;
+ // scale the element if you make a pinch gesture
+ if ((event.touches.length === 2 || syntheticPinch) && !realityEditor.device.editingState.pinchToScaleDisabled) {
+
+ if (syntheticPinch) { // happens for example on remote operator, holding a keyboard key rather than 2-finger pinch
+
+ // try to center the pinch around center of tool in screen coordinates,
+ // but use the startX/Y from synthetic pinch event as a backup value
+ let centerTouch = {
+ x: syntheticPinch.startX,
+ y: syntheticPinch.startY
+ }
+ let bounds = globalDOMCache[activeVehicle.uuid].getClientRects()[0];
+ if (bounds) {
+ centerTouch = {
+ x: bounds.left + bounds.width / 2,
+ y: bounds.top + bounds.height / 2
+ };
+ }
+
+ let outerTouch = {
+ x: event.pageX,
+ y: event.pageY
+ }
+ realityEditor.gui.ar.positioning.scaleVehicle(activeVehicle, centerTouch, outerTouch);
+
+ } else {
+
+ // consider a touch on 'object__frameKey__' and 'svgobject__frameKey__' to be on the same target
+ // also consider a touch that started on pocket-element to be on the frame element
+ var touchTargets = Array.from(event.touches).map(function(touch) {
+ var targetId = realityEditor.device.utilities.getVehicleIdFromTargetId(touch.target.id);
+ if (targetId === 'pocket-element') {
+ targetId = activeVehicle.uuid;
+ }
+ return targetId;
+ });
+
+ var areBothOnElement = touchTargets[0] === touchTargets[1];
+
+ var centerTouch;
+ var outerTouch;
+
+ if (areBothOnElement) {
+ // if you do a pinch gesture with both fingers on the frame
+ // center the scale event around the first touch the user made
+ centerTouch = {
+ x: event.touches[0].pageX,
+ y: event.touches[0].pageY
+ };
+ outerTouch = {
+ x: event.touches[1].pageX,
+ y: event.touches[1].pageY
+ };
+ } else {
+ // if you have two fingers on the screen (one on the frame, one on the canvas)
+ // make sure the scale event is centered around the frame
+ Array.from(event.touches).forEach(function(touch){
+
+ let targetId = realityEditor.device.utilities.getVehicleIdFromTargetId(touch.target.id);
+ var didTouchOnFrame = targetId === activeVehicle.uuid;
+ var didTouchOnNode = targetId === activeVehicle.frameId + activeVehicle.name;
+ var didTouchOnPocketContainer = touch.target.className === "element-template";
+ if (didTouchOnFrame || didTouchOnNode || didTouchOnPocketContainer) {
+ centerTouch = {
+ x: touch.pageX,
+ y: touch.pageY
+ };
+ } else {
+ outerTouch = {
+ x: touch.pageX,
+ y: touch.pageY
+ };
+ }
+ });
+ }
+
+ realityEditor.gui.ar.positioning.scaleVehicle(activeVehicle, centerTouch, outerTouch);
+ }
+
+ // otherwise, if you just have one finger on the screen, move the frame you're on if you can
+ } else if (event.touches.length === 1) {
+
+ // cannot move static copy frames
+ if (activeVehicle.staticCopy) {
+ return;
+ }
+
+ // cannot move nodes inside static copy frames
+ if (typeof activeVehicle.objectId !== "undefined" && typeof activeVehicle.frameId !== "undefined") {
+ var parentFrame = realityEditor.getFrame(activeVehicle.objectId, activeVehicle.frameId);
+ if (parentFrame && parentFrame.staticCopy) {
+ return;
+ }
+ }
+
+ realityEditor.gui.ar.positioning.y =event.touches[0].pageY;
+ realityEditor.gui.ar.positioning.x = event.touches[0].pageX;
+ realityEditor.gui.ar.positioning.moveVehicleToScreenCoordinate(activeVehicle, event.touches[0].pageX, event.touches[0].pageY, true);
+
+ var isDeletableVehicle = activeVehicle.type === 'logic' || (globalStates.guiState === "ui" && activeVehicle && activeVehicle.location === "global");
+
+ // visual feedback if you move over the trash
+ if (this.isPointerInTrashZone(event.pageX, event.pageY) && isDeletableVehicle) {
+ overlayDiv.classList.add('overlayNegative');
+ } else {
+ overlayDiv.classList.remove('overlayNegative');
+ }
+
+ }
+ }
+
+ this.callbackHandler.triggerCallbacks('onDocumentMultiTouchMove', {event: event});
+};
+
+/**
+ * Determines if the x, y position on the phone screen falls on top of any visible screen
+ * (Can be used to make sure grouping or memory creation don't happen when you're trying to interact with a screen)
+ * @param {number} screenX
+ * @param {number} screenY
+ * @return {boolean}
+ */
+realityEditor.device.checkIfTouchWithinScreenBounds = function(screenX, screenY) {
+
+ var isWithinBounds = false;
+
+ // for every visible screen, calculate this touch's exact x,y coordinate within that screen plane
+ for (var frameKey in realityEditor.gui.screenExtension.visibleScreenObjects) {
+ if (!realityEditor.gui.screenExtension.visibleScreenObjects.hasOwnProperty(frameKey)) continue;
+ var visibleScreenObject = realityEditor.gui.screenExtension.visibleScreenObjects[frameKey];
+ var point = realityEditor.gui.ar.utilities.screenCoordinatesToTargetXY(visibleScreenObject.object, screenX, screenY);
+ // visibleScreenObject.x = point.x;
+ // visibleScreenObject.y = point.y;
+
+ let targetSize = realityEditor.gui.utilities.getTargetSize(visibleScreenObject.object);
+ var isWithinWidth = Math.abs(point.x) < (targetSize.width * 1000)/2;
+ var isWithinHeight = Math.abs(point.y) < (targetSize.height * 1000)/2;
+
+ console.log(point, isWithinWidth, isWithinHeight);
+
+ if (isWithinWidth && isWithinHeight) {
+ isWithinBounds = true;
+ }
+
+ }
+
+ return isWithinBounds;
+
+};
+
+/**
+ * pop into unconstrained mode if pull out z > threshold
+ * @param {Frame|Node} activeVehicle
+ */
+realityEditor.device.checkIfFramePulledIntoUnconstrained = function(activeVehicle) {
+
+ // many conditions to check to see if it has this feature enabled
+ var ableToBePulled = !(this.editingState.unconstrained || globalStates.unconstrainedPositioning) &&
+ (!globalStates.freezeButtonState || realityEditor.device.environment.ignoresFreezeButton()) &&
+ realityEditor.gui.ar.positioning.isVehicleUnconstrainedEditable(activeVehicle);
+
+ if (!ableToBePulled) { return; }
+
+ if (!this.editingState.initialCameraPosition) {
+ this.editingState.initialCameraPosition = realityEditor.sceneGraph.getWorldPosition('CAMERA');
+
+ } else {
+ let camPos = realityEditor.sceneGraph.getWorldPosition('CAMERA');
+ let dx = camPos.x - this.editingState.initialCameraPosition.x;
+ let dy = camPos.y - this.editingState.initialCameraPosition.y;
+ let dz = camPos.z - this.editingState.initialCameraPosition.z;
+
+ let cameraMoveDistance = Math.sqrt(dx * dx + dy * dy + dz * dz);
+
+ // TODO ben: for frames on screen object, if direction is towards screen then push into screen instead
+
+ if (cameraMoveDistance > globalStates.framePullThreshold) {
+ console.log('pop into unconstrained editing mode');
+
+ realityEditor.app.tap();
+
+ // create copy of static frame when it gets pulled out
+ if (activeVehicle.staticCopy) {
+ realityEditor.network.createCopyOfFrame(objects[this.editingState.object].ip, this.editingState.object, this.editingState.frame);
+ activeVehicle.staticCopy = false;
+ }
+
+ this.editingState.unconstrained = true;
+ this.editingState.initialCameraPosition = null;
+
+ // tell the renderer to freeze the current matrix as the unconstrained position on the screen
+ realityEditor.gui.ar.draw.matrix.copyStillFromMatrixSwitch = true;
+ // store this so we can undo the move if needed (e.g. image target disappears)
+ realityEditor.device.editingState.startingMatrix = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid).localMatrix;
+ realityEditor.device.editingState.startingTransform = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid).getTransformMatrix();
+
+ this.callbackHandler.triggerCallbacks('onFramePulledIntoUnconstrained', {activeVehicle: activeVehicle});
+ }
+ }
+};
+
+/**
+ * Exposes all touchend events to the touchInputs module for additional functionality (e.g. screens).
+ * Keeps track of how many touches are currently on the screen.
+ * If this touch was the last one on the editingMode element, stop editing it.
+ * @param {TouchEvent} event
+ */
+realityEditor.device.onDocumentMultiTouchEnd = function (event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) {
+ return;
+ }
+ modifyTouchEventIfDesktop(event);
+
+ realityEditor.device.touchEventObject(event, "touchend", realityEditor.device.touchInputs.screenTouchEnd);
+ cout("onDocumentMultiTouchEnd");
+
+ // if you started editing with beginTouchEditing instead of touchevent on element, programmatically trigger onElementMultiTouchEnd
+ var editingVehicleTouchIndex = this.currentScreenTouches.map(function(elt) { return elt.targetId; }).indexOf((this.editingState.node || this.editingState.frame));
+ if (editingVehicleTouchIndex === -1) {
+ realityEditor.device.onElementMultiTouchEnd(event);
+ }
+
+ // if multitouch, stop tracking the touches that were removed but keep tracking the ones still there
+ if (event.touches.length > 0) {
+ // find which touch to remove from the currentScreenTouches
+ var remainingTouches = Array.from(event.touches).map(function(touch) {
+ return touch.identifier; //touch.target.id.replace(/^(svg)/,"")
+ });
+
+ var indicesToRemove = [];
+ this.currentScreenTouches.forEach(function(elt, index) {
+ // this touch isn't here anymore
+ if (remainingTouches.indexOf(elt.identifier) === -1) {
+ indicesToRemove.push(index);
+ }
+ });
+
+ // remove them in a separate loop because it can cause problems to remove elements from the same loop you're iterating over
+ indicesToRemove.forEach(function(index) {
+ realityEditor.device.currentScreenTouches.splice(index, 1);
+ });
+ } else {
+ this.currentScreenTouches = [];
+
+ // realityEditor.gui.menus.buttonOn([]);
+ var didDisplayCrafting = globalStates.currentLogic; // proxy to determine if crafting board is open / we shouldn't reset the menu
+ if (!didDisplayCrafting) {
+ var didDisplayGroundplane = realityEditor.gui.settings.toggleStates.visualizeGroundPlane;
+ if (didDisplayGroundplane) {
+ realityEditor.gui.menus.switchToMenu('groundPlane');
+ } else {
+ realityEditor.gui.menus.switchToMenu('main');
+ }
+ }
+ }
+
+ // stop editing the active frame or node if there are no more touches on it
+ if (this.editingState.object) {
+ // TODO: touchesOnActiveVehicle returns 0 if you tapped through a fullscreen frame, because the touch targetId doesnt update to be the thing behind it
+ var touchesOnActiveVehicle = this.currentScreenTouches.map(function(elt) { return elt.targetId; }).filter(function(touchTarget) {
+ return (touchTarget === this.editingState.frame || touchTarget === this.editingState.node || touchTarget === "pocket-element");
+ }.bind(this));
+
+ var activeVehicle = this.getEditingVehicle();
+
+ if (touchesOnActiveVehicle.length === 0) {
+ console.log('this is the last touch - hide editing overlay');
+
+ // TODO: if pocketNode.node === activeVehicle, move node to closestFrameToScreenPosition upon dropping it
+ // if (activeVehicle === pocketNode.node) {
+ //
+ // var closest = realityEditor.gui.ar.getClosestFrameToScreenCoordinates(event.pageX, event.pageY);
+ //
+ // // set the name of the node by counting how many logic nodes the frame already has
+ // var closestFrame = realityEditor.getFrame(closest[0], closest[1]);
+ // var logicCount = Object.values(closestFrame.nodes).filter(function (node) {
+ // return node.type === 'logic'
+ // }).length;
+ // pocketNode.name = "LOGIC" + logicCount;
+ //
+ // }
+
+ if (activeVehicle && !globalStates.editingMode) {
+ globalDOMCache[(this.editingState.node || this.editingState.frame)].querySelector('.corners').style.visibility = 'hidden';
+ }
+
+ this.resetEditingState();
+
+ } else {
+ // if there's still a touch on it (it was being scaled), reset touch offset so vehicle doesn't jump
+ this.editingState.touchOffset = null;
+ realityEditor.gui.ar.positioning.moveVehicleToScreenCoordinate(activeVehicle, event.touches[0].pageX, event.touches[0].pageY, true);
+
+ }
+ }
+
+ // if tap on background when no visible objects, auto-focus camera
+ // if (event.target.id === 'canvas') {
+ // if (Object.keys(realityEditor.gui.ar.draw.visibleObjects).length === 0) {
+ // realityEditor.app.focusCamera();
+ // }
+ // }
+
+ this.callbackHandler.triggerCallbacks('onDocumentMultiTouchEnd', {event: event});
+};
+
+/**
+ * @typedef {Object} ScreenEventObject
+ * @desc Data structure to hold touch events to be sent to screens
+ * @property {number|null} version
+ * @property {string|null} object
+ * @property {string|null} frame
+ * @property {string|null} node
+ * @property {number} x
+ * @property {number} y
+ * @property {number} type
+ * @property {Array.<{screenX: number, screenY: number, type: string}>} touches
+ */
+
+/**
+ * @type {ScreenEventObject}
+ */
+realityEditor.device.eventObject = {
+ version : null,
+ object: null,
+ frame : null,
+ node : null,
+ x: 0,
+ y: 0,
+ type: null,
+ touches:[
+ {
+ screenX: 0,
+ screenY: 0,
+ type:null
+ },
+ {
+ screenX: 0,
+ screenY: 0,
+ type:null
+ }
+ ]
+};
+
+/**
+ * Parses a TouchEvent into a useful format for the screenExtension module and sends it via the callback
+ * @param {TouchEvent} evt
+ * @param {string} type
+ * @param {Function} cb
+ */
+realityEditor.device.touchEventObject = function (evt, type, cb) {
+ if(!evt.touches) return;
+ if (evt.touches.length >= 1) {
+ realityEditor.device.eventObject.x = evt.touches[0].screenX;
+ realityEditor.device.eventObject.y = evt.touches[0].screenY;
+ realityEditor.device.eventObject.type = type;
+ realityEditor.device.eventObject.touches[0].screenX = evt.touches[0].screenX;
+ realityEditor.device.eventObject.touches[0].screenY = evt.touches[0].screenY;
+ realityEditor.device.eventObject.touches[0].type = type;
+
+ if (type === 'touchstart') {
+
+ var didJustAddPocket = false;
+ if (realityEditor.device.eventObject.object && realityEditor.device.eventObject.frame) {
+ var existingEventFrame = realityEditor.getFrame(realityEditor.device.eventObject.object, realityEditor.device.eventObject.frame);
+ didJustAddPocket = (existingEventFrame && existingEventFrame === pocketFrame.vehicle && pocketFrame.waitingToRender);
+ }
+
+ if (!didJustAddPocket) {
+ realityEditor.device.eventObject.object = null;
+ realityEditor.device.eventObject.frame = null;
+ var ele = evt.target;
+ while (ele && ele.tagName !== "BODY" && ele.tagName !== "HTML") {
+ if (ele.objectId && ele.frameId) {
+ realityEditor.device.eventObject.object = ele.objectId;
+ realityEditor.device.eventObject.frame = ele.frameId;
+ break;
+ }
+ ele = ele.parentElement;
+ }
+ }
+ }
+ }
+ if (evt.touches.length >= 2) {
+ realityEditor.device.eventObject.touches[1].screenX = evt.touches[1].screenX;
+ realityEditor.device.eventObject.touches[1].screenY = evt.touches[1].screenY;
+ realityEditor.device.eventObject.touches[1].type = type;
+ } else if (type === 'touchend') {
+ realityEditor.device.eventObject.x = evt.pageX;
+ realityEditor.device.eventObject.y = evt.pageY;
+ realityEditor.device.eventObject.type = type;
+ realityEditor.device.eventObject.touches[0].screenX = evt.pageX;
+ realityEditor.device.eventObject.touches[0].screenY = evt.pageY;
+ realityEditor.device.eventObject.touches[0].type = type;
+ } else {
+ realityEditor.device.eventObject.touches[1] = {};
+ }
+ cb(realityEditor.device.eventObject);
+};
+
+realityEditor.device.toolInteractionCallbacks = [];
+realityEditor.device.onToolInteraction = function(callback) {
+ this.toolInteractionCallbacks.push(callback);
+};
diff --git a/src/device/keyboardEvents.js b/src/device/keyboardEvents.js
new file mode 100644
index 000000000..4233e0cfb
--- /dev/null
+++ b/src/device/keyboardEvents.js
@@ -0,0 +1,158 @@
+createNameSpace("realityEditor.device.keyboardEvents");
+
+/**
+ * @fileOverview realityEditor.device.keyboardEvents.js
+ * Provides a central location where document keyboard events are handled.
+ * Additional modules and experiments can plug into these for touch interaction.
+ */
+
+(function(exports) {
+
+ var callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('device/keyboardEvents');
+ let keyboardCurrentlyOpen = false;
+
+ // register normal/flying mode callbacks, so that when enter fly mode in remote operator, spatialCursor & spatialIndicator's screenX & screenY also switches to screen center
+ let isFlying = false;
+
+ /**
+ * Public init method sets up module and registers callbacks in other modules
+ */
+ function initService() {
+ window.addEventListener('keyup', keyUpHandler);
+ window.addEventListener('keydown', keyDownHandler);
+ window.addEventListener('keyup', (e) => {
+ handleFlyMode(e);
+ });
+ document.addEventListener('pointerlockchange', handleFlyModeEscapeKey);
+ realityEditor.network.addPostMessageHandler('resetScroll', function() {
+ resetScroll();
+ setTimeout(function() {
+ resetScroll(); // also do it after a slight delay
+ }, 100);
+ });
+ }
+
+ /**
+ * Corrects any buggy scrolling that may have occurred when typing in a frame
+ * @deprecated - shouldn't be needed if frames use the new openKeyboard/closeKeyboard API
+ */
+ function resetScroll() {
+ if (window.scrollX !== 0 || window.scrollY !== 0) {
+ window.scrollTo(0,0);
+ }
+ }
+
+ /**
+ * key up event handler that is always present.
+ * @param {KeyboardEvent} event
+ */
+ function keyUpHandler(event) {
+ callbackHandler.triggerCallbacks('keyUpHandler', {event: event});
+ }
+
+ /**
+ * key down event handler that is always present.
+ * @param {KeyboardEvent} event
+ */
+ function keyDownHandler(event) {
+ callbackHandler.triggerCallbacks('keyDownHandler', {event: event});
+ }
+
+ /**
+ * Adds a callback function that will be invoked when the specified button is pressed
+ * @param {string} functionName
+ * @param {function} callback
+ */
+ function registerCallback(functionName, callback) {
+ if (!callbackHandler) {
+ callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('device/keyboardEvents');
+ }
+ callbackHandler.registerCallback(functionName, callback);
+ }
+
+ function handleFlyMode(e) {
+ if (isKeyboardActive()) return; // ignore if a tool is using the keyboard
+ if (e.key === 'f' || e.key === 'F') {
+ isFlying = !isFlying;
+ }
+ }
+
+ // todo: if detected a pointer lock change, then wait for 1 seconds before triggering the next pointer lock change
+
+ function handleFlyModeEscapeKey() {
+ if (document.pointerLockElement === document.body) {
+ callbackHandler.triggerCallbacks('enterFlyMode', {isFlying: true});
+ } else if (document.pointerLockElement === null) {
+ callbackHandler.triggerCallbacks('enterNormalMode', {isFlying: false, from: 'triggered from ESC key'});
+ }
+ }
+
+ /**
+ * Creates an invisible contenteditable div that we can focus on to open the keyboard.
+ * This allows frames to use an API to safely open a keyboard and listen to events without encountering webkit keyboard bugs.
+ */
+ function createKeyboardInputDiv() {
+ var keyboardInput = document.createElement('div');
+ keyboardInput.id = 'keyboardInput';
+ keyboardInput.setAttribute('contenteditable', 'true');
+ document.body.appendChild(keyboardInput);
+ keyboardInput.style.position = 'absolute';
+ keyboardInput.style.left = 0;
+ keyboardInput.style.top = 0;
+ keyboardInput.style.opacity = 0;
+
+ document.getElementById('keyboardInput').addEventListener('focusout', function() {
+ console.log('keyboard hidden');
+ callbackHandler.triggerCallbacks('keyboardHidden', null);
+ });
+ }
+
+ /**
+ * Programmatically opens the keyboard by focusing on a placeholder element.
+ * @todo: there is a bug where multiple frames can think they have keyboard focus if you don't call closeKeyboard between each openKeyboard
+ */
+ function openKeyboard() {
+ if (!document.getElementById('keyboardInput')) {
+ createKeyboardInputDiv();
+ }
+
+ // todo: if the keyboard is already open, notify previous active iframe that something else opened it
+ // closeKeyboard(); // this almost works (if you also add a setTimeout on the focus(), but it cancels the current iframe's focus, too)
+
+ document.getElementById('keyboardInput').focus();
+
+ keyboardCurrentlyOpen = true;
+ }
+
+ /**
+ * Programmatically closes the keyboard by blurring (un-focusing) and disabling the placeholder element
+ * From: https://stackoverflow.com/a/11160055/1190267
+ */
+ function closeKeyboard() {
+ if (!document.getElementById('keyboardInput')) {
+ createKeyboardInputDiv();
+ }
+
+ document.getElementById('keyboardInput').setAttribute('readonly', 'readonly');
+ document.getElementById('keyboardInput').setAttribute('disabled', 'true');
+
+ setTimeout(function() {
+ document.getElementById('keyboardInput').blur();
+ document.getElementById('keyboardInput').removeAttribute('readonly');
+ document.getElementById('keyboardInput').removeAttribute('disabled');
+ }, 100);
+
+ keyboardCurrentlyOpen = false;
+ }
+
+ function isKeyboardActive() {
+ return keyboardCurrentlyOpen;
+ }
+
+ exports.initService = initService;
+ exports.registerCallback = registerCallback;
+ exports.openKeyboard = openKeyboard;
+ exports.closeKeyboard = closeKeyboard;
+ exports.isKeyboardActive = isKeyboardActive;
+
+})(realityEditor.device.keyboardEvents);
diff --git a/src/device/layout.js b/src/device/layout.js
new file mode 100644
index 000000000..2815cace4
--- /dev/null
+++ b/src/device/layout.js
@@ -0,0 +1,324 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace('realityEditor.device.layout');
+
+/**
+ * @fileOverview realityEditor.device.layout.js
+ * Adjusts the user interface layout for different screen sizes.
+ * @todo currently just adjusts for iPhoneX shape, but eventually all screen changes should be moved here
+ */
+
+(function(exports) {
+
+ // layout constants, regardless of screen size
+ let MENU_HEIGHT = 320;
+ let TRASH_WIDTH = 60;
+ let CRAFTING_MENU_BAR_WIDTH = 62;
+
+ // can be used to offset the right edge of the screen to fit non-rectangular screen shapes.
+ let rightEdgeOffset = 0;
+
+ // keep track of orientation from onOrientationChanged, e.g. 'landscapeLeft' vs 'landscapeRight'
+ let currentOrientation;
+
+ let knownDeviceName;
+
+ // by default, trash is by right edge of screen, but you can use setTrashZoneRect to define different bounds
+ let customTrashZone = null;
+
+ // the set of which toolIds are listening to onWindowResized events
+ let toolSubscriptions = {};
+
+ let callbacks = {
+ onWindowResized: []
+ }
+
+ function initService() {
+ /**
+ * Listen for messages that set up the subscription for spatialInterface.onWindowResized(({width, height})=>{}) tool API
+ */
+ realityEditor.network.addPostMessageHandler('sendWindowResize', (eventData, fullMessageContent) => {
+ toolSubscriptions[fullMessageContent.frame] = true;
+ });
+
+ /**
+ * This is the main window resize event listener for the project.
+ * Other modules should use realityEditor.device.layout.onWindowResized(({width, height})=>{})
+ * rather than adding another window.onResize listener, so that code triggers in the right order
+ */
+ window.addEventListener('resize', () => {
+ // noinspection JSSuspiciousNameCombination
+ globalStates.height = window.innerWidth;
+ // noinspection JSSuspiciousNameCombination
+ globalStates.width = window.innerHeight;
+
+ // reformat pocket tile size/arrangement
+ realityEditor.gui.pocket.onWindowResized();
+
+ // Resize the canvas used for drawing node links
+ let nodeConnectionCanvas = document.querySelector('.canvas-node-connections');
+ if (nodeConnectionCanvas) {
+ nodeConnectionCanvas.width = window.innerWidth;
+ nodeConnectionCanvas.height = window.innerHeight;
+ nodeConnectionCanvas.style.width = nodeConnectionCanvas.width + 'px';
+ nodeConnectionCanvas.style.height = nodeConnectionCanvas.height + 'px';
+ }
+
+ // adjust the size of each tool's container div to match the viewport...
+ // ...this is the magic that makes the CSS rendering put everything in the right coordinate system
+ // additionally, adjust fullscreen tools to maintain fullscreen size
+ realityEditor.forEachFrameInAllObjects((objectKey, frameKey) => {
+ let container = globalDOMCache['object' + frameKey];
+ let iframe = globalDOMCache['iframe' + frameKey];
+ let cover = globalDOMCache[frameKey];
+ // this is essential for rendering
+ if (container) {
+ container.style.width = `${window.innerWidth}px`;
+ container.style.height = `${window.innerHeight}px`;
+ }
+ // this adjusts the fullscreen iframes to continue to be fullscreen
+ if (iframe && iframe.classList.contains('webGlFrame')) {
+ iframe.style.width = `${window.innerWidth}px`;
+ iframe.style.height = `${window.innerHeight}px`;
+ if (cover) {
+ cover.style.width = `${window.innerWidth}px`;
+ cover.style.height = `${window.innerHeight}px`;
+ }
+ }
+ });
+
+ // trigger other modules that have subscribed using realityEditor.device.layout.onWindowResized(...)
+ callbacks.onWindowResized.forEach(callback => {
+ callback({
+ width: window.innerWidth,
+ height: window.innerHeight
+ });
+ });
+
+ // post a onWindowResized message into each tool that has subscribed to spatialInterface.onWindowResized(...)
+ Object.keys(toolSubscriptions).forEach(frameKey => {
+ let iframe = document.getElementById('iframe' + frameKey);
+ if (!iframe) return;
+ let eventData = {
+ onWindowResized: {
+ width: window.innerWidth,
+ height: window.innerHeight
+ }
+ };
+ iframe.contentWindow.postMessage(JSON.stringify(eventData), '*');
+ });
+ });
+ }
+
+ /**
+ * Other modules can subscribe to window resize events via this method, rather than adding a new resize listener
+ * @param {function} callback
+ */
+ function onWindowResized(callback) {
+ callbacks.onWindowResized.push(callback);
+ }
+
+ /**
+ * Center the menu buttons vertically on screens taller than MENU_HEIGHT.
+ * Adjusts the CSS of various UI elements (buttons, pocket, settings menu, crafting board)
+ * to fit awkward, non-rectangular screens (looking at you, iPhone X).
+ */
+ function adjustForScreenSize() {
+ var menuHeightDifference = globalStates.width - MENU_HEIGHT;
+
+ // vertically center the menu if the screen is taller than 320 px
+ document.getElementById('UIButtons').style.top = menuHeightDifference / 2 + 'px';
+
+ // vertically center the crafting board by updating the global variable it uses
+ CRAFTING_GRID_HEIGHT = globalStates.width - menuHeightDifference;
+
+ adjustRightEdgeIfNeeded();
+ }
+
+ /**
+ * Adjust the UI to look good on all screens, including iPhone 10, 11, etc with a non-rectangular right side.
+ */
+ function adjustRightEdgeIfNeeded() {
+ rightEdgeOffset = calculateRightEdgeOffset();
+
+ // adjust right edge of interface for iPhone X
+
+ let scaleFactor = (window.innerWidth - rightEdgeOffset) / window.innerWidth;
+
+ // menu buttons
+ document.querySelector('#UIButtons').style.width = window.innerWidth - rightEdgeOffset + 'px';
+ document.querySelector('#UIButtons').style.right = rightEdgeOffset + 'px';
+
+ // pocket
+ if (!TEMP_DISABLE_MEMORIES) {
+ document.querySelector('.memoryBar').style.transformOrigin = 'left top';
+ document.querySelector('.memoryBar').style.transform = 'scale(' + scaleFactor * 0.99 + ')'; // 0.99 factor makes sure it fits
+ }
+ document.querySelector('#pocketScrollBar').style.right = (window.innerWidth - realityEditor.gui.pocket.getWidth()) + 'px'; //75 + rightEdgeOffset + 'px';
+ document.querySelector('.palette').style.width = '100%';
+ document.querySelector('.palette').style.transformOrigin = 'left top';
+ document.querySelector('.palette').style.transform = 'scale(' + scaleFactor * 0.99 + ')';
+ document.querySelector('.nodeMemoryBar').style.transformOrigin = 'left top';
+ document.querySelector('.nodeMemoryBar').style.transform = 'scale(' + scaleFactor * 0.99 + ')';
+
+ // settings
+ document.querySelector('#settingsIframe').style.width = document.body.offsetWidth - rightEdgeOffset + 'px';
+ let edgeDiv = document.getElementById('settingsEdgeDiv');
+ if (!edgeDiv) {
+ edgeDiv = document.createElement('div');
+ edgeDiv.id = 'settingsEdgeDiv';
+ edgeDiv.style.backgroundColor = 'rgb(34, 34, 34)';
+ edgeDiv.style.position = 'absolute';
+ edgeDiv.style.display = 'none';
+ document.body.appendChild(edgeDiv);
+ }
+ edgeDiv.style.left = document.body.offsetWidth - rightEdgeOffset + 'px';
+ edgeDiv.style.width = rightEdgeOffset + 'px';
+ edgeDiv.style.top = 0;
+ edgeDiv.style.height = document.body.offsetHeight;
+
+ // crafting
+ realityEditor.gui.crafting.menuBarWidth = CRAFTING_MENU_BAR_WIDTH + rightEdgeOffset;
+ }
+
+ /**
+ * Use either the device identifier, or the screen size as a proxy to determine the margin on the right edge
+ * These need to be hard-coded / updated whenever a new device is released with a unique screen size and edge-offset
+ * @return {number}
+ */
+ function calculateRightEdgeOffset() {
+ // if weird shape is flipped to the left side of screen, right edge offset is always 0
+ if (currentOrientation === 'landscapeLeft') {
+ // TODO: create a leftEdgeOffset that gets applied instead
+ return 0;
+ }
+
+ // if we have access to the device name, calculate edge based on this info
+ if (knownDeviceName && !realityEditor.gui.settings.toggleStates['demoAspectRatio']) {
+
+ if (knownDeviceName === 'iPhone10,3' || knownDeviceName === 'iPhone10,6' || knownDeviceName === 'iPhone11,8') {
+ return 74;
+ } else if (knownDeviceName === 'iPhone14,2' || knownDeviceName === 'iPhone14,3' || knownDeviceName === 'iPhone14,4' || knownDeviceName === 'iPhone14,5' ||
+ knownDeviceName === 'iPhone13,2' || knownDeviceName === 'iPhone13,3' || knownDeviceName === 'iPhone13,4' ||
+ knownDeviceName === 'iPhone12,1' || knownDeviceName === 'iPhone12,3' || knownDeviceName === 'iPhone12,5' ||
+ knownDeviceName === 'iPhone11,2' || knownDeviceName === 'iPhone11,4' || knownDeviceName === 'iPhone11,6') {
+ return 37;
+ }
+ return 0;
+
+ } else {
+ // otherwise, we can be fairly accurate by looking at have specific offsets
+ if (window.innerWidth === 856 && window.innerHeight === 375) {
+ return 74; // iPhoneX has the most widest aspect ratio
+ } else if (window.innerWidth >= 812 && window.innerHeight >= 375) {
+ return 37; // the "Max" phones have half the inset
+ }
+ return 0;
+ }
+ }
+
+ function setTrashZoneRect(x, y, width, height) {
+ customTrashZone = {
+ x: x,
+ y: y,
+ width: width,
+ height: height
+ };
+ }
+
+ /**
+ * Returns the x-coordinate of the edge of the trash drop-zone, adjusted for different screen sizes.
+ * @return {number}
+ */
+ function getTrashThresholdX() {
+ return (globalStates.height - TRASH_WIDTH - rightEdgeOffset);
+ }
+
+ /**
+ * Because we flip the entire webview with native code, the UI is correct, but we just need to fix the projection matrix
+ * because the camera view relative to the webview is rotated 180 degrees.
+ * The default UI was built for "landscapeRight" mode (left-handed).
+ * @param {string} orientationString - "landscapeLeft", "landscapeRight", "portrait", "portraitUpsideDown", or "unknown"
+ * @todo - on portrait mode detected, make big changes to pocket, menus, button rotations, crafting, etc
+ */
+ function onOrientationChanged(orientationString) {
+ if (orientationString === 'landscapeRight') { // default
+ globalStates.deviceOrientationRight = true;
+ realityEditor.gui.ar.updateProjectionMatrix(false);
+
+ } else if (orientationString === 'landscapeLeft') { // flipped
+ globalStates.deviceOrientationRight = false;
+ realityEditor.gui.ar.updateProjectionMatrix(true);
+
+ }
+
+ currentOrientation = orientationString;
+ adjustRightEdgeIfNeeded(); // see if we need to update the right edge offset
+ }
+
+ /**
+ * Update the layout again once we know which device we have
+ * @param {string} deviceName - a machine ID / mobile device code e.g. 'iPhone8,1' (iPhone 6s) 'iPhone10,1' (iPhone 8)
+ */
+ function adjustForDevice(deviceName) {
+ knownDeviceName = deviceName;
+ adjustRightEdgeIfNeeded();
+ }
+
+ exports.initService = initService;
+ exports.adjustForScreenSize = adjustForScreenSize;
+ exports.getTrashThresholdX = getTrashThresholdX;
+ exports.onOrientationChanged = onOrientationChanged;
+ exports.adjustForDevice = adjustForDevice;
+ exports.setTrashZoneRect = setTrashZoneRect;
+ exports.getCustomTrashZone = () => { return customTrashZone; }
+ exports.onWindowResized = onWindowResized;
+
+})(realityEditor.device.layout);
diff --git a/src/device/modeTransition.js b/src/device/modeTransition.js
new file mode 100644
index 000000000..beb379849
--- /dev/null
+++ b/src/device/modeTransition.js
@@ -0,0 +1,196 @@
+createNameSpace("realityEditor.device.modeTransition");
+
+import { PinchGestureRecognizer } from './PinchGestureRecognizer.js';
+
+const MAX_PINCH_AMOUNT = 1000; // how far you need to drag to trigger the full transition
+const MODES = Object.freeze({
+ AR: 'AR',
+ REMOTE_OPERATOR: 'REMOTE_OPERATOR'
+});
+let currentMode = null;
+let pinchAmount = 0;
+let backgroundDiv = null;
+
+let callbacks = {
+ onRemoteOperatorShown: [],
+ onRemoteOperatorHidden: [],
+ onTransitionPercent: [],
+ onDeviceCameraPosition: [],
+ onModeTransitionPinchStart: [],
+ onModeTransitionPinchEnd: []
+}
+
+let prevEnvironmentVariables = {};
+let prevMatrices = {
+ projection: null,
+ realProjection: null,
+ unflippedRealProjection: null
+};
+
+(function(exports) {
+
+ function initService() {
+ currentMode = getInitialMode();
+
+ // disables AR<>VR slider if not in the Toolbox AR app
+ if (!realityEditor.device.environment.isWithinToolboxApp()) {
+ return;
+ }
+
+ // set up the pinch gesture to transition from AR to VR mode
+ let pinchGestureRecognizer = new PinchGestureRecognizer();
+
+ pinchGestureRecognizer.onPinchStart(_ => {
+ callbacks.onModeTransitionPinchStart.forEach(callback => {
+ callback();
+ })
+ });
+ pinchGestureRecognizer.onPinchEnd(_ => {
+ callbacks.onModeTransitionPinchEnd.forEach(callback => {
+ callback();
+ });
+ });
+ pinchGestureRecognizer.onPinchChange(scrollAmount => {
+ pinchAmount += scrollAmount;
+ pinchAmount = Math.max(0, Math.min(MAX_PINCH_AMOUNT, pinchAmount));
+ setTransitionPercent(Math.min(1, Math.max(0, pinchAmount / MAX_PINCH_AMOUNT)));
+ });
+ }
+
+ function getInitialMode() {
+ return realityEditor.device.environment.isWithinToolboxApp() ?
+ MODES.AR :
+ MODES.REMOTE_OPERATOR;
+ }
+
+ function switchToAR() {
+ if (currentMode === MODES.AR) return;
+ if (!realityEditor.device.environment.isWithinToolboxApp()) return;
+ currentMode = MODES.AR;
+
+ // this will tell the addon to hide the 3D model
+ callbacks.onRemoteOperatorHidden.forEach(cb => {
+ cb();
+ });
+
+ // restore any environment variables to their AR mode values
+ let env = realityEditor.device.environment.variables;
+ for (const [key, value] of Object.entries(prevEnvironmentVariables)) {
+ env[key] = value;
+ delete prevEnvironmentVariables[key];
+ }
+
+ // restore the projection matrix from Vuforia
+ globalStates.projectionMatrix = JSON.parse(JSON.stringify(prevMatrices.projection));
+ globalStates.realProjectionMatrix = JSON.parse(JSON.stringify(prevMatrices.realProjection));
+ globalStates.unflippedRealProjectionMatrix = JSON.parse(JSON.stringify(prevMatrices.unflippedRealProjection));
+
+ if (backgroundDiv) {
+ document.body.removeChild(backgroundDiv);
+ }
+ }
+
+ function switchToRemoteOperator() {
+ if (currentMode === MODES.REMOTE_OPERATOR) return;
+ currentMode = MODES.REMOTE_OPERATOR;
+
+ // store the AR values of the projection matrices
+ prevMatrices.projection = JSON.parse(JSON.stringify(globalStates.projectionMatrix));
+ prevMatrices.realProjection = JSON.parse(JSON.stringify(globalStates.realProjectionMatrix));
+ prevMatrices.unflippedRealProjection = JSON.parse(JSON.stringify(globalStates.unflippedRealProjectionMatrix));
+
+ // trigger the remote operator addon to initialize
+ callbacks.onRemoteOperatorShown.forEach(cb => {
+ cb();
+ });
+
+ // update any environment variables for VR mode
+ let env = realityEditor.device.environment.variables;
+ prevEnvironmentVariables.supportsDistanceFading = env.supportsDistanceFading;
+ env.supportsDistanceFading = false; // this prevents things from disappearing when the camera zooms out
+ prevEnvironmentVariables.ignoresFreezeButton = env.ignoresFreezeButton;
+ env.ignoresFreezeButton = true; // no need to "freeze the camera" on desktop
+ prevEnvironmentVariables.lineWidthMultiplier = env.lineWidthMultiplier;
+ env.lineWidthMultiplier = 5; // makes links thicker (more visible)
+ prevEnvironmentVariables.distanceScaleFactor = env.distanceScaleFactor;
+ env.distanceScaleFactor = 30; // makes distance-based interactions work at further distances than mobile
+ prevEnvironmentVariables.newFrameDistanceMultiplier = env.newFrameDistanceMultiplier;
+ env.newFrameDistanceMultiplier = 6;
+ prevEnvironmentVariables.isCameraOrientationFlipped = env.isCameraOrientationFlipped;
+ env.isCameraOrientationFlipped = true;
+ prevEnvironmentVariables.hideOriginCube = env.hideOriginCube;
+ env.hideOriginCube = true; // don't show a set of cubes at the world origin
+ prevEnvironmentVariables.addOcclusionGltf = env.addOcclusionGltf;
+ env.addOcclusionGltf = false; // don't add transparent world gltf, because we're already adding the visible mesh
+ prevEnvironmentVariables.transformControlsSize = env.transformControlsSize;
+ env.transformControlsSize = 0.3; // gizmos for ground plane anchors are smaller
+ prevEnvironmentVariables.defaultShowGroundPlane = env.defaultShowGroundPlane;
+ env.defaultShowGroundPlane = true;
+ prevEnvironmentVariables.groundWireframeColor = env.groundWireframeColor;
+ env.groundWireframeColor = 'rgb(255, 240, 0)'; // make the ground holo-deck styled
+ // All other environment variables usually set in desktopAdapter don't need to be modified
+
+ if (!backgroundDiv) {
+ backgroundDiv = document.createElement('div');
+ backgroundDiv.id = 'remoteOperatorBackgroundBlur';
+ }
+ document.body.appendChild(backgroundDiv);
+ backgroundDiv.style.backgroundColor = 'rgba(50, 50, 50, 0.01)';
+
+ let menuBarDiv = document.querySelector('.desktopMenuBar');
+ menuBarDiv.style.display = 'none';
+ }
+
+ function isARMode() {
+ if (!currentMode) currentMode = getInitialMode();
+ return currentMode === MODES.AR;
+ }
+
+ // the pinch gesture or the slider component can both trigger the transition using this
+ function setTransitionPercent(percent) {
+ if (!realityEditor.device.environment.isWithinToolboxApp()) return;
+ if (percent < 0.01) {
+ switchToAR();
+ } else {
+ switchToRemoteOperator(percent);
+ }
+
+ callbacks.onTransitionPercent.forEach(cb => {
+ cb(percent);
+ });
+
+ if (backgroundDiv) {
+ // start fading in background at 5%, finish at 10%
+ let opacity = Math.max(0, Math.min(1, (percent - 0.05) * 20));
+ backgroundDiv.style.backgroundColor = `rgba(50, 50, 50, ${opacity})`;
+ }
+
+ // update the pinch amount to allow smooth interoperability between pinch and slider interaction
+ pinchAmount = Math.max(0, Math.min(MAX_PINCH_AMOUNT, percent * MAX_PINCH_AMOUNT));
+ }
+
+ // we can transition back to the correct place by using this
+ function setDeviceCameraPosition(cameraMatrix) {
+ callbacks.onDeviceCameraPosition.forEach(cb => {
+ cb(cameraMatrix);
+ });
+ }
+
+ exports.initService = initService;
+ exports.switchToAR = switchToAR;
+ exports.switchToRemoteOperator = switchToRemoteOperator;
+ exports.isARMode = isARMode;
+ exports.setTransitionPercent = setTransitionPercent;
+ exports.setDeviceCameraPosition = setDeviceCameraPosition;
+
+ // Callback handlers
+ exports.onRemoteOperatorShown = (callback) => { callbacks.onRemoteOperatorShown.push(callback); }
+ exports.onRemoteOperatorHidden = (callback) => { callbacks.onRemoteOperatorHidden.push(callback); }
+ exports.onTransitionPercent = (callback) => { callbacks.onTransitionPercent.push(callback); }
+ exports.onDeviceCameraPosition = (callback) => { callbacks.onDeviceCameraPosition.push(callback); }
+ exports.onModeTransitionPinchStart = (callback) => { callbacks.onModeTransitionPinchStart.push(callback); }
+ exports.onModeTransitionPinchEnd = (callback) => { callbacks.onModeTransitionPinchEnd.push(callback); }
+
+}(realityEditor.device.modeTransition));
+
+export const initService = realityEditor.device.modeTransition.initService;
diff --git a/src/device/multiclientUI.js b/src/device/multiclientUI.js
new file mode 100644
index 000000000..726fd556e
--- /dev/null
+++ b/src/device/multiclientUI.js
@@ -0,0 +1,188 @@
+/*
+* Copyright ยฉ 2018 PTC
+*
+* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/.
+*/
+
+createNameSpace('realityEditor.device.multiclientUI');
+
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+
+(function(exports) {
+ let allConnectedCameras = {};
+ let isCameraSubscriptionActiveForObject = {};
+
+ const wireVertex = `
+ attribute vec3 center;
+ varying vec3 vCenter;
+ void main() {
+ vCenter = center;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+ `;
+
+ const wireFragment = `
+ uniform float thickness;
+ uniform vec3 color;
+ varying vec3 vCenter;
+
+ void main() {
+ vec3 afwidth = fwidth(vCenter.xyz);
+ vec3 edge3 = smoothstep((thickness - 1.0) * afwidth, thickness * afwidth, vCenter.xyz);
+ float edge = 1.0 - min(min(edge3.x, edge3.y), edge3.z);
+ if (edge < 0.5) {
+ discard;
+ }
+ gl_FragColor.rgb = gl_FrontFacing ? color : (color * 0.5);
+ gl_FragColor.a = edge;
+ }
+ `;
+
+ const wireMat = new THREE.ShaderMaterial({
+ uniforms: {
+ thickness: {
+ value: 5.0,
+ },
+ color: {
+ value: new THREE.Color(0.9, 0.9, 1.0),
+ },
+ },
+ vertexShader: wireVertex,
+ fragmentShader: wireFragment,
+ side: THREE.DoubleSide,
+ });
+ wireMat.extensions.derivatives = true;
+ window.wireMat = wireMat;
+
+ function initService() {
+ // if (!realityEditor.device.desktopAdapter.isDesktop()) { return; }
+ realityEditor.network.addObjectDiscoveredCallback(function(object, objectKey) {
+ setTimeout(function() {
+ setupWorldSocketSubscriptionsIfNeeded(objectKey);
+ }, 100); // give time for bestWorldObject to update before checking
+ });
+
+ update();
+ }
+
+ function setupWorldSocketSubscriptionsIfNeeded(objectKey) {
+ if (isCameraSubscriptionActiveForObject[objectKey]) {
+ return;
+ }
+
+ // subscribe to remote operator camera positions
+ // right now this assumes there will only be one world object in the network
+ let object = realityEditor.getObject(objectKey);
+ if (object && (object.isWorldObject || object.type === 'world')) {
+ console.log('multiclientUI subscribing', objectKey);
+ realityEditor.network.realtime.subscribeToCameraMatrices(objectKey, onCameraMatrix);
+ isCameraSubscriptionActiveForObject[objectKey] = true;
+ }
+ }
+
+ function onCameraMatrix(data) {
+ let msgData = JSON.parse(data);
+ if (typeof msgData.cameraMatrix !== 'undefined' && typeof msgData.editorId !== 'undefined') {
+ allConnectedCameras[msgData.editorId] = msgData.cameraMatrix;
+ }
+ }
+
+ // helper function to generate an integer hash from a string (https://stackoverflow.com/a/15710692)
+ function hashCode(s) {
+ return s.split("").reduce(function(a,b){a=((a<<5)-a)+b.charCodeAt(0);return a&a},0);
+ }
+
+ function update() {
+ // this remote operator's camera position already gets sent in desktopCamera.js
+ // here we render boxes at the location of each other camera...
+
+ try {
+ Object.keys(allConnectedCameras).forEach(function(editorId) {
+ let cameraMatrix = allConnectedCameras[editorId];
+ let existingMesh = realityEditor.gui.threejsScene.getObjectByName('camera_' + editorId);
+ if (!existingMesh) {
+ // each client gets a random but consistent color based on their editorId
+ let id = Math.abs(hashCode(editorId));
+ const color = `hsl(${(id % Math.PI) * 360 / Math.PI}, 100%, 50%)`;
+ const geo = new THREE.IcosahedronBufferGeometry(100);
+ geo.deleteAttribute('normal');
+ geo.deleteAttribute('uv');
+
+ const vectors = [
+ new THREE.Vector3(1, 0, 0),
+ new THREE.Vector3(0, 1, 0),
+ new THREE.Vector3(0, 0, 1)
+ ];
+
+ const position = geo.attributes.position;
+ const centers = new Float32Array(position.count * 3);
+
+ for (let i = 0, l = position.count; i < l; i ++) {
+ vectors[i % 3].toArray(centers, i * 3);
+ }
+
+ geo.setAttribute('center', new THREE.BufferAttribute(centers, 3));
+
+ const mat = wireMat.clone();
+ mat.uniforms.color.value = new THREE.Color(color);
+ const mesh = new THREE.Mesh(geo, mat);
+
+ // const fov = 0.1 * Math.PI;
+ // const points = [
+ // // new THREE.Vector2(100 * Math.sin(fov), 100 * Math.cos(fov)),
+ // new THREE.Vector2(0, 0),
+ // new THREE.Vector2(15 * 1000 * Math.sin(fov), 15 * 1000 * Math.cos(fov)),
+ // ];
+ // const coneGeo = new THREE.LatheGeometry(points, 4);
+ const coneGeo = new THREE.ConeGeometry(7.5 * 1000, 15 * 1000, 4);
+ const coneMesh = new THREE.Mesh(
+ coneGeo,
+ new THREE.MeshBasicMaterial({
+ color: new THREE.Color(color),
+ transparent: true,
+ opacity: 0.05,
+ side: THREE.DoubleSide,
+ })
+ );
+ // coneMesh.rotation.x = -Math.PI / 2;
+ // coneMesh.rotation.y = Math.PI / 4;
+ // coneMesh.position.z = 0; // 7.5 * 1000;
+ coneMesh.rotation.x = Math.PI / 2;
+ coneMesh.rotation.y = Math.PI / 4;
+ coneMesh.position.z = -7.5 * 1000;
+
+ const coneMesh2 = new THREE.Mesh(
+ coneGeo,
+ new THREE.MeshBasicMaterial({
+ color: new THREE.Color(color),
+ wireframe: true,
+ })
+ );
+ coneMesh2.rotation.x = -Math.PI / 2;
+ coneMesh2.rotation.y = Math.PI / 4;
+ coneMesh2.position.z = 0; // 7.5 * 1000;
+
+
+ existingMesh = new THREE.Group();
+ existingMesh.add(coneMesh);
+ existingMesh.add(coneMesh2);
+ existingMesh.add(mesh);
+
+ existingMesh.name = 'camera_' + editorId;
+ existingMesh.matrixAutoUpdate = false;
+ realityEditor.gui.threejsScene.addToScene(existingMesh);
+ }
+ realityEditor.gui.threejsScene.setMatrixFromArray(existingMesh.matrix, cameraMatrix);
+ });
+ } catch (e) {
+ console.warn(e);
+ }
+
+ requestAnimationFrame(update);
+ }
+
+ exports.initService = initService;
+})(realityEditor.device.multiclientUI);
+
diff --git a/src/device/onLoad.js b/src/device/onLoad.js
new file mode 100644
index 000000000..51774e742
--- /dev/null
+++ b/src/device/onLoad.js
@@ -0,0 +1,268 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.device");
+
+/**
+ * @fileOverview realityEditor.device.onLoad.js
+ * Sets the application's window.onload function to trigger this init method, which sets up the GUI and networking.
+ */
+
+// add-ons can register a function to be called instead of getVuforiaReady
+realityEditor.device.initFunctions = [];
+
+realityEditor.device.loaded = false;
+/**
+ * When the index.html first finishes loading, set up the:
+ * Sidebar menu buttons,
+ * Pocket and memory bars,
+ * Background canvas,
+ * Touch Event Listeners,
+ * Network callback function,
+ * ... and notify the native iOS code that the user interface finished loading
+ */
+realityEditor.device.onload = async function () {
+
+ realityEditor.gui.modal.createNotificationContainer();
+
+ // Initialize some global variables for the device session
+ this.cout('Running on platform: ' + globalStates.platform);
+ if (globalStates.platform !== 'iPad' && globalStates.platform !== 'iPhone' && globalStates.platform !== 'iPod touch') {
+ globalStates.platform = false;
+ }
+
+ // Add-ons may need to modify globals or do other far-reaching changes that
+ // other services will need to pick up in their initializations
+ await realityEditor.addons.onInit();
+
+ // Check whether we're offline by adding a cache-busting search parameter
+ fetch(window.location + '/?offlineCheck=' + Date.now()).then(res => {
+ if (!res.headers.has('X-Offline-Cache')) {
+ return;
+ }
+
+ let message = 'Network Offline: Showing last known state. Most functionality is disabled.';
+ // showBannerNotification removes notification after set time so no additional function is needed
+ realityEditor.gui.modal.showBannerNotification(message, 'offlineUIcontainer', 'offlineUItext', 5000);
+ });
+
+ // set up the global canvas for drawing the links
+ globalCanvas.canvas = document.getElementById('canvas');
+ globalCanvas.canvas.width = globalStates.height; // TODO: fix width vs height mismatch once and for all
+ globalCanvas.canvas.height = globalStates.width;
+ globalCanvas.context = globalCanvas.canvas.getContext('2d');
+
+ realityEditor.device.environment.initService();
+
+ // adds touch handlers for each of the menu buttons
+ if (!realityEditor.device.environment.variables.overrideMenusAndButtons) {
+ realityEditor.gui.menus.init();
+
+ // set active buttons and preload some images
+ realityEditor.gui.menus.switchToMenu("main", ["gui"], ["reset", "unconstrained"]);
+ realityEditor.gui.buttons.initButtons();
+ }
+
+ // initialize additional services
+ try {
+ realityEditor.device.initService();
+ realityEditor.device.layout.initService();
+ realityEditor.device.modeTransition.initService();
+ realityEditor.device.touchInputs.initService();
+ realityEditor.device.videoRecording.initService();
+ realityEditor.device.tracking.initService();
+ realityEditor.device.profiling.initService();
+ realityEditor.gui.ar.frameHistoryRenderer.initService();
+ realityEditor.gui.ar.grouping.initService();
+ realityEditor.gui.ar.anchors.initService();
+ realityEditor.gui.ar.groundPlaneAnchors.initService();
+ realityEditor.gui.ar.groundPlaneRenderer.initService();
+ realityEditor.gui.ar.areaTargetScanner.initService();
+ realityEditor.gui.ar.areaCreator.initService();
+ realityEditor.gui.ar.videoPlayback.initService();
+ realityEditor.gui.settings.setupSettingsMenu.initService();
+ realityEditor.device.touchPropagation.initService();
+ realityEditor.network.discovery.initService();
+ realityEditor.network.realtime.initService();
+ realityEditor.gui.crafting.initService();
+ realityEditor.worldObjects.initService();
+ realityEditor.device.distanceScaling.initService();
+ realityEditor.device.keyboardEvents.initService();
+ realityEditor.network.frameContentAPI.initService();
+ realityEditor.envelopeManager.initService();
+ realityEditor.network.availableFrames.initService();
+ realityEditor.network.search.initService();
+ realityEditor.sceneGraph.initService();
+ realityEditor.gui.glRenderer.initService();
+ realityEditor.gui.threejsScene.initService();
+ realityEditor.measure.clothSimulation.initService();
+ // realityEditor.device.multiclientUI.initService();
+ realityEditor.avatar.initService();
+ realityEditor.humanPose.initService();
+ realityEditor.motionStudy.initService();
+ realityEditor.oauth.initService();
+ realityEditor.spatialCursor.initService();
+ realityEditor.gui.spatialIndicator.initService();
+ realityEditor.gui.spatialArrow.initService();
+ realityEditor.gui.recentlyUsedBar.initService();
+ realityEditor.gui.envelopeIconRenderer.initService();
+ realityEditor.gui.search.initService();
+ realityEditor.ai.initService();
+ } catch (initError) {
+ // show an error message rather than crash entirely; otherwise Vuforia Engine will never start
+ console.error('error in initService functions, might lead to corrupted app state', initError);
+ try {
+ let initializeMessage = 'Error initializing. Restart app or contact support.';
+ // showBannerNotification removes notification after set time so no additional function is needed
+ realityEditor.gui.modal.showBannerNotification(initializeMessage, 'initializeErrorUI', 'initializeErrorText', 5000);
+ } catch (alertError) {
+ alert(`Error initializing. Restart app or contact support. ${initError}, ${alertError}`);
+ }
+ }
+
+ realityEditor.app.promises.getDeviceReady().then(deviceName => {
+ globalStates.device = deviceName;
+ console.log('The Reality Editor is loaded on a ' + globalStates.device);
+ realityEditor.device.layout.adjustForDevice(deviceName);
+ });
+
+ globalStates.tempUuid = realityEditor.device.utilities.uuidTimeShort();
+ this.cout("This editor's session UUID: " + globalStates.tempUuid);
+
+ // assign global pointers to frequently used UI elements
+ overlayDiv = document.getElementById('overlay');
+ overlayDiv2 = document.getElementById('overlay2');
+
+ // center the menu vertically if the screen is taller than 320 px
+ var MENU_HEIGHT = 320;
+ var menuHeightDifference = globalStates.width - MENU_HEIGHT;
+ document.getElementById('UIButtons').style.top = menuHeightDifference/2 + 'px';
+ CRAFTING_GRID_HEIGHT = globalStates.width - menuHeightDifference;
+
+ // set up the pocket and memory bars
+ if (!TEMP_DISABLE_MEMORIES) {
+ realityEditor.gui.memory.initMemoryBar();
+ } else {
+ var pocketMemoryBar = document.querySelector('.memoryBar');
+ pocketMemoryBar.parentElement.removeChild(pocketMemoryBar);
+ }
+ realityEditor.gui.memory.nodeMemories.initMemoryBar();
+ realityEditor.gui.pocket.pocketInit();
+
+ // add a callback for messages posted up to the application from children iframes
+ window.addEventListener("message", realityEditor.network.onInternalPostMessage.bind(realityEditor.network), false);
+
+ // adds all the event handlers for setting up the editor
+ realityEditor.device.addDocumentTouchListeners();
+
+ // adjust for iPhoneX size if needed
+ realityEditor.device.layout.adjustForScreenSize();
+
+ // adjust when phone orientation changes - also triggers one time immediately with the initial orientation
+ if (realityEditor.device.environment.variables.listenForDeviceOrientationChanges) {
+ realityEditor.app.enableOrientationChanges('realityEditor.device.layout.onOrientationChanged');
+ }
+
+ // prevent touch events on overlayDiv
+ overlayDiv.addEventListener('touchstart', function (e) {
+ e.preventDefault();
+ });
+
+ // release pointerevents that hit the background so that they can trigger pointerenter events on other elements
+ document.body.addEventListener('gotpointercapture', function(evt) {
+ evt.target.releasePointerCapture(evt.pointerId);
+ });
+
+ const SHOW_FPS_STATS = false;
+ let stats;
+ if (SHOW_FPS_STATS) {
+ stats = new Stats();
+ document.body.appendChild(stats.dom);
+ }
+
+ // start TWEEN library for animations
+ (function animate(time) {
+ realityEditor.gui.ar.draw.frameNeedsToBeRendered = true;
+ // TODO This is a hack to keep the crafting board running
+ if (globalStates.freezeButtonState && !realityEditor.device.environment.providesOwnUpdateLoop()) {
+ realityEditor.gui.ar.draw.update(realityEditor.gui.ar.draw.visibleObjectsCopy);
+ }
+ requestAnimationFrame(animate);
+ TWEEN.update(time);
+
+ if (SHOW_FPS_STATS) {
+ stats.update();
+ }
+ })();
+
+ if (realityEditor.device.initFunctions.length === 0) {
+ realityEditor.app.promises.didGrantNetworkPermissions().then(success => {
+ // network permissions are no longer required for the app to function, but we can
+ // provide UI feedback if they try to use a feature (discovering unknown servers) that relies on this
+ if (typeof success === 'boolean') {
+ realityEditor.device.environment.variables.hasLocalNetworkAccess = success;
+ }
+
+ // start the AR framework in native iOS
+ realityEditor.app.promises.getVuforiaReady().then(success => {
+ realityEditor.app.callbacks.vuforiaIsReady(success);
+ });
+ });
+ } else {
+ realityEditor.device.initFunctions.forEach(function(initFunction) {
+ initFunction();
+ });
+ }
+
+ this.cout("onload");
+
+ realityEditor.device.loaded = true;
+};
+
+window.onload = realityEditor.device.onload;
diff --git a/src/device/profiling.js b/src/device/profiling.js
new file mode 100644
index 000000000..6b4b981d8
--- /dev/null
+++ b/src/device/profiling.js
@@ -0,0 +1,264 @@
+createNameSpace("realityEditor.device.profiling");
+
+import { ProfilerSettingsUI } from "../gui/ProfilerSettingsUI.js";
+
+(function(exports) {
+ let isShown = false;
+ let isActivated = false;
+ let profilerSettingsUI = null;
+
+ let processTimes = {};
+ let processCategories = {};
+ let lastUpdateTimes = {};
+ const IDLE_TIMEOUT = 5000; // if 5s pass with no new start/stop processes, reset the average/min/max
+
+ function initService() {
+ realityEditor.network.addPostMessageHandler('profilerStartTimeProcess', (msgContent, _fullMessage) => {
+ startTimeProcess(msgContent.name, { numStopsRequired: msgContent.numStopsRequired });
+ });
+
+ realityEditor.network.addPostMessageHandler('profilerStopTimeProcess', (msgContent, _fullMessage) => {
+ let options = {
+ showMessage: msgContent.showMessage,
+ showAggregate: msgContent.showAggregate,
+ displayTimeout: msgContent.displayTimeout,
+ includeCount: msgContent.includeCount
+ };
+ stopTimeProcess(msgContent.name, msgContent.category, options);
+ });
+
+ realityEditor.network.addPostMessageHandler('profilerLogMessage', (msgContent, _fullMessage) => {
+ let formattedTime = formatLogTime();
+ let displayText = `${msgContent.message} ${formattedTime} `;
+ let options = {
+ displayTimeout: msgContent.displayTimeout
+ }
+ logIndividualProcess(displayText, options);
+ });
+
+ realityEditor.network.addPostMessageHandler('profilerCountMessage', (msgContent, _fullMessage) => {
+ logProcessCount(msgContent.message);
+ });
+ }
+
+ function formatLogTime() {
+ const now = new Date();
+ const hours = String(now.getHours()).padStart(2, '0');
+ const minutes = String(now.getMinutes()).padStart(2, '0');
+ const seconds = String(now.getSeconds()).padStart(2, '0');
+ const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
+
+ // Output will be like: [12:34:56.789]
+ return `[${hours}:${minutes}:${seconds}.${milliseconds}]`;
+ }
+
+ function startTimeProcess(processTitle, options = { numStopsRequired: null }) {
+ if (!isShown) return;
+ if (!isActivated) return;
+
+ if (typeof processTimes[processTitle] === 'undefined') {
+ processTimes[processTitle] = {};
+ }
+ processTimes[processTitle].start = performance.now();
+ if (options.numStopsRequired) {
+ processTimes[processTitle].numStopsRequired = options.numStopsRequired;
+ processTimes[processTitle].numStopsAccumulated = 0;
+ }
+ }
+
+ function stopTimeProcess(processTitle, category, options = { showMessage: false, showAggregate: true, displayTimeout: 3000, includeCount: true }) {
+ if (!isShown) return;
+ if (!isActivated) return;
+ if (!profilerSettingsUI) return;
+
+ let process = processTimes[processTitle];
+ if (typeof process === 'undefined') {
+ return;
+ }
+
+ if (typeof processTimes[processTitle].numStopsRequired !== 'undefined') {
+ processTimes[processTitle].numStopsAccumulated += 1;
+ if (processTimes[processTitle].numStopsAccumulated < processTimes[processTitle].numStopsRequired) {
+ return; // wait until we receive enough stops
+ }
+ }
+
+ process.end = performance.now();
+
+ let timeBetweenCategoryUpdates = process.end - (lastUpdateTimes[category] || 0);
+ lastUpdateTimes[processTitle] = performance.now();
+
+ if (category) {
+ lastUpdateTimes[category] = lastUpdateTimes[processTitle];
+ }
+
+ if (!process.start || !process.end) return;
+
+ let time = (process.end - process.start)
+ let displayTime = time.toFixed(2);
+ let numStopsText = processTimes[processTitle].numStopsAccumulated ? `(${processTimes[processTitle].numStopsAccumulated} stops)` : '';
+ let labelText = `${processTitle}: ${yellow(displayTime)} ms ${numStopsText}`;
+
+ setTimeout(() => {
+ delete processTimes[processTitle];
+ }, 10);
+
+ if (options.showMessage) {
+ logIndividualProcess(processTitle, labelText, options);
+ }
+
+ if (!category) return;
+ if (!options.showAggregate) return;
+
+ let info = updateCategory(category, time, timeBetweenCategoryUpdates);
+ if (info) {
+ let meanT = info.mean.toFixed(2);
+ let minT = info.fastest.toFixed(2);
+ let maxT = info.slowest.toFixed(2);
+ let countString = options.includeCount ? ` (${info.count})` : '';
+ let meanLabelText = `${category}${countString} โโ mean: ${yellow(meanT)} โโ min: ${yellow(minT)} โโ max: ${yellow(maxT)}`;
+ profilerSettingsUI.addOrUpdateLabel(`mean_${category}`, meanLabelText, { pinToTop: true });
+ } else {
+ console.warn('no category info', category, processTitle, processCategories);
+ }
+ }
+
+ function logIndividualProcess(processTitle, options = { displayTimeout: 3000, labelText: null }) {
+ if (!isShown) return;
+ if (!isActivated) return;
+ if (!profilerSettingsUI) return;
+
+ profilerSettingsUI.addOrUpdateLabel(processTitle, options.labelText || processTitle);
+
+ // remove after 3 seconds if no updates between now and then
+ setTimeout(() => {
+ // TODO: if logging as part of stopTime with aggregate, don't remove later labels when earlier labels' timeouts trigger
+ profilerSettingsUI.removeLabel(processTitle);
+ }, options.displayTimeout);
+ }
+
+ function logProcessCount(processTitle) {
+ if (!isShown) return;
+ if (!isActivated) return;
+ if (!profilerSettingsUI) return;
+
+ let categoryName = `${processTitle}_count`;
+ if (typeof processCategories[categoryName] === 'undefined') {
+ processCategories[categoryName] = {
+ count: 1
+ };
+ } else {
+ processCategories[categoryName].count += 1;
+ }
+
+ let countLabelText = `${processTitle}: ${processCategories[categoryName].count} times `;
+ profilerSettingsUI.addOrUpdateLabel(`${categoryName}`, countLabelText, { pinToTop: true });
+ }
+
+ // show aggregate mean/min/max times for recent tasks of this category
+ function updateCategory(category, time, timeBetweenCategoryUpdates) {
+ if (typeof processCategories[category] === 'undefined') {
+ processCategories[category] = {
+ fastest: time,
+ slowest: time,
+ mean: time,
+ count: 1,
+ numDisplayResets: 0
+ };
+ } else if (timeBetweenCategoryUpdates > IDLE_TIMEOUT) {
+ let numDisplayResets = processCategories[category].numDisplayResets + 1;
+ processCategories[category] = {
+ fastest: time,
+ slowest: time,
+ mean: time,
+ count: 1,
+ numDisplayResets: numDisplayResets
+ };
+ } else {
+ let prevCount = processCategories[category].count;
+ let prevMean = processCategories[category].mean;
+
+ processCategories[category].fastest = Math.min(processCategories[category].fastest, time);
+ processCategories[category].slowest = Math.max(processCategories[category].slowest, time);
+ processCategories[category].mean = (prevCount * prevMean + time) / (prevCount + 1); // update mean
+ processCategories[category].count += 1;
+ }
+
+ return processCategories[category];
+ }
+
+ function yellow(text) {
+ return `${text} `;
+ }
+
+ function show() {
+ isShown = true;
+ if (!profilerSettingsUI) {
+ profilerSettingsUI = new ProfilerSettingsUI();
+ }
+ profilerSettingsUI.show();
+ profilerSettingsUI.setEnableMetrics(true);
+ }
+
+ function hide() {
+ isShown = false;
+ if (profilerSettingsUI) {
+ profilerSettingsUI.hide();
+ }
+ }
+
+ function activate() {
+ isActivated = true;
+ }
+
+ function deactivate() {
+ isActivated = false;
+ }
+
+ function isEnabled() {
+ return isShown && isActivated;
+ }
+
+ // Helper functions useful for users of startTimeProcess/stopTimeProcess
+
+ // computes the FNV-1a hash of a string - useful as a UUID for a stringified matrix
+ function getShortHashForString(str) {
+ let hash = 2166136261n; // Initialize to an offset_basis for FNV-1a 32bit
+ for(let i = 0; i < str.length; i++) {
+ hash ^= BigInt(str.charCodeAt(i));
+ hash *= 16777619n;
+ }
+ return (hash & 0xFFFFFFFFn).toString(16).padStart(8, '0');
+ }
+
+ // helper function to count the number of frames on all objects which are subscribed to matrices
+ function countSubscribedFrames() {
+ return Object.values(objects).reduce((totalCount, obj) => {
+ const thisObjectCount = Object.keys(obj.frames).reduce((frameCount, frameKey) => {
+ let frame = obj.frames[frameKey];
+ let sendsMatrix = frame.sendMatrix || (frame.sendMatrices && (frame.sendMatrices.devicePose ||
+ frame.sendMatrices.groundPlane || frame.sendMatrices.anchoredModelView ||
+ frame.sendMatrices.allObjects || frame.sendMatrices.model || frame.sendMatrices.view));
+ return frameCount + (sendsMatrix ? 1 : 0);
+ }, 0);
+ return totalCount + thisObjectCount;
+ }, 0);
+ }
+
+ exports.initService = initService;
+ exports.show = show;
+ exports.hide = hide;
+ exports.activate = activate;
+ exports.deactivate = deactivate;
+ exports.isEnabled = isEnabled;
+ // logging methods
+ exports.startTimeProcess = startTimeProcess;
+ exports.stopTimeProcess = stopTimeProcess;
+ exports.logIndividualProcess = logIndividualProcess;
+ exports.logProcessCount = logProcessCount;
+ // helper function
+ exports.getShortHashForString = getShortHashForString;
+ exports.countSubscribedFrames = countSubscribedFrames;
+}(realityEditor.device.profiling));
+
+export const initService = realityEditor.device.profiling.initService;
diff --git a/src/device/touchInputs.js b/src/device/touchInputs.js
new file mode 100644
index 000000000..1d86c07d7
--- /dev/null
+++ b/src/device/touchInputs.js
@@ -0,0 +1,54 @@
+createNameSpace("realityEditor.device.touchInputs");
+
+/**
+ * @fileOverview realityEditor.device.touchInputs.js
+ * Provides a central location where document multi-touch events are handled.
+ * Additional modules and experiments (e.g. the screenExtension) can plug into these for touch interaction.
+ */
+
+(function(exports) {
+
+ /**
+ * Public init method sets up module and registers callbacks in other modules
+ */
+ function initService() {
+ realityEditor.gui.ar.draw.addUpdateListener(update);
+ }
+
+ /**
+ * Document touch down event handler that is always present.
+ * @param {TouchEvent} eventObject
+ */
+ function screenTouchStart(eventObject){
+ realityEditor.gui.screenExtension.touchStart(eventObject)
+ }
+
+ /**
+ * Document touch up event handler that is always present.
+ * @param {TouchEvent} eventObject
+ */
+ function screenTouchEnd(eventObject){
+ realityEditor.gui.screenExtension.touchEnd(eventObject);
+ }
+
+ /**
+ * Document touch move event handler that is always present.
+ * @param {TouchEvent} eventObject
+ */
+ function screenTouchMove(eventObject){
+ realityEditor.gui.screenExtension.touchMove(eventObject);
+ }
+
+ /**
+ * Update function that is always present and gets called as often as Vuforia update loop (AR rendering) occurs.
+ */
+ function update(){
+ realityEditor.gui.screenExtension.update();
+ }
+
+ exports.initService = initService;
+ exports.screenTouchStart = screenTouchStart;
+ exports.screenTouchEnd = screenTouchEnd;
+ exports.screenTouchMove = screenTouchMove;
+
+})(realityEditor.device.touchInputs);
diff --git a/src/device/touchPropagation.js b/src/device/touchPropagation.js
new file mode 100644
index 000000000..b6efda150
--- /dev/null
+++ b/src/device/touchPropagation.js
@@ -0,0 +1,169 @@
+createNameSpace("realityEditor.device.touchPropagation");
+
+/**
+ * @fileOverview realityEditor.device.touchPropagation.js
+ * Allows touches to be rejected or accepted by fullscreen and non-fullscreen frames,
+ * and to pass through them to the next overlapping frame if possible
+ */
+
+(function(exports) {
+
+ /**
+ * The cachedTarget stores which frame ultimately accepted your touchdown event,
+ * so that it can be used as the target for future touchmove events rather than recalculating each time.
+ * @type {string} - uuid of the frame
+ */
+ var cachedTarget = null;
+
+ /**
+ * Sets up the touch propagation model by listening for accepted and unaccepted touches
+ */
+ function initService() {
+ // listen for messages posted up from frame content windows
+ realityEditor.network.addPostMessageHandler('unacceptedTouch', handleUnacceptedTouch);
+ realityEditor.network.addPostMessageHandler('acceptedTouch', handleAcceptedTouch);
+
+ // be notified when certain touch event functions get triggered in device/index.js
+ realityEditor.device.registerCallback('resetEditingState', resetCachedTarget);
+ realityEditor.device.registerCallback('onDocumentMultiTouchEnd', resetCachedTarget);
+
+ // handle touch events that hit realityInteraction divs within frames
+ realityEditor.network.addPostMessageHandler('pointerDownResult', handlePointerDownResult);
+ }
+
+ function handlePointerDownResult(eventData, fullMessageContent) {
+ // pointerDownResult
+ console.log(eventData, fullMessageContent);
+
+ if (eventData === 'interaction') {
+ console.log('TODO: cancel the moveDelay timer to prevent accidental moves?');
+ } else if (eventData === 'nonInteraction') {
+ console.log('TODO: immediately begin moving!');
+ realityEditor.device.beginTouchEditing(fullMessageContent.object, fullMessageContent.frame, null);
+ // clear the timer that would start dragging the previously traversed frame
+ realityEditor.device.clearTouchTimer();
+
+ }
+ }
+
+ /**
+ * When a touch goes into an frame that has registered a touchDecider function, it has the option to reject a touch
+ * (meaning the touch did not collide with any of its contents). In this case, we calculate the next frame underneath
+ * that one, (if any), and send the touch into it to see whether this one will accept it.
+ * @param {{x: number, y: number, pointerId: number, type: string, pointerType: string}} eventData - touch event data
+ * @param {Object} fullMessageContent - the full JSON message posted by the frame, including ID of its object, frame, etc
+ */
+ function handleUnacceptedTouch(eventData, fullMessageContent) {
+
+ console.log('handleUnacceptedTouch');
+ // eventData.x is the x coordinate projected within the previouslyTouched iframe. we need to get position on screen
+ var touchPosition = realityEditor.gui.ar.positioning.getMostRecentTouchPosition();
+ eventData.x = touchPosition.x;
+ eventData.y = touchPosition.y;
+
+ // clear the timer that would start dragging the previously traversed frame
+ realityEditor.device.clearTouchTimer();
+
+ // don't recalculate correct target on every touchmove if already cached the target
+ if (cachedTarget) {
+ stopHidingFramesForTouchDuration();
+ var touchedElement = document.getElementById(cachedTarget);
+ dispatchSyntheticEvent(touchedElement, eventData);
+ return;
+ }
+
+ // tag the element that rejected the touch so that it becomes hidden but can be restored
+ var previouslyTouchedElement = globalDOMCache['object' + fullMessageContent.frame];
+ previouslyTouchedElement.dataset.displayAfterTouch = previouslyTouchedElement.style.display;
+
+ // hide each tagged element. we may need to hide more than just this previouslyTouchedElement
+ // (in case there are multiple fullscreen frames)
+ var overlappingDivs = realityEditor.device.utilities.getAllDivsUnderCoordinate(eventData.x, eventData.y);
+ overlappingDivs.filter(function(elt) {
+ return (elt.parentNode && typeof elt.parentNode.dataset.displayAfterTouch !== 'undefined');
+ }).forEach(function(elt) {
+ elt.parentNode.style.display = 'none'; // TODO: instead of changing display, maybe just change pointerevents css to none
+ });
+
+ // find the next overlapping div that hasn't been traversed (and therefore hidden) yet
+ var newTouchedElement = document.elementFromPoint(eventData.x, eventData.y) || document.body;
+ // var newCoords = webkitConvertPointFromPageToNode(newTouchedElement, new WebKitPoint(eventData.x, eventData.y));
+ // eventData.x = newCoords.x;
+ // eventData.y = newCoords.y;
+ dispatchSyntheticEvent(newTouchedElement, eventData);
+
+ // re-show each tagged element
+ overlappingDivs.filter(function(elt) {
+ return (elt.parentNode && typeof elt.parentNode.dataset.displayAfterTouch !== 'undefined');
+ }).forEach(function(elt) {
+ elt.parentNode.style.display = elt.parentNode.dataset.displayAfterTouch;
+ });
+
+ // we won't get an acceptedTouch message if the newTouchedElement isn't a frame, so auto-trigger it
+ var isFrameElement = newTouchedElement.id.indexOf(fullMessageContent.object) > -1;
+ if (!isFrameElement) {
+ handleAcceptedTouch(eventData, {frame: newTouchedElement.id});
+ }
+
+ }
+
+ /**
+ * When a touch goes into a frame and the frame doesn't actively reject it, it will send back
+ * an acceptedTouch message. When we receive this, cache the target frame as a shortcut for
+ * future touch events, and restore any state that was modified while searching for this target.
+ * @param {{x: number, y: number, pointerId: number, type: string, pointerType: string}} eventData - touch event data
+ * @param {Object} fullMessageContent - the full JSON message posted by the frame, including ID of its object, frame, etc
+ */
+ function handleAcceptedTouch(eventData, fullMessageContent) {
+ if (eventData.type === 'pointerdown') {
+ cachedTarget = fullMessageContent.frame;
+ }
+
+ stopHidingFramesForTouchDuration();
+ }
+
+ /**
+ * Remove tag from frames that have been hidden for the current touch.
+ */
+ function stopHidingFramesForTouchDuration() {
+ Array.from(document.querySelectorAll('[data-display-after-touch]')).forEach(function(element) {
+ delete element.dataset.displayAfterTouch;
+ });
+ }
+
+ /**
+ * Helper function to trigger a fake pointer event on the specified target
+ * @param {HTMLElement} target
+ * @param {{x: number, y: number, pointerId: number, type: string, pointerType: string}} eventData
+ */
+ function dispatchSyntheticEvent(target, eventData) {
+ var syntheticEvent = new PointerEvent(eventData.type, {
+ view: window,
+ bubbles: true,
+ cancelable: true,
+ pointerId: eventData.pointerId,
+ pointerType: eventData.pointerType,
+ x: eventData.x,
+ y: eventData.y,
+ clientX: eventData.x,
+ clientY: eventData.y,
+ pageX: eventData.x,
+ pageY: eventData.y,
+ screenX: eventData.x,
+ screenY: eventData.y
+ });
+ target.dispatchEvent(syntheticEvent);
+ }
+
+ /**
+ * On touch up (or any other reason editing state should reset), clears the cached target frame
+ * so that we can recalculate a new target on the next touch down event
+ */
+ function resetCachedTarget() {
+ cachedTarget = null;
+ stopHidingFramesForTouchDuration();
+ }
+
+ exports.initService = initService;
+
+})(realityEditor.device.touchPropagation);
diff --git a/src/device/tracking.js b/src/device/tracking.js
new file mode 100644
index 000000000..348f29d4c
--- /dev/null
+++ b/src/device/tracking.js
@@ -0,0 +1,176 @@
+createNameSpace("realityEditor.device.tracking");
+
+/**
+ * @fileOverview
+ * This module is responsible for responding to information about the device's tracking state and capabilities,
+ * It can enable/disable/restart behavior as needed and communicate the tracking status to the user.
+ */
+(function(exports) {
+
+ let isRelocalizing = false;
+ let timeRelocalizing = 0;
+ let relocalizingStartTime = null;
+
+ let currentStatusInfo = null;
+
+ function initService() {
+ realityEditor.app.subscribeToAppLifeCycleEvents('realityEditor.device.tracking.onAppLifeCycleEvent');
+
+ realityEditor.app.callbacks.handleDeviceTrackingStatus(handleTrackingStatus);
+
+ let cameraExists = realityEditor.sceneGraph && realityEditor.sceneGraph.getSceneNodeById('CAMERA');
+
+ let isTrackingInitialized = !realityEditor.device.environment.waitForARTracking();
+
+ if (cameraExists && !realityEditor.gui.ar.utilities.isIdentityMatrix(realityEditor.sceneGraph.getSceneNodeById('CAMERA').worldMatrix)) {
+ isTrackingInitialized = true;
+ }
+
+ if (!isTrackingInitialized) {
+ waitForTracking(true);
+ }
+ }
+
+ function waitForTracking(noDescriptionText) {
+
+ // hide all AR elements and canvas lines
+ document.getElementById('GUI').classList.add('hiddenWhileLoading');
+ document.getElementById('canvas').classList.add('hiddenWhileLoading');
+
+ let headerText = 'Initializing AR Tracking...';
+ let descriptionText = noDescriptionText ? '' : 'Move your camera around to speed up the process';
+
+ let notification = realityEditor.gui.modal.showSimpleNotification(
+ headerText, descriptionText, function () {
+ console.log('closed...');
+ }, realityEditor.device.environment.variables.layoutUIForPortrait);
+
+ const dismissNotification = () => {
+ document.getElementById('GUI').classList.remove('hiddenWhileLoading');
+ document.getElementById('canvas').classList.remove('hiddenWhileLoading');
+ notification.dismiss();
+ };
+
+ realityEditor.app.callbacks.onTrackingInitialized(dismissNotification);
+ realityEditor.app.callbacks.onVuforiaInitFailure(dismissNotification);
+ }
+
+ function onAppLifeCycleEvent(eventName) {
+ console.log('APP LIFE-CYCLE EVENT: ' + eventName);
+
+ switch (eventName) {
+ case 'appDidBecomeActive':
+ break;
+ case 'appWillResignActive':
+ break;
+ case 'appDidEnterBackground':
+ // hide AR elements and show UI until we receive a new valid camera matrix
+ waitForTracking();
+ break;
+ case 'appWillEnterForeground':
+ break;
+ case 'appWillTerminate':
+ break;
+ default:
+ break;
+ }
+ }
+
+
+ /*
+ NORMAL, ///< Status is normal, ie not \ref NO_POSE or \ref LIMITED.
+ UNKNOWN, ///< Unknown reason for the tracking status.
+ INITIALIZING, ///< The tracking system is currently initializing.
+ RELOCALIZING, ///< The tracking system is currently relocalizing.
+ EXCESSIVE_MOTION, ///< The device is moving too fast.
+ INSUFFICIENT_FEATURES, ///< There are insufficient features available in the scene.
+ INSUFFICIENT_LIGHT, ///< There is insufficient light available in the scene.
+ NO_DETECTION_RECOMMENDING_GUIDANCE ///< Could not snap the target
+ */
+
+ function handleTrackingStatus(trackingStatus, trackingStatusInfo) {
+ if (trackingStatus === 'LIMITED') {
+ // show the UI
+ showLimitedTrackingUI(trackingStatusInfo);
+ currentStatusInfo = trackingStatusInfo;
+
+ } else {
+ currentStatusInfo = null;
+ }
+ }
+
+ function showLimitedTrackingUI(statusInfo) {
+ let readableStatus = 'Limited AR tracking';
+
+ switch (statusInfo) {
+ case 'INITIALIZING':
+ readableStatus += ' - Initializing';
+ break;
+ case 'RELOCALIZING':
+ if (!isRelocalizing) {
+ relocalizingStartTime = Date.now();
+ isRelocalizing = true;
+ } else {
+ timeRelocalizing = Date.now() - relocalizingStartTime;
+ }
+
+ // TODO: only bother relocalizing if any tools have been added to world local - otherwise it
+ // shouldn't matter whether you restart tracking immediately
+ if (timeRelocalizing > 4000) {
+ if (willRelocalizingHaveEffect()) {
+ readableStatus = 'Trouble re-localizing - move device to the same position it was at when the app was last closed, or tap here to restart AR tracking';
+ } else {
+ realityEditor.app.restartDeviceTracker();
+ }
+ } else {
+ readableStatus += ' - Re-localizing device';
+ }
+ break;
+ case 'EXCESSIVE_MOTION':
+ readableStatus += ' - Excessive motion';
+ break;
+ case 'INSUFFICIENT_FEATURES':
+ readableStatus += ' - Insufficient features in view';
+ break;
+ case 'INSUFFICIENT_LIGHT':
+ readableStatus += ' - View is too dark';
+ break;
+ case 'NO_DETECTION_RECOMMENDING_GUIDANCE':
+ break;
+ default:
+ break;
+ }
+
+ if (statusInfo !== 'RELOCALIZING') {
+ isRelocalizing = false;
+ relocalizingStartTime = null;
+ timeRelocalizing = 0;
+ }
+
+ // create UI if needed
+ // showBannerNotification removes notification after set time so no additional function is needed
+ let trackingStatusUI = document.getElementById('trackingStatusUI');
+ if (!trackingStatusUI) {
+ realityEditor.gui.modal.showBannerNotification(readableStatus, 'trackingStatusUI', 'trackingStatusText', 5000);
+ let trackingStatusNotification = document.getElementById('trackingStatusUI');
+ trackingStatusNotification.addEventListener('pointerup', statusBarPointerUp);
+ }
+ }
+
+ function willRelocalizingHaveEffect() {
+ // if there are no tools attached to _WORLD_local, it doesn't matter, so just restart instead of prompting user
+ let localWorldObject = realityEditor.getObject(realityEditor.worldObjects.getLocalWorldId());
+ return (localWorldObject && Object.keys(localWorldObject.frames).length > 0);
+ }
+
+ function statusBarPointerUp() {
+ if (currentStatusInfo === 'RELOCALIZING') {
+ console.log('tapped on relocalizing banner');
+ realityEditor.app.restartDeviceTracker();
+ }
+ }
+
+ exports.initService = initService;
+ exports.onAppLifeCycleEvent = onAppLifeCycleEvent; // public so accessible as native app API callback
+
+}(realityEditor.device.tracking));
diff --git a/src/device/utilities.js b/src/device/utilities.js
new file mode 100644
index 000000000..e229f8324
--- /dev/null
+++ b/src/device/utilities.js
@@ -0,0 +1,301 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/**
+ * Created by heun on 12/27/16.
+ */
+
+createNameSpace("realityEditor.device.utilities");
+
+/**
+ * @fileOverview realityEditor.device.utilities.js
+ * Provides device-level utility functions such as generating UUIDs and logging debug messages.
+ */
+
+/**
+ * @desc function to print to console based on debug mode set to true
+ **/
+window.cout = function cout() {
+ if (globalStates.debug) {
+ console.log.apply(this, arguments);
+ }
+}
+
+/**
+ * Generates a random 12 character unique identifier using uppercase, lowercase, and numbers (e.g. "OXezc4urfwja")
+ * @return {string}
+ */
+realityEditor.device.utilities.uuidTime = function () {
+ var dateUuidTime = new Date();
+ var abcUuidTime = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ var stampUuidTime = parseInt(Math.floor((Math.random() * 199) + 1) + "" + dateUuidTime.getTime()).toString(36);
+ while (stampUuidTime.length < 12) stampUuidTime = abcUuidTime.charAt(Math.floor(Math.random() * abcUuidTime.length)) + stampUuidTime;
+ return stampUuidTime;
+};
+
+/**
+ * Generates a random 8 character unique identifier using uppercase, lowercase, and numbers (e.g. "jzY3y338")
+ * @return {string}
+ */
+realityEditor.device.utilities.uuidTimeShort = function () {
+ var dateUuidTime = new Date();
+ var abcUuidTime = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ var stampUuidTime = parseInt("" + dateUuidTime.getMilliseconds() + dateUuidTime.getMinutes() + dateUuidTime.getHours() + dateUuidTime.getDay()).toString(36);
+ while (stampUuidTime.length < 8) stampUuidTime = abcUuidTime.charAt(Math.floor(Math.random() * abcUuidTime.length)) + stampUuidTime;
+ return stampUuidTime;
+};
+
+/**
+ * Generates a random integer between min and max, including both ends of the range.
+ * (e.g. min=1, max=3, can return 1, 2, or 3)
+ * @param {number} min
+ * @param {number} max
+ * @return {number}
+ */
+realityEditor.device.utilities.randomIntInc = function(min, max) {
+ return Math.floor(Math.random() * (max - min + 1) + min);
+};
+
+// ----- Utilities for adding and removing events in a stable way ----- //
+
+/**
+ * Converts the string it is called on into a 32-bit integer hash code
+ * (e.g. 'abcdef'.hashCode() = -1424385949)
+ * The same string always returns the same hash code, which can be easily compared for equality.
+ * Source: http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
+ * @return {number}
+ */
+String.prototype.hashCode = function() {
+ var hash = 0, i, chr;
+ if (this.length === 0) return hash;
+ for (i = 0; i < this.length; i++) {
+ chr = this.charCodeAt(i);
+ hash = ((hash << 5) - hash) + chr;
+ hash |= 0; // Convert to 32bit integer
+ }
+ return hash;
+};
+
+/**
+ * Adds an event listener in a special way so that it can be properly removed later,
+ * even if its function signature changed when it was added with bind, by storing a UUID reference to it in a dictionary.
+ * https://stackoverflow.com/questions/11565471/removing-event-listener-which-was-added-with-bind
+ *
+ * @example this.addBoundListener(div, 'pointerdown', realityEditor.gui.crafting.eventHandlers.onPointerDown, realityEditor.gui.crafting.eventHandlers);
+ *
+ * @param {HTMLElement} element - the element to add the eventListener to
+ * @param {string} eventType - the type of the event, e.g. 'pointerdown'
+ * @param {Function} functionReference - the function to trigger
+ * @param {object} bindTarget - the argument to go within functionReference.bind(___)
+ */
+realityEditor.device.utilities.addBoundListener = function(element, eventType, functionReference, bindTarget) {
+ var boundFunctionReference = functionReference.bind(bindTarget);
+ var functionUUID = this.getEventUUID(element, eventType, functionReference);
+ if (boundListeners.hasOwnProperty(functionUUID)) {
+ this.removeBoundListener(element, eventType, functionReference);
+ }
+ boundListeners[functionUUID] = boundFunctionReference;
+ element.addEventListener(eventType, boundFunctionReference, false);
+};
+
+/**
+ * Generates a unique string address for a bound event listener, so that it can be looked up again.
+ * @param {HTMLElement} element
+ * @param {string} eventType
+ * @param {Function} functionReference
+ * @return {string} - e.g. myDiv_pointerdown_1424385949
+ */
+realityEditor.device.utilities.getEventUUID = function(element, eventType, functionReference) {
+ return element.id + '_' + eventType + '_' + functionReference.toString().hashCode();
+};
+
+// function getBoundListener(element, eventType, functionReference) {
+// var functionUUID = getEventUUID(element, eventType, functionReference);
+// return boundListeners[functionUUID];
+// }
+
+/**
+ * Looks up the bound listener by its eventUUID, and properly removes it.
+ * @param element
+ * @param eventType
+ * @param functionReference
+ */
+realityEditor.device.utilities.removeBoundListener = function(element, eventType, functionReference) {
+ var functionUUID = this.getEventUUID(element, eventType, functionReference);
+ var boundFunctionReference = boundListeners[functionUUID];
+ if (boundFunctionReference) {
+ element.removeEventListener(eventType, boundFunctionReference, false);
+ delete boundListeners[functionUUID];
+ }
+};
+
+/**
+ * Helper function to get a list of all divs intersecting a given screen (x, y) coordinate.
+ * @param {number} x
+ * @param {number} y
+ * @return {Array.}
+ */
+realityEditor.device.utilities.getAllDivsUnderCoordinate = function(x, y) {
+ return document.elementsFromPoint(x,y).filter(elt => {
+ return elt.tagName !== 'BODY' && elt.tagName !== 'HTML';
+ });
+};
+
+/**
+ * Decodes an image/jpeg encoded as a base64 string, into a blobUrl that can be loaded as an img src
+ * https://stackoverflow.com/questions/7650587/using-javascript-to-display-blob
+ * @param {string} base64String - a Base64 encoded string representation of a jpg image
+ * @return {string}
+ */
+realityEditor.device.utilities.decodeBase64JpgToBlobUrl = function(base64String) {
+ var blob = this.b64toBlob(base64String, 'image/jpeg');
+ var blobUrl = URL.createObjectURL(blob);
+ return blobUrl;
+
+};
+
+/**
+ * https://stackoverflow.com/questions/16245767/creating-a-blob-from-a-base64-string-in-javascript
+ * @param {string} b64Data - a Base64 encoded string
+ * @param {string} contentType - the MIME type, e.g. 'image/jpeg', 'video/mp4', or 'text/plain' (
+ * @param {number|undefined} sliceSize - number of bytes to process at a time (default 512). Affects performance.
+ * @return {Blob}
+ */
+realityEditor.device.utilities.b64toBlob = function(b64Data, contentType, sliceSize) {
+ contentType = contentType || '';
+ sliceSize = sliceSize || 512;
+
+ var byteCharacters = atob(b64Data);
+ var byteArrays = [];
+
+ for (var offset = 0; offset < byteCharacters.length; offset += sliceSize) {
+ var slice = byteCharacters.slice(offset, offset + sliceSize);
+
+ var byteNumbers = new Array(slice.length);
+ for (var i = 0; i < slice.length; i++) {
+ byteNumbers[i] = slice.charCodeAt(i);
+ }
+
+ var byteArray = new Uint8Array(byteNumbers);
+
+ byteArrays.push(byteArray);
+ }
+
+ return new Blob(byteArrays, {type: contentType});
+};
+
+/**
+ * Computes the difference between two arrays of primitive values (string or number - not objects)
+ * and presents the results in terms of what was added or subtracted from the first to the second
+ * @param {Array} oldArray
+ * @param {Array} newArray
+ * @return {{additions: Array, subtractions: Array, isEqual: boolean}}
+ */
+realityEditor.device.utilities.diffArrays = function(oldArray, newArray) {
+ var additions = [];
+ var subtractions = [];
+ var isEqual = true;
+
+ if (oldArray && newArray) {
+ oldArray.forEach(function(elt) {
+ if (newArray.indexOf(elt) === -1) {
+ subtractions.push(elt);
+ isEqual = false;
+ }
+ });
+
+ newArray.forEach(function(elt) {
+ if (oldArray.indexOf(elt) === -1) {
+ additions.push(elt);
+ isEqual = false;
+ }
+ });
+ } else {
+ if (!oldArray && newArray) {
+ additions = newArray;
+ isEqual = false;
+ }
+
+ if (oldArray && !newArray) {
+ subtractions = oldArray;
+ isEqual = false;
+ }
+ }
+
+ return {
+ additions: additions,
+ subtractions: subtractions,
+ isEqual: isEqual
+ }
+};
+
+/**
+ * Helper function tells if tapped the background (and excludes edge-case: multi-touch gesture while selecting a vehicle)
+ * @param {PointerEvent} event
+ * @return {boolean}
+ */
+realityEditor.device.utilities.isEventHittingBackground = function(event) {
+ let activeVehicle = realityEditor.device.getEditingVehicle();
+ return (event.target.tagName === 'BODY' || event.target.id === 'mainThreejsCanvas' ||
+ event.target.id === 'canvas' || event.target.id === 'groupSVG' ||
+ event.target.className === 'memoryBackground') && !activeVehicle;
+};
+
+/**
+ * Helper function to take the id of a DOM element and give the uuid
+ * e.g. (svguuid -> uuid) or (uuidcorners -> uuid)
+ * @todo: what if an object's name starts with svg?
+ * @param {string} targetId
+ * @return {string}
+ */
+realityEditor.device.utilities.getVehicleIdFromTargetId = function(targetId) {
+ targetId = targetId.replace(/^(svg)/,'');
+ targetId = targetId.replace(/(corners)$/,'');
+ return targetId;
+};
diff --git a/src/device/videoRecording.js b/src/device/videoRecording.js
new file mode 100644
index 000000000..e81ccacf8
--- /dev/null
+++ b/src/device/videoRecording.js
@@ -0,0 +1,360 @@
+createNameSpace("realityEditor.device.videoRecording");
+
+/**
+ * @fileOverview realityEditor.device.videoRecording.js
+ * Contains the service code to interact with the native API for recording
+ * the camera feed and adding video frames to objects.
+ * Shows visual feedback while recording.
+ */
+
+(function(exports) {
+
+ //TODO: no need to keep in privateState object - can be independent local variables
+ var privateState = {
+ isRecording: false,
+ visibleObjects: {},
+ recordingObjectKey: null,
+ startMatrix: null,
+ virtualizerCallback: null
+ };
+
+ /**
+ * Public init method sets up module and registers callbacks in other modules
+ */
+ function initService() {
+
+ realityEditor.gui.ar.draw.addUpdateListener(function(visibleObjects) {
+
+ // highlight or dim the video record button if there are visible objects, to show that it is able to be used
+ var noVisibleObjects = Object.keys(visibleObjects).length === 0;
+ if (realityEditor.gui.settings.toggleStates.videoRecordingEnabled) {
+ var buttonOpacity = (noVisibleObjects && !privateState.isRecording) ? 0.2 : 1.0;
+ var recordButton = document.querySelector('#recordButton');
+ if (recordButton) {
+ recordButton.style.opacity = buttonOpacity;
+ }
+ }
+
+ privateState.visibleObjects = visibleObjects;
+
+ });
+ }
+
+ /**
+ * Starts or stops recording, and returns whether the recording is newly turned on (true) or off (false)
+ * @return {boolean}
+ */
+ function toggleRecording() {
+ if (privateState.isRecording) {
+ stopRecording();
+ return false;
+ } else {
+ startRecordingOnClosestObject();
+ return true;
+ }
+ }
+
+ /**
+ * Starts a camera recording that will attach itself as a frame to the closest object when finished
+ */
+ function startRecordingOnClosestObject() {
+ if (privateState.isRecording) {
+ console.log('cannot start new recording until previous is finished');
+ return;
+ }
+ var closestObjectKey = realityEditor.gui.ar.getClosestObject()[0];
+ if (closestObjectKey) {
+ // var startingMatrix = realityEditor.getObject(closestObjectKey)
+ // var startingMatrix = privateState.visibleObjects[closestObjectKey] || realityEditor.gui.ar.utilities.newIdentityMatrix();
+ // realityEditor.app.startVideoRecording(closestObjectKey, startingMatrix); // TODO: don't need to send in starting matrix anymore
+ realityEditor.app.startVideoRecording(closestObjectKey, realityEditor.getObject(closestObjectKey).ip);
+ privateState.isRecording = true;
+ privateState.recordingObjectKey = closestObjectKey;
+ privateState.startMatrix = realityEditor.gui.ar.utilities.copyMatrix(privateState.visibleObjects[closestObjectKey]);
+ getRecordingIndicator().style.display = 'inline';
+ }
+ }
+
+ /**
+ * Stops recording a current video and sends it to server to add as a frame
+ */
+ function stopRecording() {
+ if (!privateState.isRecording) {
+ console.log('cannot stop a recording because a recording was not started');
+ return;
+ }
+
+ var videoId = realityEditor.device.utilities.uuidTime();
+
+ createVideoFrame(privateState.recordingObjectKey, videoId, privateState.visibleObjects[privateState.recordingObjectKey]);
+
+ realityEditor.app.stopVideoRecording(videoId);
+ privateState.isRecording = false;
+ privateState.recordingObjectKey = null;
+ getRecordingIndicator().style.display = 'none';
+ }
+
+ /**
+ * Programmatically generates a videoRecording frame, attached to the specified object at the given location,
+ * with the provided videoId which can be used to download the video file from the server.
+ * @param {string} objectKey - objectId to attach to
+ * @param {string} videoId - uuid of the video file (without the .mp4)
+ * @param {Array.} objectMatrix - the matrix at the camera position when you stop recording.
+ * if the object isn't visible, uses the camera position from when you started recording.
+ */
+ function createVideoFrame(objectKey, videoId, objectMatrix) {
+ if (typeof objectMatrix === 'undefined') {
+ objectMatrix = privateState.startMatrix;
+ }
+
+ var object = realityEditor.getObject(objectKey);
+
+ var frameType = 'videoRecording';
+ var frameKey = objectKey + frameType + videoId;
+
+ var frame = new Frame();
+
+ frame.objectId = objectKey;
+ frame.uuid = frameKey;
+ frame.name = frameType + videoId;
+ console.log('created video frame with name ' + frame.name);
+
+ frame.ar.x = 0;
+ frame.ar.y = 0;
+ frame.ar.scale = globalStates.defaultScale;
+ frame.frameSizeX = 760; //globalStates.height;
+ frame.frameSizeY = 460; //globalStates.width;
+
+ // console.log("closest Frame", closestObject.averageScale);
+
+ frame.location = 'global';
+ frame.src = frameType;
+
+ // set other properties
+
+ frame.animationScale = 0;
+ frame.begin = realityEditor.gui.ar.utilities.newIdentityMatrix();
+ frame.width = frame.frameSizeX;
+ frame.height = frame.frameSizeY;
+ console.log('created video frame with width/height' + frame.width + '/' + frame.height);
+ frame.loaded = false;
+ frame.screen = {
+ x: frame.ar.x,
+ y: frame.ar.y,
+ scale: frame.ar.scale
+ };
+ frame.screenZ = 1000;
+ frame.temp = realityEditor.gui.ar.utilities.newIdentityMatrix();
+
+ frame.fullScreen = false;
+ frame.sendMatrix = false;
+ frame.sendAcceleration = false;
+ frame.integerVersion = 300;
+
+ // add each node with a non-empty name
+ var videoPath = realityEditor.network.getURL(object.ip, realityEditor.network.getPort(object), '/obj/' + object.name + '/videos/' + videoId + '.mp4');
+
+ var nodes = [
+ {name: 'play', type: 'node', x: 40, y: 0},
+ {name: 'progress', type: 'node', x: -40, y: 0},
+ {name: 'storage', type: 'storeData', publicData: {data: videoPath}}
+ ];
+
+ nodes.forEach( function (nodeData) {
+
+ var nodeName = nodeData.name;
+ var nodeType = nodeData.type;
+ var nodeUuid = frameKey + nodeName;
+
+ frame.nodes[nodeUuid] = new Node();
+ var addedNode = frame.nodes[nodeUuid];
+
+ addedNode.objectId = objectKey;
+ addedNode.frameId = frameKey;
+ addedNode.name = nodeName;
+ addedNode.type = nodeType;
+ addedNode.frameSizeX = 220;
+ addedNode.frameSizeY = 220;
+ addedNode.x = nodeData.x || 0; //realityEditor.device.utilities.randomIntInc(0, 200) - 100;
+ addedNode.y = nodeData.y || 0; //realityEditor.device.utilities.randomIntInc(0, 200) - 100;
+ addedNode.scale = globalStates.defaultScale;
+
+ if (typeof nodeData.publicData !== 'undefined') {
+ addedNode.publicData = nodeData.publicData;
+ }
+
+ });
+
+ object.frames[frameKey] = frame;
+ console.log(frame);
+
+ // position it in front of the camera
+ moveFrameToCameraForObjectMatrix(objectKey, frameKey, objectMatrix);
+
+ // send it to the server
+ realityEditor.network.postNewFrame(object.ip, objectKey, frame);
+ }
+
+ // TODO: turn into a cleaner, more reusable function in a better location
+ /**
+ * Calculates what the frame.ar.matrix needs to be in order to place the frame at the camera position on the provided object.
+ * Usually objectMatrix is the current visibleObjects matrix for this object, but by saving the matrix from a previous
+ * time, you can place the frame at the camera position that the camera was at at the time you saved the objectMatrix.
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {Array.} objectMatrix
+ */
+ function moveFrameToCameraForObjectMatrix(objectKey, frameKey, objectMatrix) {
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+
+ // recompute frame.temp for the new object
+ realityEditor.gui.ar.utilities.multiplyMatrix(objectMatrix, globalStates.projectionMatrix, frame.temp);
+ frame.begin = realityEditor.gui.ar.utilities.copyMatrix(pocketBegin);
+
+ // compute frame.matrix based on new object
+ var resultMatrix = [];
+ realityEditor.gui.ar.utilities.multiplyMatrix(frame.begin, realityEditor.gui.ar.utilities.invertMatrix(frame.temp), resultMatrix);
+ realityEditor.gui.ar.positioning.setPositionDataMatrix(frame, resultMatrix); // TODO: fix this somehow, make it more understandable
+
+ // reset frame.begin
+ frame.begin = realityEditor.gui.ar.utilities.newIdentityMatrix();
+ }
+
+ /**
+ * Lazy instantiation and getter of a red dot element to indicate that a recording is in process
+ * @return {Element}
+ */
+ function getRecordingIndicator() {
+ var recordingIndicator = document.querySelector('#recordingIndicator');
+ if (!recordingIndicator) {
+ recordingIndicator = document.createElement('div');
+ recordingIndicator.id = 'recordingIndicator';
+ recordingIndicator.style.position = 'absolute';
+ recordingIndicator.style.left = '10px';
+ recordingIndicator.style.top = '10px';
+ recordingIndicator.style.width = '30px';
+ recordingIndicator.style.height = '30px';
+ recordingIndicator.style.backgroundColor = 'red';
+ recordingIndicator.style.borderRadius = '15px';
+ document.body.appendChild(recordingIndicator);
+ }
+ return recordingIndicator;
+ }
+
+ //////////////////////////////////////////
+ // Video Recording Within Frame //
+ //////////////////////////////////////////
+
+ /**
+ * Public method that lets another module trigger video recording. Providing the frame path allows us to store
+ * the object's matrix at the time of starting the recording, so that the resulting frame can be placed correctly.
+ * @param {string} objectKey
+ * @param {string} _frameKey
+ */
+ function startRecordingForFrame(objectKey, _frameKey) {
+ // var startingMatrix = privateState.visibleObjects[objectKey] || realityEditor.gui.ar.utilities.newIdentityMatrix();
+
+ // realityEditor.app.startVideoRecording(objectKey, startingMatrix); // TODO: don't need to send in starting matrix anymore
+ let object = realityEditor.getObject(objectKey);
+ realityEditor.app.startVideoRecording(objectKey, object.ip, realityEditor.network.getPort(object));
+ }
+
+ /**
+ * Stop the video recording, and send a message with its videoFilePath into the frame that triggered the action
+ * @param {string} objectKey
+ * @param {string} frameKey
+ */
+ function stopRecordingForFrame(objectKey, frameKey) {
+ var videoId = realityEditor.device.utilities.uuidTime();
+ realityEditor.app.stopVideoRecording(videoId);
+ var object = realityEditor.getObject(objectKey);
+ var thisMsg = {
+ videoFilePath: realityEditor.network.getURL(object.ip, realityEditor.network.getPort(object), '/obj/' + object.name + '/videos/' + videoId + '.mp4')
+ };
+ globalDOMCache["iframe" + frameKey].contentWindow.postMessage(JSON.stringify(thisMsg), '*');
+ }
+
+ //////////////////////////////////////////
+ // 3D Video Recording //
+ //////////////////////////////////////////
+
+ const sendRzvIoMessage = (command, msg) => {
+ if (window.rzvIo && window.rzvIo.readyState === WebSocket.OPEN) {
+ window.rzvIo.send(JSON.stringify(Object.assign({
+ command: command
+ }, msg)));
+ }
+ }
+
+ function start3DVideoRecording() {
+ sendRzvIoMessage('/videoRecording/start');
+ }
+
+ function stop3DVideoRecording() {
+ sendRzvIoMessage('/videoRecording/stop');
+ }
+
+ //////////////////////////////////////////
+ // Virtualizer Recording //
+ //////////////////////////////////////////
+
+ // Captures color, depth, and pose data.
+ function startVirtualizerRecording() {
+ const bestWorldObject = realityEditor.worldObjects.getBestWorldObject();
+ const onSettings = (networkId, networkSecret) => {
+ privateState.virtualizerData = {
+ networkId,
+ networkSecret
+ }
+ console.log(`Starting virtualizer recording on ${bestWorldObject.ip} with ${networkId} and ${networkSecret}`);
+ realityEditor.app.appFunctionCall("enablePoseTracking", {
+ ip: bestWorldObject.ip,
+ port: bestWorldObject.port.toString(),
+ networkId,
+ networkSecret,
+ });
+ setTimeout(() => {
+ realityEditor.app.appFunctionCall('startVirtualizerRecording', {});
+ }, 1000);
+ }
+
+ const localSettingsHost = `localhost:${realityEditor.device.environment.getLocalServerPort()}`;
+ if (window.location.host.split(':')[0] !== localSettingsHost.split(':')[0]) {
+ const networkId = /\/n\/([^/]+)/.exec(window.location.pathname)[1];
+ const networkSecret = /\/s\/([^/]+)/.exec(window.location.pathname)[1];
+ onSettings(networkId, networkSecret);
+ } else {
+ fetch((realityEditor.network.useHTTPS ? 'https' : 'http') + `://${localSettingsHost}/hardwareInterface/edgeAgent/settings`).then(res => res.json()).then(settings => {
+ onSettings(settings.networkUUID, settings.networkSecret);
+ });
+ }
+ }
+
+ function stopVirtualizerRecording(callback) {
+ realityEditor.app.appFunctionCall('stopVirtualizerRecording', {}, 'realityEditor.device.videoRecording.onStopVirtualizerRecording("__ARG1__", "__ARG2__");');
+ privateState.virtualizerCallback = callback;
+ }
+
+ function onStopVirtualizerRecording(recordingId, deviceId) {
+ const baseUrl = `https://spatial.ptc.io/stable/n/${privateState.virtualizerData.networkId}/s/${privateState.virtualizerData.networkSecret}`;
+ privateState.virtualizerCallback(baseUrl, recordingId, deviceId);
+ }
+
+ //////////////////////////////////////////
+
+ exports.initService = initService;
+ exports.toggleRecording = toggleRecording;
+ exports.startRecordingOnClosestObject = startRecordingOnClosestObject;
+ exports.stopRecording = stopRecording;
+
+ exports.startRecordingForFrame = startRecordingForFrame;
+ exports.stopRecordingForFrame = stopRecordingForFrame;
+
+ exports.start3DVideoRecording = start3DVideoRecording;
+ exports.stop3DVideoRecording = stop3DVideoRecording;
+
+ exports.startVirtualizerRecording = startVirtualizerRecording;
+ exports.stopVirtualizerRecording = stopVirtualizerRecording;
+ exports.onStopVirtualizerRecording = onStopVirtualizerRecording;
+
+}(realityEditor.device.videoRecording));
diff --git a/src/envelopeManager.js b/src/envelopeManager.js
new file mode 100644
index 000000000..8cd0027f3
--- /dev/null
+++ b/src/envelopeManager.js
@@ -0,0 +1,847 @@
+createNameSpace("realityEditor.envelopeManager");
+
+/**
+ * @fileOverview realityEditor.envelopeManager
+ * This manages all communication with and between envelope frames and their contents.
+ * It listens for envelope messages and uses that to update the editor UI (e.g. adding an [X] button), and to
+ * relay messages to contained frames from envelopes (e.g. show/hide when open/close).
+ * Also responsible for notifying envelopes when potential frames are added or removed from them.
+ */
+
+(function(exports) {
+
+ /**
+ * @typedef {Object} Envelope
+ * @property {string} object
+ * @property {string} frame
+ * @property {string} type
+ * @property {Array.} compatibleFrameTypes
+ * @property {Array.} containedFrameIds
+ * @property {boolean} isOpen
+ * @property {boolean} hasFocus
+ * @property {boolean} isFull2D
+ */
+
+ /**
+ * @type {Object.}
+ */
+ var knownEnvelopes = {};
+
+ let alreadyProcessedUrlToolId = false;
+
+ let callbacks = {
+ onExitButtonShown: [],
+ onExitButtonHidden: [],
+ onFullscreenFull2DToggled: []
+ };
+
+ /**
+ * Init envelope manager module
+ */
+ function initService() {
+ realityEditor.network.addPostMessageHandler('envelopeMessage', handleEnvelopeMessage);
+
+ realityEditor.gui.pocket.registerCallback('frameAdded', onFrameAdded);
+
+ realityEditor.device.registerCallback('vehicleDeleted', onVehicleDeleted); // deleted using userinterface
+ realityEditor.network.registerCallback('vehicleDeleted', onVehicleDeleted); // deleted using server
+
+ realityEditor.network.registerCallback('elementReloaded', onElementReloaded);
+ realityEditor.network.registerCallback('elementLoaded', onElementReloaded);
+ // realityEditor.gui.ar.draw.registerCallback('fullScreenEjected', onFullScreenEjected); // this is handled already in network/frameContentAPI the same way as it is for any exclusiveFullScreen frame, so no need to listen/handle the event here
+ realityEditor.network.registerCallback('vehicleReattached', function(params) {
+ setTimeout(function() {
+ onVehicleReattached(params);
+ }, 500); // send after a delay so original messages have a chance to be processed first
+ });
+
+ realityEditor.gui.pocket.addElementHighlightFilter(function(pocketFrameNames) {
+ var frameTypesToHighlight = getCurrentCompatibleFrameTypes();
+ return pocketFrameNames.filter(function(frameName) {
+ return frameTypesToHighlight.indexOf(frameName) > -1;
+ });
+ });
+ }
+
+ /**
+ * Gets triggered when a frame declares itself to be an envelope.
+ * This is where we can detect if it was added programmatically by one of its children frames that required it,
+ * and if so, open this envelope and set up its relationships with its children
+ * @param {string} objectKey
+ * @param {string} frameKey
+ */
+ function onEnvelopeRegistered(objectKey, frameKey) {
+
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ if (frame && typeof frame.autoAddedEnvelope !== 'undefined') {
+
+ // then open the envelope you just added
+ openEnvelope(frameKey);
+
+ // queue up a frameAdded event in the envelopeManager
+ // when the envelope's iframe loads, send this event into the envelope
+ // to set up all relationships between the contained frame and its envelope
+
+ onFrameAdded({
+ objectKey: frame.autoAddedEnvelope.containedFrameToAdd.objectKey,
+ frameKey: frame.autoAddedEnvelope.containedFrameToAdd.frameKey,
+ frameType: frame.autoAddedEnvelope.containedFrameToAdd.frameType
+ }); // todo: can simplify to just frame.autoAddedEnvelope.containedFrameToAdd
+ }
+
+ realityEditor.gui.recentlyUsedBar.onEnvelopeRegistered(frame);
+ realityEditor.gui.envelopeIconRenderer.onEnvelopeRegistered(knownEnvelopes[frameKey]);
+
+ if (alreadyProcessedUrlToolId) return;
+
+ // Parse the URL for a ?toolId, and open the envelope if possible
+ let searchParams = new URLSearchParams(window.location.search);
+ let toolboxActiveToolId = searchParams.get('toolId');
+ if (toolboxActiveToolId && frameKey === toolboxActiveToolId) {
+ alreadyProcessedUrlToolId = true; // prevent weird behavior if the tool reloads/re-registers
+ setTimeout(() => {
+ // for now, open it after a slight delay so it doesn't get closed by another open envelope
+ // todo: don't rely on a timeout
+ openEnvelope(frameKey, false);
+ setTimeout(() => {
+ focusEnvelope(frameKey, false);
+ }, 1000);
+ }, 1000);
+ }
+ }
+
+ /**
+ * @param {Object} eventData - contents of 'envelopeMessage' object
+ * @param {Object} fullMessageContent - the full JSON message posted by the frame, including ID of its object, frame, etc
+ */
+ function handleEnvelopeMessage(eventData, fullMessageContent) {
+
+ // registers new envelopes with the system
+ if (typeof eventData.isEnvelope !== 'undefined') {
+ if (eventData.isEnvelope) {
+ knownEnvelopes[fullMessageContent.frame] = {
+ object: fullMessageContent.object,
+ frame: fullMessageContent.frame,
+ compatibleFrameTypes: eventData.compatibleFrameTypes,
+ containedFrameIds: []
+ };
+ // check if registered envelope was autoAdded and needs to be configured
+ onEnvelopeRegistered(fullMessageContent.object, fullMessageContent.frame);
+ } else {
+ if (knownEnvelopes[fullMessageContent.frame]) {
+ delete knownEnvelopes[fullMessageContent.frame];
+ }
+ }
+ }
+
+ // responds to an envelope opening
+ if (typeof eventData.open !== 'undefined') {
+ openEnvelope(fullMessageContent.frame, true);
+ }
+
+ // responds to an envelope closing
+ if (typeof eventData.close !== 'undefined') {
+ closeEnvelope(fullMessageContent.frame, true);
+ }
+
+ // generally not used, but responds to an envelope removing its 2D layer
+ if (typeof eventData.blur !== 'undefined') {
+ blurEnvelope(fullMessageContent.frame, true);
+ }
+
+ // generally not used, but responds to an envelope restoring its 2D layer
+ if (typeof eventData.focus !== 'undefined') {
+ focusEnvelope(fullMessageContent.frame, true);
+ }
+
+ // keeps mapping of envelopes -> containedFrames up to date
+ if (typeof eventData.containedFrameIds !== 'undefined') {
+ if (knownEnvelopes[fullMessageContent.frame]) {
+ knownEnvelopes[fullMessageContent.frame].containedFrameIds = eventData.containedFrameIds;
+
+ // if we added any new frames, and they are visible but the envelope is closed, then hide them
+ if (!knownEnvelopes[fullMessageContent.frame].isOpen) {
+ closeEnvelope(fullMessageContent.frame, true);
+ } else if (!knownEnvelopes[fullMessageContent.frame].hasFocus) {
+ blurEnvelope(fullMessageContent.frame, true);
+ }
+ }
+ }
+ }
+
+ /**
+ * Opens an envelope and/or responds to an envelope opening to update UI and other frames appropriately
+ * @param {string} frameId
+ * @param {boolean} wasTriggeredByEnvelope - if triggered by itself, doesnt need to update iframe contents
+ */
+ function openEnvelope(frameId, wasTriggeredByEnvelope) {
+ const envelope = knownEnvelopes[frameId];
+ if (envelope.isOpen) return;
+
+ envelope.isOpen = true;
+ envelope.hasFocus = true;
+
+ // callbacks inside the envelope are auto-triggered if it opens itself, but need to be triggered if opened externally
+ if (!wasTriggeredByEnvelope) {
+ sendMessageToEnvelope(frameId, {
+ open: true
+ });
+ }
+
+ // show all contained frames
+ sendMessageToEnvelopeContents(frameId, {
+ showContainedFrame: true
+ });
+
+ let containedFrameIds = envelope.containedFrameIds;
+ containedFrameIds.forEach(function(id) {
+ let element = globalDOMCache['object' + id];
+ if (element) {
+ element.classList.remove('hiddenEnvelopeContents');
+ }
+ });
+
+ if (globalDOMCache[frameId]) {
+ globalDOMCache[frameId].classList.remove('iframeOverlayWithoutFocus');
+ globalDOMCache['iframe' + frameId].classList.remove('iframeOverlayWithoutFocus');
+ }
+
+ // adjust exit/cancel/back buttons for # of open frames
+ updateExitButton();
+
+ realityEditor.gui.recentlyUsedBar.onOpen(envelope);
+ realityEditor.gui.envelopeIconRenderer.onOpen(envelope);
+ }
+
+ /**
+ * Closes an envelope and/or responds to an envelope closing to update UI and other frames appropriately
+ * @param {string} frameId
+ * @param {boolean} wasTriggeredByEnvelope - can be triggered in multiple ways e.g. the exit button or from within the envelope
+ */
+ function closeEnvelope(frameId, wasTriggeredByEnvelope) {
+ const envelope = knownEnvelopes[frameId];
+ if (!envelope.isOpen) return;
+
+ envelope.isOpen = false;
+ envelope.hasFocus = false;
+
+ // callbacks inside the envelope are auto-triggered if it opens itself, but need to be triggered if opened externally
+ if (!wasTriggeredByEnvelope) {
+ sendMessageToEnvelope(frameId, {
+ close: true
+ });
+ }
+
+ // hide all contained frames
+ sendMessageToEnvelopeContents(frameId, {
+ showContainedFrame: false
+ });
+
+ // TODO: hide contained frames at a higher level by giving them some property or CSS class
+ // TODO: after 3 seconds, kill/unload them? (make sure it doesn't interfere with envelope when it opens again
+
+ let containedFrameIds = envelope.containedFrameIds;
+ containedFrameIds.forEach(function(id) {
+ let element = globalDOMCache['object' + id];
+ if (element) {
+ element.classList.add('hiddenEnvelopeContents');
+ }
+ });
+
+ if (globalDOMCache[frameId]) {
+ globalDOMCache[frameId].classList.remove('iframeOverlayWithoutFocus');
+ globalDOMCache['iframe' + frameId].classList.remove('iframeOverlayWithoutFocus');
+ }
+
+ // adjust exit/cancel/back buttons for # of open frames
+ updateExitButton();
+
+ realityEditor.gui.recentlyUsedBar.onClose(envelope);
+ realityEditor.gui.envelopeIconRenderer.onClose(envelope);
+ let avatarId = realityEditor.avatar.getAvatarObjectKeyFromSessionId(globalStates.tempUuid);
+ realityEditor.ai.onClose(envelope, avatarId);
+ }
+
+ /**
+ * Restore focus to an envelope, showing its 2D UI
+ * @param {string} frameId
+ * @param {boolean} wasTriggeredByEnvelope
+ */
+ function focusEnvelope(frameId, wasTriggeredByEnvelope = false) {
+ if (!knownEnvelopes[frameId]) return;
+ if (knownEnvelopes[frameId].hasFocus) return;
+
+ // first, blur or close the current envelope if there is one focused
+ getOpenEnvelopes().forEach(openEnvelope => {
+ if (openEnvelope.hasFocus) {
+ if (openEnvelope.isFull2D) {
+ realityEditor.envelopeManager.closeEnvelope(openEnvelope.frame);
+ } else {
+ realityEditor.envelopeManager.blurEnvelope(openEnvelope.frame);
+ }
+ }
+ });
+
+ knownEnvelopes[frameId].hasFocus = true;
+
+ // callbacks inside the envelope are auto-triggered if it opens itself, but need to be triggered if opened externally
+ if (!wasTriggeredByEnvelope) {
+ sendMessageToEnvelope(frameId, {
+ focus: true
+ });
+ }
+
+ if (globalDOMCache[frameId]) {
+ globalDOMCache[frameId].classList.remove('iframeOverlayWithoutFocus');
+ globalDOMCache['iframe' + frameId].classList.remove('iframeOverlayWithoutFocus');
+ }
+
+ // adjust exit/cancel/back buttons for # of open frames
+ updateExitButton();
+
+ // hide the temporary icon
+ realityEditor.gui.envelopeIconRenderer.onFocus(knownEnvelopes[frameId]);
+ // focusing an app also brings it to the front of the bar, same as opening it
+ realityEditor.gui.recentlyUsedBar.onOpen(knownEnvelopes[frameId]);
+ let avatarId = realityEditor.avatar.getAvatarObjectKeyFromSessionId(globalStates.tempUuid); // todo Steve: when another user open the envelope, it automatically opens & minimizes on my end, and here outputs that I opened it myself. Need to find a way to get the other user avatar's name and replace my name here
+ realityEditor.ai.onOpen(knownEnvelopes[frameId], avatarId);
+ }
+
+ /**
+ * Remove focus, by hiding controls and/or responds to an envelope closing to update UI and other frames appropriately
+ * @param {string} frameId
+ * @param {boolean} wasTriggeredByEnvelope - can be triggered in multiple ways e.g. the minimize button or from within the envelope
+ */
+ function blurEnvelope(frameId, wasTriggeredByEnvelope = false) {
+ if (!knownEnvelopes[frameId]) return;
+ if (!knownEnvelopes[frameId].hasFocus) return;
+
+ knownEnvelopes[frameId].hasFocus = false;
+
+ // callbacks inside the envelope are auto-triggered if it opens itself, but need to be triggered if opened externally
+ if (!wasTriggeredByEnvelope) {
+ sendMessageToEnvelope(frameId, {
+ blur: true
+ });
+ }
+
+ if (globalDOMCache[frameId]) {
+ globalDOMCache[frameId].classList.add('iframeOverlayWithoutFocus');
+ globalDOMCache['iframe' + frameId].classList.add('iframeOverlayWithoutFocus');
+ }
+
+ // adjust exit/cancel/back buttons for # of open frames
+ updateExitButton();
+
+ realityEditor.gui.envelopeIconRenderer.onBlur(knownEnvelopes[frameId]);
+ let avatarId = realityEditor.avatar.getAvatarObjectKeyFromSessionId(globalStates.tempUuid);
+ realityEditor.ai.onBlur(knownEnvelopes[frameId], avatarId);
+ }
+
+ function createExitButton() {
+ let exitButton = document.createElement('img');
+ exitButton.classList.add('envelopeMenuButton');
+ exitButton.src = 'svg/envelope-x-button.svg';
+ exitButton.id = 'exitEnvelopeButton';
+ exitButton.style.top = realityEditor.device.environment.variables.screenTopOffset + 'px';
+ document.body.appendChild(exitButton);
+
+ exitButton.addEventListener('pointerup', function() {
+ getOpenEnvelopes().forEach(function(envelope) {
+ if (envelope.hasFocus) {
+ closeEnvelope(envelope.frame);
+ }
+ });
+ });
+ return exitButton;
+ }
+
+ function createMinimizeButton() {
+ let minimizeButton = document.createElement('img');
+ minimizeButton.classList.add('envelopeMenuButton');
+ minimizeButton.src = 'svg/envelope-collapse-button.svg';
+ minimizeButton.id = 'minimizeEnvelopeButton';
+ minimizeButton.style.top = realityEditor.device.environment.variables.screenTopOffset + 'px';
+ document.body.appendChild(minimizeButton);
+
+ minimizeButton.addEventListener('pointerup', function() {
+ // TODO: only minimize the envelope that has focus, not all of them
+ getOpenEnvelopes().forEach(function(envelope) {
+ if (envelope.hasFocus) {
+ blurEnvelope(envelope.frame);
+ }
+ });
+ });
+ return minimizeButton;
+ }
+
+ /**
+ * Creates/renders an [X] button in the top left corner if there are any open envelopes, which can be used to close them
+ * Also creates a second button, which is used to remove focus from the focused envelope, if it has a 3D scene
+ */
+ function updateExitButton() {
+ let numberOfOpenEnvelopes = getOpenEnvelopes().length;
+ let numberOfFocusedEnvelopes = getFocusedEnvelopes().length;
+ // Full2D tools are not "blurrable" because they don't have a 3D scene that can remain in the background when their 2D layer loses focus
+ let numberOfBlurrableEnvelopes = getFocusedEnvelopes().filter(envelope => !envelope.isFull2D).length;
+ let exitButton = document.getElementById('exitEnvelopeButton');
+ let minimizeButton = document.getElementById('minimizeEnvelopeButton');
+
+ // exit button shows anytime an envelope is open+focused
+ let showExitButton = numberOfOpenEnvelopes > 0 && numberOfFocusedEnvelopes > 0;
+ // minimize button only shows if the open+focused envelope is also not a Full2D envelope
+ let showMinimizeButton = numberOfBlurrableEnvelopes > 0;
+
+ if (showMinimizeButton) {
+ if (!minimizeButton) minimizeButton = createMinimizeButton();
+ minimizeButton.style.display = 'inline';
+ } else {
+ if (minimizeButton) minimizeButton.style.display = 'none';
+ }
+
+ if (showExitButton) {
+ if (!exitButton) exitButton = createExitButton();
+ exitButton.style.display = 'inline';
+ callbacks.onExitButtonShown.forEach(cb => cb(exitButton, minimizeButton));
+ } else {
+ if (exitButton) exitButton.style.display = 'none';
+ callbacks.onExitButtonHidden.forEach(cb => cb(exitButton, minimizeButton));
+ }
+ }
+
+ exports.onExitButtonHidden = (callback) => {
+ callbacks.onExitButtonHidden.push(callback);
+ }
+
+ exports.onExitButtonShown = (callback) => {
+ callbacks.onExitButtonShown.push(callback);
+ }
+
+ exports.onFullscreenFull2DToggled = (callback) => {
+ callbacks.onFullscreenFull2DToggled.push(callback);
+ }
+
+ /**
+ * When a new frame is added and finishes loading, tell any open envelopes about it so they can "claim" it if they choose
+ * @param {{objectKey: string, frameKey: string, frameType: string}} params
+ */
+ function onFrameAdded(params) {
+ try {
+ addRequiredEnvelopeIfNeeded(params.objectKey, params.frameKey, params.frameType);
+ } catch (e) {
+ console.warn('error adding required envelope');
+ }
+
+ attemptWithRetransmission(function() {
+ sendMessageToOpenEnvelopes({
+ onFrameAdded: {
+ objectId: params.objectKey,
+ frameId: params.frameKey,
+ frameType: params.frameType
+ }
+ }, params.frameType, { requiresFocus: true });
+ }, function() {
+ return globalDOMCache['iframe' + params.frameKey] && globalDOMCache['iframe' + params.frameKey].getAttribute('loaded');
+ }, 500, 10);
+ }
+
+ function attemptWithRetransmission(callback, conditionToProceed, timeBetweenAttempts, numAttemptsLeft) {
+ if (typeof conditionToProceed === 'undefined' || conditionToProceed()) {
+ console.log('attempt transmission');
+ callback();
+ }
+
+ if (typeof conditionToProceed === 'undefined' || !conditionToProceed()) {
+ console.log('condition not satisfied... retransmit in ' + timeBetweenAttempts + 'ms (' + (numAttemptsLeft-1) + ')');
+ setTimeout(function() {
+ numAttemptsLeft--;
+ if (numAttemptsLeft > 0) {
+ attemptWithRetransmission(callback, conditionToProceed, timeBetweenAttempts, numAttemptsLeft); // keeps checking
+ }
+ }, timeBetweenAttempts);
+ }
+ }
+
+ /**
+ * When a frame is deleted, send a message to open envelopes so they can update internal state if they owned it.
+ * If an envelope frame is deleted, delete its contained frames.
+ * @param {{objectKey: string, frameKey: string, additionalInfo:{frameType: string}|undefined }} params
+ */
+ function onVehicleDeleted(params) {
+ if (params.objectKey && params.frameKey && !params.nodeKey) { // only send message about frames, not nodes
+ // right now messages all envelopes, not just the one that contained the deleted frame
+ // TODO: test with more than one envelope open at a time (stackable envelopes)
+ sendMessageToOpenEnvelopes({
+ onFrameDeleted: {
+ objectId: params.objectKey,
+ frameId: params.frameKey,
+ frameType: params.additionalInfo.frameType
+ }
+ });
+
+ // if deleted frame was an envelope, delete its contained frames too
+ if (typeof knownEnvelopes[params.frameKey] !== 'undefined') {
+ let deletedEnvelope = knownEnvelopes[params.frameKey];
+
+ deletedEnvelope.containedFrameIds.forEach(function(containedFrameKey) {
+ // contained frame always belongs to same object as envelope, so ok to use params.objectKey
+ var frameToDelete = realityEditor.getFrame(params.objectKey, containedFrameKey);
+ if (!frameToDelete) { return; }
+ realityEditor.device.deleteFrame(frameToDelete, params.objectKey, containedFrameKey);
+ });
+
+ if (deletedEnvelope.isFull2D) {
+ hideBlurredBackground(params.frameKey);
+ }
+
+ delete knownEnvelopes[params.frameKey];
+
+ // if deleted envelope was the open envelope, remove the close/minimize buttons
+ updateExitButton();
+ }
+ }
+ }
+
+ /**
+ * Programmatically re-close an envelope if its child frame reloads, otherwise the child can get stranded as visible
+ * @param {{objectKey: string, frameKey: string, nodeKey: string}} params
+ */
+ function onElementReloaded(params) {
+ if (params.nodeKey) { return; } // for now only frames can be in envelopes
+
+ // see if it belongs to a closed envelope
+ Object.values(knownEnvelopes).filter(function(envelope) {
+ return !envelope.isOpen;
+ }).filter(function(envelope) {
+ return envelope.containedFrameIds.includes(params.frameKey);
+ }).forEach(function(envelope) {
+ // should belong to at most 1 envelope at a time.. but we'll do for each just in case that changes
+ closeEnvelope(envelope.frame);
+ console.log('closing parent envelope: ' + envelope.frame);
+ });
+
+ // send message to open envelopes so that it gets updates properly if a tool on another object loads
+ sendMessageToOpenEnvelopes({
+ onFrameLoaded: {
+ objectId: params.objectKey,
+ frameId: params.frameKey,
+ frameType: params.frameType
+ }
+ }, params.frameType);
+ }
+
+ /**
+ * When a frame gets reattached e.g. from an object to the world, make sure that the envelope it belongs to
+ * keeps track of its new object id
+ * @param {{oldObjectKey: string, oldFrameKey: string, newObjectKey: string, newFrameKey: string, frameType: string}} params
+ */
+ function onVehicleReattached(params) {
+
+ attemptWithRetransmission(function() {
+ updateContainedFrameId(params.oldObjectKey, params.oldFrameKey, params.newObjectKey, params.newFrameKey, params.frameType);
+ }, undefined, //function() {
+ // return globalDOMCache['iframe' + params.frameKey] && globalDOMCache['iframe' +
+ // params.frameKey].getAttribute('loaded');
+ // },
+ 500, 10);
+ }
+
+ function updateContainedFrameId(oldObjectKey, oldFrameKey, newObjectKey, newFrameKey, frameType) {
+ // check if the old id belongs to any envelope
+ Object.values(knownEnvelopes).filter(function(envelope) {
+ return envelope.containedFrameIds.includes(oldFrameKey);
+ }).forEach(function(envelope) {
+
+ console.log('reattach frame ' + oldFrameKey + ' in envelope ' + envelope.frame);
+
+ // remove this frame from the envelope and replace it with the new id
+ sendMessageToEnvelope(envelope.frame, {
+ onFrameDeleted: {
+ objectId: oldObjectKey,
+ frameId: oldFrameKey,
+ frameType: frameType
+ }
+ });
+
+ // add the new id to the envelope after slight delay
+ setTimeout(function() {
+ sendMessageToEnvelope(envelope.frame, {
+ onFrameAdded: {
+ objectId: newObjectKey,
+ frameId: newFrameKey,
+ frameType: frameType
+ }
+ });
+ console.log('reattached frame is now ' + newFrameKey + ' in envelope ' + envelope.frame);
+ }, 500);
+ });
+ }
+
+ /**
+ * Gets triggered when any frame is created. Checks if that frame requires to be inside an envelope of a certain type,
+ * and if so, adds that envelope and puts this frame inside that envelope.
+ * @param {string} objectKey
+ * @param {string} frameKey - the uuid of the frame that was just added
+ * @param {string} frameType - used to retrieve metadata for the frame type that was added
+ */
+ function addRequiredEnvelopeIfNeeded(objectKey, frameKey, frameType) {
+
+ var realityElements = realityEditor.gui.pocket.getRealityElements();
+ var realityElement = realityElements.find(function(elt) { return elt.properties.name === frameType; });
+
+ // check if an additional envelope frame needs to be added
+ if (realityElement.requiredEnvelope) {
+ console.log('this frame needs an envelope: ' + realityElement.requiredEnvelope);
+ console.log(realityElement);
+ var frameTypeNeeded = realityElement.requiredEnvelope; // this will be 'loto-envelope'
+
+ // check if an envelope of type frameTypeNeeded is already open
+ var openEnvelopes = getOpenEnvelopes();
+ var openEnvelopeTypes = openEnvelopes.map(function(envelopeData) {
+ return getFrameTypeFromKey(envelopeData.object, envelopeData.frame);
+ });
+ var isRequiredEnvelopeOpen = openEnvelopeTypes.indexOf(frameTypeNeeded) > -1;
+
+ if (!isRequiredEnvelopeOpen) {
+ console.log('an envelope of the required type does not exist!');
+ // tell the pocket to createFrame(frameTypeNeeded, ...)
+
+ // get the realityElement for the necessary envelope
+ var envelopeData = realityElements.find(function(elt) { return elt.name === frameTypeNeeded; });
+ // var touchPosition = realityEditor.gui.ar.positioning.getMostRecentTouchPosition();
+
+ var touchPosition = {
+ x: 100 + Math.random() * (globalStates.height - 200),
+ y: 100 + Math.random() * (globalStates.width - 200)
+ };
+
+ if (envelopeData) {
+ let addedElement = realityEditor.gui.pocket.createFrame(envelopeData.name, {
+ startPositionOffset: envelopeData.startPositionOffset,
+ width: envelopeData.width,
+ height: envelopeData.height,
+ pageX: touchPosition.x,
+ pageY: touchPosition.y,
+ noUserInteraction: true
+ });
+
+ console.log('added an envelope (maybe in time?)', addedElement);
+
+ realityEditor.gui.ar.positioning.moveFrameToCamera(addedElement.objectId, addedElement.uuid);
+
+ // not loaded yet, so flag it with a certain property so we can catch it when it fully loads
+ addedElement.autoAddedEnvelope = {
+ shouldOpenOnLoad: true,
+ containedFrameToAdd: {
+ objectKey: objectKey,
+ frameKey: frameKey,
+ frameType: frameType
+ }
+ };
+ }
+
+ } else {
+ console.log('dont need to create a new envelope because the required one is already open');
+ }
+ }
+ }
+
+ /**
+ * Sends an arbitrary message to the specified envelope.
+ * If a compatibilityTypeRequirement is provided, filters out envelopes that don't support that type of frame.
+ * @param {string} envelopeFrameKey
+ * @param {*} message
+ * @param {Array.|undefined} compatibilityTypeRequirement
+ */
+ function sendMessageToEnvelope(envelopeFrameKey, message, compatibilityTypeRequirement) {
+ var envelope = knownEnvelopes[envelopeFrameKey];
+
+ // if we specify that the message should only be sent to envelopes of a certain type, make other envelopes ignore the message
+ if (typeof compatibilityTypeRequirement !== 'undefined') {
+ if (envelope.compatibleFrameTypes.indexOf(compatibilityTypeRequirement) === -1) {
+ return;
+ }
+ }
+
+ var envelopeMessage = {
+ envelopeMessage: message
+ };
+
+ realityEditor.network.postMessageIntoFrame(envelopeFrameKey, envelopeMessage);
+ }
+
+ /**
+ * Sends a message to all open envelopes.
+ * If a compatibilityTypeRequirement is provided, filters out envelopes that don't support that type of frame.
+ * @param {Object} message
+ * @param {string|undefined} compatibilityTypeRequirement
+ * @param {*} options
+ */
+ function sendMessageToOpenEnvelopes(message, compatibilityTypeRequirement, options = { requiresFocus: false}) {
+ for (var frameKey in knownEnvelopes) {
+ var envelope = knownEnvelopes[frameKey];
+ if (envelope.isOpen && (envelope.hasFocus || !options.requiresFocus)) {
+ sendMessageToEnvelope(frameKey, message, compatibilityTypeRequirement);
+ }
+ }
+ }
+
+ /**
+ * Sends a message to all the frames contained by the specified envelope frame with.
+ * @param {string} envelopeFrameKey
+ * @param {Object} message
+ */
+ function sendMessageToEnvelopeContents(envelopeFrameKey, message) {
+ var envelope = knownEnvelopes[envelopeFrameKey];
+ if (!envelope) {
+ console.warn('couldn\'t find the envelope you are trying to message (' + envelopeFrameKey + ')');
+ return;
+ }
+
+ // the envelope doesn't need to be open for these messages to propagate to its children
+ var envelopeMessage = {
+ envelopeMessage: {
+ sendMessageToContents: message
+ }
+ };
+
+ // we send the message to the envelope, which forwards it to its contained frames
+ realityEditor.network.postMessageIntoFrame(envelopeFrameKey, envelopeMessage);
+ }
+
+ /**
+ * Helper function to return a list of open envelopes.
+ * @return {Array.}
+ */
+ function getOpenEnvelopes() {
+ return Object.values(knownEnvelopes).filter(function(envelope) {
+ return envelope.isOpen; // && !envelope.hasFocus;
+ });
+ }
+
+ /**
+ * Helper function to return the envelope that has focus, if any
+ * @return {Array.}
+ */
+ function getFocusedEnvelopes() {
+ return Object.values(knownEnvelopes).filter((envelope) => {
+ return envelope.hasFocus;
+ });
+ }
+
+ /**
+ * Helper function to get a list of all compatible frame types of any open envelopes (compatible with envelope x OR y, not x AND y)
+ * @return {Array.}
+ */
+ function getCurrentCompatibleFrameTypes() {
+ var allCompatibleFrameTypes = [];
+ getOpenEnvelopes().forEach(function(envelope) {
+ envelope.compatibleFrameTypes.forEach(function(frameType) {
+ if (allCompatibleFrameTypes.indexOf(frameType) === -1) {
+ allCompatibleFrameTypes.push(frameType);
+ }
+ });
+ });
+ return allCompatibleFrameTypes;
+ }
+
+ /**
+ * Helper function to convert a frameKey into a frame type
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @return {string|null}
+ */
+ function getFrameTypeFromKey(objectKey, frameKey) {
+ let frame = realityEditor.getFrame(objectKey, frameKey);
+ if (!frame) return null;
+ return frame.src;
+ }
+
+ function showBlurredBackground(focusedFrameId) {
+ // create a fullscreen div with webkit-backdrop-filter: blur(), if it isn't already shown
+ let blur = document.getElementById('blurredEnvelopeBackground');
+ if (!blur) {
+ blur = document.createElement('div');
+ blur.id = 'blurredEnvelopeBackground';
+ }
+ let GUI = document.getElementById('GUI');
+ // let focusedElement = document.getElementById('object' + focusedFrameId);
+ // focusedElement.parentNode.insertBefore(blur, focusedElement);
+ GUI.parentNode.insertBefore(blur, GUI);
+ blur.style.display = 'inline';
+
+ if (globalDOMCache[focusedFrameId]) {
+ globalDOMCache[focusedFrameId].classList.add('deactivatedIframeOverlay');
+ }
+
+ if (knownEnvelopes[focusedFrameId]) {
+ knownEnvelopes[focusedFrameId].isFull2D = true;
+ updateExitButton();
+ }
+
+ // hide all other frames and icons while the full2D frame is open
+ let otherFrames = Array.from(document.querySelectorAll('.visibleFrameContainer, .visibleFrame')).filter(element => {
+ return !element.id.includes(focusedFrameId);
+ });
+ otherFrames.forEach(frame => {
+ frame.classList.add('hiddenByFull2DBlurredBackground');
+ });
+
+ // just hiding the iframes still leaves their proxied gl content on the screen. hide the canvas.
+ // this should be safe to do because the focused full2D tool is 2D by nature and shouldn't be using the 3D canvas
+ let webGlCanvas = document.getElementById('glcanvas');
+ if (webGlCanvas) {
+ webGlCanvas.classList.add('hiddenByFull2DBlurredBackground');
+ }
+
+ callbacks.onFullscreenFull2DToggled.forEach(cb => cb({
+ frameId: focusedFrameId,
+ isFull2D: true
+ }));
+ }
+
+ function hideBlurredBackground(focusedFrameId) {
+ // hide the fullscreen blurred div, if it exists
+ let blur = document.getElementById('blurredEnvelopeBackground');
+ if (blur) {
+ blur.style.display = 'none';
+ }
+
+ if (globalDOMCache[focusedFrameId]) {
+ globalDOMCache[focusedFrameId].classList.remove('deactivatedIframeOverlay');
+ }
+
+ if (knownEnvelopes[focusedFrameId]) {
+ knownEnvelopes[focusedFrameId].isFull2D = false;
+ updateExitButton();
+ }
+
+ // show all frames and icons that were hidden when the full2D frame opened, and the webgl canvas
+ Array.from(document.querySelectorAll('.hiddenByFull2DBlurredBackground')).forEach(element => {
+ element.classList.remove('hiddenByFull2DBlurredBackground');
+ });
+
+ callbacks.onFullscreenFull2DToggled.forEach(cb => cb({
+ frameId: focusedFrameId,
+ isFull2D: false
+ }));
+ }
+
+ exports.initService = initService; // ideally, for a self-contained service, this is the only export.
+
+ exports.getKnownEnvelopes = function() {
+ return knownEnvelopes;
+ }
+
+ exports.showBlurredBackground = showBlurredBackground;
+ exports.hideBlurredBackground = hideBlurredBackground;
+
+ exports.getOpenEnvelopes = getOpenEnvelopes;
+ exports.getFocusedEnvelopes = getFocusedEnvelopes;
+ exports.openEnvelope = openEnvelope;
+ exports.closeEnvelope = closeEnvelope;
+ exports.focusEnvelope = focusEnvelope;
+ exports.blurEnvelope = blurEnvelope;
+ exports.getFrameTypeFromKey = getFrameTypeFromKey;
+
+}(realityEditor.envelopeManager));
diff --git a/src/gui/ProfilerSettingsUI.js b/src/gui/ProfilerSettingsUI.js
new file mode 100644
index 000000000..d9918a1bd
--- /dev/null
+++ b/src/gui/ProfilerSettingsUI.js
@@ -0,0 +1,271 @@
+export class ProfilerSettingsUI {
+ constructor() {
+ this.stats = null;
+ this.isHidden = true;
+
+ this.root = document.createElement('div');
+ this.root.id = 'profiler-settings';
+
+ // Styled via css/humanPoseAnalyzerSettingsUi.css
+ this.root.innerHTML = `
+
+
+ `;
+
+ this.addStats();
+ this.setUpEventListeners();
+ this.enableDrag();
+ document.body.appendChild(this.root);
+ let container = document.querySelector('.profiler-log-container');
+ container.style.display = 'none';
+ this.setInitialPosition();
+ this.hide(); // It is important to set the menu's position before hiding it, otherwise its width will be calculated as 0
+ }
+
+ update() {
+ if (this.isHidden) return; // cancels the update loop while hidden
+
+ try {
+ if (this.stats) {
+ this.stats.update();
+ }
+ } catch (e) {
+ console.warn(e);
+ }
+
+ requestAnimationFrame(this.update.bind(this));
+ }
+
+ addLabel(id, text, options = {}) {
+ let container = document.querySelector('.profiler-log-container');
+ let label = document.createElement('div');
+ label.id = this.getDomIdForLabelId(id);
+ label.classList.add('debugContainerLabel');
+ label.innerHTML = text;
+
+ // Append at the top
+ if (options.pinToTop && container.firstChild) {
+ container.insertBefore(label, container.firstChild);
+ } else {
+ container.appendChild(label);
+ }
+ }
+ updateLabelText(id, text) {
+ let labelDomId = this.getDomIdForLabelId(id);
+ let existingLabel = document.getElementById(labelDomId);
+ if (existingLabel) {
+ existingLabel.innerHTML = text;
+ }
+ }
+ addOrUpdateLabel(id, text, options) {
+ let labelDomId = this.getDomIdForLabelId(id);
+ let existingLabel = document.getElementById(labelDomId);
+ if (existingLabel) {
+ this.updateLabelText(id, text);
+ } else {
+ this.addLabel(id, text, options);
+ }
+ }
+ removeLabel(id) {
+ let labelDomId = this.getDomIdForLabelId(id);
+ let existingLabel = document.getElementById(labelDomId);
+ if (existingLabel && existingLabel.parentElement) {
+ existingLabel.parentElement.removeChild(existingLabel);
+ }
+ }
+ getDomIdForLabelId(id) {
+ return `ProfilerSettings_Label_${id}`;
+ }
+
+ /**
+ * Sets the initial position of the settings UI to be in the top right corner of the screen, under the navbar and menu button
+ */
+ setInitialPosition() {
+ const navbar = document.querySelector('.desktopMenuBar');
+ const navbarHeight = navbar ? navbar.offsetHeight : 0;
+ this.root.style.top = `calc(${navbarHeight}px + 2em + 5em)`;
+ this.root.style.left = '2em';
+ this.snapToFitScreen();
+ }
+
+ addStats() {
+ this.stats = new Stats();
+ let statsContainer = this.root.querySelector('.profiler-stats-container');
+ statsContainer.appendChild(this.stats.dom);
+ }
+
+ setUpEventListeners() {
+ // Toggle menu minimization when clicking on the header, but only if not dragging
+ this.root.querySelector('.hpa-settings-header').addEventListener('mousedown', event => {
+ event.stopPropagation();
+ let mouseDownX = event.clientX;
+ let mouseDownY = event.clientY;
+ const mouseUpListener = event => {
+ const mouseUpX = event.clientX;
+ const mouseUpY = event.clientY;
+ if (mouseDownX === mouseUpX && mouseDownY === mouseUpY) {
+ this.toggleMinimized();
+ }
+ this.root.querySelector('.hpa-settings-header').removeEventListener('mouseup', mouseUpListener);
+ };
+ this.root.querySelector('.hpa-settings-header').addEventListener('mouseup', mouseUpListener);
+ });
+
+ this.root.querySelector('#profiler-settings-enable-metrics').addEventListener('change', (event) => {
+ this.updateMetrics(event.target.checked);
+ });
+
+ // Add listeners to aid with clicking checkboxes
+ this.root.querySelectorAll('.hpa-settings-section-row-checkbox').forEach((checkbox) => {
+ const checkboxContainer = checkbox.parentElement;
+ checkboxContainer.addEventListener('click', () => {
+ checkbox.checked = !checkbox.checked;
+ checkbox.dispatchEvent(new Event('change'));
+ });
+ checkbox.addEventListener('click', (event) => {
+ event.stopPropagation(); // Prevent double-counting clicks
+ });
+ });
+
+ // Add click listeners to selects to stop propagation to rest of app
+ this.root.querySelectorAll('.hpa-settings-section-row-select').forEach((select) => {
+ select.addEventListener('click', (event) => {
+ event.stopPropagation();
+ });
+ });
+ }
+
+ enableDrag() {
+ let dragStartX = 0;
+ let dragStartY = 0;
+ let dragStartLeft = 0;
+ let dragStartTop = 0;
+
+ this.root.querySelector('.hpa-settings-header').addEventListener('mousedown', (event) => {
+ event.stopPropagation();
+ dragStartX = event.clientX;
+ dragStartY = event.clientY;
+ dragStartLeft = this.root.offsetLeft;
+ dragStartTop = this.root.offsetTop;
+
+ const mouseMoveListener = (event) => {
+ event.stopPropagation();
+ this.root.style.left = `${dragStartLeft + event.clientX - dragStartX}px`;
+ this.root.style.top = `${dragStartTop + event.clientY - dragStartY}px`;
+ this.snapToFitScreen();
+ }
+ const mouseUpListener = () => {
+ document.removeEventListener('mousemove', mouseMoveListener);
+ document.removeEventListener('mouseup', mouseUpListener);
+ }
+ document.addEventListener('mousemove', mouseMoveListener);
+ document.addEventListener('mouseup', mouseUpListener);
+ });
+ }
+
+ /**
+ * If the settings menu is out of bounds, snap it back into the screen
+ */
+ snapToFitScreen() {
+ const navbar = document.querySelector('.desktopMenuBar');
+ const navbarHeight = navbar ? navbar.offsetHeight : 0;
+ if (this.root.offsetTop < navbarHeight) {
+ this.root.style.top = `${navbarHeight}px`;
+ }
+ if (this.root.offsetLeft < 0) {
+ this.root.style.left = '0px';
+ }
+ if (this.root.offsetLeft + this.root.offsetWidth > window.innerWidth) {
+ this.root.style.left = `${window.innerWidth - this.root.offsetWidth}px`;
+ }
+ // Keep the header visible on the screen off the bottom
+ if (this.root.offsetTop + this.root.querySelector('.hpa-settings-header').offsetHeight > window.innerHeight) {
+ this.root.style.top = `${window.innerHeight - this.root.querySelector('.hpa-settings-header').offsetHeight}px`;
+ }
+ }
+
+ show() {
+ this.root.classList.remove('hidden');
+ this.isHidden = false;
+ this.update(); // start up the update loop again
+ }
+
+ hide() {
+ this.root.classList.add('hidden');
+ this.isHidden = true;
+ }
+
+ toggle() {
+ if (this.root.classList.contains('hidden')) {
+ this.show();
+ } else {
+ this.hide();
+ }
+ }
+
+ minimize() {
+ if (this.root.classList.contains('hidden')) {
+ return;
+ }
+ const previousWidth = this.root.offsetWidth;
+ this.root.classList.add('hpa-settings-minimized');
+ this.root.style.width = `${previousWidth}px`;
+ this.root.querySelector('.hpa-settings-header-icon').innerText = '+';
+ }
+
+ maximize() {
+ if (this.root.classList.contains('hidden')) {
+ return;
+ }
+ this.root.classList.remove('hpa-settings-minimized');
+ this.root.querySelector('.hpa-settings-header-icon').innerText = '_';
+ }
+
+ toggleMinimized() {
+ if (this.root.classList.contains('hpa-settings-minimized')) {
+ this.maximize();
+ } else {
+ this.minimize();
+ }
+ }
+
+ setEnableMetrics(enabled) {
+ this.root.querySelector('#profiler-settings-enable-metrics').checked = enabled;
+ this.updateMetrics(enabled);
+ }
+
+ updateMetrics(enabled) {
+ let container = document.querySelector('.profiler-log-container');
+ if (enabled) {
+ realityEditor.device.profiling.activate();
+ if (container) container.style.display = '';
+ } else {
+ realityEditor.device.profiling.deactivate();
+ if (container) container.style.display = 'none';
+ }
+ }
+}
diff --git a/src/gui/ViewFrustum.js b/src/gui/ViewFrustum.js
new file mode 100644
index 000000000..7b3816543
--- /dev/null
+++ b/src/gui/ViewFrustum.js
@@ -0,0 +1,346 @@
+import { ShaderChunk } from '../../thirdPartyCode/three/three.module.js';
+
+// Set this to how many users can possibly be holding virtualizers at the same time
+// This populates the frustum shader with this many placeholder frustums, since array must compile with fixed length
+const MAX_VIEW_FRUSTUMS = 5;
+
+// names of the uniforms used in the frustum vertex and fragment shaders
+const UNIFORMS = Object.freeze({
+ numFrustums: 'numFrustums',
+ frustums: 'frustums',
+});
+
+const PLANES = Object.freeze({
+ TOP: 0,
+ BOTTOM: 1,
+ LEFT: 2,
+ RIGHT: 3,
+ NEARP: 4,
+ FARP: 5
+});
+const ANG2RAD = Math.PI / 180.0;
+
+/**
+ * Geometrically defines a viewing frustum, based on the cameraInternals (FoV, aspect ratio, etc), and the
+ * position and direction of the camera. Frustum is represented internally by 6 planes (near, far, left, right, top, bottom).
+ * To tell if something is within the frustum, check whether its signed distance to all planes is positive.
+ * Source: http://www.lighthouse3d.com/tutorials/view-frustum-culling/geometric-approach-implementation/
+ */
+class ViewFrustum {
+ constructor() {
+ this.planes = [];
+ }
+ /**
+ * Configures the "shape" of the frustum based on camera properties
+ * @param {number} angle โ vertical FoV angle in degrees (e.g. iPhoneVerticalFOV = 41.22673)
+ * @param {number} ratio โ aspect ratio, e.g. 1920/1080
+ * @param {number} nearD โ near plane distance in scene units, e.g. 0.1 meters
+ * @param {number} farD โ far plane distance in scene units, e.g. 5 meters
+ * @param {boolean|undefined} dontAutoRecompute - pass in true if you plan to call setCameraDef immediately afterwards with new params
+ */
+ setCameraInternals(angle, ratio, nearD, farD, dontAutoRecompute) {
+ // store the information
+ this.ratio = ratio;
+ this.angle = angle;
+ this.nearD = nearD;
+ this.farD = farD;
+
+ // compute width and height of the near and far plane sections
+ let tang = Math.tan(ANG2RAD * angle * 0.5) ;
+ this.nh = nearD * tang;
+ this.nw = this.nh * ratio;
+ this.fh = farD * tang;
+ this.fw = this.fh * ratio;
+
+ // Note: if you change this after setCameraDef, you need to call setCameraDef again to recompute the planes
+ if (!dontAutoRecompute && typeof this.p !== 'undefined' && typeof this.l !== 'undefined' && typeof this.u !== 'undefined') {
+ this.setCameraDef(this.p, this.l, this.u);
+ }
+ }
+
+ /**
+ * Updates the position and orientation of the view frustum by
+ * setting the position, direction, and up vector of the camera
+ * @param {number[]} p โ the position of the camera
+ * @param {number[]} l โ the *position* of what the camera is looking at (this is not the normalized forward vector)
+ * @param {number[]} u โ the normalized up vector
+ */
+ setCameraDef(p, l, u) {
+ this.p = p;
+ this.l = l;
+ this.u = u;
+ let nc,fc,X,Y,Z;
+ let utils = realityEditor.gui.ar.utilities;
+
+ // compute the Z-axis of camera
+ // this axis points in the opposite direction from the looking direction
+ Z = utils.subtract(p, l);
+ Z = utils.normalize(Z);
+
+ // X-axis of camera with given "up" vector and Z-axis
+ X = utils.crossProduct(u, Z);
+ X = utils.normalize(X);
+
+ // the real "up" vector is the cross product of Z and X
+ Y = utils.crossProduct(Z, X);
+
+ // compute the centers of the near and far planes
+ nc = utils.subtract(p, utils.scalarMultiply(Z, this.nearD));
+ fc = utils.subtract(p, utils.scalarMultiply(Z, this.farD));
+
+ // compute the 4 corners of the frustum on the near plane
+ let nearScaledX = utils.scalarMultiply(X, this.nw);
+ let nearScaledY = utils.scalarMultiply(Y, this.nh);
+ this.ntl = utils.subtract(utils.add(nc, nearScaledY), nearScaledX);
+ this.ntr = utils.add(utils.add(nc, nearScaledY), nearScaledX);
+ this.nbl = utils.subtract(utils.subtract(nc, nearScaledY), nearScaledX);
+ this.nbr = utils.add(utils.subtract(nc, nearScaledY), nearScaledX);
+
+ // compute the 4 corners of the frustum on the far plane
+ let farScaledX = utils.scalarMultiply(X, this.fw);
+ let farScaledY = utils.scalarMultiply(Y, this.fh);
+ this.ftl = utils.subtract(utils.add(fc, farScaledY), farScaledX);
+ this.ftr = utils.add(utils.add(fc, farScaledY), farScaledX);
+ this.fbl = utils.subtract(utils.subtract(fc, farScaledY), farScaledX);
+ this.fbr = utils.add(utils.subtract(fc, farScaledY), farScaledX);
+
+ // compute the six planes
+ // assumes that the points are given in counter-clockwise order
+ this.planes[PLANES.TOP] = new PlaneGeo(this.ntr, this.ntl, this.ftl);
+ this.planes[PLANES.BOTTOM] = new PlaneGeo(this.nbl, this.nbr, this.fbr);
+ this.planes[PLANES.LEFT] = new PlaneGeo(this.ntl, this.nbl, this.fbl);
+ this.planes[PLANES.RIGHT] = new PlaneGeo(this.nbr, this.ntr, this.fbr);
+ this.planes[PLANES.NEARP] = new PlaneGeo(this.ntl, this.ntr, this.nbr);
+ this.planes[PLANES.FARP] = new PlaneGeo(this.ftr, this.ftl, this.fbl);
+ // TODO: can be optimized with plane.setNormalAndPoint implementation from source website
+ }
+
+ /**
+ * @param {number[]} p โ [x, y, z]
+ * @returns {boolean} โ true if point lies within the volume of the view frustum
+ */
+ isPointInFrustum(p) {
+ for (let i = 0; i < 6; i++) {
+ if (this.planes[i].distance(p) < 0) {
+ return false; // outside
+ }
+ }
+ return true; // inside
+ }
+}
+
+/**
+ * A plane is represented in two ways: three points that sit on the plane,
+ * or by the equation Ax + By + Cz + D = 0, where [A,B,C] is the normal
+ * and D is the distance offset to the origin.
+ * Source: http://www.lighthouse3d.com/tutorials/maths/plane/
+ */
+class PlaneGeo {
+ /**
+ * You can also omit the points from the constructor and call setPoints or setNormalAndConstant to fully initialize
+ */
+ constructor(p1, p2, p3) {
+ if (p1 && p2 && p3) {
+ this.setPoints(p1, p2, p3);
+ }
+ }
+ /**
+ * Assumes points are given in counter-clockwise order.
+ * Calculates normal and constant using the points on the plane.
+ * @param {number[]} p1 - [x, y, z] array
+ * @param {number[]} p2 - [x, y, z] array
+ * @param {number[]} p3 - [x, y, z] array
+ * @returns {PlaneGeo}
+ */
+ setPoints(p1, p2, p3) {
+ let utils = realityEditor.gui.ar.utilities;
+ this.p1 = p1;
+ this.p2 = p2;
+ this.p3 = p3;
+
+ // plane is defined by Ax + By + Cz + D = 0
+ // given p1, p2, p3 (three points on the plane) we can compute A, B, C, and D
+ let v = utils.subtract(p2, p1);
+ let u = utils.subtract(p3, p1);
+ this.normal = utils.normalize(utils.crossProduct(v, u));
+ this.A = this.normal[0];
+ this.B = this.normal[1];
+ this.C = this.normal[2];
+ this.D = -1 * utils.dotProduct(this.normal, p1); // signed distance to the origin
+ return this;
+ }
+
+ /**
+ * Directly initialize the plane with its normal and constant
+ * @param {number[]} normal - [x, y, z] array
+ * @param {number} D
+ * @returns {PlaneGeo}
+ */
+ setNormalAndConstant(normal, D) {
+ this.normal = normal;
+ this.D = D;
+ return this;
+ }
+ /**
+ * Returns signed distance from point to the plane. If positive, point is on side of plane facing the normal.
+ * @param {number[]} p - [x, y, z] array
+ * @returns {number}
+ */
+ distance(p) {
+ return realityEditor.gui.ar.utilities.dotProduct(this.normal, p) + this.D;
+ }
+}
+
+/**
+ * Returns a GLSL vertex shader for culling the points that fall within view frustums.
+ * Actually doesn't do much, the magic happens in the fragment shader.
+ * @param {boolean} useLoadingAnimation โ if true, calculates distance of each point to center
+ * @param {{x: number, y: number, z: number}} center
+ * @returns {string}
+ */
+const frustumVertexShader = function({useLoadingAnimation, center}) {
+ let loadingCalcString = '';
+ let loadingUniformString = '';
+ if (useLoadingAnimation) {
+ if (!center) {
+ console.warn('trying to create loading animation shader without specifying center');
+ center = {x: 0, y: 0, z: 0};
+ }
+ loadingCalcString = `len = length(position - vec3(${center.x}, ${center.y}, ${center.z}));`;
+ loadingUniformString = `varying float len;`;
+ }
+ return ShaderChunk.meshphysical_vert
+ .replace('#include ', `#include
+ ${loadingCalcString}
+ vPosition = position.xyz; // makes position accessible in the fragment shader
+ vBarycentric = a_barycentric; // Pass barycentric to fragment shader for wireframe effect
+ `).replace('#include ', `#include
+ ${loadingUniformString}
+ attribute vec3 a_barycentric;
+ varying vec3 vBarycentric;
+ varying vec3 vPosition;
+ `);
+}
+
+/**
+ * Returns a GLSL fragment shader for culling the points that fall within view frustums.
+ * Takes in an array of Frustum structs, which each have 6 vec3's (plane normals) and 6 floats (plane constants)
+ * The frustums (uniform) array should have length MAX_VIEW_FRUSTUMS, but only the first numFrustums (uniform)
+ * will be applied to discard points from rendering. The rest should have placeholder values.
+ *
+ * This version of the shader applies a wireframe effect within the frustum instead of discarding all points.
+ * Note: you must first do geometry.toNonIndexed() and assigned barycentric coordinates to each vertex to do the wireframe effect
+ * @returns {string}
+ */
+const frustumFragmentShader = function({useLoadingAnimation, inverted}) {
+ let loadingUniformString = '';
+ let loadingConditionString = '';
+ if (useLoadingAnimation) {
+ loadingUniformString = `
+ varying float len;
+ uniform float maxHeight;`
+ loadingConditionString = inverted ? 'if (len < maxHeight) discard;' : 'if (len > maxHeight) discard;';
+ }
+ let condition = `
+ ${loadingConditionString}
+ // we compare the viewing angle to the frustum direction, to show wireframe more if viewing from an off-angle
+ float maxViewAngleSimilarity = 0.0;
+
+ bool clipped = false;
+ for (int i = 0; i < numFrustums; i++)
+ {
+ bool isInside = isInsideFrustum(frustums[i]);
+ if (isInside) {
+ // by taking the max, we will set the transparency by the most-aligned frustum to this view, ignoring the others
+ maxViewAngleSimilarity = max(maxViewAngleSimilarity, abs(frustums[i].viewAngleSimilarity));
+ }
+ clipped = clipped || isInside;
+ // if (clipped) discard; // uncomment to fully discard all points within frustums instead of wireframing them
+ }
+ `;
+ return ShaderChunk.meshphysical_frag
+ .replace('#include ', `
+ ${condition}
+
+ #include `)
+ .replace('#include ', `#include
+ // make the texture darker if a client connects
+ if (numFrustums > 0 && !clipped) {
+ gl_FragColor.r *= 0.8;
+ gl_FragColor.g *= 0.8;
+ gl_FragColor.b *= 0.8;
+
+ // render the area inside the frustum as a wireframe
+ } else if (clipped) {
+ // mesh fades out if it's cutout by a frustum that is closely aligned with the viewing angle
+ float textureOpacity = 0.3 * (0.1 + max(0.0, 0.95 - maxViewAngleSimilarity));
+ float wireframeOpacity = 0.3 * (0.1 + max(0.0, 0.8 - maxViewAngleSimilarity)); // wireframe fades out earlier
+
+ // show wireframe by calculating whether this point is very close to any of the three triangle edges
+ float min_dist = min(min(vBarycentric.x, vBarycentric.y), vBarycentric.z);
+ float edgeIntensity = 1.0 - step(0.03, min_dist); // 1 if on edge, 0 otherwise. Adjust 0.03 to make wireframe thicker/thinner.
+
+ if (edgeIntensity > 0.5) { // the "wireframe" is rendered by brightening the edges 50%
+ float r = 0.5 + 0.5 * gl_FragColor.r;
+ float g = 0.5 + 0.5 * gl_FragColor.g;
+ float b = 0.5 + 0.5 * gl_FragColor.b;
+ gl_FragColor = edgeIntensity * vec4(r, g, b, wireframeOpacity);
+ } else {
+ gl_FragColor.a = textureOpacity;
+ }
+ }
+ `)
+ .replace(`#include `, `
+ #include
+ ${loadingUniformString}
+ uniform int numFrustums; // current number of frustums to apply
+ struct Frustum { // each Frustum is defined by 24 values (6 normals + 6 constants)
+ vec3 normal1;
+ vec3 normal2;
+ vec3 normal3;
+ vec3 normal4;
+ vec3 normal5;
+ vec3 normal6;
+ float D1;
+ float D2;
+ float D3;
+ float D4;
+ float D5;
+ float D6;
+ float viewAngleSimilarity; // 1 if camera is pointing in same direction as frustum
+ };
+ uniform Frustum frustums[${MAX_VIEW_FRUSTUMS}]; // MAX number of frustums that can cull the geometry
+
+ varying vec3 vBarycentric;
+ varying vec3 vPosition;
+ // todo: this shader only works if the mesh is exported with origin at (0,0,0)
+ // and has identity scale and rotation (1 unit = 1 meter)
+ // ... perhaps swapping vPosition to vWorldPosition could fix this?
+ // varying vec3 vWorldPosition;
+
+ bool isInsidePlane(vec3 normal, float D, vec3 point)
+ {
+ return dot(normal, point) + D > 0.0;
+ }
+
+ bool isInsideFrustum(Frustum f)
+ {
+ bool inside1 = isInsidePlane(f.normal1, f.D1, vPosition); // top (when un-rotated)
+ bool inside2 = isInsidePlane(f.normal2, f.D2, vPosition); // bottom
+ bool inside3 = isInsidePlane(f.normal3, f.D3, vPosition); // left
+ bool inside4 = isInsidePlane(f.normal4, f.D4, vPosition); // right (when un-rotated)
+ bool inside5 = isInsidePlane(f.normal5, f.D5, vPosition); // near
+ bool inside6 = isInsidePlane(f.normal6, f.D6, vPosition); // far
+
+ return (inside1 && inside2 && inside3 && inside4 && inside5 && inside6);
+ }
+ `);
+}
+
+export {
+ MAX_VIEW_FRUSTUMS,
+ UNIFORMS,
+ ViewFrustum,
+ frustumFragmentShader,
+ frustumVertexShader
+}
diff --git a/src/gui/ar/Followable.js b/src/gui/ar/Followable.js
new file mode 100644
index 000000000..91ee1f4eb
--- /dev/null
+++ b/src/gui/ar/Followable.js
@@ -0,0 +1,40 @@
+/**
+ * Classes that can be followed (e.g. CameraVis, VideoPlayer, MotionStudy) can
+ * adhere to this interface by subclassing it and overriding the methods
+ */
+export class Followable {
+ constructor(id, displayName, parentNode) {
+ this.id = id;
+ this.displayName = displayName;
+ this.sceneNodeId = realityEditor.sceneGraph.addVisualElement(id, parentNode);
+ this.sceneNode = realityEditor.sceneGraph.getSceneNodeById(this.sceneNodeId);
+ this.frameKey = null; // assign a frameKey if you want it to be able to focus/blur the linked envelope
+ }
+ doesOverrideCameraUpdatesInFirstPerson() {
+ return false; // return true in subclass to eliminate jitter by handling camera updates when following in first person
+ }
+ updateSceneNode() {
+ // Important to implement: will be triggered in the camera update loop.
+ // this is where you should update the position/rotation of the sceneNode
+ // e.g. this.sceneNode.setLocalMatrix(this.mesh.matrix.elements)
+ }
+ enableFirstPersonMode() {
+ // Optionally add any side effects that should happen when the viewer
+ // zooms in as close as possible (e.g. for CameraVis, change shader mode)
+ }
+ disableFirstPersonMode() {
+ // Optionally add any side effects that should happen when not fully
+ // zoomed in. Note: triggers repeatedly.
+ }
+ onFollowDistanceUpdated(_distanceMm) {
+ // Optionally respond to camera distance updates. (e.g. for VideoPlayer,
+ // show/hide the camera mesh if distance > 3000 mm)
+ }
+ onCameraStartedFollowing() {
+ // Optionally trigger an effect when the viewer begins to follow this
+ }
+ onCameraStoppedFollowing() {
+ // Optionally trigger an effect the viewer stops following this
+ }
+
+}
diff --git a/src/gui/ar/anchors.js b/src/gui/ar/anchors.js
new file mode 100644
index 000000000..6c8a2dd68
--- /dev/null
+++ b/src/gui/ar/anchors.js
@@ -0,0 +1,523 @@
+createNameSpace("realityEditor.gui.ar.anchors");
+
+/**
+ * @fileOverview
+ */
+
+(function(exports) {
+
+ let anchorObjects = {};
+ let utilities = realityEditor.gui.ar.utilities;
+ let fullscreenAnchor = null;
+ const anchorContentSize = 300;
+ const anchorDistanceThreshold = 2000; // disappear if further away than 2 meters
+ let anchorsOutsideOfViewport = {};
+
+ function initService() {
+ realityEditor.gui.ar.draw.addVisibleObjectModifier(modifyVisibleObjects);
+ realityEditor.gui.ar.draw.addUpdateListener(onUpdate);
+
+ realityEditor.gui.settings.addToggle('Hide Anchor Icons', 'don\'t accidentally reposition anchors', 'hideAnchorIcons', '../../../svg/foundObjectAnchor.svg', false, function(newValue) {
+ // only draw frame ghosts while in programming mode if we're not in power-save mode
+ if (newValue) {
+ hideAnchorIcons();
+ } else {
+ showAnchorIcons();
+ }
+ });
+ }
+
+ /**
+ * Anchor objects are uniquely defined by a heartbeat with checksum=0 and the isAnchor property
+ * @param heartbeat
+ * @return {boolean|*}
+ */
+ function isAnchorHeartbeat(heartbeat) {
+ let checksumIsZero = heartbeat.tcs === 0;
+ let object = realityEditor.getObject(heartbeat.id);
+ let jsonIsAnchor = object ? object.isAnchor : false;
+ return checksumIsZero && jsonIsAnchor;
+ }
+
+ /**
+ * Helper function to register this heartbeat as a recognized anchor
+ * @param heartbeat
+ */
+ function createAnchorFromHeartbeat(heartbeat) {
+ if (typeof anchorObjects[heartbeat.id] !== 'undefined') {
+ return;
+ }
+ anchorObjects[heartbeat.id] = heartbeat;
+ }
+
+ /**
+ * Helper function returns whether this object ID is an anchor (and thus has no target data)
+ * @param objectId
+ * @return {boolean}
+ */
+ function isAnchorObject(objectId) {
+ let object = realityEditor.getObject(objectId);
+ if (!object) { return false; }
+ if (realityEditor.humanPose.utils.isHumanPoseObject(object) || realityEditor.avatar.utils.isAvatarObject(object)) { return false; }
+ return anchorObjects.hasOwnProperty(objectId);
+ }
+
+ /**
+ * This gets triggered at the beginning of gui.ar.draw.update
+ * We use this function to inject anchor objects' model matrices into the visibleObjects in the
+ * gui.ar.draw.update function
+ * @param visibleObjects
+ * @todo: store which world they are relative to, rather than finding closest world each time
+ * (e.g. even if the closest world to the camera changes, the anchor should stay relative to
+ * its world)
+ */
+ function modifyVisibleObjects(visibleObjects) {
+ // if there's no visible world object other than the world_local, ignore all this code
+ let bestWorldObject = realityEditor.worldObjects.getBestWorldObject();
+ if (!bestWorldObject || bestWorldObject.objectId === realityEditor.worldObjects.getLocalWorldId()) {
+ return;
+ }
+
+ let anchorObjectIds = Object.keys(objects).filter(function(objectKey) {
+ return isAnchorObject(objectKey);
+ });
+
+ // if there are no anchor objects, ignore all this code
+ if (anchorObjectIds.length === 0) {
+ return;
+ }
+
+ anchorObjectIds.forEach(function(objectKey) {
+ // object.matrix is its position relative to the world..
+ // e.g. if object.matrix is identity, its visibleObjects matrix should be equal to
+ // the visibleObjects matrix of its world
+ let objectMatrix = realityEditor.getObject(objectKey).matrix || utilities.newIdentityMatrix();
+
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(objectKey);
+ if (sceneNode) {
+ let worldObjectSceneNode = realityEditor.sceneGraph.getSceneNodeById(bestWorldObject.objectId);
+ sceneNode.setParent(worldObjectSceneNode);
+ sceneNode.setLocalMatrix(objectMatrix);
+ }
+
+ // only adds this object to visibleObjects if the position is within the camera's
+ // cone of view and close enough to the camera
+ if (shouldAddToVisibleObjects(objectKey)) {
+ visibleObjects[objectKey] = objectMatrix;
+ } else {
+ hideAnchorElementIfNeeded(objectKey);
+ }
+ });
+ }
+
+ /**
+ * hide and remove object from visibleObjects if it is outside the viewport or too far away
+ * only exception is if it's fullscreen - then automatically visible
+ * @param {string} objectKey
+ * @return {boolean}
+ */
+ function shouldAddToVisibleObjects(objectKey) {
+ // TODO ben: reimplement with canUnload
+ let isOutsideViewport = false; //realityEditor.gui.ar.positioning.canUnload(objectKey,
+ // finalAnchorMatrices[objectKey], anchorContentSize/2, anchorContentSize/2);
+ let distanceToCamera = realityEditor.sceneGraph.getDistanceToCamera(objectKey);
+
+ if (fullscreenAnchor === objectKey) {
+ return true;
+ }
+
+ let isDistanceOk = distanceToCamera < getAnchorDistanceThreshold(objectKey) || !realityEditor.device.environment.supportsDistanceFading();
+
+ return !isOutsideViewport && isDistanceOk;
+ }
+
+ /**
+ * This gets triggered at the end of gui.ar.draw.update
+ * @param visibleObjects
+ */
+ function onUpdate(visibleObjects) {
+ for (var objectKey in visibleObjects) {
+ if (!visibleObjects.hasOwnProperty(objectKey)) continue;
+ if (!isAnchorObject(objectKey)) continue;
+
+ // create the DOM element and render with the correct transformation
+ if (!globalDOMCache['anchor' + objectKey]) {
+ createAnchorElement(objectKey); // creates DOM and adds to sceneGraph
+ }
+
+ // render it fullscreen and skip 3d rendering early if it is being "carried"
+ if (fullscreenAnchor === objectKey) {
+ let zIndex = 5000; // defaults to front of screen
+ globalDOMCache['anchor' + objectKey].style.transform =
+ 'matrix3d(1, 0, 0, 0,' +
+ '0, 1, 0, 0,' +
+ '0, 0, 1, 0,' +
+ '0, 0, ' + zIndex + ', 1)';
+ continue;
+ }
+
+ // retrieve final value computed by scene graph
+ let visualElementNode = realityEditor.sceneGraph.getVisualElement('anchor' + objectKey);
+ let finalMatrix = realityEditor.sceneGraph.getCSSMatrix(visualElementNode.id);
+
+ let activeElt = globalDOMCache['anchor' + objectKey];
+
+ // render if within view frustum and within distance threshold (last frame)
+ if (!anchorsOutsideOfViewport[objectKey]) {
+ activeElt.style.transform = 'matrix3d(' + finalMatrix.toString() + ')';
+ } else {
+ hideAnchorElementIfNeeded(objectKey); // hide if it was outside (last frame)
+ }
+
+ // hide if it is too far away or entirely behind the camera
+ let distanceToCamera = realityEditor.sceneGraph.getDistanceToCamera(objectKey);
+ let isDistanceOk = distanceToCamera < getAnchorDistanceThreshold(objectKey) || !realityEditor.device.environment.supportsDistanceFading();
+ let isNowOutsideViewport = !isDistanceOk;
+ // TODO: re-implement canUnload for more stringent viewport culling when outside frustum
+
+ if (isNowOutsideViewport) {
+ hideAnchorElementIfNeeded(objectKey); // hide if newly outside this frame
+ } else {
+ // show anchor if it was outside viewport but now it isn't
+ if (anchorsOutsideOfViewport[objectKey]) {
+ delete anchorsOutsideOfViewport[objectKey];
+ activeElt.classList.remove('outsideOfViewport');
+ activeElt.style.transform = 'matrix3d(' + finalMatrix.toString() + ')';
+ }
+ }
+ }
+ }
+
+ /**
+ * anchorDistanceThreshold is the default, but expands to stay visible if its tools have a
+ * visibility distance that is larger than the default anchor threshold
+ * @param objectKey
+ * @return {number}
+ */
+ function getAnchorDistanceThreshold(objectKey) {
+ let maxFrameDistanceThreshold = 0;
+ realityEditor.forEachFrameInObject(objectKey, function(objectKey, frameKey) {
+ let frame = realityEditor.getFrame(objectKey, frameKey);
+ let distanceScale = realityEditor.gui.ar.getDistanceScale(frame);
+ // multiply the default min distance by the amount this frame distance has been scaled up
+ let scaleFactor = 0.8; // discount the distance of frames compared to the anchor threshold
+ let distanceThreshold = scaleFactor * (distanceScale * realityEditor.device.distanceScaling.getDefaultDistance());
+ if (distanceThreshold > maxFrameDistanceThreshold) {
+ maxFrameDistanceThreshold = distanceThreshold;
+ }
+ });
+ return Math.max(anchorDistanceThreshold, maxFrameDistanceThreshold);
+ }
+
+ /**
+ * Helper function to ensure the HTML element for an anchor gets removed properly
+ * @param {string} objectKey
+ */
+ function hideAnchorElementIfNeeded(objectKey) {
+ let activeElt = globalDOMCache['anchor' + objectKey];
+
+ if (fullscreenAnchor === objectKey) {
+ return; // don't hide the fullscreen anchor otherwise no way to go back
+ }
+
+ if (activeElt && (!anchorsOutsideOfViewport[objectKey] || !activeElt.classList.contains('outsideOfViewport'))) {
+ anchorsOutsideOfViewport[objectKey] = true; // make sure to keep track of this property
+ activeElt.classList.add('outsideOfViewport');
+ }
+ }
+
+ /**
+ * Creates a DOM element for the given object.
+ * Element must be constructed in a certain way to render correctly in 3d space.
+ * @param {string} objectKey
+ */
+ function createAnchorElement(objectKey) {
+ let anchorContainer = document.createElement('div');
+ anchorContainer.id = 'anchor' + objectKey;
+ anchorContainer.classList.add('anchorContainer', 'ignorePointerEvents', 'main', 'visibleFrameContainer');
+
+ if (realityEditor.gui.settings.toggleStates.hideAnchorIcons) {
+ anchorContainer.classList.add('hiddenAnchor');
+ }
+
+ // IMPORTANT NOTE: the container size MUST be the size of the screen for the 3d math to work
+ // This is the same size as the containers that frames get added to.
+ // If size differs, rendering will be inconsistent between frames and anchors.
+ anchorContainer.style.width = globalStates.height + 'px';
+ anchorContainer.style.height = globalStates.width + 'px';
+
+ // the contents are a different size than the screen, so we add another div and center it
+ let anchorContents = document.createElement('div');
+ anchorContents.id = 'anchorContents' + objectKey;
+ anchorContents.classList.add('anchorContents', 'usePointerEvents');
+ anchorContents.style.left = (globalStates.height/2 - anchorContentSize/2) + 'px';
+ anchorContents.style.top = (globalStates.width/2 - anchorContentSize/2) + 'px';
+
+ anchorContainer.appendChild(anchorContents);
+ document.getElementById('GUI').appendChild(anchorContainer);
+
+ globalDOMCache['anchor' + objectKey] = anchorContainer;
+ globalDOMCache['anchorContents' + objectKey] = anchorContents;
+
+ updateAnchorGraphics(objectKey, true);
+
+ // attach event listeners
+ anchorContents.addEventListener('pointerup', function(event) {
+ if (realityEditor.device.environment.requiresMouseEvents() && event.button === 2) { return; } // ignore right-clicks
+
+ onAnchorTapped(objectKey);
+ });
+
+ // add a scene node to object for the anchor graphics and rotate it 180 degrees so it faces correct direction
+ let objectSceneNode = realityEditor.sceneGraph.getSceneNodeById(objectKey);
+ let elementMatrix = [];
+ let scale = 0.5;
+ let transform = [
+ scale, 0, 0, 0,
+ 0, scale, 0, 0,
+ 0, 0, scale, 0,
+ 0, 0, 0, 1
+ ];
+ utilities.multiplyMatrix(transform, makeRotationZ(Math.PI), elementMatrix);
+ realityEditor.sceneGraph.addVisualElement(anchorContainer.id, objectSceneNode, undefined, elementMatrix);
+ }
+
+ var makeRotationZ = function ( theta ) {
+ var c = Math.cos( theta ), s = Math.sin( theta );
+ return [ c, -s, 0, 0,
+ s, c, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1];
+ };
+
+ /**
+ * Toggle between fullscreen and 3d modes when an anchor is tapped
+ * @todo: maybe only allow this in repositioning mode so it doesn't happen accidentally?
+ * @param {string} objectKey
+ */
+ function onAnchorTapped(objectKey) {
+ if (!fullscreenAnchor) {
+ // setting fullscreenAnchor to the object ID is all that is necessary to pick it up
+ fullscreenAnchor = objectKey;
+ } else {
+ // tapping on the fullscreen anchor drops the anchor at the phone's exact position
+ if (fullscreenAnchor === objectKey) {
+ // calculates position relative to world so that anchor is positioned at the camera
+ if (!realityEditor.device.environment.isCameraOrientationFlipped()) {
+ realityEditor.sceneGraph.moveSceneNodeToCamera(objectKey, true);
+
+ } else {
+ // needs to be upside-down relative to camera in certain environments
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(objectKey);
+ let cameraNode = realityEditor.sceneGraph.getSceneNodeById('CAMERA');
+ let initialVehicleMatrix = [
+ 1, 0, 0, 0,
+ 0, -1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ];
+ sceneNode.setPositionRelativeTo(cameraNode, initialVehicleMatrix);
+ }
+
+ // store the new relative position of the anchor to the world
+ let anchorObject = realityEditor.getObject(objectKey);
+ anchorObject.matrix = realityEditor.sceneGraph.getSceneNodeById(objectKey).localMatrix;
+
+ // upload to the server for persistence. scene graph makes sure it uploads position relative to world
+ realityEditor.sceneGraph.network.uploadObjectPosition(objectKey);
+
+ fullscreenAnchor = null;
+ }
+ }
+
+ // update the HTML of the anchor based on whether it is now fullscreen or not
+ updateAnchorGraphics(objectKey);
+ }
+
+ /**
+ * Swaps the contents of the anchor's HTML container to be either a small icon in space with
+ * the object name, or a fullscreen element that expands to fill the screen and has 4
+ * corners and a center crosshair with the object name.
+ * @param {string} objectKey
+ * @param {boolean} forceCreation - tries to be efficient and not recreate inner graphics
+ * every time, but pass in true the first time it is created to ensure this happens at
+ * least onc
+ */
+ function updateAnchorGraphics(objectKey, forceCreation) {
+ let container = globalDOMCache['anchor' + objectKey];
+ let element = globalDOMCache['anchorContents' + objectKey];
+ if (fullscreenAnchor === objectKey && (!element.classList.contains('anchorContentsFullscreen') || forceCreation)) {
+
+ // this will make sure it isn't inside an invisible container
+ container.classList.add('anchorContainerFullscreen');
+
+ // first, hide the sidebar buttons
+ document.querySelector('#UIButtons').classList.add('hiddenButtons');
+
+ // fill the width and height of the screen
+ element.classList.add('anchorContentsFullscreen');
+
+ // some style needs to be applied with js to override other runtime calculated properties
+ element.style.left = 0;
+ element.style.top = 0;
+
+ // rebuild the SVG at correct size to fill the screen's corners
+ element.innerHTML = '';
+
+ let margin = 5;
+
+ let topLeft = document.createElement('img');
+ topLeft.src = '../../../svg/anchorTopLeft.svg';
+ topLeft.classList.add('anchorCorner');
+ topLeft.style.left = margin + 'px';
+ topLeft.style.top = margin + 'px';
+
+ let topRight = document.createElement('img');
+ topRight.src = '../../../svg/anchorTopRight.svg';
+ topRight.classList.add('anchorCorner');
+ topRight.style.right = margin + 'px';
+ topRight.style.top = margin + 'px';
+
+ let bottomLeft = document.createElement('img');
+ bottomLeft.src = '../../../svg/anchorBottomLeft.svg';
+ bottomLeft.classList.add('anchorCorner');
+ bottomLeft.style.left = margin + 'px';
+ bottomLeft.style.bottom = margin + 'px';
+
+ let bottomRight = document.createElement('img');
+ bottomRight.src = '../../../svg/anchorBottomRight.svg';
+ bottomRight.classList.add('anchorCorner');
+ bottomRight.style.right = margin + 'px';
+ bottomRight.style.bottom = margin + 'px';
+
+ let centerContainer = document.createElement('div');
+ centerContainer.classList.add('anchorCenter');
+ let size = (0.6 * globalStates.width);
+ centerContainer.style.left = (globalStates.height/2 - size/2) + 'px';
+ centerContainer.style.top = (globalStates.width/2 - size/2) + 'px';
+
+ let centerSvg = document.createElement('img');
+ centerSvg.src = '../../../svg/anchorCenter.svg';
+ centerContainer.appendChild(centerSvg);
+
+ // add a textfield with the object name
+ let textfield = document.createElement('div');
+ textfield.classList.add('anchorTextField');
+ textfield.innerText = realityEditor.getObject(objectKey).name;
+ centerContainer.appendChild(textfield);
+
+ element.appendChild(topLeft);
+ element.appendChild(topRight);
+ element.appendChild(bottomLeft);
+ element.appendChild(bottomRight);
+ element.appendChild(centerContainer);
+
+ // this needs to happen after elements have been added to the DOM
+ resizeAnchorText(objectKey);
+ } else if (element.classList.contains('anchorContentsFullscreen') || forceCreation) {
+
+ container.classList.remove('anchorContainerFullscreen');
+
+ // first show the sidebar buttons
+ document.querySelector('#UIButtons').classList.remove('hiddenButtons');
+
+ // resize it to be a small centered icon in its container
+ element.classList.remove('anchorContentsFullscreen');
+ element.style.left = (globalStates.height/2 - anchorContentSize/2) + 'px';
+ element.style.top = (globalStates.width/2 - anchorContentSize/2) + 'px';
+
+ // rebuild the HTML with an SVG icon
+ element.innerHTML = '';
+ let anchorContentsPlaced = document.createElement('img');
+ anchorContentsPlaced.src = '../../../svg/anchor.svg';
+ anchorContentsPlaced.classList.add('anchorContentsPlaced');
+ element.appendChild(anchorContentsPlaced);
+
+ // create the text field again
+ let textfield = document.createElement('div');
+ textfield.classList.add('anchorTextField');
+ textfield.innerText = realityEditor.getObject(objectKey).name;
+ element.appendChild(textfield);
+
+ resizeAnchorText(objectKey);
+ }
+ }
+
+ /**
+ * Re-sizes the object name inside the anchor to fit the text box background
+ * @param {string} objectKey
+ */
+ function resizeAnchorText(objectKey) {
+ let anchorElement = globalDOMCache['anchorContents' + objectKey];
+ let textfield = anchorElement.querySelector('.anchorTextField');
+
+ const maxFontSize = 18;
+ const minTextWidth = 105;
+
+ // prep by resetting so we can compute size with default params
+ textfield.style.width = '';
+ textfield.style.fontSize = maxFontSize + 'px';
+
+ // resize text to fit after it renders once
+ requestAnimationFrame(function() {
+ let desiredWidth = anchorElement.clientWidth * 0.35;
+ let anchorCenter = anchorElement.querySelector('.anchorCenter');
+ if (anchorCenter) {
+ desiredWidth = anchorCenter.clientWidth * 0.35;
+ }
+ let realWidth = parseFloat(getComputedStyle(textfield).width);
+
+ let percent = desiredWidth / realWidth;
+ let newFontSize = Math.min(maxFontSize, maxFontSize * percent);
+ textfield.style.fontSize = newFontSize + 'px';
+
+ // update with set width/height after calculating so text is centered
+ if (desiredWidth < minTextWidth || isNaN(desiredWidth)) {
+ desiredWidth = minTextWidth;
+ }
+ textfield.style.width = desiredWidth + 'px';
+ let realHeight = parseFloat(getComputedStyle(textfield).height);
+ textfield.style.lineHeight = realHeight + 'px';
+ });
+ }
+
+ function snapAnchorToScreen(objectKey) {
+ fullscreenAnchor = objectKey;
+ // update the HTML of the anchor based on whether it is now fullscreen or not
+ updateAnchorGraphics(objectKey);
+
+ // make sure it doesn't get stuck with isOutsideViewport invisibility
+ if (anchorsOutsideOfViewport[objectKey]) {
+ delete anchorsOutsideOfViewport[objectKey];
+ globalDOMCache['anchor' + objectKey].classList.remove('outsideOfViewport');
+ }
+ }
+
+ // TODO: associate each anchor with a world, and only return true if that particular world has been seen
+ // for now, returns true if any world other than world_local has been seen
+ function isAnchorObjectDetected(_objectKey) {
+ let bestWorldObject = realityEditor.worldObjects.getBestWorldObject();
+ if (!bestWorldObject) { return false; }
+ return bestWorldObject.objectId !== realityEditor.worldObjects.getLocalWorldId();
+ }
+
+ function hideAnchorIcons() {
+ Array.from(document.querySelectorAll('.anchorContainer')).forEach(function(anchorContainer) {
+ anchorContainer.classList.add('hiddenAnchor');
+ });
+ }
+
+ function showAnchorIcons() {
+ Array.from(document.querySelectorAll('.anchorContainer')).forEach(function(anchorContainer) {
+ anchorContainer.classList.remove('hiddenAnchor');
+ });
+ }
+
+ exports.initService = initService;
+ exports.isAnchorHeartbeat = isAnchorHeartbeat;
+ exports.createAnchorFromHeartbeat = createAnchorFromHeartbeat;
+ exports.isAnchorObject = isAnchorObject;
+ exports.snapAnchorToScreen = snapAnchorToScreen;
+ exports.isAnchorObjectDetected = isAnchorObjectDetected;
+
+})(realityEditor.gui.ar.anchors);
diff --git a/src/gui/ar/areaCreator.js b/src/gui/ar/areaCreator.js
new file mode 100644
index 000000000..61ecf0bd2
--- /dev/null
+++ b/src/gui/ar/areaCreator.js
@@ -0,0 +1,595 @@
+/*
+* Created by Daniel Dangond on 12/06/21.
+*
+* Copyright (c) 2021 PTC Inc
+*
+* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/.
+*/
+
+createNameSpace("realityEditor.gui.ar.areaCreator");
+
+/**
+ * @fileOverview realityEditor.gui.ar.areaCreator
+ * Provides an API for tools to call in order to prompt the user to draw an area on their screen which then gets
+ * returned to the tool
+ */
+
+realityEditor.gui.ar.areaCreator.Modes = {
+ DISABLED: "DISABLED",
+ DRAW_MODE_SELECT: "DRAW_MODE_SELECT",
+ AREA_CREATE: "AREA_CREATE",
+ HEIGHT_SET: "HEIGHT_SET",
+}
+realityEditor.gui.ar.areaCreator.mode = realityEditor.gui.ar.areaCreator.Modes.DISABLED;
+
+realityEditor.gui.ar.areaCreator.pointerCallbacks = {
+ down: () => {},
+ up: () => {},
+ move: () => {}
+}
+
+realityEditor.gui.ar.areaCreator.buttonCallbacks = {
+ cancel: () => {},
+ freehand: () => {},
+ polygon: () => {},
+ confirmArea: () => {},
+ confirmHeight: () => {},
+}
+
+realityEditor.gui.ar.areaCreator.polygonPoints = [];
+realityEditor.gui.ar.areaCreator.height = 1;
+
+realityEditor.gui.ar.areaCreator.canDragTouch = false; // Avoid initial click on menu for dragging
+realityEditor.gui.ar.areaCreator.lastFreehandTime = 0; // Prevents dropping too many points in freehand mode
+realityEditor.gui.ar.areaCreator.lastPointerY = null; // Prevent jumping between points by tapping twice
+
+realityEditor.gui.ar.areaCreator.areaRender = null;
+realityEditor.gui.ar.areaCreator.needsRenderUpdate = false;
+
+realityEditor.gui.ar.areaCreator.animationCallbacks = [];
+
+realityEditor.gui.ar.areaCreator.initializedPrefabs = false;
+
+realityEditor.gui.ar.areaCreator.initService = function() {
+ realityEditor.network.addPostMessageHandler('promptForArea', this.promptForAreaHandler);
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.getPrefabs = function() {
+ if (!this.initializedPrefabs) {
+ const THREE = realityEditor.gui.threejsScene.THREE;
+ const pointPillarGeometry = new THREE.CylinderGeometry(0.02, 0.02, 1, 8).translate(0, 0.5, 0).scale(1000,1000,1000);
+ const pointPillarMaterial = new THREE.MeshBasicMaterial({color: 0x66FF66, opacity: 0.6, transparent: true});
+ this.pointPillarSource = new THREE.Mesh(pointPillarGeometry, pointPillarMaterial);
+ this.wallMaterial = new THREE.MeshBasicMaterial({color: 0x66FF66, opacity: 0.5, transparent: true, side: THREE.DoubleSide, wireframe: true});
+ this.floorMaterial = new THREE.MeshBasicMaterial({color: 0x66FF66, opacity: 0.5, transparent: true, side: THREE.DoubleSide});
+ this.initializedPrefabs = true;
+ }
+ return {
+ pointPillarSource: this.pointPillarSource,
+ wallMaterial: this.wallMaterial,
+ floorMaterial: this.floorMaterial
+ }
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.promptForAreaHandler = function(msgData) {
+ this.promptForArea(msgData.options).then(area => {
+ realityEditor.network.postMessageIntoFrame(msgData.frameKey, {area: area, canceled: false});
+ }).catch(() => {
+ realityEditor.network.postMessageIntoFrame(msgData.frameKey, {area: {}, canceled: true});
+ });
+}.bind(realityEditor.gui.ar.areaCreator);
+
+/**
+ * returns a promise that rejects if the user cancels the area creation process and that resolves with an object
+ * in the form {points: [{x:number,y:number}...], height?: number} if the user completes the area creation process.
+ * @param {object} options takes the form {drawingMode:'FREEHAND'|'POLYGON'|undefined, defineHeight:boolean}
+ */
+realityEditor.gui.ar.areaCreator.promptForArea = function(options) {
+ return new Promise((resolve, reject) => {
+ globalStates.useGroundPlane = true;
+ function confirmAreaCreation(area) {
+ resolve({
+ points: area.points,
+ height: area.height * 1000,
+ floorOffset: realityEditor.gui.ar.areaCreator.calculateFloorOffset()
+ });
+ realityEditor.gui.ar.areaCreator.disable();
+ }
+
+ function cancelAreaCreation() {
+ reject();
+ realityEditor.gui.ar.areaCreator.disable();
+ }
+
+ this.activateUI();
+ this.buttonCallbacks.cancel = () => {
+ cancelAreaCreation();
+ }
+
+ if (options.drawingMode === 'FREEHAND') {
+ this.beginAreaCreation(options.drawingMode).then(points => {
+ if (options.defineHeight) {
+ this.promptForHeight().then(height => {
+ confirmAreaCreation({points, height});
+ }).catch(cancelAreaCreation);
+ } else {
+ confirmAreaCreation({points});
+ }
+ }).catch(cancelAreaCreation);
+ } else if (options.drawingMode === 'POLYGON') {
+ this.beginAreaCreation(options.drawingMode).then(points => {
+ if (options.defineHeight) {
+ this.promptForHeight().then(height => {
+ confirmAreaCreation({points, height});
+ }).catch(cancelAreaCreation);
+ } else {
+ confirmAreaCreation({points});
+ }
+ }).catch(cancelAreaCreation);
+ } else {
+ this.promptForDrawingMode().then(drawingMode => {
+ this.beginAreaCreation(drawingMode).then(points => {
+ if (options.defineHeight) {
+ this.promptForHeight().then(height => {
+ confirmAreaCreation({points, height});
+ }).catch(cancelAreaCreation);
+ } else {
+ confirmAreaCreation({points});
+ }
+ }).catch(cancelAreaCreation);
+ }).catch(cancelAreaCreation);
+ }
+ });
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.promptForDrawingMode = function() {
+ this.mode = this.Modes.DRAW_MODE_SELECT;
+ return new Promise((resolve, reject) => {
+ this.clearCallbacks();
+ this.showDrawingModeSelectionMenu();
+
+ this.buttonCallbacks.cancel = (event) => {
+ reject();
+ this.hideDrawingModeSelectionMenu();
+ event.stopPropagation();
+ }
+ this.buttonCallbacks.freehand = (event) => {
+ resolve('FREEHAND');
+ this.hideDrawingModeSelectionMenu();
+ event.stopPropagation();
+ }
+ this.buttonCallbacks.polygon = (event) => {
+ resolve('POLYGON');
+ this.hideDrawingModeSelectionMenu();
+ event.stopPropagation();
+ }
+ });
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.beginAreaCreation = function(drawingMode) {
+ this.mode = this.Modes.AREA_CREATE;
+ return new Promise((resolve, reject) => {
+ this.clearCallbacks();
+ this.showAreaCreationMenu();
+
+ this.buttonCallbacks.cancel = (event) => {
+ reject();
+ this.hideAreaCreationMenu();
+ event.stopPropagation();
+ }
+ this.buttonCallbacks.confirmArea = (event) => {
+ resolve(this.polygonPoints);
+ this.hideAreaCreationMenu();
+ event.stopPropagation();
+ }
+
+ this.pointerCallbacks.down = (event) => {
+ const point = this.calculateGroundPlaneIntersection(event);
+ if (point) {
+ const flattenedPoint = new realityEditor.gui.threejsScene.THREE.Vector2(point.x, point.z);
+ this.polygonPoints.push(flattenedPoint);
+ if (this.polygonPoints.length >= 3) {
+ this.showAreaConfirmationButton();
+ }
+ this.needsRenderUpdate = true;
+ // stop propagation if we hit, otherwise pass the event on to the rest of the application
+ event.stopPropagation();
+ }
+ this.canDragTouch = true;
+ }
+ this.pointerCallbacks.up = () => {};
+ this.pointerCallbacks.move = (event) => {
+ const point = this.calculateGroundPlaneIntersection(event);
+ if (point && this.canDragTouch) { // Ignore initial menu touch
+ const flattenedPoint = new realityEditor.gui.threejsScene.THREE.Vector2(point.x, point.z);
+
+ const pointInterval = 200; // 1 point per 200ms
+ if (drawingMode === "FREEHAND" && Date.now() - this.lastFreehandTime > pointInterval) { // TODO: base this on distance between points, not time
+ this.lastFreehandTime = Date.now();
+ this.polygonPoints.push(flattenedPoint);
+ if (this.polygonPoints.length >= 3) {
+ this.showAreaConfirmationButton();
+ }
+ } else {
+ this.polygonPoints[this.polygonPoints.length-1] = flattenedPoint; // Drag point in polygon mode
+ }
+ this.needsRenderUpdate = true;
+ // stop propagation if we hit, otherwise pass the event on to the rest of the application
+ event.stopPropagation();
+ }
+ };
+
+ this.addAnimationCallback(this.areaAnimationCallback);
+ })
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.promptForHeight = function() {
+ this.mode = this.Modes.HEIGHT_SET;
+ return new Promise((resolve, reject) => {
+ this.clearCallbacks();
+ this.showHeightDefinitionMenu();
+
+ this.buttonCallbacks.cancel = (event) => {
+ reject();
+ this.hideHeightDefinitionMenu();
+ event.stopPropagation();
+ }
+ this.buttonCallbacks.confirmHeight = (event) => {
+ resolve(this.height);
+ this.hideHeightDefinitionMenu();
+ event.stopPropagation();
+ }
+
+ this.pointerCallbacks.move = (event) => {
+ const movementFactor = 0.003;
+ const distance = (this.lastPointerY ? this.lastPointerY - event.clientY : 0);
+ this.height += distance * movementFactor;
+ this.height = Math.max(0.001, Math.min(this.height, 1000));
+ this.needsRenderUpdate = true;
+ }
+
+ this.addAnimationCallback(this.areaAnimationCallback);
+ })
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.areaAnimationCallback = function() {
+ if (this.needsRenderUpdate) {
+ this.clearAreaRender();
+ this.generateAreaRender();
+ this.needsRenderUpdate = false;
+ }
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.clearAreaRender = function() {
+ realityEditor.gui.threejsScene.removeFromScene(this.areaRender);
+ this.areaRender = null;
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.generateAreaRender = function() {
+ const THREE = realityEditor.gui.threejsScene.THREE;
+ this.areaRender = new THREE.Group();
+ const relativePoints = this.polygonPoints.map(point => {
+ return new THREE.Vector2(point.x - this.polygonPoints[0].x, point.y - this.polygonPoints[0].y);
+ });
+ // Draw pillars
+ relativePoints.forEach(point => {
+ const pointPillar = this.getPrefabs().pointPillarSource.clone();
+ this.areaRender.add(pointPillar);
+ pointPillar.position.copy(new THREE.Vector3(point.x, 0, point.y));
+ pointPillar.scale.copy(new THREE.Vector3(1, this.height, 1));
+ });
+ if (relativePoints.length > 1) {
+ // Draw walls
+ for (let i = 0; i < relativePoints.length; i++) {
+ const wallStart = relativePoints[i];
+ const wallEnd = (i === relativePoints.length - 1) ? relativePoints[0] : relativePoints[i+1];
+ const wallWidth = wallStart.distanceTo(wallEnd);
+ const wallGeometry = new THREE.PlaneGeometry(wallWidth, this.height * 1000).translate(wallWidth / 2, this.height * 1000 / 2, 0);
+ const wall = new THREE.Mesh(wallGeometry, this.getPrefabs().wallMaterial);
+ this.areaRender.add(wall);
+ wall.position.copy(new THREE.Vector3(wallStart.x, 0, wallStart.y));
+ wall.lookAt(this.areaRender.localToWorld(new THREE.Vector3(wallEnd.x, 0, wallEnd.y)));
+ wall.rotateY(-Math.PI / 2);
+ }
+ }
+ const floorShape = new THREE.Shape(relativePoints);
+ const floorGeometry = new THREE.ShapeGeometry(floorShape);
+ floorGeometry.rotateX(Math.PI / 2); // Lay flat on ground, not vertical
+ const floor = new THREE.Mesh(floorGeometry, this.getPrefabs().floorMaterial);
+ this.areaRender.add(floor);
+ realityEditor.gui.threejsScene.addToScene(this.areaRender);
+ this.areaRender.position.copy(new THREE.Vector3(this.polygonPoints[0].x, 0, this.polygonPoints[0].y));
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.didAreaUpdate = function() {
+ return this.needsRenderUpdate;
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.calculateGroundPlaneIntersection = function(event) {
+ const groundPlane = realityEditor.gui.threejsScene.getGroundPlaneCollider();
+ const intersects = realityEditor.gui.threejsScene.getRaycastIntersects(event.clientX, event.clientY, [groundPlane.getInternalObject()]);
+ if (intersects.length > 0) {
+ let worldObjectToolboxMatrix = realityEditor.sceneGraph.getSceneNodeById(realityEditor.sceneGraph.getWorldId()).worldMatrix;
+ const worldObjectThreeMatrix = new realityEditor.gui.threejsScene.THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(worldObjectThreeMatrix, worldObjectToolboxMatrix);
+ return intersects[0].point.applyMatrix4(worldObjectThreeMatrix.invert());
+ }
+}
+
+realityEditor.gui.ar.areaCreator.cachedFloorOffset = null;
+
+realityEditor.gui.ar.areaCreator.calculateFloorOffset = function() {
+ if (this.cachedFloorOffset) {
+ return this.cachedFloorOffset;
+ }
+
+ const worldObjectToolboxMatrix = realityEditor.sceneGraph.getSceneNodeById(realityEditor.sceneGraph.getWorldId()).worldMatrix;
+ const worldObjectThreeMatrix = new realityEditor.gui.threejsScene.THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(worldObjectThreeMatrix, worldObjectToolboxMatrix);
+ const groundPlaneToolboxMatrix = realityEditor.sceneGraph.getGroundPlaneNode().worldMatrix;
+ const groundPlaneThreeMatrix = new realityEditor.gui.threejsScene.THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(groundPlaneThreeMatrix, groundPlaneToolboxMatrix);
+ const groundToWorldMatrix = groundPlaneThreeMatrix.clone().invert().multiply(worldObjectThreeMatrix);
+ const position = new realityEditor.gui.threejsScene.THREE.Vector3();
+ position.setFromMatrixPosition(groundToWorldMatrix);
+ this.cachedFloorOffset = -position.y;
+ return -position.y;
+}
+
+// ensures there's a div on top of everything that blocks touch events from reaching the tools when we're in this mode
+realityEditor.gui.ar.areaCreator.getUI = function () {
+ if (!this.UI) {
+ this.UI = document.createElement('div');
+ this.UI.style.position = 'absolute';
+ this.UI.style.left = '0';
+ this.UI.style.top = '0';
+ this.UI.style.width = '100vw';
+ this.UI.style.height = '100vh';
+ this.UI.style.display = 'none';
+ this.UI.style.pointerEvents = 'none';
+ let uiZIndex = 2900; // above scene elements, below pocket and menus
+ this.UI.style.zIndex = uiZIndex;
+ this.UI.style.transform = 'matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,' + uiZIndex + ',1)';
+ document.body.appendChild(this.UI);
+
+ this.UI.addEventListener('pointerdown', (event) => {
+ realityEditor.gui.ar.areaCreator.pointerCallbacks.down(event);
+ realityEditor.gui.ar.areaCreator.lastPointerY = event.clientY;
+ });
+ this.UI.addEventListener('pointerup', (event) => {
+ realityEditor.gui.ar.areaCreator.pointerCallbacks.up(event);
+ realityEditor.gui.ar.areaCreator.lastPointerY = null;
+ });
+ this.UI.addEventListener('pointercancel', (event) => {realityEditor.gui.ar.areaCreator.pointerCallbacks.up(event)});
+ this.UI.addEventListener('pointermove', (event) => {
+ realityEditor.gui.ar.areaCreator.pointerCallbacks.move(event);
+ realityEditor.gui.ar.areaCreator.lastPointerY = event.clientY;
+ });
+ this.UI.cancelButton = document.createElement('img');
+ this.UI.cancelButton.src = '../../../svg/areaCreator/cancelButton.svg';
+ this.UI.cancelButton.style.position = 'absolute';
+ this.UI.cancelButton.style.left = '0';
+ this.UI.cancelButton.style.top = '0';
+ this.UI.cancelButton.style.width = '15vh';
+ this.UI.cancelButton.style.height = '15vh';
+ let cancelButtonZIndex = 2902; // above areaCreator menus
+ this.UI.cancelButton.style.zIndex = `${cancelButtonZIndex}`;
+ this.UI.cancelButton.style.transform = 'matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,' + cancelButtonZIndex + ',1)';
+ this.UI.cancelButton.addEventListener('pointerdown', (event) => {realityEditor.gui.ar.areaCreator.buttonCallbacks.cancel(event)});
+
+ this.UI.appendChild(this.UI.cancelButton)
+
+ this.UI.infoDiv = document.createElement('div');
+ this.UI.infoDiv.style.position = 'absolute';
+ this.UI.infoDiv.style.top = '0';
+ this.UI.infoDiv.style.left = '0';
+ this.UI.infoDiv.style.width = '100vw';
+ this.UI.infoDiv.style.height = '15vh';
+ this.UI.infoDiv.style.textAlign = 'center';
+
+ this.UI.appendChild(this.UI.infoDiv);
+
+ this.UI.infoText = document.createElement('div');
+ this.UI.infoText.style.display = 'inline-block';
+ this.UI.infoText.style.margin = '0 auto';
+ this.UI.infoText.style.border = '4px solid white';
+ this.UI.infoText.style.backgroundColor = 'rgba(0,0,0,0.4)';
+ this.UI.infoText.style.color = 'white';
+ this.UI.infoText.style.textAlign = 'center';
+ this.UI.infoText.innerText = '';
+ this.UI.infoText.style.display = 'none';
+ this.UI.infoDiv.appendChild(this.UI.infoText);
+
+ this.UI.drawingModeMenu = document.createElement('div');
+ this.UI.drawingModeMenu.style.display = 'flex';
+ this.UI.drawingModeMenu.style.justifyContent = 'space-evenly';
+ this.UI.drawingModeMenu.style.alignItems = 'center';
+ this.UI.drawingModeMenu.style.width = '100%';
+ this.UI.drawingModeMenu.style.height = '100%';
+ this.UI.drawingModeMenu.style.padding = '0 auto';
+ this.UI.drawingModeMenu.style.display = 'none';
+ this.UI.drawingModeMenu.style.pointerEvents = 'none';
+ let drawingModeMenuZIndex = 2901; // above scene elements, below pocket and menus
+ this.UI.drawingModeMenu.style.zIndex = `${drawingModeMenuZIndex}`;
+ this.UI.drawingModeMenu.style.transform = 'matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,' + drawingModeMenuZIndex + ',1)';
+
+ const freehandButton = document.createElement('img');
+ freehandButton.src = "../../../svg/areaCreator/freehandButton.svg";
+ const polygonButton = document.createElement('img');
+ polygonButton.src = "../../../svg/areaCreator/polygonButton.svg";
+ [freehandButton,polygonButton].forEach(button => {
+ button.style.width = '50vh';
+ button.style.height = '50vh';
+ })
+
+ freehandButton.addEventListener('pointerdown', (event) => {realityEditor.gui.ar.areaCreator.buttonCallbacks.freehand(event)});
+ polygonButton.addEventListener('pointerdown', (event) => {realityEditor.gui.ar.areaCreator.buttonCallbacks.polygon(event)});
+
+ this.UI.drawingModeMenu.appendChild(freehandButton);
+ this.UI.drawingModeMenu.appendChild(polygonButton);
+
+ this.UI.appendChild(this.UI.drawingModeMenu);
+
+ this.UI.areaCreationMenu = document.createElement('div');
+ this.UI.areaCreationMenu.style.display = 'none';
+ this.UI.areaCreationMenu.style.pointerEvents = 'none';
+
+ this.UI.confirmAreaButton = document.createElement('img');
+ this.UI.confirmAreaButton.src = '../../../svg/areaCreator/confirmButton.svg';
+ this.UI.confirmAreaButton.style.position = 'absolute';
+ this.UI.confirmAreaButton.style.left = '0';
+ this.UI.confirmAreaButton.style.top = '15vh';
+ this.UI.confirmAreaButton.style.width = '15vh';
+ this.UI.confirmAreaButton.style.height = '15vh';
+ let confirmAreaButtonZIndex = 2902; // above areaCreator menus
+ this.UI.confirmAreaButton.style.zIndex = `${confirmAreaButtonZIndex}`;
+ this.UI.confirmAreaButton.style.transform = 'matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,' + confirmAreaButtonZIndex + ',1)';
+ this.UI.confirmAreaButton.style.display = 'none';
+ this.UI.confirmAreaButton.style.pointerEvents = 'none';
+ this.UI.confirmAreaButton.addEventListener('pointerdown', (event) => {realityEditor.gui.ar.areaCreator.buttonCallbacks.confirmArea(event)});
+
+ this.UI.areaCreationMenu.appendChild(this.UI.confirmAreaButton);
+
+ this.UI.appendChild(this.UI.areaCreationMenu);
+
+ this.UI.heightDefinitionMenu = document.createElement('div');
+ this.UI.heightDefinitionMenu.style.display = 'none';
+ this.UI.heightDefinitionMenu.style.pointerEvents = 'none';
+
+ const confirmHeightButton = document.createElement('img');
+ confirmHeightButton.src = '../../../svg/areaCreator/confirmButton.svg';
+ confirmHeightButton.style.position = 'absolute';
+ confirmHeightButton.style.left = '0';
+ confirmHeightButton.style.top = '15vh';
+ confirmHeightButton.style.width = '15vh';
+ confirmHeightButton.style.height = '15vh';
+ let confirmHeightButtonZIndex = 2902; // above areaCreator menus
+ confirmHeightButton.style.zIndex = `${confirmHeightButtonZIndex}`;
+ confirmHeightButton.style.transform = 'matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,' + confirmHeightButtonZIndex + ',1)';
+ confirmHeightButton.addEventListener('pointerdown', (event) => {realityEditor.gui.ar.areaCreator.buttonCallbacks.confirmHeight(event)});
+
+ this.UI.heightDefinitionMenu.appendChild(confirmHeightButton);
+
+ this.UI.appendChild(this.UI.heightDefinitionMenu);
+ }
+ return this.UI;
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.clearCallbacks = function () {
+ this.pointerCallbacks = {
+ down: () => {},
+ up: () => {},
+ move: () => {}
+ }
+ this.buttonCallbacks = {
+ cancel: () => {},
+ freehand: () => {},
+ polygon: () => {},
+ confirmArea: () => {},
+ confirmHeight: () => {}
+ }
+ this.animationCallbacks = [];
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.addAnimationCallback = function (callback) {
+ this.animationCallbacks.push(callback);
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.removeAnimationCallback = function (callback) {
+ if (this.animationCallbacks.includes(callback)) {
+ this.animationCallbacks.splice(this.animationCallbacks.indexOf(callback), 1);
+ }
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.onAnimationFrame = function () {
+ this.animationCallbacks.forEach(callback => {
+ callback();
+ })
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.activateUI = function () {
+ this.getUI().style.display = '';
+ this.getUI().style.pointerEvents = 'auto';
+ this.getUI().infoText.innerText = '';
+ this.getUI().infoText.style.display = 'none';
+ realityEditor.gui.threejsScene.onAnimationFrame(this.onAnimationFrame);
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.deactivateUI = function () {
+ this.getUI().style.display = 'none';
+ this.getUI().style.pointerEvents = 'none';
+ this.hideDrawingModeSelectionMenu();
+ this.hideAreaCreationMenu();
+ this.hideHeightDefinitionMenu();
+ realityEditor.gui.threejsScene.removeAnimationCallback(this.onAnimationFrame);
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.showDrawingModeSelectionMenu = function () {
+ this.getUI().drawingModeMenu.style.display = 'flex';
+ this.getUI().drawingModeMenu.style.pointerEvents = 'auto';
+ this.getUI().infoText.innerText = 'Select a drawing mode.';
+ this.getUI().infoText.style.display = 'inline-block';
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.hideDrawingModeSelectionMenu = function () {
+ this.getUI().drawingModeMenu.style.display = 'none';
+ this.getUI().drawingModeMenu.style.pointerEvents = 'none';
+ this.getUI().infoText.innerText = '';
+ this.getUI().infoText.style.display = 'none';
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.showAreaCreationMenu = function () {
+ this.getUI().areaCreationMenu.style.display = '';
+ this.getUI().areaCreationMenu.style.pointerEvents = 'auto';
+ this.getUI().infoText.innerText = 'Define an area by drawing on the ground.';
+ this.getUI().infoText.style.display = 'inline-block';
+ realityEditor.gui.ar.areaCreator.hideAreaConfirmationButton(); // Starts hidden because requires 3 points
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.hideAreaCreationMenu = function () {
+ this.getUI().areaCreationMenu.display = 'none';
+ this.getUI().areaCreationMenu.pointerEvents = 'none';
+ this.getUI().infoText.innerText = '';
+ this.getUI().infoText.style.display = 'none';
+ realityEditor.gui.ar.areaCreator.hideAreaConfirmationButton();
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.showAreaConfirmationButton = function () {
+ this.UI.confirmAreaButton.style.display = '';
+ this.UI.confirmAreaButton.style.pointerEvents = 'auto';
+}
+
+realityEditor.gui.ar.areaCreator.hideAreaConfirmationButton = function () {
+ this.UI.confirmAreaButton.style.display = 'none';
+ this.UI.confirmAreaButton.style.pointerEvents = 'none';
+}
+
+realityEditor.gui.ar.areaCreator.showHeightDefinitionMenu = function () {
+ this.getUI().heightDefinitionMenu.style.display = '';
+ this.getUI().heightDefinitionMenu.style.pointerEvents = 'auto';
+ this.getUI().infoText.innerText = 'Swipe vertically to set the height of the area.';
+ this.getUI().infoText.style.display = 'inline-block';
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.hideHeightDefinitionMenu = function () {
+ this.getUI().heightDefinitionMenu.style.display = 'none';
+ this.getUI().heightDefinitionMenu.style.pointerEvents = 'none';
+ this.getUI().infoText.innerText = '';
+ this.getUI().infoText.style.display = 'none';
+}.bind(realityEditor.gui.ar.areaCreator);
+
+realityEditor.gui.ar.areaCreator.disable = function () {
+ this.mode = this.Modes.DISABLED;
+ this.clearCallbacks();
+ this.polygonPoints = [];
+ this.height = 1;
+ this.canDragTouch = false;
+ this.lastPointerY = null;
+ if (this.areaRender) {
+ this.clearAreaRender();
+ }
+ this.needsRenderUpdate = false;
+ this.deactivateUI();
+}.bind(realityEditor.gui.ar.areaCreator);
diff --git a/src/gui/ar/areaTargetScanner.js b/src/gui/ar/areaTargetScanner.js
new file mode 100644
index 000000000..9f96d2aed
--- /dev/null
+++ b/src/gui/ar/areaTargetScanner.js
@@ -0,0 +1,654 @@
+createNameSpace("realityEditor.gui.ar.areaTargetScanner");
+
+(function(exports) {
+
+ let hasUserBeenNotified = false;
+
+ let foundAnyWorldObjects = false;
+ let isScanning = false;
+
+ let feedbackString = null;
+ let feedbackInterval = null;
+ let feedbackTick = 0;
+
+ const MAX_SCAN_TIME = 300;
+ let timeLeftSeconds = MAX_SCAN_TIME;
+
+ let loadingDialog = null;
+
+ let pendingAddedObjectName = null;
+
+ let sessionObjectId = null;
+ let targetUploadURL = null;
+
+ let hasFirstSeenInstantWorld = false;
+
+ const limitScanRAM = false; // if true, stop area target capture when device memory usage is high
+ let maximumPercentRAM = 0.33; // the app will stop scanning when it reaches this threshold of total device memory
+
+ let callbacks = {
+ onStartScanning: [],
+ onCaptureStatus: [],
+ onStopScanning: [],
+ onCaptureSuccessOrError: []
+ };
+
+ function initService() {
+ if (!realityEditor.device.environment.variables.supportsAreaTargetCapture) {
+ // This device doesn't support area target capture
+ return;
+ }
+
+ realityEditor.app.promises.doesDeviceHaveDepthSensor().then(supportsCapture => {
+ if (supportsCapture) {
+ initServiceInternal();
+ } else {
+ // No depth sensor - cant support area target capture
+ realityEditor.device.environment.variables.supportsAreaTargetCapture = false;
+ }
+ });
+ }
+
+ // only gets called if we know we have access to LiDAR sensor / scanning capabilities
+ function initServiceInternal() {
+ // wait until at least one server is detected
+ // wait to see if any world objects are detected on that server
+ // wait until camera device pose has been set / tracking is fully initialized
+
+ // if no world objects are detected, show a notification "No spaces detected. Scan one to begin."
+ // show "SCAN" button on bottom center of screen
+ // OR -> show a modal with this info and the button to start. can dismiss and ignore completely.
+
+ realityEditor.network.addObjectDiscoveredCallback(function(object, objectKey) {
+ // if (objectKey === realityEditor.worldObjects.getLocalWorldId()) {
+ // return; // ignore local world
+ // }
+ if (pendingAddedObjectName) {
+ if (object.name === pendingAddedObjectName) {
+ pendingObjectAdded(objectKey, object.ip, realityEditor.network.getPort(object));
+ }
+ }
+
+ // check if it's a world object
+ if (object && !object.deactivated &&
+ (object.isWorldObject || object.type === 'world') &&
+ (objectKey !== realityEditor.worldObjects.getLocalWorldId())) {
+ foundAnyWorldObjects = true;
+ }
+
+ // wait after detecting an object to check the next step
+ let delay = 5000;
+ if (object.ip === '127.0.0.1' || object.ip === 'localhost') {
+ delay = 7000;
+ }
+ setTimeout(function() {
+ if (realityEditor.device.environment.variables.automaticallyPromptForAreaTargetCapture) {
+ showNotificationIfNeeded();
+ }
+ }, delay);
+ });
+
+ realityEditor.gui.ar.draw.addUpdateListener(function(visibleObjects) {
+ if (!sessionObjectId) { return; }
+ if (isScanning) { return; }
+ if (hasFirstSeenInstantWorld) { return; }
+
+ if (typeof visibleObjects[sessionObjectId] !== 'undefined') {
+ hasFirstSeenInstantWorld = true;
+
+ if (!realityEditor.device.environment.variables.overrideAreaTargetScanningUI) {
+ getStatusTextfield().innerHTML = 'Successfully localized within new scan!'
+ getStatusTextfield().style.display = 'inline';
+
+ setTimeout(function() {
+ getStatusTextfield().innerHTML = '';
+ getStatusTextfield().style.display = 'none';
+ }, 3000);
+ }
+ }
+ });
+
+ realityEditor.app.onAreaTargetGenerateProgress('realityEditor.gui.ar.areaTargetScanner.onAreaTargetGenerateProgress');
+ }
+
+ function showNotificationIfNeeded() {
+ if (hasUserBeenNotified) {
+ // Already notified user. Ignore this time.
+ return;
+ }
+
+ if (foundAnyWorldObjects) {
+ // Found an existing world object... no need to scan a new one. Ignore this time.
+ return;
+ }
+
+ let cameraNode = realityEditor.sceneGraph.getCameraNode();
+ let hasCameraBeenLocalized = (cameraNode && !realityEditor.gui.ar.utilities.isIdentityMatrix(cameraNode.localMatrix));
+ if (!hasCameraBeenLocalized) {
+ // AR Tracking hasn't finished initializing yet... try again...
+ setTimeout(function() {
+ showNotificationIfNeeded(); // repeat until ready
+ }, 1000);
+ return;
+ }
+
+ let detectedServers = realityEditor.network.discovery.getDetectedServerIPs({limitToWorldService: true});
+
+ const headerText = 'No scans of this space detected. Make a scan?';
+ let randomServerIP = Object.keys(detectedServers).filter(detectedServer => {
+ return detectedServer !== '127.0.0.1' && detectedServer !== 'localhost';
+ })[0]; // this is guaranteed to have at least one entry if we get here
+ let descriptionText = `This will create a World Object on your edge server. Selected IP: `;
+ descriptionText += ``;
+ for (let ip of Object.keys(detectedServers)) {
+ if (ip === randomServerIP) {
+ descriptionText += `${ip} `;
+ } else {
+ descriptionText += `${ip} `;
+ }
+ }
+ descriptionText += ' ';
+ realityEditor.gui.modal.openClassicModal(headerText, descriptionText, 'Ignore', 'Begin Scan', function() {
+ // console.log('Ignore scan modal');
+ }, function() {
+ let serverIp = randomServerIP;
+ let elt = document.getElementById('modalServerIp');
+ if (elt) {
+ serverIp = elt.value;
+ }
+
+ // startScanning();
+ createPendingWorldObject(serverIp);
+ }, true);
+
+ hasUserBeenNotified = true;
+ }
+
+ function programmaticallyStartScan(serverIp) {
+ if (!realityEditor.device.environment.variables.supportsAreaTargetCapture) {
+ // Don't start scanning because device has no depth (LiDAR) sensor
+ return;
+ }
+
+ if (typeof serverIp !== 'undefined') {
+ createPendingWorldObject(serverIp);
+ } else {
+ let detectedServers = realityEditor.network.discovery.getDetectedServerIPs({limitToWorldService: true});
+ let randomServerIP = Object.keys(detectedServers)[0] || 'localhost';
+ //.filter(detectedServer => {
+ // return detectedServer !== '127.0.0.1';
+ //})[0];
+ createPendingWorldObject(randomServerIP);
+ }
+ }
+
+ function startScanning() {
+ if (isScanning) {
+ // already scanning.. ignore.
+ return;
+ }
+ isScanning = true;
+ timeLeftSeconds = MAX_SCAN_TIME;
+
+ realityEditor.app.areaTargetCaptureStart(sessionObjectId, 'realityEditor.gui.ar.areaTargetScanner.captureStatusHandler');
+
+ // TODO: turn app into scanning mode, disabling any AR rendering and other UI
+
+ // add a stop button to the screen that can be pressed to trigger stopScanning
+ if (!realityEditor.device.environment.variables.overrideAreaTargetScanningUI) {
+ getRecordingIndicator().style.display = 'inline';
+ }
+ getStopButton().style.display = 'inline';
+ getTimerTextfield().style.display = 'inline';
+
+ if (!feedbackInterval) {
+ feedbackInterval = setInterval(printFeedback, 1000);
+ }
+
+ callbacks.onStartScanning.forEach(cb => {
+ cb();
+ });
+ }
+
+ /**
+ * Lazy instantiation and getter of a red dot element to indicate that a recording is in process
+ * @return {Element}
+ */
+ function getRecordingIndicator() {
+ var div = document.querySelector('#scanRecordingIndicator');
+ if (!div) {
+ div = document.createElement('div');
+ div.id = 'scanRecordingIndicator';
+ div.style.position = 'absolute';
+ div.style.left = '10px';
+ div.style.top = '10px';
+ div.style.width = '30px';
+ div.style.height = '30px';
+ div.style.backgroundColor = 'red';
+ div.style.borderRadius = '15px';
+ document.body.appendChild(div);
+ }
+ return div;
+ }
+
+ /**
+ * Lazy instantiation and getter of the stop button to generate the area target from the scan
+ * @return {Element}
+ */
+ function getStopButton() {
+ var div = document.querySelector('#scanStopButton');
+ if (!div) {
+ div = document.createElement('div');
+ div.id = 'scanStopButton';
+ div.style.position = 'absolute';
+ div.style.left = '40vw';
+ div.style.bottom = '10vh';
+ div.style.width = '20vw';
+ div.style.height = '60px';
+ div.style.lineHeight = '60px';
+ div.style.backgroundColor = 'rgba(255,255,255,0.7)';
+ div.style.color = 'rgb(0,0,0)';
+ div.style.borderRadius = '15px';
+ div.style.textAlign = 'center';
+ div.style.fontSize = '20px';
+ div.style.verticalAlign = 'middle';
+ const zIndex = 2901;
+ div.style.zIndex = zIndex;
+ div.style.transform = 'matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,' + zIndex + ',1)';
+ document.body.appendChild(div);
+
+ div.innerHTML = 'Stop Scanning';
+
+ div.addEventListener('pointerup', function() {
+ stopScanning();
+ });
+ }
+ return div;
+ }
+
+ /**
+ * Lazy instantiation and getter of the stop button to generate the area target from the scan
+ * @return {Element}
+ */
+ function getStatusTextfield() {
+ var div = document.querySelector('#scanStatusTextfield');
+ if (!div) {
+ div = document.createElement('div');
+ div.id = 'scanStatusTextfield';
+ div.style.position = 'absolute';
+ div.style.left = '15vw';
+ div.style.top = '10vh';
+ div.style.width = '70vw';
+ div.style.height = '60px';
+ div.style.lineHeight = '60px';
+ div.style.backgroundColor = 'rgba(255,255,255,0.5)';
+ div.style.color = 'rgb(0,0,0)';
+ div.style.borderRadius = '15px';
+ div.style.textAlign = 'center';
+ div.style.verticalAlign = 'middle';
+ document.body.appendChild(div);
+ }
+ return div;
+ }
+
+ /**
+ * Lazy instantiation and getter of the stop button to generate the area target from the scan
+ * @return {Element}
+ */
+ function getTimerTextfield() {
+ var div = document.querySelector('#scanTimerTextfield');
+ if (!div) {
+ div = document.createElement('div');
+ div.id = 'scanTimerTextfield';
+ div.style.position = 'absolute';
+ div.style.left = '40vw';
+ div.style.bottom = 'calc(10vh - 30px)';
+ div.style.width = '20vw';
+ div.style.height = '30px';
+ div.style.lineHeight = '30px';
+ div.style.color = 'rgb(255,255,255)';
+ div.style.borderRadius = '15px';
+ div.style.textAlign = 'center';
+ div.style.fontSize = '12px';
+ div.style.verticalAlign = 'middle';
+ document.body.appendChild(div);
+ }
+ return div;
+ }
+
+ function getProgressBar() {
+ let div = document.querySelector('#scanGenerateProgressBarContainer');
+ if (!div) {
+ div = document.createElement('div');
+ div.id = 'scanGenerateProgressBarContainer';
+ if (realityEditor.device.environment.variables.layoutUIForPortrait) {
+ div.style.top = 'calc(50vh + max(36vh, 36vw)/2 + 25px)';
+ } else {
+ div.style.bottom = '30px';
+ }
+ document.body.appendChild(div);
+
+ let bar = document.createElement('div');
+ bar.id = 'scanGenerateProgressBar';
+ div.appendChild(bar);
+ }
+ return div;
+ }
+
+ function stopScanning() {
+ if (!isScanning) {
+ // not scanning.. ignore.
+ }
+
+ realityEditor.app.areaTargetCaptureStop('realityEditor.gui.ar.areaTargetScanner.captureSuccessOrError');
+
+ if (!realityEditor.device.environment.variables.overrideAreaTargetScanningUI) {
+ getRecordingIndicator().style.display = 'none';
+ }
+ getStopButton().style.display = 'none';
+ getTimerTextfield().style.display = 'none';
+ getStatusTextfield().style.display = 'none';
+ isScanning = false;
+
+ feedbackString = null;
+
+ if (feedbackInterval) {
+ clearInterval(feedbackInterval);
+ feedbackInterval = null;
+ }
+
+ if (globalStates.debugSpeechConsole) {
+ let speechConsole = document.getElementById('speechConsole');
+ if (speechConsole) { speechConsole.innerHTML = ''; }
+ }
+
+ // show loading animation. hide when successOrError finishes.
+ showLoadingDialog('Generating Dataset...', 'Please wait.'); // Converting scan into AR target files.');
+
+ callbacks.onStopScanning.forEach(cb => {
+ cb();
+ });
+ }
+
+ function createPendingWorldObject(serverIp) {
+ pendingAddedObjectName = "_WORLD_instantScan" + globalStates.tempUuid;
+
+ realityEditor.network.discovery.addExceptionToPausedObjectDetections(pendingAddedObjectName);
+
+ const port = realityEditor.network.getPortByIp(serverIp);
+ addObject(pendingAddedObjectName, serverIp, port);
+
+ showLoadingDialog('Creating World Object...', 'Please wait. Generating object on server.');
+ setTimeout(function() {
+ realityEditor.app.sendUDPMessage({action: 'ping'}); // ping the servers to see if we get any new responses
+ setTimeout(function() {
+ realityEditor.app.sendUDPMessage({action: 'ping'}); // ping the servers to see if we get any new responses
+ setTimeout(function() {
+ realityEditor.app.sendUDPMessage({action: 'ping'}); // ping the servers to see if we get any new responses
+ }, 900);
+ }, 600);
+ }, 300);
+
+ // wait for a response, wait til we have the objectID and know it exists
+ }
+
+ function pendingObjectAdded(objectKey, serverIp, serverPort) {
+ // the object definitely exists...
+ pendingAddedObjectName = null;
+
+ setTimeout(function() {
+ loadingDialog.dismiss();
+ loadingDialog = null;
+ }, 500);
+
+ let objectName = realityEditor.getObject(objectKey).name;
+ sessionObjectId = objectKey;
+ targetUploadURL = realityEditor.network.getURL(serverIp, serverPort, '/content/' + objectName)
+
+ startScanning();
+ }
+
+ function addObject(objectName, serverIp, serverPort) {
+ var postUrl = realityEditor.network.getURL(serverIp, serverPort, '/')
+ var params = new URLSearchParams({action: 'new', name: objectName, isWorld: true});
+ fetch(postUrl, {
+ method: 'POST',
+ body: params
+ }).then((response) => {
+ return response.json();
+ }).then((object) => {
+ if (serverIp !== '127.0.0.1' && serverIp !== 'localhost') {
+ return;
+ }
+ let baseWorldObjectBeat = {
+ ip: 'localhost',
+ port: realityEditor.device.environment.getLocalServerPort(),
+ vn: 320,
+ pr: 'R2',
+ tcs: null,
+ zone: '',
+ };
+
+ let delay = 1000;
+ for (let i = 0; i < 7; i++) {
+ setTimeout(() => {
+ realityEditor.network.addHeartbeatObject(
+ Object.assign(baseWorldObjectBeat, object));
+ }, delay);
+ delay *= 2;
+ }
+ }).catch(e => {
+ console.error('addObject error', e);
+ });
+ }
+
+ function captureStatusHandler(status, statusInfo) {
+ if (status === 'PREPARING') {
+ getStopButton().classList.add('captureButtonInactive');
+ } else {
+ getStopButton().classList.remove('captureButtonInactive');
+ }
+
+ feedbackString = status + '... (' + statusInfo + ')';
+
+ callbacks.onCaptureStatus.forEach(cb => {
+ cb(status, statusInfo);
+ });
+ }
+
+ function printFeedback() {
+ if (!isScanning || !feedbackString) { return; }
+
+ if (!realityEditor.device.environment.variables.overrideAreaTargetScanningUI) {
+ let dots = '';
+ for (let i = 0; i < feedbackTick; i++) {
+ dots += '.';
+ }
+ getStatusTextfield().innerHTML = feedbackString + dots;
+ getStatusTextfield().style.display = 'inline';
+ }
+
+ feedbackTick += 1;
+ feedbackTick = feedbackTick % 4;
+
+ timeLeftSeconds -= 1;
+ getTimerTextfield().innerHTML = timeLeftSeconds + 's';
+ getTimerTextfield().style.display = 'inline';
+
+ if (timeLeftSeconds <= 0) {
+ stopScanning();
+ }
+ }
+
+ function onAreaTargetGenerateProgress(percentGenerated) {
+ let progressBarContainer = getProgressBar();
+ progressBarContainer.style.display = '';
+ let bar = progressBarContainer.querySelector('#scanGenerateProgressBar');
+ bar.style.width = (percentGenerated * 100) + '%';
+
+ if (loadingDialog) {
+ let description = 'Please wait. Preparing scan.';
+ if (percentGenerated > 0.05 && percentGenerated < 0.4) {
+ description = 'Please wait. Fusing depth data.';
+ } else if (percentGenerated < 0.7) {
+ description = 'Please wait. Generating textures.';
+ } else if (percentGenerated < 0.9) {
+ description = 'Please wait. Generating Vuforia dataset.';
+ } else if (percentGenerated >= 0.9) {
+ description = 'Please wait. Finalizing files for upload.';
+ }
+ loadingDialog.domElements.description.innerHTML = description;
+ }
+ }
+
+ function captureSuccessOrError(success, errorMessage) {
+ loadingDialog.dismiss();
+ loadingDialog = null;
+
+ if (success) {
+ realityEditor.app.areaTargetCaptureGenerate(targetUploadURL);
+
+ setTimeout(function() {
+ getProgressBar().style.display = 'none';
+ showLoadingDialog('Uploading Target Data...', 'Please wait. Uploading data to server.');
+
+ let alreadyProcessed = false;
+ realityEditor.app.targetDownloader.addTargetStateCallback(sessionObjectId, (targetDownloadState) => {
+ if (alreadyProcessed) { return; }
+
+ let SUCCEEDED = realityEditor.app.targetDownloader.DownloadState.SUCCEEDED;
+ if (targetDownloadState.XML === SUCCEEDED && targetDownloadState.DAT === SUCCEEDED) {
+ alreadyProcessed = true;
+
+ loadingDialog.dismiss();
+ loadingDialog = null;
+
+ // objects aren't fully initialized until they have a target.jpg, so we upload a screenshot to be the "icon"
+ realityEditor.app.getSnapshot('S', 'realityEditor.gui.ar.areaTargetScanner.onScreenshotReceived');
+ }
+ });
+ }, 1000);
+
+ showMessage('Successful capture.', 'caputureSuccessUI', 'caputureSuccessText', 2000);
+ } else {
+ showMessage('Error: ' + errorMessage, 'caputureErrorUI', 'caputureErrorText', 2000);
+ }
+
+ callbacks.onCaptureSuccessOrError.forEach(cb => {
+ cb(success, errorMessage);
+ });
+ }
+
+ function onScreenshotReceived(base64String) {
+ if (base64String === "") {
+ // got empty screenshot... try again later
+ setTimeout(function() {
+ realityEditor.app.getSnapshot('S', 'realityEditor.gui.ar.areaTargetScanner.onScreenshotReceived');
+ }, 3000);
+ return;
+ }
+ var blob = realityEditor.device.utilities.b64toBlob(base64String, 'image/jpeg');
+ uploadScreenshot(blob);
+ }
+
+ function uploadScreenshot(blob) {
+ if (!targetUploadURL || !blob) {
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('file', blob, 'screenshot-target.jpg');
+
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', targetUploadURL, true);
+
+ xhr.onload = function () {
+ if (xhr.status === 200) {
+ showMessage('Successfully uploaded icon to new world object', 'uploadSuccessUI', 'uploadSuccessText', 2000);
+ } else {
+ showMessage('Error uploading icon to new world object', 'uploadErrorUI', 'uploadErrorText', 2000);
+ }
+ };
+
+ xhr.setRequestHeader('type', 'targetUpload');
+ xhr.send(formData);
+ }
+
+ function showMessage(message, containerId, textId, lifetime) {
+ // create UI if needed
+ let notificationUI = document.getElementById(containerId);
+ if (!notificationUI) {
+ realityEditor.gui.modal.showBannerNotification(message, containerId, textId, lifetime);
+ }
+ }
+
+ function showLoadingDialog(headerText, descriptionText) {
+ if (loadingDialog) { // hide existing dialog before showing new one
+ loadingDialog.dismiss();
+ loadingDialog = null;
+ }
+
+ loadingDialog = realityEditor.gui.modal.showSimpleNotification(
+ headerText, descriptionText, function () {
+ // console.log('closed...');
+ }, realityEditor.device.environment.variables.layoutUIForPortrait);
+ }
+
+ /**
+ * Stop scanning if device is using too much memory
+ * @param {string} eventName - 'report_memory' happens every 1 second, 'UIApplicationDidReceiveMemoryWarningNotification' if problem
+ * @param {number} bytesUsed - int number of bytes used by app
+ * @param {number} percentOfDeviceUsedByApp - int number of bytes in total device RAM
+ */
+ function onAppMemoryEvent(eventName, bytesUsed, percentOfDeviceUsedByApp) {
+
+ let gigabytesUsed = bytesUsed ? bytesUsed / (1024 * 1024 * 1024) : 0;
+
+ if (globalStates.debugSpeechConsole) {
+ let speechConsole = document.getElementById('speechConsole');
+ if (!speechConsole) { return; }
+ speechConsole.innerHTML = eventName + ': using ' + gigabytesUsed.toFixed(3) + ' GB ... (' + (percentOfDeviceUsedByApp * 100).toFixed(2) + '%)';
+ }
+
+ if (!isScanning) { return; }
+
+ // UIApplicationDidReceiveMemoryWarningNotification happens too late in most cases, so we check more stringently
+ if (eventName === 'UIApplicationDidReceiveMemoryWarningNotification' ||
+ (limitScanRAM && percentOfDeviceUsedByApp > maximumPercentRAM)) {
+ stopScanning();
+ console.warn("stopping scan due to memory usage");
+ }
+ }
+
+ exports.initService = initService;
+
+ // allow external module to trigger the area target capture prompt
+ exports.programmaticallyStartScan = programmaticallyStartScan;
+ exports.onStartScanning = (callback) => {
+ callbacks.onStartScanning.push(callback);
+ };
+ exports.onStopScanning = (callback) => {
+ callbacks.onStopScanning.push(callback);
+ };
+ exports.onCaptureSuccessOrError = (callback) => {
+ callbacks.onCaptureSuccessOrError.push(callback);
+ };
+ exports.didFindAnyWorldObjects = () => {
+ let detectedObjects = realityEditor.network.discovery.getDetectedObjectsOfType('world');
+ return detectedObjects.length > 0;
+ };
+ exports.onCaptureStatus = (callback) => {
+ callbacks.onCaptureStatus.push(callback);
+ };
+ exports.getSessionObjectId = () => {
+ return sessionObjectId;
+ }
+
+ // make functions available to native app callbacks
+ exports.captureStatusHandler = captureStatusHandler;
+ exports.onAreaTargetGenerateProgress = onAreaTargetGenerateProgress;
+ exports.captureSuccessOrError = captureSuccessOrError;
+ exports.onScreenshotReceived = onScreenshotReceived;
+ exports.onAppMemoryEvent = onAppMemoryEvent;
+
+}(realityEditor.gui.ar.areaTargetScanner));
diff --git a/src/gui/ar/draw.js b/src/gui/ar/draw.js
new file mode 100644
index 000000000..e95528a94
--- /dev/null
+++ b/src/gui/ar/draw.js
@@ -0,0 +1,2414 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.gui.ar.draw");
+
+/**
+ * @fileOverview realityEditor.gui.ar.draw.js
+ * Contains the main rendering code for rendering frames and nodes into the 3D scene.
+ * Also determines when visual elements need to be hidden or shown, and has code for creating
+ * the DOM elements for new frames and nodes.
+ */
+
+/**********************************************************************************************************************
+ ******************************************** update and draw the 3D Interface ****************************************
+ **********************************************************************************************************************/
+
+
+realityEditor.gui.ar.draw.globalCanvas = globalCanvas;
+
+/**
+ * @type {Object.>}
+ */
+realityEditor.gui.ar.draw.visibleObjects = {};
+realityEditor.gui.ar.draw.visibleObjectsStatus = {};
+realityEditor.gui.ar.draw.globalStates = globalStates;
+realityEditor.gui.ar.draw.globalDOMCache = globalDOMCache;
+realityEditor.gui.ar.draw.activeObject = {};
+realityEditor.gui.ar.draw.activeFrame = {};
+realityEditor.gui.ar.draw.activeNode = {};
+realityEditor.gui.ar.draw.activeVehicle = {};
+realityEditor.gui.ar.draw.activeObjectMatrix = [];
+realityEditor.gui.ar.draw.finalMatrix = [];
+realityEditor.gui.ar.draw.rotateX = rotateX;
+
+/**
+ * @type {{temp: number[], begin: number[], end: number[], r: number[], r2: number[], r3: number[]}}
+ */
+realityEditor.gui.ar.draw.matrix = {
+ worldReference: null,
+ temp: [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ],
+ begin: [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ],
+ end: [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ],
+ r: [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ],
+ r2: [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ],
+ r3 :[
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ]
+};
+realityEditor.gui.ar.draw.tempMatrix = {
+ worldOffset :[
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ],
+ objectOffset :[
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ]
+};
+realityEditor.gui.ar.draw.objectKey = "";
+realityEditor.gui.ar.draw.frameKey = "";
+realityEditor.gui.ar.draw.nodeKey = "";
+realityEditor.gui.ar.draw.activeKey = "";
+realityEditor.gui.ar.draw.type = "";
+realityEditor.gui.ar.draw.notLoading = "";
+realityEditor.gui.ar.draw.utilities = realityEditor.gui.ar.utilities;
+
+/**
+ * don't render the following node types:
+ * @type {Array.}
+ */
+realityEditor.gui.ar.draw.hiddenNodeTypes = [
+ 'storeData',
+ 'invisible'
+];
+
+/**
+ * Array of registered callbacks for the update function
+ * @type {Array}
+ */
+realityEditor.gui.ar.draw.updateListeners = [];
+realityEditor.gui.ar.draw.visibleObjectModifiers = [];
+
+/**
+ * Registers a callback from an external module to be updated every frame with the visibleObjects matrices
+ * @param {function} callback
+ */
+realityEditor.gui.ar.draw.addUpdateListener = function (callback) {
+ this.updateListeners.push(callback);
+};
+
+/**
+ * Registers a callback for other modules to modify the list of visible objects before rendering takes place
+ * Passes in a mutable set of objectId:matrix pairs that can be added to (or removed), e.g. anchors can use this to
+ * be rendered by the regular update loop even though they aren't detected by Vuforia
+ * @param {function} callback
+ */
+realityEditor.gui.ar.draw.addVisibleObjectModifier = function (callback) {
+ this.visibleObjectModifiers.push(callback);
+};
+
+/**
+ * @type {CallbackHandler}
+ */
+realityEditor.gui.ar.draw.callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('gui/ar/draw');
+
+/**
+ * Adds a callback function that will be invoked when the specified function is called
+ * @param {string} functionName
+ * @param {function} callback
+ */
+realityEditor.gui.ar.draw.registerCallback = function(functionName, callback) {
+ if (!this.callbackHandler) {
+ this.callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('gui/ar/draw');
+ }
+ this.callbackHandler.registerCallback(functionName, callback);
+};
+
+/**
+ * The most recently received set of matrices for the currently visible objects.
+ * A set of {objectId: matrix} pairs, one per recognized target
+ * @type {Object.>}
+ */
+realityEditor.gui.ar.draw.visibleObjectsCopy = {};
+
+realityEditor.gui.ar.draw.m1 = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+];
+
+realityEditor.gui.ar.draw.worldCorrection = null;
+
+realityEditor.gui.ar.draw.currentClosestObject = null;
+
+/**
+ * Main update loop.
+ * A wrapper for the real realityEditor.gui.ar.draw.update update function.
+ * Calling it this way, using requestAnimationFrame, makes it render more smoothly.
+ * (A different update loop inside of desktopAdapter is used on desktop devices to include camera manipulations)
+ */
+// realityEditor.gui.ar.draw.updateLoop = function () {
+// realityEditor.gui.ar.draw.update(realityEditor.gui.ar.draw.visibleObjectsCopy, realityEditor.gui.ar.draw.areMatricesPrecomputed);
+// requestAnimationFrame(realityEditor.gui.ar.draw.updateLoop);
+// };
+
+realityEditor.gui.ar.draw.lowFrequencyUpdateCounter = 0;
+realityEditor.gui.ar.draw.lowFrequencyUpdateCounterMax = 30; // how many frames pass per lowFrequencyUpdate
+realityEditor.gui.ar.draw.isLowFrequencyUpdateFrame = false;
+realityEditor.gui.ar.draw.isObjectWithNoFramesVisible = false;
+realityEditor.gui.ar.draw.visibleObjectsStatusTimes = {};
+
+realityEditor.gui.ar.draw.updateExtendedTrackingVisibility = function(visibleObjects) {
+ for (var objectKey in visibleObjects) {
+ if (this.visibleObjectsStatus[objectKey] === 'EXTENDED_TRACKED') {
+ if (!globalStates.freezeButtonState) {
+ if (this.visibleObjectsStatusTimes[objectKey] > 60) {
+ delete visibleObjects[objectKey];
+ } else {
+ this.visibleObjectsStatusTimes[objectKey] += 1;
+ }
+ }
+ } else { // status === 'TRACKED'
+ this.visibleObjectsStatusTimes[objectKey] = 0;
+ }
+ }
+};
+
+realityEditor.gui.ar.draw.frameNeedsToBeRendered = true;
+realityEditor.gui.ar.draw.prevSuppressedRendering = false;
+
+/**
+ * Previously triggered directly by the native app when the AR engine updates with a new set of recognized targets,
+ * But now gets called 60FPS regardless of the AR engine, and just uses the most recent set of matrices.
+ * @param {Object.>} visibleObjects - set of {objectId: matrix} pairs, one per recognized target
+ */
+realityEditor.gui.ar.draw.update = function (visibleObjects) {
+ if (realityEditor.device.environment.isObjectRenderingSuppressed()) {
+ if (!this.prevSuppressedRendering) {
+ let toolContainer = document.getElementById('GUI');
+ let canvas = document.getElementById('canvas');
+ let glcanvas = document.getElementById('glcanvas');
+ let threejsCanvas = document.getElementById('mainThreejsCanvas');
+ [toolContainer, canvas, glcanvas, threejsCanvas].forEach(eltToHide => {
+ eltToHide.classList.add('suppressedRendering');
+ });
+ }
+ this.prevSuppressedRendering = true;
+ return; // ignore render loop while suppressing renderer
+ } else if (this.prevSuppressedRendering) {
+ this.prevSuppressedRendering = false;
+ // un-hide the hidden tools and canvases when suppressObjectRendering variable first changes
+ let toolContainer = document.getElementById('GUI');
+ let canvas = document.getElementById('canvas');
+ let glcanvas = document.getElementById('glcanvas');
+ let threejsCanvas = document.getElementById('mainThreejsCanvas');
+ [toolContainer, canvas, glcanvas, threejsCanvas].forEach(eltToHide => {
+ eltToHide.classList.remove('suppressedRendering');
+ });
+ }
+
+ //if (!realityEditor.gui.ar.draw.frameNeedsToBeRendered) { return; } // don't recompute multiple times between a single animation frames (not compatible with WebXR)
+ realityEditor.gui.ar.draw.frameNeedsToBeRendered = false; // gets set back to true by requestAnimationFrame code
+
+ var objectKey;
+ var frameKey;
+ var nodeKey;
+
+ this.ar.utilities.timeSynchronizer(timeCorrection);
+
+ if (globalStates.guiState === "logic") {
+ this.gui.crafting.redrawDataCrafting(); // todo maybe animation frame
+ }
+
+ // TODO: not currently used, needs to be adjusted to be useful
+ // if (realityEditor.gui.settings.toggleStates.extendedTracking) {
+ // this.updateExtendedTrackingVisibility(visibleObjects);
+ // }
+
+ // allow other modules to modify the set of objects currently seen (except while frozen)
+ if (!globalStates.freezeButtonState) {
+ this.visibleObjectModifiers.forEach(function(callback) {
+ callback(visibleObjects);
+ });
+ }
+
+ // the included matrices aren't used anymore, but the list of object IDs is used to determine what to render
+ this.visibleObjects = visibleObjects;
+
+ // erases anything on the background canvas
+ if (this.globalCanvas.hasContent === true) {
+ this.globalCanvas.context.clearRect(0, 0, this.globalCanvas.canvas.width, this.globalCanvas.canvas.height);
+ this.globalCanvas.hasContent = false;
+ }
+
+ // make sure that all Spatial Questions are empty
+ realityEditor.gui.spatial.clearSpatialList();
+
+ // this is a quick hack but maybe needs to move somewhere else.
+ // I dont know if this is the right spot. //TODO: what is this actually doing?
+ for (objectKey in objects) {
+ // if (this.doesObjectContainStickyFrame(objectKey) && !(objectKey in visibleObjects)) {
+ if (realityEditor.getObject(objectKey).containsStickyFrame && !(objectKey in visibleObjects)) {
+ visibleObjects[objectKey] = [];
+ }
+ }
+
+ if (this.lowFrequencyUpdateCounter >= this.lowFrequencyUpdateCounterMax) {
+ this.isLowFrequencyUpdateFrame = true;
+ this.lowFrequencyUpdateCounter = 0;
+ } else {
+ this.isLowFrequencyUpdateFrame = false;
+ this.lowFrequencyUpdateCounter++;
+ }
+
+ // checks if you detect an object with no frames within the viewport, so that you can provide haptic feedback
+
+ let visibleNonWorldObjects = [];
+ let worldObjectKeys = realityEditor.worldObjects.getWorldObjectKeys();
+ Object.keys(visibleObjects).forEach(function(tempObjectKey) {
+ if (!worldObjectKeys.includes(tempObjectKey)) {
+ visibleNonWorldObjects.push(tempObjectKey);
+ }
+ });
+
+ if (visibleNonWorldObjects.length > 0) {
+ if (this.isLowFrequencyUpdateFrame) {
+ if (realityEditor.gui.ar.utilities.getAllVisibleFramesFast().length === 0) {
+ this.isObjectWithNoFramesVisible = true;
+ } else {
+ this.isObjectWithNoFramesVisible = false;
+ }
+ }
+ } else {
+ this.isObjectWithNoFramesVisible = false;
+ }
+
+ // each sceneGraphNode's local matrix gets updated with the visibleObjectMatrix in app/callbacks.js
+ // so each frame, we just need to recompute everything's worldMatrix if their localMatrix changed
+ realityEditor.sceneGraph.calculateFinalMatrices(Object.keys(visibleObjects));
+
+ if (globalStates.inTransitionObject && globalStates.inTransitionFrame) {
+ realityEditor.sceneGraph.calculateFinalMatrices([globalStates.inTransitionObject]);
+ }
+
+ realityEditor.gui.spatial.collectSpatialLists();
+
+ // iterate over every object and decide whether or not to render it based on what the AR engine has detected
+ for (objectKey in objects) {
+ // if (!objects.hasOwnProperty(objectKey)) { continue; }
+
+ this.activeObject = realityEditor.getObject(objectKey);
+ if (!this.activeObject) { continue; }
+
+ // for now, totally ignore avatar objects in the rendering engine
+ // TODO: if we want to render tools relative to each avatar, we can remove this and add them to the visibleObjects list
+ if (realityEditor.avatar.utils.isAvatarObject(this.activeObject)) { continue; }
+ if (realityEditor.humanPose.utils.isHumanPoseObject(this.activeObject)) { continue; }
+
+ // if this object was detected by the AR engine this frame, render its nodes and/or frames
+ if (this.visibleObjects.hasOwnProperty(objectKey)) {
+
+ // make the object visible
+ this.activeObject.visibleCounter = timeForContentLoaded;
+ this.setObjectVisible(this.activeObject, true);
+
+ // TODO: check if this needs to be fixed for desktop, now that we have a different method for worldCorrection / world origins
+ if (realityEditor.device.environment.shouldBroadcastUpdateObjectMatrix()) {
+ if (realityEditor.gui.ar.draw.worldCorrection !== null) {
+ console.warn('Should never get here until we fix worldCorrection');
+ if (!this.activeObject.isWorldObject) {
+ // properly accounts for world correction
+ realityEditor.gui.ar.utilities.multiplyMatrix(this.visibleObjects[objectKey], realityEditor.gui.ar.utilities.invertMatrix(realityEditor.gui.ar.draw.worldCorrection), this.activeObject.matrix);
+ // this.activeObject.matrix = realityEditor.gui.ar.utilities.copyMatrix(this.visibleObjects[objectKey]); // old version didn't include worldCorrection
+ realityEditor.network.realtime.broadcastUpdateObjectMatrix(objectKey, this.activeObject.matrix, realityEditor.sceneGraph.getWorldId());
+ }
+ }
+ }
+
+ // iterate over every frame it contains, add iframes if necessary, and update the iframe CSS3D matrix to render in correct position
+ for (frameKey in objects[objectKey].frames) {
+
+ this.activeFrame = realityEditor.getFrame(objectKey, frameKey);
+
+ // allows backwards compatibility for frames that don't have a visualization property
+ if (!this.activeFrame.hasOwnProperty('visualization')) {
+ this.activeFrame.visualization = "ar";
+ }
+
+ this.activeKey = frameKey;
+ this.activeVehicle = this.activeFrame;
+ this.activeType = "ui";
+
+ // TODO ben: re-enable intended behavior
+ // // I think this might be a hack and it could be done in a much better way.
+ // if(!this.modelViewMatrices[objectKey][0] && this.activeFrame.fullScreen !== 'sticky' ){
+ // this.hideTransformed(this.activeKey, this.activeVehicle, this.globalDOMCache, this.cout);
+ // continue;
+ // }
+
+ // perform all the 3D calculations and CSS updates to actually show the frame and render in the correct position
+ this.drawTransformed(objectKey, this.activeKey, this.activeType, this.activeVehicle, this.notLoading,
+ this.globalDOMCache, this.globalStates, this.globalCanvas,
+ this.activeObjectMatrix, this.matrix, this.finalMatrix, this.utilities, this.cout);
+
+ // if a DOM element hasn't been added for this frame yet, add it and load the correct src into an iframe
+ var frameUrl = realityEditor.network.getURL(this.activeObject.ip, realityEditor.network.getPort(objects[objectKey]), "/obj/" + this.activeObject.name + "/frames/" + this.activeFrame.name + "/");
+ this.addElement(frameUrl, objectKey, frameKey, null, this.activeType, this.activeVehicle);
+
+ // if we're not viewing frames (e.g. should be viewing nodes instead), hide the frame
+ if (globalStates.guiState !== 'ui') {
+ this.hideTransformed(this.activeKey, this.activeVehicle, this.globalDOMCache, this.cout);
+ }
+
+ // iterate over every node in this frame, and perform the same rendering process as for the frames
+ for (nodeKey in this.activeFrame.nodes) {
+ // render the nodes if we're in node/logic viewing mode
+ if (globalStates.guiState === "node" || globalStates.guiState === "logic") {
+
+ this.activeNode = realityEditor.getNode(objectKey, frameKey, nodeKey);
+ this.activeKey = nodeKey;
+ this.activeVehicle = this.activeNode;
+ this.activeType = this.activeNode.type;
+
+ // nodes of certain types are invisible and don't need to be rendered (e.g. storeData nodes)
+ if (this.hiddenNodeTypes.indexOf(this.activeType) > -1) { continue; }
+ // the above check is deprecated: new nodes will have an invisible property
+ if (this.activeNode.invisible) { continue; }
+
+ // perform all the 3D calculations and CSS updates to actually show the node and render in the correct position
+ this.drawTransformed(objectKey, this.activeKey, this.activeType, this.activeVehicle, this.notLoading,
+ this.globalDOMCache, this.globalStates, this.globalCanvas,
+ this.activeObjectMatrix, this.matrix, this.finalMatrix, this.utilities, this.cout);
+
+ // if a DOM element hasn't been added for this node yet, add it and load the correct src into an iframe
+ var nodeUrl = realityEditor.network.getURL(this.activeObject.ip,realityEditor.network.getPort(objects[objectKey]), "/nodes/" + this.activeType + "/index.html");
+ this.addElement(nodeUrl, objectKey, frameKey, nodeKey, this.activeType, this.activeVehicle);
+
+ } else {
+
+ // if we're not in node/logic viewing mode, hide the nodes
+ this.activeNode = realityEditor.getNode(objectKey, frameKey, nodeKey);
+ this.activeKey = nodeKey;
+ this.activeVehicle = this.activeNode;
+ this.hideTransformed(this.activeKey, this.activeVehicle, this.globalDOMCache, this.cout);
+
+ }
+ }
+ }
+
+ // if this object was NOT detected by the AR engine, hide its nodes and frames or perform edge-case functionality
+ // check if objectVisible so that this only happens once for each object
+ } else if (this.activeObject.objectVisible) {
+
+ // setting objectVisible = false makes sure we don't unnecessarily repeatedly hide it
+ realityEditor.gui.ar.draw.setObjectVisible(this.activeObject, false);
+
+ var wereAnyFramesMovedToGlobal = false;
+
+ for (frameKey in objects[objectKey].frames) {
+ // if (!objects[objectKey].frames.hasOwnProperty(frameKey)) { continue; }
+
+ this.activeFrame = realityEditor.getFrame(objectKey, frameKey);
+ if (!this.activeFrame) { continue; }
+
+ this.activeKey = frameKey;
+ this.activeVehicle = this.activeFrame;
+ this.activeType = "ui";
+
+ // preserve frame globally when its object disappears if it is being moved in unconstrained editing
+ if (realityEditor.device.isEditingUnconstrained(this.activeVehicle) && this.activeVehicle.location === 'global') {
+
+ wereAnyFramesMovedToGlobal = true;
+ globalStates.inTransitionObject = objectKey;
+ globalStates.inTransitionFrame = frameKey;
+
+ // if not unconstrained editing a global frame, hide it
+ } else {
+
+ var startingMatrix = realityEditor.device.editingState.startingMatrix;
+
+ // unconstrained editing local frame - can't transition to global, but reset its matrix to what it was before starting to edit
+ if (realityEditor.device.isEditingUnconstrained(this.activeVehicle) && startingMatrix) {
+ realityEditor.sceneGraph.getSceneNodeById(frameKey).setLocalMatrix(startingMatrix);
+ }
+
+ // hide the frame
+ this.hideTransformed(this.activeKey, this.activeVehicle, this.globalDOMCache, this.cout);
+
+ // hide each node within this frame
+ for (nodeKey in this.activeFrame.nodes) {
+ // if (!this.activeFrame.nodes.hasOwnProperty(nodeKey)) { continue; }
+
+ this.activeNode = realityEditor.getNode(objectKey, frameKey, nodeKey);
+ this.activeKey = nodeKey;
+ this.activeVehicle = this.activeNode;
+ this.activeType = this.activeNode.type;
+
+ // unconstrained editing local node - can't transition to global, but reset its matrix to what it was before starting to edit
+ if (realityEditor.device.isEditingUnconstrained(this.activeNode) && startingMatrix) {
+ realityEditor.sceneGraph.getSceneNodeById(nodeKey).setLocalMatrix(startingMatrix);
+ }
+
+ // hide the node
+ this.hideTransformed(this.activeKey, this.activeVehicle, this.globalDOMCache, this.cout);
+ }
+
+ }
+
+ }
+
+ // remove editing states related to this object (unless transitioned a frame to global)
+ if (!wereAnyFramesMovedToGlobal && realityEditor.device.editingState.object === objectKey) {
+ realityEditor.device.resetEditingState();
+ }
+
+ // if this object was NOT detected by the AR engine, AND its frames/nodes have already been hidden, continuously
+ // continuously check if enough time has passed to completely kill its content from the DOM
+ } else {
+ this.killObjects(this.activeKey, this.activeObject, this.globalDOMCache);
+ }
+
+ }
+
+ // draw all lines - links and cutting lines
+ if ((globalStates.guiState === "node" || globalStates.guiState === "logic")) {
+
+ // render each link
+ realityEditor.forEachFrameInAllObjects(function(objectKey, frameKey) {
+ realityEditor.gui.ar.lines.drawAllLines(realityEditor.getFrame(objectKey, frameKey), globalCanvas.context);
+ });
+
+ // render the cutting line if you are dragging on the background (not in editing mode)
+ if (!globalStates.editingMode) {
+ this.ar.lines.drawInteractionLines();
+ }
+ }
+
+ // render the frame that was pulled off of one object and is being moved through global space to a new object
+ if (globalStates.inTransitionObject && globalStates.inTransitionFrame) {
+
+ this.activeObject = objects[globalStates.inTransitionObject];
+ this.activeObject.visibleCounter = timeForContentLoaded;
+ this.activeObject.objectVisible = true;
+
+ objectKey = globalStates.inTransitionObject;
+ frameKey = globalStates.inTransitionFrame;
+
+ this.activeObjectMatrix = [];
+
+ // TODO: finish this new method of transferring frames immediately so they can be pushed into screens in one motion
+ /*
+ var numObjectsVisible = Object.keys(this.visibleObjects).length;
+ var areAnyObjectsVisible = numObjectsVisible > 0;
+ var isSingleObjectVisible = numObjectsVisible === 1;
+ var isSingleScreenObjectVisible = false;
+ var isDifferentSingleScreenObjectVisible = false;
+
+ if (isSingleObjectVisible) {
+ var visibleObjectKey = Object.keys(this.visibleObjects)[0];
+ var visibleObject = realityEditor.getObject(visibleObjectKey);
+ isSingleScreenObjectVisible = visibleObject.visualization === 'screen';
+ isDifferentObjectVisible = visibleObjectKey !== globalStates.inTransitionObject;
+ if (isSingleScreenObjectVisible && isDifferentObjectVisible) {
+ console.log('should attach to new object now');
+ }
+ }
+ */
+
+ // render the transition frame even if its object is not visible
+ if (!this.visibleObjects.hasOwnProperty(objectKey)) {
+
+ this.activeFrame = this.activeObject.frames[frameKey];
+ this.activeKey = frameKey;
+ this.activeObjectMatrix = this.activeFrame.temp;
+
+ this.drawTransformed(objectKey, this.activeKey, this.activeType, this.activeFrame, this.notLoading, this.globalDOMCache, this.globalStates, this.globalCanvas, this.activeObjectMatrix, this.matrix, this.finalMatrix, this.utilities, this.cout);
+ }
+
+ }
+
+ // if needed, reset acceleration data from devicemotion events
+ if (globalStates.acceleration.motion !== 0) {
+ globalStates.acceleration = {
+ x: 0,
+ y: 0,
+ z: 0,
+ alpha: 0,
+ beta: 0,
+ gamma: 0,
+ motion: 0
+ }
+ }
+
+ // Adds a pulsing vibration that you can feel when you are looking at an object that has no frames.
+ // Provides haptic feedback to give you the confidence that you can add frames to what you are looking at.
+ if (this.isObjectWithNoFramesVisible) {
+ var closestObject = realityEditor.getObject(realityEditor.gui.ar.getClosestObject()[0]);
+ if (closestObject && !closestObject.isWorldObject) {
+ var delay = closestObject.isWorldObject ? 1000 : 500;
+ if (!visibleObjectTapInterval || delay !== visibleObjectTapDelay) {
+ // tap once, immediately
+ realityEditor.app.tap();
+
+ clearInterval(visibleObjectTapInterval);
+ visibleObjectTapInterval = null;
+
+ // then tap every 0.5 seconds if you're looking at an image/object target
+ // or every 1 seconds if you're looking at the world object
+ visibleObjectTapInterval = setInterval(function () {
+ if (!globalStates.freezeButtonState) {
+ const TAP_WHEN_NO_FRAMES_VISIBLE = false;
+ if (TAP_WHEN_NO_FRAMES_VISIBLE) {
+ realityEditor.app.tap();
+ }
+ }
+ }, delay);
+
+ // keep track of the the tap delay used, so that you can adjust the interval when switching between world and image targets
+ visibleObjectTapDelay = delay;
+ }
+ }
+ } else {
+ if (visibleObjectTapInterval) {
+ clearInterval(visibleObjectTapInterval);
+ visibleObjectTapInterval = null;
+ }
+ }
+
+ if (this.closestObjectListeners.length > 0 && this.isLowFrequencyUpdateFrame) {
+ var newClosestObject = realityEditor.gui.ar.getClosestObject()[0];
+ if (newClosestObject !== this.currentClosestObject) {
+ this.closestObjectListeners.forEach(function(callback) {
+ callback(this.currentClosestObject, newClosestObject);
+ }.bind(this));
+ this.currentClosestObject = newClosestObject;
+ }
+ }
+
+ // make the update loop extensible by additional services that wish to subscribe to matrix updates
+ this.updateListeners.forEach(function(callback) {
+ // warning: sends a reference to the original set of matrices, for performance reasons, instead of a deep clone.
+ // services that subscribe to this are responsible to not mutate this object.
+ callback(realityEditor.gui.ar.draw.visibleObjects);
+ });
+};
+
+realityEditor.gui.ar.draw.closestObjectListeners = [];
+
+/**
+ * Adds a callback that will be triggered whenever the closest object changes
+ * @param {function} callback - first parameter is old closest object, second is new
+ */
+realityEditor.gui.ar.draw.onClosestObjectChanged = function(callback) {
+ this.closestObjectListeners.push(callback);
+};
+
+/**
+ * Detach the oldFrameKey frame from oldObjectKey object,
+ * and attach instead to newObjectKey object, assigning it a new uuid of newFrameKey.
+ * Also needs to rename all of its nodes with correct paths,
+ * update all relevant DOM element ids,
+ * possibly update the editing state and screen object pointers,
+ * delete the old frame from the old object server and upload to the new object server,
+ * and modify all of the links going to and from its nodes,
+ * syncing links with the server so that data gets routed correctly.
+ * @param {string} oldObjectKey
+ * @param {string} oldFrameKey
+ * @param {string} newObjectKey
+ * @param {string} newFrameKey
+ */
+realityEditor.gui.ar.draw.moveFrameToNewObject = function(oldObjectKey, oldFrameKey, newObjectKey, newFrameKey) {
+
+ if (oldObjectKey === newObjectKey && oldFrameKey === newFrameKey) return; // don't need to do anything
+
+ var oldObject = realityEditor.getObject(oldObjectKey);
+ var newObject = realityEditor.getObject(newObjectKey);
+
+ var frame = realityEditor.getFrame(oldObjectKey, oldFrameKey);
+
+ if (frame.location !== 'global') {
+ console.warn('WARNING: TRYING TO DELETE A LOCAL FRAME');
+ return;
+ }
+
+ // invalidate vehicleKeyCache
+ delete realityEditor.vehicleKeyCache[oldFrameKey];
+
+ let frameSceneNode = realityEditor.sceneGraph.getSceneNodeById(oldFrameKey);
+ // this will recompute a new position for it so it stays in same place relative to camera/world
+ realityEditor.sceneGraph.changeParent(frameSceneNode, newObjectKey, true);
+ realityEditor.sceneGraph.changeId(frameSceneNode, newFrameKey);
+
+ // rename nodes and give new keys
+ var newNodes = {};
+ for (var oldNodeKey in frame.nodes) {
+ var node = frame.nodes[oldNodeKey];
+ var newNodeKey = newFrameKey + node.name;
+ node.objectId = newObjectKey;
+ node.frameId = newFrameKey;
+ node.uuid = newNodeKey;
+ newNodes[node.uuid] = node;
+ delete frame.nodes[oldNodeKey];
+
+ // invalidate vehicleKeyCache
+ delete realityEditor.vehicleKeyCache[oldNodeKey];
+
+ // update the scene graph
+ let nodeSceneNode = realityEditor.sceneGraph.getSceneNodeById(oldNodeKey);
+ realityEditor.sceneGraph.changeId(nodeSceneNode, newNodeKey);
+
+ // update the DOM elements for each node
+ // (only if node has been loaded to DOM already - doesn't happen if haven't ever switched to node view)
+ if (globalDOMCache[oldNodeKey]) {
+ // update their keys in the globalDOMCache
+ globalDOMCache['object' + newNodeKey] = globalDOMCache['object' + oldNodeKey];
+ globalDOMCache['iframe' + newNodeKey] = globalDOMCache['iframe' + oldNodeKey];
+ globalDOMCache[newNodeKey] = globalDOMCache[oldNodeKey];
+ globalDOMCache['svg' + newNodeKey] = globalDOMCache['svg' + oldNodeKey];
+ delete globalDOMCache['object' + oldNodeKey];
+ delete globalDOMCache['iframe' + oldNodeKey];
+ delete globalDOMCache[oldNodeKey];
+ delete globalDOMCache['svg' + oldNodeKey];
+
+ // re-assign ids to DOM elements
+ globalDOMCache['object' + newNodeKey].id = 'object' + newNodeKey;
+ globalDOMCache['iframe' + newNodeKey].id = 'iframe' + newNodeKey;
+ globalDOMCache[newNodeKey].id = newNodeKey;
+ globalDOMCache[newNodeKey].objectId = newObjectKey;
+ globalDOMCache[newNodeKey].frameId = newFrameKey;
+ globalDOMCache[newNodeKey].nodeId = newNodeKey;
+ globalDOMCache['svg' + newNodeKey].id = 'svg' + newNodeKey;
+
+ // update iframe attributes
+ globalDOMCache['iframe' + newNodeKey].setAttribute("data-frame-key", newFrameKey);
+ globalDOMCache['iframe' + newNodeKey].setAttribute("data-object-key", newObjectKey);
+ globalDOMCache['iframe' + newNodeKey].setAttribute("data-node-key", newNodeKey);
+
+ globalDOMCache['iframe' + newNodeKey].setAttribute("onload", 'realityEditor.network.onElementLoad("' + newObjectKey + '","' + newFrameKey + '","' + newNodeKey + '")');
+ try {
+ let reloadSrc = globalDOMCache['iframe' + newNodeKey].src;
+ globalDOMCache['iframe' + newNodeKey].src = reloadSrc; // this is intentionally the same src
+ } catch (e) {
+ console.warn('error reloading node src for ' + newNodeKey);
+ }
+ } else {
+ node.loaded = false;
+ }
+ }
+
+ frame.nodes = newNodes;
+ frame.objectId = newObjectKey;
+ frame.uuid = newFrameKey;
+
+ // update any variables in the application with the old keys to use the new keys
+ if (realityEditor.device.editingState.object === oldObjectKey) {
+ realityEditor.device.editingState.object = newObjectKey;
+ }
+ if (realityEditor.device.editingState.frame === oldFrameKey) {
+ realityEditor.device.editingState.frame = newFrameKey;
+ }
+ if (realityEditor.gui.screenExtension.screenObject.object === oldObjectKey) {
+ realityEditor.gui.screenExtension.screenObject.object = newObjectKey;
+ }
+ if (realityEditor.gui.screenExtension.screenObject.frame === oldFrameKey) {
+ realityEditor.gui.screenExtension.screenObject.frame = newFrameKey;
+ }
+
+ // update the DOM elements for the frame with new ids
+ // (only if node has been loaded to DOM already - doesn't happen if haven't ever switched to ui view)
+ if (globalDOMCache[oldFrameKey]) {
+ // update their keys in the globalDOMCache
+ globalDOMCache['object' + newFrameKey] = globalDOMCache['object' + oldFrameKey];
+ globalDOMCache['iframe' + newFrameKey] = globalDOMCache['iframe' + oldFrameKey];
+ globalDOMCache[newFrameKey] = globalDOMCache[oldFrameKey];
+ globalDOMCache['svg' + newFrameKey] = globalDOMCache['svg' + oldFrameKey];
+
+ // re-assign ids to DOM elements
+ globalDOMCache['object' + newFrameKey].id = 'object' + newFrameKey;
+ globalDOMCache['iframe' + newFrameKey].id = 'iframe' + newFrameKey;
+ globalDOMCache[newFrameKey].id = newFrameKey;
+ globalDOMCache[newFrameKey].objectId = newObjectKey;
+ globalDOMCache[newFrameKey].frameId = newFrameKey;
+ globalDOMCache['svg' + newFrameKey].id = 'svg' + newFrameKey;
+
+ // update iframe attributes
+ globalDOMCache['iframe' + newFrameKey].setAttribute("data-frame-key", newFrameKey);
+ globalDOMCache['iframe' + newFrameKey].setAttribute("data-object-key", newObjectKey);
+
+ globalDOMCache['iframe' + newFrameKey].setAttribute("onload", 'realityEditor.network.onElementLoad("' + newObjectKey + '","' + newFrameKey + '","' + null + '")');
+
+ var newSrc = realityEditor.network.availableFrames.getFrameSrc(newObjectKey, frame.src);
+ try {
+ globalDOMCache['iframe' + newFrameKey].src = newSrc;
+ } catch (e) {
+ console.warn('error reloading frame src for ' + newFrameKey);
+ }
+ } else {
+ frame.loaded = false;
+ }
+
+ // add the frame to the new object and post the new frame on the server (must exist there before we can update the links)
+ objects[newObjectKey].frames[newFrameKey] = frame;
+ var newObjectIP = realityEditor.getObject(newObjectKey).ip;
+ realityEditor.network.postNewFrame(newObjectIP, newObjectKey, frame, function(err) {
+
+ if (err) {
+ console.warn('server returned error when moving frame to new object');
+ }
+
+ // update all links locally and on the server
+ // loop through all frames
+ realityEditor.forEachFrameInAllObjects(function(thatObjectKey, thatFrameKey) {
+ var thatFrame = realityEditor.getFrame(thatObjectKey, thatFrameKey);
+
+ // loop through all links in that frame
+ for (var linkKey in thatFrame.links) {
+ var link = thatFrame.links[linkKey];
+ var didLinkChange = false;
+
+ // update the start of the link
+ if (link.objectA === oldObjectKey && link.frameA === oldFrameKey) {
+ link.objectA = newObjectKey;
+ link.frameA = newFrameKey;
+ link.nodeA = newFrameKey + link.namesA[2];
+ link.namesA[0] = newObject.name;
+ didLinkChange = true;
+ }
+
+ // update the end of the link
+ if (link.objectB === oldObjectKey && link.frameB === oldFrameKey) {
+ link.objectB = newObjectKey;
+ link.frameB = newFrameKey;
+ link.nodeB = newFrameKey + link.namesB[2];
+ link.namesB[0] = newObject.name;
+ didLinkChange = true;
+ }
+
+ // only change the link on the server if its objectA or objectB changed
+ if (didLinkChange) {
+ var linkObjectIP = realityEditor.getObject(thatObjectKey).ip;
+ // remove link from old frame (locally and on the server)
+ delete thatFrame.links[linkKey];
+ realityEditor.network.deleteLinkFromObject(linkObjectIP, thatObjectKey, thatFrameKey, linkKey);
+ // add link to new frame (locally and on the server -- post link to server adds it locally too)
+ realityEditor.network.postLinkToServer(link, linkKey);
+ }
+ }
+ });
+
+ // update the publicData on the server to point to the new path
+ if (publicDataCache.hasOwnProperty(oldFrameKey)) {
+ // update locally
+ publicDataCache[newFrameKey] = publicDataCache[oldFrameKey];
+ delete publicDataCache[oldFrameKey];
+
+ // update on the server
+ realityEditor.network.deletePublicData(oldObject.ip, oldObjectKey, oldFrameKey);
+ realityEditor.network.postPublicData(newObject.ip, newObjectKey, newFrameKey, publicDataCache[newFrameKey]);
+ }
+
+ // remove the frame from the old object
+ delete objects[oldObjectKey].frames[oldFrameKey];
+ realityEditor.network.deleteFrameFromObject(oldObject.ip, oldObjectKey, oldFrameKey);
+ });
+};
+
+/**
+ * When a transition frame is dropped somewhere it cannot be transferred to (empty space, no object visible),
+ * returns the frame back to the position where it came from. Update state and remove DOM elements if necessary.
+ */
+realityEditor.gui.ar.draw.returnTransitionFrameBackToSource = function() {
+
+ var frameInMotion = realityEditor.getFrame(globalStates.inTransitionObject, globalStates.inTransitionFrame);
+ realityEditor.gui.ar.draw.hideTransformed(globalStates.inTransitionFrame, frameInMotion, globalDOMCache, cout);
+
+ if (realityEditor.device.editingState.startingMatrix) {
+ realityEditor.sceneGraph.getSceneNodeById(globalStates.inTransitionFrame).setLocalMatrix(realityEditor.device.editingState.startingMatrix);
+ }
+
+ // TODO: remove temp and begin now that scene graph handles positioning
+ frameInMotion.temp = realityEditor.gui.ar.utilities.newIdentityMatrix();
+ frameInMotion.begin = realityEditor.gui.ar.utilities.newIdentityMatrix();
+
+ // update any variables in the application with the old keys to use the new keys
+ // TODO: do these need to be set here or will they update automatically elsewhere?
+ if (realityEditor.gui.screenExtension.screenObject.object === globalStates.inTransitionObject)
+ realityEditor.gui.screenExtension.screenObject.object = null;
+ if (realityEditor.gui.screenExtension.screenObject.frame === globalStates.inTransitionFrame)
+ realityEditor.gui.screenExtension.screenObject.frame = null;
+
+ globalStates.inTransitionObject = null;
+ globalStates.inTransitionFrame = null;
+};
+
+/**
+ * When an inTransitionFrame is dropped onto an object, assign it new matrices to try to preserve its position,
+ * and call the moveFrameToNewObject function to do the majority of the work of reassigning it to the new object
+ * @param {string} oldObjectKey
+ * @param {string} oldFrameKey
+ * @param {string} newObjectKey
+ * @param {string} newFrameKey
+ */
+realityEditor.gui.ar.draw.moveTransitionFrameToObject = function(oldObjectKey, oldFrameKey, newObjectKey, newFrameKey) {
+ this.moveFrameToNewObject(oldObjectKey, oldFrameKey, newObjectKey, newFrameKey);
+ globalStates.inTransitionObject = null;
+ globalStates.inTransitionFrame = null;
+};
+
+/**
+ * (One of the most important and heavily-used functions in the Editor.)
+ * Renders a specific frame or node with the correct CSS3D transformations based on all application state.
+ * Also determines if the DOM element needs to be shown or hidden.
+ * The long list of parameters is for optimization purposes. Using a local variable is faster than a global one,
+ * so references to many global variables are passed in as shortcuts. This function gets called 60 FPS for every
+ * frame and node on any currently-visible objects, so small optimizations here make a big difference on performance.
+ * @param modelViewMatrices - contains the modelview matrices for visible objects
+ * @param objectKey - the uuid of the object that this frame or node belongs to
+ * @param activeKey - the uuid of the frame or node to render
+ * @param activeType - 'node' or 'ui' depending on if it's a node or frame element
+ * @param activeVehicle - the Frame or Node reference. "Vehicle" means "Frame or Node". (something you can move)
+ * @param notLoading - starts false when this vehicle's element is initialized. gets set to the vehicle's uuid when it loads
+ * @param globalDOMCache - reference to global variable
+ * @param globalStates - reference to global variable
+ * @param globalCanvas - reference to global variable
+ * @param activeObjectMatrix - the result of multiplying the object's modelview matrix, the projection matrix, and the screen rotation matrix
+ * @param matrix - object containing several matrix references that can be used as temporary registers for multiplication results.
+ * includes matrix.temp, matrix.begin, matrix.end, matrix.r, matrix.r2, and matrix.r3
+ * @param finalMatrix - stores the resulting final CSS3D matrix for the vehicle @todo this doesnt seem to be used anywhere?
+ * @param utilities - reference to realityEditor.gui.ar.utilities
+ * @param _cout - reference to debug logging function (unused)
+ */
+realityEditor.gui.ar.draw.drawTransformed = function (objectKey, activeKey, activeType, activeVehicle, notLoading, globalDOMCache, globalStates, globalCanvas, activeObjectMatrix, matrix, finalMatrix, utilities, _cout) {
+ // it's ok if the frame isn't visible anymore if we're in the node view - render it anyways
+ var shouldRenderFramesInNodeView = (globalStates.guiState === 'node' && activeType === 'ui'); // && globalStates.renderFrameGhostsInNodeViewEnabled;
+
+ if (notLoading !== activeKey && activeVehicle.loaded === true && activeVehicle.visualization !== "screen") {
+
+ //todo this reference can be faster when taking the local
+ var editingVehicle = realityEditor.device.getEditingVehicle();
+ var thisIsBeingEdited = (editingVehicle === activeVehicle);
+
+ var activePocketFrameWaiting = activeVehicle === pocketFrame.vehicle && pocketFrame.waitingToRender;
+ var activePocketNodeWaiting = activeVehicle === pocketNode.vehicle && pocketNode.waitingToRender;
+
+ // make visible a frame or node if it was previously hidden
+ // waits to make visible until positionOnLoad has been applied, to avoid one frame rendered in wrong position
+ if (!shouldRenderFramesInNodeView && !activeVehicle.visible && !(activePocketFrameWaiting || activePocketNodeWaiting)) {
+
+ activeVehicle.visible = true;
+
+ var container = globalDOMCache["object" + activeKey];
+ let iFrame = globalDOMCache["iframe" + activeKey];
+ var overlay = globalDOMCache[activeKey];
+ var canvas = globalDOMCache["svg" + activeKey];
+
+ if (!container) {
+ activeVehicle.loaded = false;
+ return;
+ }
+
+ if (activeType === 'ui') {
+ container.classList.remove('hiddenFrameContainer');
+ container.classList.add('visibleFrameContainer');
+ container.classList.remove('displayNone');
+
+ } else {
+ container.classList.remove('hiddenNodeContainer');
+ container.classList.add('visibleNodeContainer');
+
+ }
+
+ iFrame.classList.remove('hiddenFrame');
+ iFrame.classList.add('visibleFrame');
+
+ overlay.style.visibility = 'visible';
+
+ if (globalStates.editingMode) {
+ canvas.classList.add('visibleEditingSVG');
+ // canvas.style.visibility = 'visible';
+ // canvas.style.display = 'inline';
+
+ overlay.querySelector('.corners').style.visibility = 'visible';
+
+ } else {
+ // canvas.style.display = 'none';
+ canvas.classList.remove('visibleEditingSVG');
+
+ overlay.querySelector('.corners').style.visibility = 'hidden';
+
+ }
+
+ if (activeType === 'ui') {
+ iFrame.contentWindow.postMessage(
+ JSON.stringify(
+ {
+ visibility: "visible",
+ interface: globalStates.interface
+ }), '*');
+ }
+
+ if (activeType === "logic" && objectKey !== "pocket") {
+ if(activeVehicle.animationScale === 1) {
+ globalDOMCache["logic" + activeKey].className = "mainEditing scaleOut";
+ activeVehicle.animationScale = 0;
+ }
+ }
+
+ // re-activate the activeScreenObject when it reappears
+ var screenExtension = realityEditor.gui.screenExtension;
+ if (screenExtension.registeredScreenObjects[activeKey]) {
+
+ if (!screenExtension.visibleScreenObjects.hasOwnProperty(activeKey)) {
+ screenExtension.visibleScreenObjects[activeKey] = {
+ object: objectKey,
+ frame: activeKey,
+ node: null,
+ x: 0,
+ y: 0,
+ touches: null
+ };
+ }
+ }
+
+ }
+
+ // render visible frame/node
+ if ((activeVehicle.visible || shouldRenderFramesInNodeView) || activePocketFrameWaiting || activePocketNodeWaiting) {
+
+ // safety mechanism to prevent bugs where tries to manipulate a DOM element that doesn't exist
+ if (!globalDOMCache["object" + activeKey]) {
+ activeVehicle.visible = false;
+ return;
+ }
+
+ if (globalDOMCache['object' + activeKey].classList.contains('displayNone')) { // TODO: speedup with flag
+ globalDOMCache['object' + activeKey].classList.remove('displayNone');
+ console.warn('removing displayNone in drawTransformed, should happen before this');
+ }
+
+ // push matrices into iframe as early as possible to reduce lag
+ // these coordinate systems are based purely on the scene graph, so they can happen early in this function
+ if (activeType === "ui") {
+ realityEditor.network.frameContentAPI.sendCoordinateSystemsToIFrame(activeVehicle.objectId, activeVehicle.uuid);
+ }
+
+ // can't change while frozen so don't recalculate
+ if (realityEditor.device.environment.supportsDistanceFading() &&
+ (!globalStates.freezeButtonState || realityEditor.device.environment.ignoresFreezeButton())) {
+ // fade out frames and nodes when they move beyond a certain distance
+ var distance = realityEditor.sceneGraph.getDistanceToCamera(activeKey); //activeVehicle.screenZ;
+ var distanceScale = realityEditor.gui.ar.getDistanceScale(activeVehicle);
+ // multiply the default min distance by the amount this frame distance has been scaled up
+ var distanceThreshold = (distanceScale * realityEditor.device.distanceScaling.getDefaultDistance());
+ var isDistantVehicle = distance > distanceThreshold;
+ var isAlmostDistantVehicle = distance > (distanceThreshold * 0.8);
+
+ // hide visuals if not already hidden
+ if (isDistantVehicle && activeVehicle.screenOpacity !== 0) {
+ globalDOMCache["object" + activeKey].classList.add('distantFrame');
+ activeVehicle.screenOpacity = 0;
+
+
+ } else if (!isDistantVehicle) {
+
+ // show visuals if not already shown
+ if (activeVehicle.screenOpacity === 0) {
+ globalDOMCache["object" + activeKey].classList.remove('distantFrame'); // show again, but fade out opacity if within a narrow threshold
+ }
+
+ if (isAlmostDistantVehicle) {
+ // full opacity if within 80% of the threshold. fades out linearly to zero opacity at 100% of the threshold
+ var opacity = 1.0 - ((distance - 0.8 * distanceThreshold) / (0.2 * distanceThreshold));
+ globalDOMCache["object" + activeKey].style.opacity = opacity;
+ activeVehicle.screenOpacity = opacity;
+ } else {
+ // remove the CSS property so it doesn't override other classes added to this frame/node
+ globalDOMCache["object" + activeKey].style.opacity = '';
+ activeVehicle.screenOpacity = 1;
+ }
+ }
+ }
+
+ if (typeof activeVehicle.isPendingInitialPlacement !== 'undefined') {
+ let touchPosition = realityEditor.gui.ar.positioning.getMostRecentTouchPosition();
+ realityEditor.gui.ar.positioning.moveVehicleToScreenCoordinate(activeVehicle, touchPosition.x, touchPosition.y, true);
+ let keys = activeVehicle.isPendingInitialPlacement;
+ realityEditor.device.beginTouchEditing(keys.objectKey, keys.frameKey, keys.nodeKey);
+ delete activeVehicle.isPendingInitialPlacement;
+ }
+
+ // set initial position of frames and nodes placed in from pocket
+ // 1. drop directly onto target plane if in freeze state (or quick-tapped the frame)
+ // 2. otherwise float in unconstrained slightly in front of the editor camera
+ // 3. animate so it looks like it is being pushed from pocket
+ if (activePocketNodeWaiting && typeof activeVehicle.mostRecentFinalMatrix !== 'undefined') {
+ this.addPocketVehicle(pocketNode);
+ }
+ if (activePocketFrameWaiting && typeof activeVehicle.mostRecentFinalMatrix !== 'undefined') {
+ this.addPocketVehicle(pocketFrame);
+ }
+
+ if (globalStates.editingMode || thisIsBeingEdited) {
+ // show the svg overlay if needed (doesn't always get added correctly in the beginning so this is the safest way to ensure it appears)
+ var svg = globalDOMCache["svg" + activeKey];
+ if (svg.children.length === 0) {
+ let iFrame = globalDOMCache["iframe" + activeKey];
+ svg.style.width = iFrame.style.width;
+ svg.style.height = iFrame.style.height;
+ realityEditor.gui.ar.moveabilityOverlay.createSvg(svg);
+ }
+
+ // TODO ben: what are these?
+ // todo test if this can be made touch related
+ // if (activeType === "logic") {
+ // utilities.copyMatrixInPlace(activeObjectMatrix, activeVehicle.temp);
+ // }
+
+ if (realityEditor.device.isEditingUnconstrained(activeVehicle)) {
+
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(activeKey);
+ let cameraNode = realityEditor.sceneGraph.getSceneNodeById('CAMERA');
+
+ // TODO: also show "shadow" on ground plane on remote operator while moving, to help position it
+
+ // when you first trigger unconstrained repositioning, attach the tool to the camera so that its
+ // matrix gets stored "frozen" relative to the camera and moves with it
+ if (matrix.copyStillFromMatrixSwitch) {
+ let relativeMatrix = sceneNode.getMatrixRelativeTo(cameraNode);
+ activeVehicle.begin = utilities.copyMatrix(relativeMatrix); // todo: do we still need the .begin matrix?
+ matrix.copyStillFromMatrixSwitch = false;
+ realityEditor.sceneGraph.changeParent(sceneNode, 'CAMERA', true);
+ }
+
+ // this forces it to broadcast its position in realtime to other clients
+ sceneNode.setLocalMatrix(sceneNode.localMatrix);
+ }
+ }
+
+ // TODO ben: add in animation matrix
+ // multiply in the animation matrix if you are editing this frame in unconstrained mode.
+ // in the future this can be expanded but currently this is the only time it gets animated.
+ // if (realityEditor.device.isEditingUnconstrained(activeVehicle)) {
+ // var animatedFinalMatrix = [];
+ // utilities.multiplyMatrix(finalMatrix, editingAnimationsMatrix, animatedFinalMatrix);
+ // utilities.copyMatrixInPlace(animatedFinalMatrix, finalMatrix);
+ // }
+
+ // TODO: do this on frame touch up (snap position when editing ends), or if unconstrained editing (visual feedback when ready to snap)
+ // this.snapFrameMatrixIfNecessary(activeVehicle, activeKey);
+
+ // we want nodes closer to camera to have higher z-coordinate, so that they are rendered in front
+ // but we want all of them to have a positive value so they are rendered in front of background canvas
+ // and frames with developer=false should have the lowest positive value
+
+ finalMatrix = utilities.copyMatrix(realityEditor.sceneGraph.getCSSMatrix(activeKey));
+
+ if (activeVehicle.alwaysFaceCamera === true) {
+ // this gives a pretty good billboard effect, as long as you aren't looking from top-down
+ let modelMatrix = realityEditor.sceneGraph.getModelMatrixLookingAt(activeKey, 'CAMERA');
+ let modelViewMatrix = [];
+ utilities.multiplyMatrix(modelMatrix, realityEditor.sceneGraph.getViewMatrix(), modelViewMatrix);
+
+ // In AR mode, we need to use this lookAt method, because camera up vec doesn't always match scene up vec
+ if (realityEditor.device.environment.isARMode()) {
+ utilities.multiplyMatrix(modelViewMatrix, globalStates.projectionMatrix, finalMatrix);
+ } else {
+ // the lookAt method isn't perfect โ it has a singularity as you approach top or bottom
+ // so let's correct the scale and remove the rotation โ this works on desktop because camera up = scene up
+ let scale = realityEditor.sceneGraph.getSceneNodeById(activeKey).getVehicleScale();
+ let constructedModelViewMatrix = [
+ scale, 0, 0, 0,
+ 0, -scale, 0, 0,
+ 0, 0, scale, 0,
+ modelViewMatrix[12], modelViewMatrix[13], modelViewMatrix[14], 1
+ ];
+ utilities.multiplyMatrix(constructedModelViewMatrix, globalStates.projectionMatrix, finalMatrix);
+ }
+ }
+
+ // TODO ben: sceneGraph probably gives better data for z-depth relative to camera
+ activeVehicle.screenZ = finalMatrix[14]; // but save pre-processed z position to use later to calculate screenLinearZ
+
+ finalMatrix[14] = realityEditor.gui.ar.positioning.getFinalMatrixScreenZ(finalMatrix[14], thisIsBeingEdited, shouldRenderFramesInNodeView);
+
+ activeVehicle.mostRecentFinalMatrix = finalMatrix; // TODO ben: remove mostRecentFinalMatrix
+
+ // draw transformed
+ if (activeVehicle.fullScreen !== true && activeVehicle.fullScreen !== 'sticky') {
+
+ let activeElt = globalDOMCache["object" + activeKey];
+ if (!activeVehicle.isOutsideViewport) {
+ // normalize the matrix and clear the last column, to avoid some browser-specific bugs
+ let normalizedMatrix = realityEditor.gui.ar.utilities.normalizeMatrix(finalMatrix);
+ normalizedMatrix[3] = 0;
+ normalizedMatrix[7] = 0;
+ normalizedMatrix[11] = 0;
+ activeElt.style.transform = 'matrix3d(' + normalizedMatrix.toString() + ')';
+
+ // if tool is rendering while it should be behind the camera, visually hide it (for now)
+ if (normalizedMatrix[14] < 0) {
+ activeElt.classList.add('elementBehindCamera');
+ } else {
+ activeElt.classList.remove('elementBehindCamera');
+ }
+ } else if (!activeElt.classList.contains('outsideOfViewport')) {
+ activeElt.classList.add('outsideOfViewport');
+ }
+
+ // draw a placeholder for unloaded vehicles to provide better visual feedback while they're loading
+ let iframe = globalDOMCache['iframe' + activeKey];
+ if (!iframe.dataset.doneLoading || activeVehicle.isOutsideViewport) {
+ if (realityEditor.sceneGraph.getSceneNodeById(activeKey)) {
+ if (realityEditor.sceneGraph.isInFrontOfCamera(activeKey)) {
+ this.debugDrawVehicle(activeVehicle, finalMatrix);
+ }
+ }
+ }
+
+ if (this.isLowFrequencyUpdateFrame && realityEditor.device.environment.variables.enableViewFrustumCulling && !(globalStates.disableUnloading)) {
+
+ // if too far beyond visibility threshold, unload and render a little dot instead
+ let distanceThreshold = 1.2 * realityEditor.gui.ar.getDistanceScale(activeVehicle) * realityEditor.device.distanceScaling.getDefaultDistance();
+
+ var isNowOutsideViewport = realityEditor.gui.ar.positioning.canUnload(activeKey, finalMatrix, parseInt(activeVehicle.frameSizeX)/2, parseInt(activeVehicle.frameSizeY)/2, distanceThreshold);
+
+ if (isNowOutsideViewport) {
+ if (!activeVehicle.isOutsideViewport || !activeElt.classList.contains('outsideOfViewport')) {
+ // Moved out
+ activeVehicle.isOutsideViewport = true;
+ activeElt.classList.add('outsideOfViewport');
+ let iframe = globalDOMCache['iframe' + activeKey];
+ if (iframe) {
+ iframe.dataset.src = iframe.src;
+ delete iframe.src;
+ delete iframe.dataset.doneLoading;
+ }
+ }
+ } else {
+ if (activeVehicle.isOutsideViewport) {
+ // Moved in
+ activeVehicle.isOutsideViewport = false;
+ activeElt.classList.remove('outsideOfViewport');
+
+ let iframe = globalDOMCache['iframe' + activeKey];
+ if (iframe && iframe.dataset.src) {
+ iframe.src = iframe.dataset.src;
+ delete iframe.dataset.src;
+ // can detect in onElementLoad whether loaded for first time or reloaded
+ iframe.dataset.isReloading = true;
+ }
+ }
+ }
+ }
+ } else {
+ if (realityEditor.isVehicleAFrame(activeVehicle)) {
+ this.updateStickyFrameCss(activeKey, activeVehicle.fullScreen);
+ } else {
+ // fullscreen nodes can be dragged around, need to be updated
+ let zIndex = parseInt(globalDOMCache['object' + activeKey].style.zIndex || 5000);
+ globalDOMCache['object' + activeKey].style.transform =
+ 'matrix3d(' + activeVehicle.scale + ', 0, 0, 0,' +
+ '0, ' + activeVehicle.scale + ', 0, 0,' +
+ '0, 0, 1, 0,' +
+ activeVehicle.x + ', ' + activeVehicle.y + ', ' + zIndex + ', 1)';
+ }
+ }
+
+ if (activeVehicle.fullScreen) {
+ let clientRect = globalDOMCache[activeKey].getClientRects()[0];
+ if (!clientRect) {
+ let style = window.getComputedStyle(globalDOMCache[activeKey]);
+ clientRect = {
+ top: parseFloat(style.top),
+ left: parseFloat(style.left),
+ width: parseFloat(style.width),
+ height: parseFloat(style.height),
+ };
+ }
+ activeVehicle.screenX = clientRect.left + clientRect.width/2;
+ activeVehicle.screenY = clientRect.top + clientRect.height/2;
+ activeVehicle.screenZ = 500; // this gives it a good link line width
+ } else {
+ activeVehicle.screenX = finalMatrix[12] / finalMatrix[15] + (globalStates.height / 2);
+ activeVehicle.screenY = finalMatrix[13] / finalMatrix[15] + (globalStates.width / 2);
+ }
+
+ if (thisIsBeingEdited) {
+ realityEditor.device.checkIfFramePulledIntoUnconstrained(activeVehicle);
+ }
+
+ if (this.isLowFrequencyUpdateFrame && activeVehicle.fullScreen === true && realityEditor.isVehicleAFrame(activeVehicle)) {
+ // update z-order of fullscreen frames so that closest ones get put in front of further-back ones
+ let distanceToFullscreenFrame = realityEditor.sceneGraph.getDistanceToCamera(activeKey);
+ const zPosition = activeVehicle.fullscreenZPosition ? (activeVehicle.fullscreenZPosition) : globalStates.defaultFullscreenFrameZ - Math.log(distanceToFullscreenFrame);
+ globalDOMCache["object" + activeKey].style.transform = 'matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,' + zPosition + ',1)';
+ }
+
+ if (activeType === "ui") {
+ let sendMatrices = activeVehicle.sendMatrices;
+ if (activeVehicle.sendMatrix || activeVehicle.sendAcceleration || activeVehicle.sendScreenPosition ||
+ activeVehicle.sendPositionInWorld || activeVehicle.sendDeviceDistance || activeVehicle.sendObjectPositions ||
+ sendMatrices && (sendMatrices.devicePose || sendMatrices.groundPlane || sendMatrices.anchoredModelView || sendMatrices.allObjects || sendMatrices.model || sendMatrices.view)) {
+
+ var thisMsg = {};
+
+ if (activeVehicle.sendMatrix === true) {
+ // TODO ben: send translation iff not three.js fullscreen
+ if (activeVehicle.alwaysFaceCamera) {
+ let modelMatrix = realityEditor.sceneGraph.getModelMatrixLookingAt(activeVehicle.uuid, 'CAMERA');
+ // TODO: fixup the scale and rotation similar to the other alwaysFaceCamera conditional
+ let modelViewMatrix = [];
+ utilities.multiplyMatrix(modelMatrix, realityEditor.sceneGraph.getViewMatrix(), modelViewMatrix);
+ thisMsg.modelViewMatrix = modelViewMatrix;
+ } else {
+ thisMsg.modelViewMatrix = realityEditor.sceneGraph.getModelViewMatrix(activeVehicle.uuid);
+ }
+ }
+
+ if (sendMatrices.model === true) {
+ thisMsg.modelMatrix = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid).worldMatrix;
+ }
+
+ if (sendMatrices.view === true) {
+ thisMsg.viewMatrix = realityEditor.sceneGraph.getViewMatrix();
+ }
+
+ if (sendMatrices.devicePose === true) {
+ thisMsg.devicePose = realityEditor.sceneGraph.getSceneNodeById('CAMERA').worldMatrix;
+ }
+
+ if (sendMatrices.groundPlane === true) {
+ thisMsg.groundPlaneMatrix = realityEditor.sceneGraph.getGroundPlaneModelViewMatrix();
+ thisMsg.floorOffset = realityEditor.gui.ar.areaCreator.calculateFloorOffset();
+ }
+
+ if (sendMatrices.anchoredModelView === true) {
+ thisMsg.anchoredModelView = realityEditor.gui.ar.groundPlaneAnchors.getMatrix(activeVehicle.uuid);
+ }
+
+ if (sendMatrices.allObjects === true) {
+ thisMsg.allObjects = this.visibleObjects; // TODO ben: get correct matrices from scene graph
+ }
+
+ if (activeVehicle.sendAcceleration === true) {
+ thisMsg.acceleration = globalStates.acceleration;
+ }
+
+ if (activeVehicle.sendScreenPosition === true) {
+ var halfWidth = parseInt(activeVehicle.frameSizeX)/2;
+ var halfHeight = parseInt(activeVehicle.frameSizeY)/2;
+
+ thisMsg.frameScreenPosition = {
+ upperLeft: realityEditor.sceneGraph.getScreenPosition(activeKey, [-halfWidth, -halfHeight, 0, 1]),
+ center: realityEditor.sceneGraph.getScreenPosition(activeKey, [0, 0, 0, 1]),
+ lowerRight: realityEditor.sceneGraph.getScreenPosition(activeKey, [halfWidth, halfHeight, 0, 1])
+ };
+ }
+
+ if (activeVehicle.sendPositionInWorld === true) {
+ // check what it's best worldId should be
+ let worldObjectId = realityEditor.sceneGraph.getWorldId();
+ // only works if its localized against a world object
+ if (worldObjectId) {
+ let toolSceneNode = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid);//.worldMatrix;
+ let worldSceneNode = realityEditor.sceneGraph.getSceneNodeById(worldObjectId);//.worldMatrix;
+ let relativeMatrix = toolSceneNode.getMatrixRelativeTo(worldSceneNode);
+
+ thisMsg.positionInWorld = {
+ objectId: objectKey,
+ worldId: worldObjectId,
+ worldMatrix: relativeMatrix
+ }
+ }
+ }
+
+ if (activeVehicle.sendDeviceDistance === true) {
+ thisMsg.deviceDistance = realityEditor.sceneGraph.getDistanceToCamera(activeVehicle.uuid);
+ }
+
+ if (typeof activeVehicle.sendObjectPositions !== 'undefined') {
+ thisMsg.objectPositions = realityEditor.gui.ar.positioning.getObjectPositionsOfTypes(activeVehicle.sendObjectPositions, true);
+ }
+
+ if (realityEditor.device.profiling.isEnabled()) {
+ let matrixHash = realityEditor.device.profiling.getShortHashForString(JSON.stringify(realityEditor.sceneGraph.getCameraNode().worldMatrix));
+ let processName = `cameraUpdate_${matrixHash}`;
+ realityEditor.device.profiling.stopTimeProcess(processName, 'cameraUpdates');
+ }
+
+ if (activeType === 'ui') {
+ globalDOMCache["iframe" + activeKey].contentWindow.postMessage(JSON.stringify(thisMsg), '*');
+ }
+
+ }
+ }
+
+ activeVehicle.screenLinearZ = (((10001 - (20000 / activeVehicle.screenZ)) / 9999) + 1) / 2;
+ // map the linearized zBuffer to the final ball size
+ activeVehicle.screenLinearZ = utilities.map(activeVehicle.screenLinearZ, 0.996, 1, 50, 1);
+
+ // Animate and show the 4 colored quadrants of the logic node if we touch near it
+ if (activeType === "logic" && objectKey !== "pocket") {
+ let currentTouchPosition = realityEditor.gui.ar.positioning.getMostRecentTouchPosition();
+ let logicNodeBounds = globalDOMCache[activeKey].getClientRects()[0];
+ if (logicNodeBounds) { // only calculate if the node has a valid element on screen
+ let estimatedCenter = {
+ x: logicNodeBounds.left + logicNodeBounds.width/2,
+ y: logicNodeBounds.top + logicNodeBounds.height/2
+ };
+
+ let distanceVector = {
+ x: currentTouchPosition.x - estimatedCenter.x,
+ y: currentTouchPosition.y - estimatedCenter.y
+ };
+ let distanceMoved = Math.sqrt(distanceVector.x * distanceVector.x + distanceVector.y * distanceVector.y);
+
+ // if we're too close to its center, dont expand. instead, let us hold to drag it around.
+ let minExpansionThreshold = 30;
+ let maxExpansionThreshold = 30 + logicNodeBounds.width;
+ let isTouchCloseButNotTooClose = distanceMoved > minExpansionThreshold && distanceMoved < maxExpansionThreshold;
+
+ // don't show the logic ports if you are dragging anything around, or if this logic is locked
+ if (globalProgram.objectA && isTouchCloseButNotTooClose && !activeVehicle.lockPassword && !editingVehicle) {
+ globalCanvas.hasContent = true;
+
+ if (activeVehicle.animationScale === 0 && !globalStates.editingMode) {
+ globalDOMCache["logic" + activeKey].className = "mainEditing scaleIn";
+ }
+ activeVehicle.animationScale = 1;
+ } else {
+ if (activeVehicle.animationScale === 1) {
+ globalDOMCache["logic" + activeKey].className = "mainEditing scaleOut";
+ }
+ activeVehicle.animationScale = 0;
+ }
+ }
+ }
+
+ // temporary UI styling to visualize locks
+
+ var LOCK_SERVICE_ENABLED = false;
+
+ if (LOCK_SERVICE_ENABLED) {
+ if (activeType !== "ui") {
+ if (!!activeVehicle.lockPassword && activeVehicle.lockType === "full") {
+ globalDOMCache["iframe" + activeKey].style.opacity = 0.25;
+ } else if (!!activeVehicle.lockPassword && activeVehicle.lockType === "half") {
+ globalDOMCache["iframe" + activeKey].style.opacity = 0.75;
+ } else {
+ globalDOMCache["iframe" + activeKey].style.opacity = 1.0;
+ }
+ }
+ }
+
+ }
+
+ } else if (activeType === "ui" && activeVehicle.visualization === "screen") {
+ this.hideScreenFrame(activeKey);
+ }
+
+ if (shouldRenderFramesInNodeView && !globalStates.renderFrameGhostsInNodeViewEnabled) {
+ this.hideScreenFrame(activeKey);
+ }
+
+ if (typeof activeVehicle.ignoreAllTouches !== 'undefined' && globalDOMCache['object' + activeKey]) {
+ if (activeVehicle.ignoreAllTouches) {
+ if ( !globalDOMCache['object' + activeKey].classList.contains('ignoreAllTouches') ) {
+ globalDOMCache['object' + activeKey].classList.add('ignoreAllTouches');
+ globalDOMCache['iframe' + activeKey].classList.add('ignoreAllTouches');
+ globalDOMCache[activeKey].classList.add('ignoreAllTouches');
+ }
+ } else {
+ if ( globalDOMCache['object' + activeKey].classList.contains('ignoreAllTouches') ) {
+ globalDOMCache['object' + activeKey].classList.remove('ignoreAllTouches');
+ globalDOMCache['iframe' + activeKey].classList.remove('ignoreAllTouches');
+ globalDOMCache[activeKey].classList.remove('ignoreAllTouches');
+ }
+ }
+ }
+};
+
+realityEditor.gui.ar.draw.debugDrawVehicle = function(activeVehicle, finalMatrix) {
+ let bbox = realityEditor.gui.ar.positioning.getVehicleBoundingBoxFast(finalMatrix, parseInt(activeVehicle.frameSizeX)/2, parseInt(activeVehicle.frameSizeY)/2);
+ let thisColor = 'rgba(0,255,255,0.3)';
+ // 72 is a magic number that seems to work so that this had a pseudo-3d radius of frameSizeX/2
+ let thisSize = (parseInt(activeVehicle.frameSizeX)/2) / realityEditor.sceneGraph.getDistanceToCamera(activeVehicle.uuid) * 72;
+ this.globalCanvas.context.beginPath();
+ this.globalCanvas.context.fillStyle = thisColor;
+ this.globalCanvas.context.arc(bbox.center.x, bbox.center.y, thisSize, 0, Math.PI * 2);
+ this.globalCanvas.context.fill();
+ this.globalCanvas.hasContent = true;
+};
+
+/**
+ * Temporarily disabled function that will snap the frame to the target plane
+ * (by removing its rotation components) if the amount of rotation is very small
+ * @todo: only do this if it is also close to the target plane in the Z direction
+ * @param {Frame|Node} activeVehicle
+ * @param {string} activeKey
+ */
+realityEditor.gui.ar.draw.snapFrameMatrixIfNecessary = function(activeVehicle, activeKey) {
+ var positionData = realityEditor.gui.ar.positioning.getPositionData(activeVehicle);
+
+ // start with the frame's matrix
+ var snappedMatrix = this.ar.utilities.copyMatrix(positionData.matrix);
+
+ // calculate its rotation in Euler Angles about the X and Y axis, using a bunch of quaternion math in the background
+ var xRotation = this.ar.utilities.getRotationAboutAxisX(snappedMatrix);
+ var yRotation = this.ar.utilities.getRotationAboutAxisY(snappedMatrix);
+ var snapX = false;
+ var snapY = false;
+
+ // see if the xRotation is close enough to neutral
+ if (0.5 - Math.abs( Math.abs(xRotation) / Math.PI - 0.5) < 0.05) {
+ // globalDOMCache["iframe" + activeKey].classList.add('snapX');
+ snapX = true;
+ } else {
+ // globalDOMCache["iframe" + activeKey].classList.remove('snapX');
+ }
+
+ // see if the yRotation is close enough to neutral
+ if (0.5 - Math.abs( Math.abs(yRotation) / Math.PI - 0.5) < 0.05) {
+ // globalDOMCache["iframe" + activeKey].classList.add('snapY');
+ snapY = true;
+ } else {
+ // globalDOMCache["iframe" + activeKey].classList.remove('snapY');
+ }
+
+ /**
+ * Removes all rotation components from a modelView matrix
+ * Given a modelview matrix, computes its rotation as a quaternion, find the inverse, and multiplies the original
+ * matrix by that inverse rotation to remove its rotation
+ * @param {Array.} mat
+ * @return {Array}
+ */
+ function computeSnappedMatrix(mat) {
+ var res = [];
+ var rotationQuaternion = realityEditor.gui.ar.utilities.getQuaternionFromMatrix(mat);
+ var inverseRotationQuaternion = realityEditor.gui.ar.utilities.invertQuaternion(rotationQuaternion);
+ var inverseRotationMatrix = realityEditor.gui.ar.utilities.getMatrixFromQuaternion(inverseRotationQuaternion);
+ realityEditor.gui.ar.utilities.multiplyMatrix(snappedMatrix, inverseRotationMatrix, res);
+ return res;
+ }
+
+
+ globalDOMCache["iframe" + activeKey].classList.remove('snappableFrame');
+
+ if ( !realityEditor.device.isEditingUnconstrained(activeVehicle) && snapX && snapY) {
+ // actually update the frame's matrix if meets the conditions
+ snappedMatrix = computeSnappedMatrix(this.ar.utilities.copyMatrix(positionData.matrix));
+ realityEditor.gui.ar.positioning.setPositionDataMatrix(activeVehicle, snappedMatrix);
+ } else if (snapX && snapY) {
+ // otherwise if it is close but you are still moving it, show some visual feedback to warn you it will snap
+ globalDOMCache["iframe" + activeKey].classList.add('snappableFrame');
+ }
+};
+
+/**
+ * Updates the visibility / touch events of a sticky fullscreen frame differently than other frames,
+ * because they can't rely on events to trigger them becoming visible or invisible, need to check state each frame
+ * @param {string} activeKey
+ * @param {boolean} _isFullscreen (unused)
+ */
+realityEditor.gui.ar.draw.updateStickyFrameCss = function(activeKey, _isFullScreen) {
+ // sticky frames need a special process to show and hide depending on guiState....
+ if (globalStates.guiState === 'node' &&
+ (globalDOMCache['object' + activeKey].classList.contains('visibleFrameContainer') ||
+ globalDOMCache['iframe' + activeKey].classList.contains('visibleFrame') ||
+ globalDOMCache[activeKey].classList.contains('usePointerEvents'))) {
+
+ globalDOMCache['object' + activeKey].classList.remove('visibleFrameContainer');
+ globalDOMCache['object' + activeKey].classList.add('hiddenFrameContainer');
+
+ // if (!isFullScreen) {
+ globalDOMCache['iframe' + activeKey].classList.remove('visibleFrame');
+ globalDOMCache['iframe' + activeKey].classList.add('hiddenFrame');
+ // }
+
+ globalDOMCache[activeKey].classList.remove('usePointerEvents');
+ globalDOMCache[activeKey].classList.add('ignorePointerEvents');
+
+ } else if (globalStates.guiState === 'ui' &&
+ (globalDOMCache['object' + activeKey].classList.contains('hiddenFrameContainer') ||
+ globalDOMCache['object' + activeKey].classList.contains('outsideOfViewport') ||
+ globalDOMCache['iframe' + activeKey].classList.contains('hiddenFrame') ||
+ globalDOMCache[activeKey].classList.contains('ignorePointerEvents'))) {
+ globalDOMCache['object' + activeKey].classList.remove('outsideOfViewport');
+
+ globalDOMCache['object' + activeKey].classList.add('visibleFrameContainer');
+ globalDOMCache['object' + activeKey].classList.remove('hiddenFrameContainer');
+
+ globalDOMCache['iframe' + activeKey].classList.add('visibleFrame');
+ globalDOMCache['iframe' + activeKey].classList.remove('hiddenFrame');
+
+ globalDOMCache[activeKey].classList.add('usePointerEvents');
+ globalDOMCache[activeKey].classList.remove('ignorePointerEvents');
+
+ }
+};
+
+// Valentin: Speeding up the calls by placing the variables outside of the scope into an object. As such Javascript does not need to handle memory for it.
+
+realityEditor.gui.ar.draw.getMatrixValues = {
+ utils: realityEditor.gui.ar.utilities,
+ r1: [],
+ r2: [],
+ r3: [],
+ finalMatrix: [],
+ rotateX : rotateX,
+ scale : []
+};
+
+/**
+ * Ensures that a frame gets display:none applied to it when it is pushed into the screen.
+ * @param {string} activeKey
+ */
+realityEditor.gui.ar.draw.hideScreenFrame = function(activeKey) {
+ if (globalDOMCache["object" + activeKey]) {
+ globalDOMCache["object" + activeKey].classList.add('displayNone');
+ }
+};
+
+/**
+ * Triggered when a frame gets pulled into AR.
+ * Removes the display:none applied to a frame by the corresponding hideScreenFrame call.
+ * @param {string} activeKey
+ */
+realityEditor.gui.ar.draw.showARFrame = function(activeKey) {
+ if (globalDOMCache["object" + activeKey]) {
+ globalDOMCache["object" + activeKey].classList.remove('displayNone');
+ }
+};
+
+/**
+ * A one-time action that sets up the frame or node added from the pocket in the correct place and begins editing it
+ * @param {PocketContainer} pocketContainer - either pocketFrame or pocketNode
+ */
+realityEditor.gui.ar.draw.addPocketVehicle = function(pocketContainer) {
+
+ // drop frames in from pocket, floating in front of screen in unconstrained mode, aligned with the touch position
+
+ let activeKey = pocketContainer.vehicle.uuid;
+ var activeFrameKey = pocketContainer.vehicle.frameId || pocketContainer.vehicle.uuid;
+ var activeNodeKey = pocketContainer.vehicle.uuid === activeFrameKey ? null : pocketContainer.vehicle.uuid;
+
+ let spatialCursorMatrix = realityEditor.spatialCursor.getOrientedCursorRelativeToWorldObject();
+ if (spatialCursorMatrix) {
+ this.addPocketVehicleAtCursorPosition(pocketContainer);
+ return;
+ }
+
+ let distanceInFrontOfCamera = 400 * realityEditor.device.environment.variables.newFrameDistanceMultiplier;
+ realityEditor.gui.ar.positioning.moveFrameToCamera(pocketContainer.vehicle.objectId, activeKey, distanceInFrontOfCamera);
+
+ // TODO: automatically recognize when CSS matrix is out of date, so that we don't need to manually recalculate here
+ realityEditor.sceneGraph.calculateFinalMatrices([pocketContainer.vehicle.objectId]);
+
+ // only start editing (and animate) it if you didn't do a quick tap that already released by the time it loads
+ if (pocketContainer.type !== 'ui' || realityEditor.device.currentScreenTouches.map(function(elt){return elt.targetId;}).indexOf("pocket-element") > -1) {
+
+ // immediately start placing the pocket frame in unconstrained mode
+ realityEditor.device.editingState.unconstrained = true;
+
+ // Several steps to translate it exactly to be centered on the touch when it gets added
+ // 1. calculate where the center of the frame would naturally end up on the screen, given the moveFrameToCamera matrix
+ let defaultScreenCenter = realityEditor.sceneGraph.getScreenPosition(activeKey);
+ //realityEditor.gui.ar.positioning.getScreenPosition(pocketContainer.vehicle.objectId, activeFrameKey, true, false, false, false, false).center;
+ let touchPosition = realityEditor.gui.ar.positioning.getMostRecentTouchPosition();
+ // 2. calculate the correct touch offset as if you placed it at the default position (doesn't actually set x and y)
+ realityEditor.gui.ar.positioning.moveVehicleToScreenCoordinate(pocketContainer.vehicle, defaultScreenCenter.x, defaultScreenCenter.y, true);
+ // 3. actually move it to the touch position (sets x and y), now that it knows the relative offset from the default
+ realityEditor.gui.ar.positioning.moveVehicleToScreenCoordinate(pocketContainer.vehicle, touchPosition.x, touchPosition.y, true);
+ // 4. add a flag so that we can finalize its position and begin dragging the next time drawTransformed is called
+ pocketContainer.vehicle.isPendingInitialPlacement = {
+ objectKey: pocketContainer.vehicle.objectId,
+ frameKey: activeFrameKey,
+ nodeKey: activeNodeKey
+ };
+ // animate it as flowing out of the pocket
+ this.startPocketDropAnimation(200, 0, 0, distanceInFrontOfCamera/3);
+ }
+
+ // clear some flags so it gets rendered after this occurs
+ pocketContainer.positionOnLoad = null;
+ pocketContainer.waitingToRender = false;
+
+ realityEditor.network.postVehiclePosition(pocketContainer.vehicle);
+
+ // realityEditor.gui.ar.positioning.setPositionDataMatrix(activeVehicle, snappedMatrix);
+
+ // setTimeout(function() {
+ // var keys = realityEditor.getKeysFromVehicle(pocketContainer.vehicle);
+ // var propertyPath = pocketContainer.vehicle.hasOwnProperty('visualization') ? 'ar.matrix' : 'matrix';
+ // realityEditor.network.realtime.broadcastUpdate(keys.objectKey, keys.frameKey, keys.nodeKey, propertyPath, newMatrixValue);
+ // }, 500);
+};
+
+realityEditor.gui.ar.draw.addPocketVehicleAtCursorPosition = function(pocketContainer) {
+ // clear some flags so it gets rendered after this occurs
+ pocketContainer.positionOnLoad = null;
+ pocketContainer.waitingToRender = false;
+
+ realityEditor.device.resetEditingState();
+
+ realityEditor.network.postVehiclePosition(pocketContainer.vehicle);
+}
+
+/**
+ * Run an animation on the frame being dropped in from the pocket, performing a smooth tweening of its last matrix element
+ * The frame scales down (moves away from camera) the bigger that 15th element is
+ * @param {number} timeInMilliseconds - how long the animation takes (default 250ms)
+ * @param {number} startX - the frame starts out with this X translation and returns to its regular X
+ * @param {number} startY - the frame starts out with this Y translation and returns to its regular Y
+ * @param {number} startZ - the frame starts out with this Z translation and returns to its regular Z
+ */
+realityEditor.gui.ar.draw.startPocketDropAnimation = function(timeInMilliseconds, startX, startY, startZ) {
+ var duration = timeInMilliseconds || 250;
+ if (!startX && !startY && !startZ) { return; } // if motion unspecified or all are zero, skip animation
+
+ // reset this so that the initial distance to screens gets calculated when the pocketAnimation ends
+ // (or else it automatically gets pushed in by its own animation)
+ if (globalStates.initialDistance) {
+ globalStates.initialDistance = null;
+ }
+
+ var position = {x: startX, y: startY, z: startZ};
+ pocketDropAnimation = new TWEEN.Tween(position)
+ .to({x: 0, y: 0, z: 0}, duration)
+ .easing(TWEEN.Easing.Quadratic.Out)
+ .onUpdate(function() {
+ editingAnimationsMatrix[12] = position.x;
+ editingAnimationsMatrix[13] = position.y;
+ editingAnimationsMatrix[14] = position.z;
+ }).onComplete(function() {
+ editingAnimationsMatrix[12] = 0;
+ editingAnimationsMatrix[13] = 0;
+ editingAnimationsMatrix[14] = 0;
+ realityEditor.gui.ar.positioning.stopRepositioning(); // trigger drag matrix to be recomputed
+ pocketDropAnimation = null;
+ }).onStop(function() {
+ editingAnimationsMatrix[12] = 0;
+ editingAnimationsMatrix[13] = 0;
+ editingAnimationsMatrix[14] = 0;
+ realityEditor.gui.ar.positioning.stopRepositioning();
+ pocketDropAnimation = null;
+ })
+ .start();
+};
+
+/**
+ * Hides the DOM elements for a specified frame or node if they still exist and are visible
+ * @param {string} activeKey
+ * @param {Frame|Node} activeVehicle
+ * @param {Object} globalDOMCache
+ * @param {function} cout
+ */
+realityEditor.gui.ar.draw.hideTransformed = function (activeKey, activeVehicle, globalDOMCache, cout) {
+
+ var doesDOMElementExist = !!globalDOMCache['object' + activeKey];
+ if (!doesDOMElementExist && activeVehicle.visible === true) {
+ activeVehicle.visible = false;
+ console.warn('trying to hide a frame that doesn\'t exist');
+ return;
+ }
+
+ if (activeVehicle.hasOwnProperty('fullScreen')) {
+ if (activeVehicle.fullScreen === 'sticky') {
+ return;
+ }
+ }
+
+ var isVisible = activeVehicle.visible === true;
+
+ // TODO: this makes frames disappear when object becomes invisible, but it's making the visibility message keep posting into the frame, which in response makes the node socket keep sending on loop while in the node view...
+ /*
+ if (!isVisible) {
+ var isPartiallyHiddenFrame = (activeVehicle.type === 'ui' || typeof activeVehicle.type === 'undefined') &&
+ !globalDOMCache['object' + activeKey].classList.contains('displayNone');
+ if (isPartiallyHiddenFrame) {
+ isVisible = true;
+ }
+ }
+ */
+
+ if (isVisible) {
+
+ if (activeVehicle.type === 'ui' || typeof activeVehicle.type === 'undefined') {
+ globalDOMCache['object' + activeKey].classList.remove('visibleFrameContainer');
+ globalDOMCache['object' + activeKey].classList.add('hiddenFrameContainer');
+
+ let shouldReallyHide = !this.visibleObjects.hasOwnProperty(activeVehicle.objectId) || activeVehicle.visualization === 'screen' || !this.visibleObjects[activeVehicle.objectId][0];
+ if (shouldReallyHide) {
+ globalDOMCache['object' + activeKey].classList.add('displayNone');
+ }
+
+ globalDOMCache["iframe" + activeKey].contentWindow.postMessage(
+ JSON.stringify(
+ {
+ visibility: "hidden"
+ }), '*');
+
+ } else {
+ globalDOMCache['object' + activeKey].classList.remove('visibleNodeContainer');
+ globalDOMCache['object' + activeKey].classList.add('hiddenNodeContainer');
+
+ }
+
+ // if (!activeVehicle.fullScreen) {
+ globalDOMCache['iframe' + activeKey].classList.remove('visibleFrame');
+ globalDOMCache['iframe' + activeKey].classList.add('hiddenFrame');
+ // }
+
+ // TODO: does this need to happen here?
+ // globalDOMCache["iframe" + activeKey].contentWindow.postMessage(
+ // JSON.stringify(
+ // {
+ // visibility: "hidden"
+ // }), '*');
+
+ activeVehicle.visible = false;
+ activeVehicle.visibleEditing = false;
+
+ globalDOMCache[activeKey].style.visibility = 'hidden';
+ // globalDOMCache["svg" + activeKey].style.display = 'none';
+ globalDOMCache["svg" + activeKey].classList.remove('visibleEditingSVG');
+
+ globalDOMCache[activeKey].querySelector('.corners').style.visibility = 'hidden';
+
+ // reset the active screen object when it disappears
+ if (realityEditor.gui.screenExtension.visibleScreenObjects[activeKey]) {
+ delete realityEditor.gui.screenExtension.visibleScreenObjects[activeKey];
+ }
+
+ cout("hideTransformed");
+
+ } else {
+ // for frames in node view that are technically "hidden" but still show opacity ghost...
+ // hide completely when their object stops being recognized
+
+ if (!globalDOMCache['object' + activeKey]) {
+ return;
+ }
+ if (!(activeVehicle.type === 'ui' || typeof activeVehicle.type === 'undefined')) {
+ return;
+ }
+
+ if (!globalDOMCache['object' + activeKey].classList.contains('displayNone')) {
+ let shouldReallyHide = !this.visibleObjects.hasOwnProperty(activeVehicle.objectId) || activeVehicle.visualization === 'screen' || !this.visibleObjects[activeVehicle.objectId][0];
+ if (shouldReallyHide) {
+ globalDOMCache['object' + activeKey].classList.add('displayNone');
+ }
+ }
+
+ }
+};
+
+/**
+ * If needed, creates the DOM element for a given frame or node
+ * Can be safely called multiple times for the same element (knows to ignore if its already been loaded)
+ * @param {string} thisUrl - the iframe src url
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {string} activeType - 'ui', 'node', 'logic', etc, to tag the element with
+ * @param {Frame|Node} activeVehicle - reference to the frame or node to create.
+ * it's properties are used to instantiate the correct DOM element.
+ */
+realityEditor.gui.ar.draw.addElement = function(thisUrl, objectKey, frameKey, nodeKey, activeType, activeVehicle) {
+
+ var activeKey = nodeKey ? nodeKey : frameKey;
+ var isFrameElement = activeKey === frameKey;
+
+ if (this.notLoading !== true && this.notLoading !== activeKey && activeVehicle.loaded !== true) {
+ this.notLoading = activeKey;
+
+ // assign the element some default properties if they don't exist
+ if (typeof activeVehicle.frameSizeX === 'undefined') {
+ activeVehicle.frameSizeX = activeVehicle.width || 220;
+ }
+ if (typeof activeVehicle.width === 'undefined') {
+ activeVehicle.width = activeVehicle.frameSizeX;
+ }
+ if (typeof activeVehicle.frameSizeY === 'undefined') {
+ activeVehicle.frameSizeY = activeVehicle.height || 220;
+ }
+ if (typeof activeVehicle.height === 'undefined') {
+ activeVehicle.height = activeVehicle.frameSizeY;
+ }
+ if (typeof activeVehicle.begin !== "object") {
+ activeVehicle.begin = realityEditor.gui.ar.utilities.newIdentityMatrix();
+ }
+ if (typeof activeVehicle.temp !== "object") {
+ activeVehicle.temp = realityEditor.gui.ar.utilities.newIdentityMatrix();
+ }
+ activeVehicle.animationScale = 0;
+ activeVehicle.loaded = true;
+ activeVehicle.visibleEditing = false;
+
+ // determine if the frame should be loaded locally or from the server (by default thisUrl points to server)
+ if (isFrameElement && activeVehicle.location === 'global') {
+ // loads frames from server of the object it is being added to
+ thisUrl = realityEditor.network.availableFrames.getFrameSrc(objectKey, activeVehicle.src);
+ }
+
+ // Create DOM elements for everything associated with this frame/node
+ var domElements = this.createSubElements(thisUrl, objectKey, frameKey, nodeKey, activeVehicle);
+ var addContainer = domElements.addContainer;
+ var addIframe = domElements.addIframe;
+ var addOverlay = domElements.addOverlay;
+ var addSVG = domElements.addSVG;
+
+ addOverlay.objectId = objectKey;
+ addOverlay.frameId = frameKey;
+ addOverlay.nodeId = nodeKey;
+ addOverlay.type = activeType;
+
+ // todo the event handlers need to be bound to non animated ui elements for fast movements.
+ // todo the lines need to end at the center of the square.
+
+ if (activeType === "logic") {
+
+ // add the 4-quadrant animated SVG overlay for the logic nodes
+ var addLogic = this.createLogicElement(activeVehicle, activeKey);
+ addOverlay.appendChild(addLogic);
+ globalDOMCache["logic" + activeKey] = addLogic;
+ }
+
+ // TODO: try adding to var documentFragment = document.createDocumentFragment(); while constructing, for performance
+
+ // append all the created elements to the DOM in the correct order...
+ document.getElementById("GUI").appendChild(addContainer);
+ addContainer.appendChild(addIframe);
+ addContainer.appendChild(addOverlay);
+ addOverlay.appendChild(addSVG);
+
+ // cache references to these elements to more efficiently retrieve them in the future
+ globalDOMCache[addContainer.id] = addContainer;
+ globalDOMCache[addIframe.id] = addIframe;
+ globalDOMCache[addOverlay.id] = addOverlay;
+ globalDOMCache[addSVG.id] = addSVG;
+
+ // wrapping div in corners can only be done after it has been added
+ // the width and height don't matter as much here because it will get recalculated when frame contents load
+ var padding = 24;
+ realityEditor.gui.moveabilityCorners.wrapDivWithCorners(addOverlay, padding, false, {width: activeVehicle.width + padding*2 + 'px', height: activeVehicle.height + padding*2 + 'px', visibility: 'hidden'}, null, 4, 30);
+
+ // add touch event listeners
+ realityEditor.device.addTouchListenersForElement(addOverlay, activeVehicle);
+ }
+};
+
+/**
+ * Instantiates the many different DOM elements that make up a frame or node.
+ * addContainer - holds all the different pieces of this element
+ * addIframe - loads in the content for this frame, e.g. a graph or three.js scene, or a node graphic
+ * addOverlay - an invisible overlay that catches touch events and passes into the iframe if needed
+ * addSVG - a visual feedback image that displays when you are dragging the element around
+ * @param {string} iframeSrc
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {Frame|Node} activeVehicle
+ * @return {{addContainer: HTMLDivElement, addIframe: HTMLIFrameElement, addOverlay: HTMLDivElement, addSVG: HTMLElement}}
+ */
+realityEditor.gui.ar.draw.createSubElements = function(iframeSrc, objectKey, frameKey, nodeKey, activeVehicle) {
+
+ var activeKey = nodeKey ? nodeKey : frameKey;
+
+ var addContainer = document.createElement('div');
+ addContainer.id = "object" + activeKey;
+ addContainer.classList.add("main");
+ addContainer.style.width = globalStates.height + "px";
+ addContainer.style.height = globalStates.width + "px";
+ if (nodeKey) {
+ addContainer.classList.add('hiddenNodeContainer');
+ } else {
+ addContainer.classList.add('hiddenFrameContainer');
+ }
+ addContainer.style.border = 0;
+ addContainer.classList.add('ignorePointerEvents'); // don't let invisible background from container intercept touches
+
+ var addIframe = document.createElement('iframe');
+ addIframe.id = "iframe" + activeKey;
+ addIframe.classList.add("main");
+ addIframe.frameBorder = 0;
+ addIframe.style.width = (activeVehicle.width || activeVehicle.frameSizeX) + "px";
+ addIframe.style.height = (activeVehicle.height || activeVehicle.frameSizeY) + "px";
+ addIframe.style.left = ((globalStates.height - activeVehicle.frameSizeX) / 2) + "px";
+ addIframe.style.top = ((globalStates.width - activeVehicle.frameSizeY) / 2) + "px";
+ addIframe.classList.add('hiddenFrame');
+ addIframe.src = iframeSrc;
+ addIframe.setAttribute("data-frame-key", frameKey);
+ addIframe.setAttribute("data-object-key", objectKey);
+ addIframe.setAttribute("data-node-key", nodeKey);
+ addIframe.setAttribute("onload", 'realityEditor.network.onElementLoad("' + objectKey + '","' + frameKey + '","' + nodeKey + '")');
+ // TODO: remove this 'sandbox' attribute if you try to embed iframes within the tool's iframe and you run into browser restrictions
+ let allowPopups = realityEditor.device.environment.isWithinToolboxApp() ? '' : 'allow-popups';
+ addIframe.setAttribute("sandbox", `allow-forms allow-pointer-lock allow-same-origin allow-scripts ${allowPopups}`);
+ addIframe.classList.add('usePointerEvents'); // override parent (addContainer) pointerEvents value
+
+ // TODO: try to load elements with an XHR request so they don't block the rendering loop
+
+ var addOverlay = document.createElement('div');
+ addOverlay.id = activeKey;
+ addOverlay.classList.add((globalStates.editingMode && activeVehicle.developer) ? "mainEditing" : "mainProgram");
+ addOverlay.frameBorder = 0;
+ addOverlay.style.width = activeVehicle.frameSizeX + "px";
+ addOverlay.style.height = activeVehicle.frameSizeY + "px";
+ addOverlay.style.left = ((globalStates.height - activeVehicle.frameSizeX) / 2) + "px";
+ addOverlay.style.top = ((globalStates.width - activeVehicle.frameSizeY) / 2) + "px";
+ addOverlay.style.visibility = "hidden";
+ addOverlay.style.zIndex = "3";
+ if (activeVehicle.developer) {
+ addOverlay.style["touch-action"] = "none";
+ }
+ addOverlay.classList.add('usePointerEvents'); // override parent (addContainer) pointerEvents value
+
+ var addSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ addSVG.id = "svg" + activeKey;
+ addSVG.classList.add("mainCanvas");
+ addSVG.style.width = "100%";
+ addSVG.style.height = "100%";
+ addSVG.style.zIndex = "3";
+ // addSVG.style.display = 'none';
+ addSVG.classList.add('svgDefaultState');
+ addSVG.classList.add('usePointerEvents'); // override parent (addContainer) pointerEvents value
+ addSVG.setAttribute('shape-rendering','geometricPrecision'); //'optimizeSpeed'
+
+ return {
+ addContainer: addContainer,
+ addIframe: addIframe,
+ addOverlay: addOverlay,
+ addSVG: addSVG
+ };
+};
+
+/**
+ * Gets the correct iconImage url for the logic node and posts it into the logic node iframe to be displayed.
+ * its iconImage property is either 'auto', 'custom', or 'none'
+ * @param {Logic} activeVehicle
+ */
+realityEditor.gui.ar.draw.updateLogicNodeIcon = function(activeVehicle) {
+ // add the icon image for the logic nodes
+ var logicIconSrc = realityEditor.gui.crafting.getLogicNodeIcon(activeVehicle);
+ var nodeDom = globalDOMCache["iframe" + activeVehicle.uuid];
+ if (nodeDom) {
+ nodeDom.contentWindow.postMessage( JSON.stringify({ iconImage: logicIconSrc }) , "*");
+ }
+};
+
+/**
+ * Creates the DOM element for a Logic Node
+ * @param {Frame|Node} activeVehicle
+ * @param {string} activeKey
+ * @return {HTMLDivElement}
+ */
+realityEditor.gui.ar.draw.createLogicElement = function(activeVehicle, activeKey) {
+ var size = 200;
+ var addLogic = document.createElement('div');
+ addLogic.id = "logic" + activeKey;
+ addLogic.className = "mainEditing";
+ addLogic.style.width = size + "px";
+ addLogic.style.height = size + "px";
+ addLogic.style.left = 0; //((activeVehicle.frameSizeX - size) / 2) + "px";
+ addLogic.style.top = 0; //((activeVehicle.frameSizeY - size) / 2) + "px";
+ addLogic.style.visibility = "hidden";
+
+ var svgContainer = document.createElementNS('http://www.w3.org/2000/svg', "svg");
+ svgContainer.setAttributeNS(null, "viewBox", "0 0 100 100");
+
+ var svgElement = [];
+ svgElement.push(document.createElementNS("http://www.w3.org/2000/svg", "path"));
+ svgElement[0].setAttributeNS(null, "fill", "#00ffff");
+ svgElement[0].setAttributeNS(null, "d", "M50,0V50H0V30A30,30,0,0,1,30,0Z");
+ svgElement.push(document.createElementNS("http://www.w3.org/2000/svg", "path"));
+ svgElement[1].setAttributeNS(null, "fill", "#00ff00");
+ svgElement[1].setAttributeNS(null, "d", "M100,30V50H50V0H70A30,30,0,0,1,100,30Z");
+ svgElement.push(document.createElementNS("http://www.w3.org/2000/svg", "path"));
+ svgElement[2].setAttributeNS(null, "fill", "#ffff00");
+ svgElement[2].setAttributeNS(null, "d", "M100,50V70a30,30,0,0,1-30,30H50V50Z");
+ svgElement.push(document.createElementNS("http://www.w3.org/2000/svg", "path"));
+ svgElement[3].setAttributeNS(null, "fill", "#ff007c");
+ svgElement[3].setAttributeNS(null, "d", "M50,50v50H30A30,30,0,0,1,0,70V50Z");
+
+ for (var i = 0; i < svgElement.length; i++) {
+ svgContainer.appendChild(svgElement[i]);
+ svgElement[i].number = i;
+ svgElement[i].addEventListener('pointerenter', function () {
+ globalProgram.logicSelector = this.number;
+
+ if (globalProgram.nodeA === activeKey) {
+ globalProgram.logicA = this.number;
+ } else {
+ globalProgram.logicB = this.number;
+ }
+ });
+ addLogic.appendChild(svgContainer);
+ }
+
+ return addLogic;
+};
+
+/**
+ * Helper function checks if the specified object contains any frame with sticky fullscreen property.
+ * @param {string} objectKey
+ * @return {boolean}
+ */
+realityEditor.gui.ar.draw.doesObjectContainStickyFrame = function(objectKey) {
+ var object = realityEditor.getObject(objectKey);
+ return Object.keys(object.frames).map(function(frameKey) {
+ return realityEditor.getFrame(objectKey, frameKey).fullScreen;
+ }).some(function(fullScreen) {
+ return fullScreen === 'sticky';
+ });
+};
+
+realityEditor.gui.ar.draw.doesAnythingUseGroundPlane = function() { // TODO: narrow down to visibleObjects?
+ var isAnyFrameSubscribedToGroundPlane = false;
+ realityEditor.forEachFrameInAllObjects(function(objectKey, frameKey) {
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ if (typeof frame.sendMatrices !== 'undefined') {
+ if (frame.sendMatrices.groundPlane || frame.sendMatrices.anchoredModelView) {
+ isAnyFrameSubscribedToGroundPlane = true;
+ }
+ }
+ if (frame.attachToGroundPlane) { // future-proofing in case we use attachToGroundPlane on frames in the future
+ isAnyFrameSubscribedToGroundPlane = true;
+ }
+ for (let nodeKey in frame.nodes) {
+ let node = frame.nodes[nodeKey];
+ if (node.attachToGroundPlane) {
+ isAnyFrameSubscribedToGroundPlane = true;
+ }
+ }
+ });
+ return isAnyFrameSubscribedToGroundPlane;
+};
+
+/**
+ * Helper function to iterate over all frames on currently visible objects
+ * @param {function} callback
+ */
+realityEditor.gui.ar.draw.forEachVisibleFrame = function(callback) {
+ realityEditor.forEachFrameInAllObjects( function(objectKey, frameKey) {
+ if (realityEditor.gui.ar.draw.visibleObjects.hasOwnProperty(objectKey)) { // only do this for visible objects (and the world object, of course)
+ callback(objectKey, frameKey); // populates allDistanceUIs with new distanceUIs if they don't exist yet
+ }
+ });
+};
+
+/**
+ * Returns a list of IDs for all frames that are currently fullscreen and require exclusive control of the screen
+ * @return {Array.<{objectKey: string, frameKey: string}>}
+ */
+realityEditor.gui.ar.draw.getAllVisibleExclusiveFrames = function() {
+ var exclusiveFrameKeys = [];
+ realityEditor.gui.ar.draw.forEachVisibleFrame(function(objectKey, frameKey) {
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ if (frame.fullScreen && frame.isFullScreenExclusive) {
+ exclusiveFrameKeys.push({
+ objectKey: objectKey,
+ frameKey: frameKey
+ });
+ }
+ });
+ return exclusiveFrameKeys;
+};
+
+/**
+ * Makes sure that there are no other exclusive fullscreen frames other than the specified one.
+ * (Turns off fullscreen mode for all the others)
+ * @param {string} objectKey
+ * @param {string} frameKey
+ */
+realityEditor.gui.ar.draw.ensureOnlyCurrentFullscreen = function(objectKey, frameKey) {
+ var exclusiveFrameKeys = this.getAllVisibleExclusiveFrames();
+ if (exclusiveFrameKeys.length > 1) {
+ exclusiveFrameKeys.forEach(function(keys) {
+ if (keys.frameKey !== frameKey) {
+ realityEditor.gui.ar.draw.removeFullscreenFromFrame(keys.objectKey, keys.frameKey);
+ // post a message into the ejected frame so that it can update its interface if necessary
+ realityEditor.gui.ar.draw.callbackHandler.triggerCallbacks('fullScreenEjected', {objectKey: keys.objectKey, frameKey: keys.frameKey});
+ }
+ });
+ }
+};
+
+/**
+ * Helper function called by frame API and elsewhere to stop rendering a frame as fullscreen
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {boolean|undefined} isAnimated - true for envelopes, add a minimizing animation and fade in the iframe
+ */
+realityEditor.gui.ar.draw.removeFullscreenFromFrame = function(objectKey, frameKey, isAnimated) {
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+
+ frame.fullScreen = false;
+ if (frame.uuid) {
+ globalDOMCache[frame.uuid].style.opacity = '1'; // svg overlay still exists so we can reposition, but invisible
+ }
+
+ // reset left/top offset when returns to non-fullscreen
+ if (globalDOMCache['iframe' + frame.uuid].dataset.leftBeforeFullscreen) {
+ globalDOMCache['iframe' + frame.uuid].style.left = globalDOMCache['iframe' + frame.uuid].dataset.leftBeforeFullscreen;
+ }
+ if (globalDOMCache['iframe' + frame.uuid].dataset.topBeforeFullscreen) {
+ globalDOMCache['iframe' + frame.uuid].style.top = globalDOMCache['iframe' + frame.uuid].dataset.topBeforeFullscreen;
+ }
+
+ if (globalDOMCache[frame.uuid].dataset.leftBeforeFullscreen) {
+ globalDOMCache[frame.uuid].style.left = globalDOMCache[frame.uuid].dataset.leftBeforeFullscreen;
+ }
+ if (globalDOMCache[frame.uuid].dataset.topBeforeFullscreen) {
+ globalDOMCache[frame.uuid].style.top = globalDOMCache[frame.uuid].dataset.topBeforeFullscreen;
+ }
+
+ globalDOMCache['iframe' + frame.uuid].classList.remove('webGlFrame');
+ globalDOMCache[frame.uuid].classList.remove('deactivatedIframeOverlay');
+
+ globalDOMCache['object' + frame.uuid].style.zIndex = '';
+
+ var containingObject = realityEditor.getObject(objectKey);
+ if (!containingObject.objectVisible) {
+ containingObject.objectVisible = true;
+ }
+
+ if (isAnimated) {
+ // subtly fade in the iframe instead of instantly pops up in new place
+ globalDOMCache['iframe' + frame.uuid].style.opacity = 0;
+ globalDOMCache['iframe' + frame.uuid].classList.add('envelopeFadingIn');
+ setTimeout(function() { // 50ms delay causes the CSS transition property to apply to the new opacity
+ globalDOMCache['iframe' + frame.uuid].style.opacity = 1;
+ setTimeout(function() {
+ globalDOMCache['iframe' + frame.uuid].classList.remove('envelopeFadingIn');
+ }, 1000);
+ }, 50);
+
+ const parentDiv = globalDOMCache['object' + frame.uuid];
+ let tempAnimDiv = document.createElement('div');
+ tempAnimDiv.classList.add('temp-anim-div');
+ // To obtain this hard-coded matrix3d(), I added a tool, closed it to reveal the icon, and moved the camera towards the tool,
+ // so that it almost fills up the screen in the center. And then I get the matrix3d of the object that the tool is attached to.
+ // Very hacky, hope to make it procedural in the future
+ tempAnimDiv.style.transform = 'matrix3d(643.374, -0.373505, 0.000212662, 0.000212647, 0.372554, 643.38, 0.000554764, 0.000554727, -2.77404, 4.28636, 0.500033, 0.5, -1406.67, 2173.54, 34481.6, 253.541)';
+ tempAnimDiv.style.top = '0';
+ tempAnimDiv.style.left = '0';
+ tempAnimDiv.style.width = parentDiv.style.width;
+ tempAnimDiv.style.height = parentDiv.style.height;
+ document.getElementById('GUI').appendChild(tempAnimDiv);
+ setTimeout(() => {
+ tempAnimDiv.style.transform = globalDOMCache['object' + frame.uuid].style.transform;
+ tempAnimDiv.style.width = globalDOMCache['object' + frame.uuid].childNodes[0].style.width;
+ tempAnimDiv.style.height = globalDOMCache['object' + frame.uuid].childNodes[0].style.height;
+ tempAnimDiv.style.top = globalDOMCache['object' + frame.uuid].childNodes[0].style.top;
+ tempAnimDiv.style.left = globalDOMCache['object' + frame.uuid].childNodes[0].style.left;
+ tempAnimDiv.classList.add('temp-anim-div-anim');
+ setTimeout(() => {
+ tempAnimDiv.parentElement.removeChild(tempAnimDiv);
+ }, 500);
+ }, 50);
+ }
+};
+
+/**
+ * Fully deletes DOM elements and unloads frames and nodes if they have been invisible for 3+ seconds
+ * @param {string} activeKey
+ * @param {string} activeVehicle
+ * @param {Object} globalDOMCache
+ */
+realityEditor.gui.ar.draw.killObjects = function (activeKey, activeVehicle, globalDOMCache) {
+ if(!activeVehicle.visibleCounter) {
+ return;
+ }
+ if (realityEditor.getObject(activeVehicle.objectId)) {
+ if (realityEditor.getObject(activeVehicle.objectId).containsStickyFrame) {
+ // Don't kill object with sticky frame
+ return;
+ }
+ }
+
+ if (activeVehicle.visibleCounter > 1) {
+ activeVehicle.visibleCounter--;
+ } else {
+ activeVehicle.visibleCounter--;
+ for (var activeFrameKey in activeVehicle.frames) {
+ if (!activeVehicle.frames.hasOwnProperty(activeFrameKey)) continue;
+
+ // don't kill inTransitionFrame or its nodes
+ if (activeFrameKey === globalStates.inTransitionFrame) continue;
+
+ try {
+ globalDOMCache["object" + activeFrameKey].parentNode.removeChild(globalDOMCache["object" + activeFrameKey]);
+ delete globalDOMCache["object" + activeFrameKey];
+ delete globalDOMCache["iframe" + activeFrameKey];
+ delete globalDOMCache[activeFrameKey];
+ delete globalDOMCache["svg" + activeFrameKey];
+ activeVehicle.frames[activeFrameKey].loaded = false;
+ } catch (err) {
+ this.cout("could not find any frames")
+ }
+
+
+ for (var activeNodeKey in activeVehicle.frames[activeFrameKey].nodes) {
+ if (!activeVehicle.frames[activeFrameKey].nodes.hasOwnProperty(activeNodeKey)) continue;
+ try {
+ globalDOMCache["object" + activeNodeKey].parentNode.removeChild(globalDOMCache["object" + activeNodeKey]);
+ delete globalDOMCache["object" + activeNodeKey];
+ delete globalDOMCache["iframe" + activeNodeKey];
+ delete globalDOMCache[activeNodeKey];
+ delete globalDOMCache["svg" + activeNodeKey];
+ activeVehicle.frames[activeFrameKey].nodes[activeNodeKey].loaded = false;
+ } catch (err) {
+ this.cout("could not find any nodes");
+ }
+ }
+ }
+ this.cout("killObjects");
+ }
+};
+
+/**
+ * Fully delete the DOM element for a specific frame or node
+ * (to be triggered when that frame or node is dropped on the trash)
+ * @param {string} thisActiveVehicleKey
+ * @param {Frame|Node} thisActiveVehicle
+ */
+realityEditor.gui.ar.draw.killElement = function (thisActiveVehicleKey, thisActiveVehicle) {
+ thisActiveVehicle.loaded = false;
+ if (globalDOMCache["object" + thisActiveVehicleKey]) {
+ globalDOMCache["object" + thisActiveVehicleKey].parentNode.removeChild(globalDOMCache["object" + thisActiveVehicleKey]);
+ }
+ delete globalDOMCache["object" + thisActiveVehicleKey];
+ delete globalDOMCache["iframe" + thisActiveVehicleKey];
+ delete globalDOMCache[thisActiveVehicleKey];
+ delete globalDOMCache["svg" + thisActiveVehicleKey];
+ delete globalDOMCache[thisActiveVehicleKey];
+};
+
+/**
+ * Delete a node from a frame. Remove it from the frame's nodes list, and remove the DOM elements.
+ * @param {string} objectId
+ * @param {string} frameId
+ * @param {string} nodeId
+ */
+realityEditor.gui.ar.draw.deleteNode = function (objectId, frameId, nodeId) {
+ var thisFrame = realityEditor.getFrame(objectId, frameId);
+ if (!thisFrame) return;
+
+ delete thisFrame.nodes[nodeId];
+ if (this.globalDOMCache["object" + nodeId]) {
+ if (this.globalDOMCache["object" + nodeId].parentNode) {
+ this.globalDOMCache["object" + nodeId].parentNode.removeChild(this.globalDOMCache["object" + nodeId]);
+ }
+ delete this.globalDOMCache["object" + nodeId];
+ }
+ delete this.globalDOMCache["iframe" + nodeId];
+ delete this.globalDOMCache[nodeId];
+ delete this.globalDOMCache["svg" + nodeId];
+};
+
+/**
+ * Delete a frame from an object. Remove it from the objects's frames list, and remove the DOM elements.
+ * @param {string} objectId
+ * @param {string} frameId
+ */
+realityEditor.gui.ar.draw.deleteFrame = function (objectId, frameId) {
+
+ realityEditor.forEachNodeInFrame(objectId, frameId, realityEditor.gui.ar.draw.deleteNode.bind(realityEditor.gui.ar.draw));
+
+ delete objects[objectId].frames[frameId];
+ if (this.globalDOMCache["object" + frameId]) {
+ if (this.globalDOMCache["object" + frameId].parentNode) {
+ this.globalDOMCache["object" + frameId].parentNode.removeChild(this.globalDOMCache["object" + frameId]);
+ }
+ delete this.globalDOMCache["object" + frameId];
+ }
+ delete this.globalDOMCache["iframe" + frameId];
+ delete this.globalDOMCache[frameId];
+ delete this.globalDOMCache["svg" + frameId];
+
+};
+
+/**
+ * Sets the objectVisible property of not only the object, but also all of its frames
+ * @param {Object} object - reference to the object whose property you wish to set
+ * @param {boolean} shouldBeVisible - objects that are not visible do not render their interfaces, nodes, links.
+ */
+realityEditor.gui.ar.draw.setObjectVisible = function (object, shouldBeVisible) {
+ if (!object) return;
+ object.objectVisible = shouldBeVisible;
+ for (var frameKey in object.frames) {
+ //if (!object.frames.hasOwnProperty(frameKey)) continue;
+ object.frames[frameKey].objectVisible = shouldBeVisible;
+ }
+};
diff --git a/src/gui/ar/frameHistoryRenderer.js b/src/gui/ar/frameHistoryRenderer.js
new file mode 100644
index 000000000..c3aac9e78
--- /dev/null
+++ b/src/gui/ar/frameHistoryRenderer.js
@@ -0,0 +1,644 @@
+createNameSpace("realityEditor.gui.ar.frameHistoryRenderer");
+
+/**
+ * @fileOverview realityEditor.gui.ar.frameHistoryRenderer.js
+ * Contains the service code to render partially-transparent versions of frames at
+ * their previously-saved git position, if they've been moved since then.
+ */
+
+(function(exports) {
+
+ var linesToDraw = [];
+ var missingLinksToDraw = [];
+
+ var privateState = {
+ visibleObjects: {},
+ ghostsAdded: []
+ };
+
+ var isUpdateListenerRegistered = false;
+
+ /**
+ * Public init method to enable rendering ghosts of edited frames while in editing mode.
+ */
+ function initService() {
+
+ // register callbacks to various buttons to perform commits
+ realityEditor.gui.buttons.registerCallbackForButton('reset', function(params) {
+ if (params.newButtonState === 'up') {
+ for (var objectKey in objects) {
+ if (!objects.hasOwnProperty(objectKey)) continue;
+ // only reset currently visible objects to their last commit, not everything
+ if (!realityEditor.gui.ar.draw.visibleObjects.hasOwnProperty(objectKey)) continue;
+
+ realityEditor.network.sendResetToLastCommit(objectKey);
+ }
+ }
+ });
+
+ // register callbacks to various buttons to perform commits
+ realityEditor.gui.buttons.registerCallbackForButton('commit', function(params) {
+ if (params.newButtonState === 'up') {
+
+ var objectKeysToDelete = [];
+ for (var objectKey in objects) {
+ if (!objects.hasOwnProperty(objectKey)) continue;
+ // only commit currently visible objects, not everything
+ if (!realityEditor.gui.ar.draw.visibleObjects.hasOwnProperty(objectKey)) continue;
+ objectKeysToDelete.push(objectKey);
+ }
+
+ var objectNames = objectKeysToDelete.map(function(objectKey) {
+ return realityEditor.getObject(objectKey).name;
+ });
+
+ var description = 'The following objects will be saved: ' + objectNames.join(', ');
+ console.log(description);
+
+ realityEditor.gui.modal.openRealityModal('Cancel', 'Overwrite Saved State', function() {
+ console.log('commit cancelled');
+ }, function() {
+ console.log('commit confirmed!');
+
+ objectKeysToDelete.forEach(function(objectKey) {
+ realityEditor.network.sendSaveCommit(objectKey);
+ // update local history instantly so that client and server are synchronized
+ var thisObject = realityEditor.getObject(objectKey);
+ thisObject.framesHistory = JSON.parse(JSON.stringify(thisObject.frames));
+ refreshGhosts();
+ });
+
+ });
+
+ }
+ });
+
+ // only adds the render update listener for frame history ghosts after you enter editing mode for the first time
+ // saves resources when we don't use the service
+ realityEditor.device.registerCallback('setEditingMode', function(params) {
+ if (!isUpdateListenerRegistered && params.newEditingMode) {
+ isUpdateListenerRegistered = true;
+
+ // registers a callback to the gui.ar.draw.update loop so that this module can manage its own rendering
+ realityEditor.gui.ar.draw.addUpdateListener(function(visibleObjects) {
+
+ // renders ghosts only in editing mode, which is when the commit and revert buttons are visible
+ if (globalStates.editingMode) {
+ missingLinksToDraw = [];
+
+ // depending on guiState, either render frame or node/link ghosts
+ if (globalStates.guiState === 'ui') {
+ hideNodeGhosts(visibleObjects);
+ renderFrameGhostsForVisibleObjects(visibleObjects);
+
+ } else if (globalStates.guiState === 'node') {
+ hideFrameGhosts(visibleObjects);
+ renderNodeGhostsForVisibleObjects(visibleObjects);
+ renderLinkGhostsForVisibleObjects(visibleObjects);
+ }
+
+ // remove all ghosts when an object loses visibility
+ removeGhostsOfInvisibleObjects(visibleObjects);
+
+ // draw linesToDraw on canvas
+ drawLinesFromGhosts();
+ drawMissingLinks();
+
+ // cache the most recent visible objects so we can detect when one disappears
+ privateState.visibleObjects = visibleObjects;
+
+ } else {
+ hideAllGhosts();
+ }
+
+ });
+
+ }
+ });
+ }
+
+ /**
+ * Helper function to remove any ghost frame/node/link that is currently added to the scene
+ */
+ function hideAllGhosts() {
+ privateState.ghostsAdded.forEach(function(ghostKey) {
+ hideGhost(ghostKey);
+ });
+ }
+
+ /**
+ * For every visible object, iterates over its framesHistory to remove any ghost frames
+ * @param {Object.>} visibleObjects
+ */
+ function hideFrameGhosts(visibleObjects) {
+
+ for (var objectKey in visibleObjects) {
+ if (!visibleObjects.hasOwnProperty(objectKey)) continue;
+ var thisObject = realityEditor.getObject(objectKey);
+
+ // framesHistory will contain a key/object pair for each frame that existed at the last commit
+ if (thisObject.hasOwnProperty('framesHistory')) {
+ var frameHistory = thisObject.framesHistory;
+
+ for (var ghostFrameKey in frameHistory) {
+ if (!frameHistory.hasOwnProperty(ghostFrameKey)) continue;
+
+ hideGhost(ghostFrameKey);
+ }
+ }
+ }
+
+ // also needs to reset any lines drawn from old frame position to new frame position
+ linesToDraw = [];
+ }
+
+ /**
+ * For every visible object, iterates over every node within every frame in its framesHistory to remove ghost nodes
+ * @param {Object.>} visibleObjects
+ */
+ function hideNodeGhosts(visibleObjects) {
+
+ for (var objectKey in visibleObjects) {
+ if (!visibleObjects.hasOwnProperty(objectKey)) continue;
+
+ var thisObject = realityEditor.getObject(objectKey);
+
+ // framesHistory will contain a key/object pair for each frame that existed at the last commit
+ if (thisObject.hasOwnProperty('framesHistory')) {
+ var frameHistory = thisObject.framesHistory;
+
+ for (var ghostFrameKey in frameHistory) {
+ if (!frameHistory.hasOwnProperty(ghostFrameKey)) continue;
+
+ var ghostFrame = frameHistory[ghostFrameKey];
+
+ // hide the ghost for any nodes that that the ghost frame contains
+ for (var ghostNodeKey in ghostFrame.nodes) {
+ if (!ghostFrame.nodes.hasOwnProperty(ghostNodeKey)) continue;
+
+ hideGhost(ghostNodeKey);
+ }
+ }
+ }
+ }
+
+ linesToDraw = [];
+ }
+
+ /**
+ * Populates a list of missingLinksToDraw (links that you've deleted since the last commit),
+ * by comparing the links that existed at the last commit with those that currently exist
+ * @param {Object.>} visibleObjects
+ */
+ function renderLinkGhostsForVisibleObjects(visibleObjects) {
+
+ for (var objectKey in visibleObjects) {
+ if (!visibleObjects.hasOwnProperty(objectKey)) continue;
+
+ var thisObject = realityEditor.getObject(objectKey);
+
+ if (thisObject.hasOwnProperty('framesHistory')) {
+ var frameHistory = thisObject.framesHistory;
+
+ for (var frameKey in frameHistory) {
+ if (!frameHistory.hasOwnProperty(frameKey)) continue;
+
+ // iterate over all links in the last commit
+ for (var linkKey in frameHistory[frameKey].links) {
+ if (!frameHistory[frameKey].links.hasOwnProperty(linkKey)) continue;
+
+ var ghostLink = frameHistory[frameKey].links[linkKey];
+
+ var realFrame = realityEditor.getFrame(objectKey, frameKey);
+ var wasFrameDeleted = !realFrame;
+
+ // if we deleted the frame since the last commit, don't bother rendering its old links
+ if (!wasFrameDeleted) {
+ var realLink = realFrame.links[linkKey];
+
+ // if an old link existed and it doesn't anymore, record its start and endpoint coordinates
+ if (ghostLink && !realLink) {
+
+ var startNode = realityEditor.getNode(ghostLink.objectA, ghostLink.frameA, ghostLink.nodeA);
+ var endNode = realityEditor.getNode(ghostLink.objectB, ghostLink.frameB, ghostLink.nodeB);
+
+ if (startNode && endNode) {
+ missingLinksToDraw.push({
+ startX: startNode.screenX,
+ startY: startNode.screenY,
+ endX: endNode.screenX,
+ endY: endNode.screenY
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ }
+
+ /**
+ * For every frame on every visible object, renders dotted line outlines for its nodes that have been deleted or
+ * moved since the previous commit.
+ * Also draws dotted arrow lines from old node positions to new node positions if they have been moved.
+ * @param {Object.>} visibleObjects
+ */
+ function renderNodeGhostsForVisibleObjects(visibleObjects) {
+
+ // reset linesToDraw, which will be populated with lines from old (ghost) node positions to new node positions
+ linesToDraw = [];
+
+ for (var objectKey in visibleObjects) {
+ if (!visibleObjects.hasOwnProperty(objectKey)) continue;
+
+ var thisObject = realityEditor.getObject(objectKey);
+
+ if (thisObject.hasOwnProperty('framesHistory')) {
+ var frameHistory = thisObject.framesHistory;
+
+ for (var ghostFrameKey in frameHistory) {
+ if (!frameHistory.hasOwnProperty(ghostFrameKey)) continue;
+
+ // get the ghost frame and check if it still exists
+ var ghostFrame = frameHistory[ghostFrameKey];
+ var wasFrameDeleted = !realityEditor.getFrame(objectKey, ghostFrameKey);
+
+ for (var ghostNodeKey in ghostFrame.nodes) {
+ if (!ghostFrame.nodes.hasOwnProperty(ghostNodeKey)) continue;
+
+ // get the ghost node and its corresponding current node
+ var ghostNode = ghostFrame.nodes[ghostNodeKey];
+ var realNode = realityEditor.getNode(objectKey, ghostFrameKey, ghostNodeKey);
+
+ var wasNodeDeleted = !realNode;
+
+ // if neither the frame nor the node have been deleted since the last commit,
+ // get the positions of the node then and now
+ if (!wasFrameDeleted && !wasNodeDeleted) {
+ var ghostPosition = JSON.parse(JSON.stringify(realityEditor.gui.ar.positioning.getPositionData(ghostNode)));
+ var realPosition = JSON.parse(JSON.stringify(realityEditor.gui.ar.positioning.getPositionData(realNode)));
+ }
+
+ // we need to render a ghost outline at the old node position if:
+ // 1) we deleted the frame that contains it
+ // 2) we deleted the node itself
+ // 3) the node was repositioned (x, y, scale, or matrix)
+ if (wasFrameDeleted || wasNodeDeleted || didPositionChange(ghostPosition, realPosition)) {
+
+ // actually draw the outline as a DOM element
+ renderGhost(objectKey, ghostFrameKey, ghostNodeKey, ghostFrame, ghostNode, visibleObjects[objectKey], wasFrameDeleted || wasNodeDeleted);
+
+ // in addition to rendering a ghost outline, we should draw a line from the old position to the new position,
+ // if the reason for drawing the ghost was that it was repositioned (not deleted)
+ if (!wasFrameDeleted && !wasNodeDeleted) {
+ linesToDraw.push({
+ startX: ghostNode.screenX,
+ startY: ghostNode.screenY,
+ endX: realNode.screenX,
+ endY: realNode.screenY
+ });
+ }
+
+ } else {
+ // if we shouldn't render the ghost, make sure the ghost is hidden
+ hideGhost(ghostNodeKey);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ *
+ * @param {Object.>} visibleObjects
+ */
+ function renderFrameGhostsForVisibleObjects(visibleObjects) {
+
+ // reset linesToDraw, which will be populated with lines from old (ghost) frame positions to new frame positions
+ linesToDraw = [];
+
+ for (var objectKey in visibleObjects) {
+ if (!visibleObjects.hasOwnProperty(objectKey)) continue;
+
+ var thisObject = realityEditor.getObject(objectKey);
+
+ if (thisObject.hasOwnProperty('framesHistory')) {
+ var frameHistory = thisObject.framesHistory;
+
+ for (var ghostFrameKey in frameHistory) {
+ if (!frameHistory.hasOwnProperty(ghostFrameKey)) continue;
+
+ // get the ghost frame and its corresponding current frame
+ var ghostFrame = frameHistory[ghostFrameKey];
+ var realFrame = realityEditor.getFrame(objectKey, ghostFrameKey);
+
+ var wasFrameDeleted = !realFrame;
+
+ // if the frame still exists, get the positions of the frame then and now
+ if (!wasFrameDeleted) {
+ var ghostPosition = realityEditor.gui.ar.positioning.getPositionData(ghostFrame);
+ var realPosition = realityEditor.gui.ar.positioning.getPositionData(realFrame);
+ }
+
+ // we need to render a ghost outline at the old node position if:
+ // 1) we deleted the frame
+ // 3) the frame was repositioned (x, y, scale, or matrix)
+ if (wasFrameDeleted || didPositionChange(ghostPosition, realPosition)) {
+
+ // actually render the outline as a DOM element
+ renderGhost(objectKey, ghostFrameKey, null, ghostFrame, null, visibleObjects[objectKey], wasFrameDeleted);
+
+ // in addition to rendering a ghost outline, we should draw a line from the old position to the new position,
+ // if the reason for drawing the ghost was that it was repositioned (not deleted)
+ if (!wasFrameDeleted) {
+ linesToDraw.push({
+ startX: ghostFrame.screenX,
+ startY: ghostFrame.screenY,
+ endX: realFrame.screenX,
+ endY: realFrame.screenY
+ });
+ }
+
+ } else {
+ // if we shouldn't render the ghost, make sure the ghost is hidden
+ hideGhost(ghostFrameKey);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * If an object was visible last frame (and therefore may have ghosts rendered), but it it not visible this frame,
+ * hide every ghost it might have
+ * @param {Object.>} visibleObjects
+ */
+ function removeGhostsOfInvisibleObjects(visibleObjects) {
+
+ // look at all objects that were visible last frame
+ for (var oldObjectKey in privateState.visibleObjects) {
+ if (!privateState.visibleObjects.hasOwnProperty(oldObjectKey)) continue;
+
+ // only remove ones that don't exist anymore
+ if (!visibleObjects.hasOwnProperty(oldObjectKey)) {
+
+ var thisObject = realityEditor.getObject(oldObjectKey);
+
+ if (thisObject.hasOwnProperty('framesHistory')) {
+ var frameHistory = thisObject.framesHistory;
+
+ // hide each frame ghost that the newly-removed object had // TODO: do the node ghosts ever get hidden this way?
+ for (var ghostFrameKey in frameHistory) {
+ if (!frameHistory.hasOwnProperty(ghostFrameKey)) continue;
+ hideGhost(ghostFrameKey);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * A public function visible outside of the module that can be used to force hide (and subsequently re-render) every
+ * ghost DOM element. Should be triggered when other modules can remove ghosts (e.g. the commit button pressed)
+ * // TODO: eventually register a buttonPressed callback to invert the dependency
+ */
+ function refreshGhosts() {
+
+ // gets the DOM ids of all ghost-related divs
+ var existingGhostFrameKeys = [].slice.apply(document.getElementById('GUI').children).map(function(elt){
+ return elt.id;
+ }).filter(function(id) {
+ return id.indexOf('ghost') === 0;
+ }).map(function(id) {
+ return id.substring('ghost'.length);
+ });
+
+ existingGhostFrameKeys.forEach(function(frameKey) {
+ hideGhost(frameKey);
+ });
+ }
+
+ /**
+ * Draws dotted arrows from start to end coordinates for any ghost frames/nodes that have been repositioned
+ */
+ function drawLinesFromGhosts() {
+ linesToDraw.forEach(function(line) {
+ // only draw lines if the node has moved a noticeable distance
+ var distance = Math.sqrt( (line.startX - line.endX)*(line.startX - line.endX) + (line.startY - line.endY)*(line.startY - line.endY) );
+ if (distance > 50) {
+ drawArrow(globalCanvas.context, line.startX, line.startY, line.endX, line.endY, 'rgba(0, 0, 0, 0.5)', 1, 7);
+ globalCanvas.hasContent = true; // need to set this flag to clear the canvas each frame
+ }
+ });
+ }
+
+ /**
+ * Draws dotted arrows for each of the links that have been deleted since the last commit
+ */
+ function drawMissingLinks() {
+ missingLinksToDraw.forEach(function(line) {
+ drawArrow(globalCanvas.context, line.startX, line.startY, line.endX, line.endY, 'rgba(255, 0, 124, 0.5)', 1, 7);
+ globalCanvas.hasContent = true; // need to set this flag to clear the canvas each frame
+ });
+ }
+
+ /**
+ * Renders a specific frame or node ghost DOM element by calculating its CSS3D transformation and creating the DOM
+ * element if it doesn't already exist
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string|null} nodeKey - if null, renders a frame ghost, otherwise renders the node ghost
+ * @param {Frame} ghostFrame
+ * @param {Node|null} ghostNode
+ * @param {Array.} targetMatrix - the visibleObjects[objectKey] matrix
+ * @param {boolean} wasFrameDeleted
+ */
+ function renderGhost(objectKey, frameKey, nodeKey, ghostFrame, ghostNode, targetMatrix, wasFrameDeleted) {
+
+ // some logic lets us customize the same function to render ghosts for frames and nodes
+ var isNode = !!nodeKey;
+ var ghostVehicle = isNode ? ghostNode : ghostFrame;
+ var activeKey = isNode ? nodeKey : frameKey;
+
+ // don't render ghost until real frame is rendered (fixes bug)
+ if (!globalDOMCache['iframe' + activeKey] && !wasFrameDeleted) {
+ return;
+ }
+
+ // recreate ghost for deleted frame so it changes color
+ if (wasFrameDeleted && globalDOMCache['ghost' + activeKey]) {
+ if (!globalDOMCache['ghost' + activeKey].classList.contains('frameHistoryGhostDeleted')) {
+ // hideGhost(objectKey, frameKey);
+ globalDOMCache['ghost' + activeKey].classList.add('frameHistoryGhostDeleted');
+ }
+ }
+
+ // create div for ghost if needed
+ if (!globalDOMCache['ghost' + activeKey]) {
+ createGhostElement(objectKey, activeKey, wasFrameDeleted);
+ }
+
+ // add to sceneGraph if needed
+ let elementName = 'ghost' + activeKey;
+ let ghostElementId = null;
+ // compute CSS matrix from ghost ar.x, ar.y, ar.scale, ar.matrix
+ let ghostPosition = realityEditor.gui.ar.positioning.getPositionData(ghostVehicle);
+ if (!realityEditor.sceneGraph.getVisualElement(elementName)) {
+ let parentSceneNode = null;
+ if (isNode) {
+ parentSceneNode = realityEditor.sceneGraph.getVisualElement('ghost' + frameKey);
+ } else {
+ parentSceneNode = realityEditor.sceneGraph.getSceneNodeById(objectKey);
+ }
+
+ // elementName, optionalParent, linkedDataObject (includes x,y,scale), initialLocalMatrix
+ ghostElementId = realityEditor.sceneGraph.addVisualElement(elementName, parentSceneNode, ghostVehicle, ghostPosition.matrix);
+
+ } else {
+ let ghostSceneNode = realityEditor.sceneGraph.getVisualElement(elementName);
+ ghostSceneNode.linkedVehicle = ghostVehicle; // make sure this points to up-to-date vehicle for x,y,scale
+ ghostSceneNode.setLocalMatrix(ghostPosition.matrix);
+ ghostElementId = ghostSceneNode.id;
+ }
+
+ let finalMatrix = realityEditor.sceneGraph.getCSSMatrix(ghostElementId);
+
+ // actually adjust the CSS to draw it with the correct transformation
+ globalDOMCache['ghost' + activeKey].style.transform = 'matrix3d(' + finalMatrix.toString() + ')';
+
+ // store the screenX and screenY within the ghost to help us later draw lines to the ghosts
+ var ghostCenterPosition = getDomElementCenterPosition(globalDOMCache['ghost' + activeKey]);
+ ghostVehicle.screenX = ghostCenterPosition.x;
+ ghostVehicle.screenY = ghostCenterPosition.y;
+ }
+
+ /**
+ * Remove the DOM element for the ghost of this frame or node, if it exists.
+ * Also remove this frame/node from the ghostsAdded list.
+ * @param {string} vehicleKey
+ */
+ function hideGhost(vehicleKey) {
+
+ if (globalDOMCache['ghost' + vehicleKey]) {
+ // remove the DOM element
+ globalDOMCache['ghost' + vehicleKey].parentNode.removeChild(globalDOMCache['ghost' + vehicleKey]);
+ delete globalDOMCache['ghost' + vehicleKey];
+
+ // remove from ghostsAdded list
+ var index = privateState.ghostsAdded.indexOf(vehicleKey);
+ if (index !== -1) privateState.ghostsAdded.splice(index, 1);
+ }
+
+ }
+
+ /**
+ * Creates a dotted-outline DOM element for the given frame or node, using its width and height.
+ * Styles it differently (red) if the reason for the ghost is that the frame/node was deleted.
+ * Also add it to the ghostsAdded list, to keep track of which ghosts are in existence.
+ * @param {string} objectKey
+ * @param {string} vehicleKey
+ * @param {boolean} wasFrameDeleted
+ */
+ function createGhostElement(objectKey, vehicleKey, wasFrameDeleted) {
+
+ var ghostDiv = document.createElement('div');
+ ghostDiv.id = 'ghost' + vehicleKey;
+ ghostDiv.classList.add('frameHistoryGhost', 'main', 'ignorePointerEvents', 'visibleFrameContainer');
+ if (wasFrameDeleted) {
+ ghostDiv.classList.add('frameHistoryGhostDeleted');
+ }
+
+ // we use the width and height of the real frame DOM element to make this one match that size // TODO: check, does this still work when the real frame was deleted?
+ if (globalDOMCache['iframe' + vehicleKey]) {
+ ghostDiv.style.width = parseInt(globalDOMCache['iframe' + vehicleKey].style.width) + 'px';
+ ghostDiv.style.height = parseInt(globalDOMCache['iframe' + vehicleKey].style.height) + 'px';
+ ghostDiv.style.left = parseInt(globalDOMCache['iframe' + vehicleKey].style.left) + 'px';
+ ghostDiv.style.top = parseInt(globalDOMCache['iframe' + vehicleKey].style.top) + 'px';
+ }
+ document.getElementById('GUI').appendChild(ghostDiv);
+ globalDOMCache['ghost' + vehicleKey] = ghostDiv;
+
+ // maintain a ghostsAdded list so that we can remove them all on demand
+ privateState.ghostsAdded.push(vehicleKey);
+ }
+
+ /**
+ * Utility function tells if the two positions are different. Defaults to false if either is null.
+ * @param {{x: number, y: number, scale: number, matrix: Array.}} oldPosition
+ * @param {{x: number, y: number, scale: number, matrix: Array.}} newPosition
+ * @return {boolean}
+ */
+ function didPositionChange(oldPosition, newPosition) {
+ if (!oldPosition || !newPosition) return false;
+
+ return (oldPosition.x !== newPosition.x ||
+ oldPosition.y !== newPosition.y ||
+ oldPosition.scale !== newPosition.scale ||
+ JSON.stringify(oldPosition.matrix) !== JSON.stringify(newPosition.matrix)
+ );
+ }
+
+ /**
+ * Utility function gets the approximate center (x,y) position of the DOM element, by querying the DOM clientRects
+ * @param {HTMLElement} domElement
+ * @return {{x: number, y: number}}
+ */
+ function getDomElementCenterPosition(domElement) {
+ return {
+ x: domElement.getClientRects()[0].left + domElement.getClientRects()[0].width/2,
+ y: domElement.getClientRects()[0].top + domElement.getClientRects()[0].height/2
+ }
+ }
+
+ /**
+ * Draws a line with an arrow head on the provided canvas context.
+ * @param {CanvasRenderingContext2D} ctx - HTML5 Canvas context to draw on
+ * @param {number} startX
+ * @param {number} startY
+ * @param {number} endX
+ * @param {number} endY
+ * @param {string} color
+ * @param {number} lineWidth
+ * @param {number} headLength
+ */
+ function drawArrow(ctx, startX, startY, endX, endY, color, lineWidth, headLength){
+ // variables to be used when creating the arrow
+ var headlen = headLength || 10;
+ var angle = Math.atan2(endY-startY,endX-startX);
+
+ // starting path of the arrow from the start square to the end square and drawing the stroke
+ ctx.beginPath();
+ ctx.moveTo(startX, startY);
+ ctx.lineTo(endX, endY);
+ ctx.strokeStyle = color || "#cc0000";
+ ctx.lineWidth = lineWidth || 22;
+ ctx.setLineDash([lineWidth * 3]);
+ ctx.stroke();
+
+ // starting a new path from the head of the arrow to one of the sides of the point
+ ctx.beginPath();
+ ctx.moveTo(endX, endY);
+ ctx.lineTo(endX-headlen*Math.cos(angle-Math.PI/7),endY-headlen*Math.sin(angle-Math.PI/7));
+
+ // path from the side point of the arrow, to the other side point
+ ctx.lineTo(endX-headlen*Math.cos(angle+Math.PI/7),endY-headlen*Math.sin(angle+Math.PI/7));
+
+ // path from the side point back to the tip of the arrow, and then again to the opposite side point
+ ctx.lineTo(endX, endY);
+ ctx.lineTo(endX-headlen*Math.cos(angle-Math.PI/7),endY-headlen*Math.sin(angle-Math.PI/7));
+
+ // draws the paths created above
+ ctx.strokeStyle = color || "#cc0000";
+ ctx.lineWidth = lineWidth || 22;
+ ctx.setLineDash([]);
+ ctx.stroke();
+ ctx.fillStyle = color || "#cc0000";
+ ctx.fill();
+ }
+
+ exports.initService = initService;
+
+}(realityEditor.gui.ar.frameHistoryRenderer));
diff --git a/src/gui/ar/groundPlaneAnchors.js b/src/gui/ar/groundPlaneAnchors.js
new file mode 100644
index 000000000..1817b60cd
--- /dev/null
+++ b/src/gui/ar/groundPlaneAnchors.js
@@ -0,0 +1,281 @@
+/*
+* Created by Ben Reynolds on 10/08/20.
+*
+* Copyright (c) 2020 PTC Inc
+*
+* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/.
+*/
+
+createNameSpace("realityEditor.gui.ar.groundPlaneAnchors");
+
+/**
+ * @fileOverview realityEditor.gui.ar.groundPlaneAnchors
+ * A surface anchor is generated for each tool by calculating its position relative to the groundplane and projecting that onto the groundplane.
+ * Dragging a surface anchor sends a raycast into the scene, which reports the position it collides with the groundplane or world gltf model...
+ * ... based on this point's relative position to the surface anchor, the tool's localMatrix is updated, which in effect moves the anchor to that spot.
+ */
+
+(function(exports) {
+ let knownAnchorNodes = {};
+ let threejsGroups = {};
+ let isPositioningMode = false;
+ let selectedGroupKey = null;
+ let initialLocalMatrix = null;
+ let isFirstDragUpdate = false;
+ let originColor = 0xffffff;
+ let mouseCursorMesh = null;
+ let initialCalculationMesh = null;
+ let transformControls = {};
+
+ function initService() {
+ realityEditor.gui.settings.addToggle('Reposition Ground Anchors', 'surface anchors can be dragged to move tools', 'repositionGroundAnchors', '../../../svg/move.svg', false, function(newValue) {
+ togglePositioningMode(newValue);
+ }, { dontPersist: true });
+
+ realityEditor.gui.ar.draw.addUpdateListener(function(visibleObjects) {
+ try {
+ update(visibleObjects);
+ } catch (e) {
+ console.warn(e);
+ }
+ });
+
+ realityEditor.gui.buttons.registerCallbackForButton('setting', function(_params) {
+ updatePositioningMode(); // check if positioning mode needs update due to settings menu state
+ });
+
+ updatePositioningMode();
+ }
+
+ /**
+ * Public function that the APIs can use to retrieve the modelView of a tool's surface anchor
+ * @param {string} vehicleId
+ * @returns {Array.}
+ */
+ function getMatrix(vehicleId) {
+ if (knownAnchorNodes[vehicleId]) {
+ return realityEditor.sceneGraph.getModelViewMatrix(knownAnchorNodes[vehicleId].id);
+ }
+ return null;
+ }
+
+ function update(visibleObjects) {
+ for (let objectKey in visibleObjects) {
+ let object = realityEditor.getObject(objectKey);
+ if (!object) { continue; }
+
+ for (let frameKey in object.frames) {
+ if (frameKey === selectedGroupKey) { continue; } // don't update tools currently being dragged
+
+ let frame = realityEditor.getFrame(objectKey, frameKey);
+ if (!frame) { continue; }
+ updateFrame(frameKey);
+ }
+ }
+ }
+
+ function updateFrame(frameKey) {
+ if (!knownAnchorNodes[frameKey]) { return; }
+ if (!threejsGroups[frameKey]) { return; }
+
+ // get world matrix of frame
+ let frameNode = realityEditor.sceneGraph.getSceneNodeById(frameKey);
+ // get world matrix of ground plane
+ let groundPlaneNode = realityEditor.sceneGraph.getSceneNodeById('GROUNDPLANE');
+
+ // calculate frame relative to ground plane
+ let relativeMatrix = frameNode.getMatrixRelativeTo(groundPlaneNode);
+ let anchoredMatrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ relativeMatrix[12], 0, relativeMatrix[14], 1
+ ];
+
+ // set the anchor matrix by taking the x, z position
+ knownAnchorNodes[frameKey].setLocalMatrix(anchoredMatrix);
+
+ threejsGroups[frameKey].position.set(relativeMatrix[12], 0, relativeMatrix[14]);
+ }
+
+ // when we add a sceneNode for a tool, also add one to the groundplane that is associated with it
+ function sceneNodeAdded(objectKey, frameKey, _thisFrame, _matrix) {
+
+ // elementName, optionalParent, linkedDataObject, initialLocalMatrix
+ let elementName = getElementName(frameKey);
+ // let linkedDataObject = thisFrame;
+ let parentNode = realityEditor.sceneGraph.getSceneNodeById('GROUNDPLANE');
+ let sceneNodeId = realityEditor.sceneGraph.addVisualElement(elementName, parentNode); //, linkedDataObject);
+
+ knownAnchorNodes[frameKey] = realityEditor.sceneGraph.getSceneNodeById(sceneNodeId);
+
+ // add an element to the three.js scene
+ let group = createAnchorGroup(frameKey);
+ realityEditor.gui.threejsScene.addToScene(group); // this adds it to the ground plane group by default
+ threejsGroups[frameKey] = group;
+ }
+
+ // the initial calculation mesh stays in the location a tool's surface anchor was at when you first started dragging it.
+ function getInitialCalculationMesh() {
+ if (!initialCalculationMesh) {
+ const THREE = realityEditor.gui.threejsScene.THREE;
+ let size = 100;
+ initialCalculationMesh = new THREE.Mesh(new THREE.BoxGeometry(size, size, size),new THREE.MeshBasicMaterial({color: 0xffffff, opacity: 0.3, transparent: true}));
+ initialCalculationMesh.name = 'initialCalculationMesh';
+ initialCalculationMesh.visible = isPositioningMode;
+ realityEditor.gui.threejsScene.addToScene(initialCalculationMesh); // this adds it to the ground plane group by default
+ }
+ return initialCalculationMesh;
+ }
+
+ // helper function to create the geometry for a surface anchor, including its X-Z axis handles
+ function createAnchorGroup(frameKey) {
+ const THREE = realityEditor.gui.threejsScene.THREE;
+
+ let originSize = 100;
+ const group = new THREE.Mesh(new THREE.BoxGeometry(originSize, originSize, originSize), new THREE.MeshBasicMaterial({color: originColor}));
+ group.name = getElementName(frameKey) + '_group';
+ group.visible = isPositioningMode;
+
+ const options = {
+ size: realityEditor.device.environment.variables.transformControlsSize || 1,
+ hideY: true
+ }
+
+ let transformControl = realityEditor.gui.threejsScene.addTransformControlsTo(group, options, onChange, onDraggingChanged);
+ transformControl.attachedGroupName = group.name;
+ transformControl.attachedFrameKey = frameKey;
+
+ transformControls[frameKey] = transformControl;
+
+ if (!isPositioningMode || globalStates.settingsButtonState) {
+ group.visible = false;
+ transformControl.visible = false;
+ transformControl.enabled = false;
+ }
+
+ return group;
+ }
+
+ // helper function to get a consistent name for a scenegraph node for the given frame's surface anchor
+ function getElementName(frameKey) {
+ return frameKey + '_groundPlaneAnchor';
+ }
+
+ // show and hide the anchors as well as the touch event catcher
+ function togglePositioningMode(newValue) {
+ if (typeof newValue !== 'undefined') {
+ isPositioningMode = newValue;
+ } else {
+ isPositioningMode = !isPositioningMode;
+ }
+ updatePositioningMode(); // refreshes the effects of the current mode
+ }
+
+ // we only render everything if the settings menu isn't shown, so as not to interfere with settings touch events
+ // as a result, this needs to also be called every time the settings menu shows or hides
+ function updatePositioningMode() {
+ for (let key in threejsGroups) {
+ updateGroupVisibility(threejsGroups[key], key);
+ }
+ if (mouseCursorMesh) { mouseCursorMesh.visible = false; }
+ if (initialCalculationMesh) { initialCalculationMesh.visible = false; }
+ }
+
+ function updateGroupVisibility(group, key) {
+ group.visible = isPositioningMode && !globalStates.settingsButtonState;
+
+ // hide if it belongs to a closed envelope
+ let hiddenInEnvelope = false;
+ let knownEnvelopes = realityEditor.envelopeManager.getKnownEnvelopes();
+ Object.keys(knownEnvelopes).forEach(function(envelopeKey) {
+ if (hiddenInEnvelope) { return; }
+ let envelopeInfo = knownEnvelopes[envelopeKey];
+ let containsThisGroup = envelopeInfo.containedFrameIds.includes(key);
+ if (containsThisGroup && !envelopeInfo.isOpen) {
+ hiddenInEnvelope = true;
+ }
+ });
+
+ if (hiddenInEnvelope) {
+ group.visible = false;
+ }
+
+ transformControls[key].visible = group.visible;
+ transformControls[key].enabled = group.visible;
+ }
+
+ // helper function to get the x,z coords of a threejs object based on its matrix
+ function getPositionXZ(threeJsObject) {
+ if (!threeJsObject || typeof threeJsObject.matrix === 'undefined') { return null; }
+ return {
+ x: threeJsObject.matrix.elements[12],
+ z: threeJsObject.matrix.elements[14]
+ };
+ }
+
+ function onChange(e) {
+ if (e.target.attachedFrameKey === selectedGroupKey) {
+
+ // move tool to correct position
+ let oldAnchorLocalPosition = getPositionXZ(getInitialCalculationMesh());
+ let newAnchorLocalPosition = getPositionXZ(threejsGroups[selectedGroupKey]); //getAnchorMeshByFrameKey(selectedGroupKey));
+
+ console.log(newAnchorLocalPosition);
+
+ let dx = newAnchorLocalPosition.x - oldAnchorLocalPosition.x;
+ let dz = newAnchorLocalPosition.z - oldAnchorLocalPosition.z;
+
+ if (isFirstDragUpdate) {
+ dx = 0;
+ dz = 0;
+ isFirstDragUpdate = false;
+ }
+
+ let frameSceneNode = realityEditor.sceneGraph.getSceneNodeById(selectedGroupKey);
+ let localMatrix = realityEditor.gui.ar.utilities.copyMatrix(initialLocalMatrix);
+ localMatrix[12] += dx;
+ localMatrix[14] += dz;
+ frameSceneNode.setLocalMatrix(localMatrix);
+ }
+ }
+
+ function onDraggingChanged(e) {
+ if (e.value) {
+ console.log('started drag on ' + e.target.attachedGroupName);
+ selectedGroupKey = e.target.attachedFrameKey;
+ let frameSceneNode = realityEditor.sceneGraph.getSceneNodeById(selectedGroupKey);
+ if (frameSceneNode) {
+ initialLocalMatrix = realityEditor.gui.ar.utilities.copyMatrix(frameSceneNode.localMatrix)
+ }
+ let initialMesh = getInitialCalculationMesh();
+ initialMesh.visible = true;
+ let anchorGroupPosition = getPositionXZ(threejsGroups[selectedGroupKey]);
+ initialMesh.position.set(anchorGroupPosition.x, 0, anchorGroupPosition.z);
+ isFirstDragUpdate = true;
+ } else {
+ console.log('stopped drag on ' + e.target.attachedGroupName);
+
+ realityEditor.device.sendEditingStateToFrameContents(selectedGroupKey, false);
+
+ // post its position to the server so it persists
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(selectedGroupKey);
+ if (sceneNode && sceneNode.linkedVehicle) {
+ realityEditor.network.postVehiclePosition(sceneNode.linkedVehicle);
+ console.log('post vehicle position');
+ }
+
+ selectedGroupKey = null;
+ initialLocalMatrix = null;
+ isFirstDragUpdate = false;
+ getInitialCalculationMesh().visible = false;
+ }
+ }
+
+ exports.initService = initService;
+ exports.getMatrix = getMatrix;
+ exports.sceneNodeAdded = sceneNodeAdded;
+ exports.togglePositioningMode = togglePositioningMode;
+}(realityEditor.gui.ar.groundPlaneAnchors));
diff --git a/src/gui/ar/groundPlaneRenderer.js b/src/gui/ar/groundPlaneRenderer.js
new file mode 100644
index 000000000..c7af47b81
--- /dev/null
+++ b/src/gui/ar/groundPlaneRenderer.js
@@ -0,0 +1,217 @@
+/*
+* Created by Ben Reynolds on 10/08/20.
+*
+* Copyright (c) 2020 PTC Inc
+*
+* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/.
+*/
+
+// import * as THREE from "../../../thirdPartyCode/three/three.module";
+import { InfiniteGridHelper } from '../../../thirdPartyCode/THREE.InfiniteGridHelper/InfiniteGridHelper.module.js';
+import { LayerConfig } from '../scene/Camera.js';
+
+createNameSpace("realityEditor.gui.ar.groundPlaneRenderer");
+
+(function(exports) {
+
+ const maxVisibilityDistanceInMm = 50000; // grid fades into distance 50 meters away from camera
+ const gridSquareSizeInMm = 500;
+ const gridRegionSizeInMm = gridSquareSizeInMm * 10; // each 10 grid squares are grouped by a thicker line
+
+ let shouldVisualize = false;
+ var isUpdateListenerRegistered = false;
+
+ let gridHelper = null; // this is the actual groundplane (THREE.InfiniteGridHelper)
+ let origin = null; // a small cube that is placed on the groundplane origin
+ let target = null; // this is where the center of the screen raycasts against the groundplane
+ let cachedGroundPlaneCollider = null;
+
+ let centerPoint = new WebKitPoint(globalStates.height/2, globalStates.width/2);
+
+ /**
+ * Public init method to enable rendering ghosts of edited frames while in editing mode.
+ */
+ function initService() {
+
+ let defaultShow = realityEditor.device.environment.variables.defaultShowGroundPlane;
+ realityEditor.gui.settings.addToggle('Visualize Ground Plane', 'shows detected ground plane', 'visualizeGroundPlane', '../../../svg/powerSave.svg', defaultShow, function(newValue) {
+ // only draw frame ghosts while in programming mode if we're not in power-save mode
+ shouldVisualize = newValue;
+
+ if (newValue) {
+ globalStates.useGroundPlane = true; // makes sure the groundPlane position gets recalculated
+ startVisualization();
+ realityEditor.gui.menus.switchToMenu('groundPlane');
+ } else {
+ stopVisualization();
+ realityEditor.gui.menus.switchToMenu('main');
+ }
+ }, { dontPersist: true });
+
+ // register callbacks to various buttons to perform commits
+ realityEditor.gui.buttons.registerCallbackForButton('groundPlaneReset', function(params) {
+ if (params.newButtonState === 'down') {
+ // search for groundplane when button is pressed
+ realityEditor.app.callbacks.startGroundPlaneTrackerIfNeeded();
+ }
+ });
+
+ // when the app loads, check once if it needs groundPlane and start up the tracker if so
+ // TODO: wait until camera moves enough before trying to detect groundplane or it goes to origin
+ setTimeout(function() {
+ realityEditor.app.callbacks.startGroundPlaneTrackerIfNeeded();
+ }, 1000);
+ }
+
+ function startVisualization() {
+ globalStates.useGroundPlane = true;
+ if (!gridHelper) {
+ // check that the ground plane exists before we start the visualization
+ let gpId = realityEditor.sceneGraph.NAMES.GROUNDPLANE;
+ let gpRxId = gpId + realityEditor.sceneGraph.TAGS.ROTATE_X;
+ let groundPlaneSceneNode = realityEditor.sceneGraph.getSceneNodeById(gpRxId);
+ if (!groundPlaneSceneNode) {
+ groundPlaneSceneNode = realityEditor.sceneGraph.getSceneNodeById(gpId);
+ }
+
+ // Ground plane must exist... if it doesn't reschedule this to happen later
+ if (!groundPlaneSceneNode) {
+ setTimeout(function() {
+ startVisualization();
+ }, 100);
+ return;
+ }
+ }
+
+ const THREE = realityEditor.gui.threejsScene.THREE;
+
+ // create an infinite grid that fades into the distance, along the groundplane
+ if (!gridHelper) {
+ const colorGrid = new THREE.Color(realityEditor.device.environment.variables.groundWireframeColor);
+ // scene scale is in milimeters
+ gridHelper = new InfiniteGridHelper(gridSquareSizeInMm, gridRegionSizeInMm, 0.075, colorGrid, maxVisibilityDistanceInMm);
+ gridHelper.name = 'groundPlaneVisualizer';
+ gridHelper.layers.set(LayerConfig.LAYER_BACKGROUND);
+ realityEditor.gui.threejsScene.addToScene(gridHelper, {occluded: true});
+ }
+
+ // don't show origin on devices that don't support AR tracking, because it's to help debug the groundplane tracker
+ if (!origin && realityEditor.device.environment.variables.waitForARTracking) {
+ origin = new THREE.Group();
+ const length = 100;
+ const height = 10;
+ const crossHairColor = 0xffffff;
+
+ let horizontal = new THREE.Mesh(new THREE.BoxGeometry(length,height,height), new THREE.MeshBasicMaterial({color: crossHairColor}));
+ origin.add(horizontal);
+ let vertical = new THREE.Mesh(new THREE.BoxGeometry(height,height,length), new THREE.MeshBasicMaterial({color: crossHairColor}));
+ origin.add(vertical);
+
+ realityEditor.gui.threejsScene.addToScene(origin, {occluded: false});
+ }
+
+ // create a moving panel on the ground with four corners (using 8 boxes for the lines) and a center dot
+ if (!target && realityEditor.device.environment.variables.waitForARTracking) {
+ target = new THREE.Group();
+ realityEditor.gui.threejsScene.addToScene(target, {occluded: true});
+
+ const halfWidth = 64;
+ const cornerSize = halfWidth/4;
+ const cornerHeight = cornerSize/4;
+ const cornerColor = 0x00ffff;
+
+ // add a dot in the middle that is similarly sized to each of the corners
+ let center = new THREE.Mesh(new THREE.BoxGeometry(cornerSize,cornerHeight,cornerSize), new THREE.MeshBasicMaterial({color:0x00ffff}));
+ target.add(center);
+
+ // x and z position the corner origin
+ // dx and dz adjust the position of each of the two crossbars that form that corner
+ let corners = {
+ topLeft: { x: -1, z: -1, rot: 0 },
+ bottomLeft: { x: -1, z: 1, rot: Math.PI/2 },
+ bottomRight: { x: 1, z: 1, rot: Math.PI },
+ topRight: { x: 1, z: -1, rot: Math.PI*3/2 }
+ };
+
+ Object.values(corners).forEach(info => {
+ let corner = new THREE.Group();
+ corner.position.set(info.x * halfWidth, 0, info.z * halfWidth);
+ corner.rotateY(info.rot);
+ target.add(corner);
+
+ let horizontal = new THREE.Mesh(new THREE.BoxGeometry(cornerHeight,cornerHeight,cornerSize), new THREE.MeshBasicMaterial({color: cornerColor}));
+ horizontal.position.set(-cornerSize/2, 0, 0);
+ corner.add(horizontal);
+
+ let vertical = new THREE.Mesh(new THREE.BoxGeometry(cornerSize,cornerHeight,cornerHeight), new THREE.MeshBasicMaterial({color: cornerColor}));
+ vertical.position.set(0, 0, -cornerSize/2);
+ corner.add(vertical);
+ });
+ }
+
+ // add/activate the update loop
+ if (!isUpdateListenerRegistered) {
+ // registers a callback to the gui.ar.draw.update loop so that this module can manage its own rendering
+ realityEditor.gui.ar.draw.addUpdateListener(onUpdate);
+ isUpdateListenerRegistered = true;
+ }
+ }
+
+ function stopVisualization() {
+ globalStates.useGroundPlane = false;
+ if (gridHelper) {
+ realityEditor.gui.threejsScene.removeFromScene(gridHelper);
+ gridHelper = null;
+ }
+ if (target) {
+ realityEditor.gui.threejsScene.removeFromScene(target);
+ target = null;
+ }
+ if (origin) {
+ realityEditor.gui.threejsScene.removeFromScene(origin);
+ origin = null;
+ }
+ }
+
+ function onUpdate(_visibleObjects) {
+ // render the ground plane visualizer
+ if (!shouldVisualize) { return; } // TODO: actively unsubscribe on stop, so we don't have to ignore loop here
+
+ if (!cachedGroundPlaneCollider) {
+ cachedGroundPlaneCollider = realityEditor.gui.threejsScene.getGroundPlaneCollider(); // grid helper has holes so use plane collider
+ }
+ if (!cachedGroundPlaneCollider) {
+ return;
+ }
+
+ if (target) {
+ // raycast from center of screen onto groundplane and move the visualizer to the resulting (x,y)
+ let raycastIntersects = realityEditor.gui.threejsScene.getRaycastIntersects(centerPoint.x, centerPoint.y, [cachedGroundPlaneCollider.getInternalObject()]);
+ if (raycastIntersects.length === 0) { return; }
+
+ // transform the world coordinate into the groundplane coordinate system
+ gridHelper.worldToLocal(raycastIntersects[0].scenePoint);
+
+ target.position.set(raycastIntersects[0].scenePoint.x, 0, raycastIntersects[0].scenePoint.z);
+ }
+ }
+
+ exports.updateGridStyle = ({color, thickness}) => {
+ if (!gridHelper) return;
+ const THREE = realityEditor.gui.threejsScene.THREE;
+ if (typeof color !== 'undefined') {
+ gridHelper.material.color = new THREE.Color(color);
+ gridHelper.material.uniforms.uColor.value = new THREE.Color(color);
+ }
+ if (typeof thickness !== 'undefined') {
+ gridHelper.material.uniforms.uThickness.value = thickness;
+ }
+ };
+
+ exports.initService = initService;
+ exports.startVisualization = startVisualization;
+ exports.stopVisualization = stopVisualization;
+
+}(realityEditor.gui.ar.groundPlaneRenderer));
diff --git a/src/gui/ar/grouping.js b/src/gui/ar/grouping.js
new file mode 100644
index 000000000..1cbdd4596
--- /dev/null
+++ b/src/gui/ar/grouping.js
@@ -0,0 +1,835 @@
+createNameSpace("realityEditor.gui.ar.grouping");
+
+/**
+ * @fileOverview realityEditor.grouping.js
+ * Contains functions that render groups and selection GUI
+ * as well as creating groups.
+ * Registers callback listeners for rendering and touch events to keep dependencies acyclic
+ */
+
+(function(exports) {
+
+ /**
+ * Maps each groupID to its set of group members (frameKeys)
+ * @type {Object.>}
+ */
+ var groupStruct = {};
+
+ /**
+ * Maps each frameKey to its relativeWorldPosition to the selected frame
+ * @type {Object.>}
+ */
+ var groupRelativePositions = {};
+
+ /**
+ * @type {Object.}
+ */
+ var frameToObj = {};
+
+ /**
+ * Keeps track of where the line starts
+ * @type {Array.>}
+ */
+ var points = [];
+
+ /**
+ * Keeps track of the lasso polyline
+ * @type {SVGPolylineElement|null}
+ */
+ var lasso = null;
+
+ /**
+ * @type {Boolean} Whether a tap has already occurred and is set to be a double tap
+ */
+ var isDoubleTap = false;
+
+ /**
+ * @type {{active: Boolean, object: Array., frame: Array.}}
+ * object and frame currently not in use
+ */
+ var selectingState = {
+ active: false,
+ object: [],
+ frame: []
+ };
+
+ /**
+ * @type {boolean}
+ */
+ var isUnconstrainedEditingGroup = false;
+
+ /**
+ * Initialize the grouping service regardless of whether it is enabled onLoad
+ * Subscribe to touches and rendering events, and a variety of other frame events,
+ * but only respond to them if the grouping service is currently enabled at the time of the event
+ */
+ function initService() {
+
+ // render hulls on every update (iff grouping mode enabled)
+ realityEditor.gui.ar.draw.addUpdateListener(function() {
+ if (realityEditor.gui.settings.toggleStates.groupingEnabled) {
+
+ // draw hulls if any of their elements are being moved, or if the lasso is active
+ var shouldDrawHulls = false;
+ if (selectingState.active) {
+ shouldDrawHulls = true;
+ }
+ if (realityEditor.device.editingState.frame) {
+ shouldDrawHulls = true;
+ }
+
+ var svg = document.getElementById("groupSVG");
+ if (shouldDrawHulls) {
+ if (svg.classList.contains('groupOutlineFadeOut')) {
+ svg.classList.remove('groupOutlineFadeOut');
+ }
+ } else {
+ if (!svg.classList.contains('groupOutlineFadeOut')) {
+ svg.classList.add('groupOutlineFadeOut');
+ }
+ // clearHulls(svg);
+ }
+
+ drawGroupHulls();
+
+ if (isUnconstrainedEditingGroup && !realityEditor.device.editingState.unconstrainedDisabled) {
+
+ let activeVehicle = realityEditor.device.getEditingVehicle();
+ let selectedSceneNode = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid);
+ forEachGroupedFrame(activeVehicle, function(groupedFrame) {
+ let groupedSceneNode = realityEditor.sceneGraph.getSceneNodeById(groupedFrame.uuid);
+ let relativePosition = groupRelativePositions[groupedFrame.uuid];
+ groupedSceneNode.setPositionRelativeTo(selectedSceneNode, relativePosition);
+ }, true);
+ }
+ }
+ });
+
+ // -- be notified when certain touch event functions get triggered in device/index.js -- //
+
+ // on touch down, start creating a lasso if you double tap on the background
+ realityEditor.device.registerCallback('onDocumentMultiTouchStart', function(params) {
+ if (realityEditor.gui.settings.toggleStates.groupingEnabled) {
+ console.log('grouping.js: onDocumentMultiTouchStart', params);
+
+ // If the event is hitting the background and it isn't the multi-touch to scale an object
+ if (realityEditor.device.utilities.isEventHittingBackground(params.event)) {
+ if (params.event.touches.length < 2) {
+ console.log('did tap on background in grouping mode');
+
+ // handling double taps
+ if (!isDoubleTap) { // on first tap
+ isDoubleTap = true;
+
+ // if no follow up tap within time reset
+ setTimeout(function() {
+ isDoubleTap = false;
+ }, 300);
+ } else { // registered double tap and start drawing selection lasso
+ selectingState.active = true;
+ // var svg = document.getElementById("groupSVG");
+ //TODO: start drawing
+ startLasso(params.event.pageX, params.event.pageY);
+ }
+ }
+ } else {
+ // else if hitting a grouped frame, preserve relative locations between it and its groupies
+ var activeVehicle = realityEditor.device.getEditingVehicle();
+ if (activeVehicle) {
+ let selectedSceneNode = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid);
+
+ forEachGroupedFrame(activeVehicle, function(groupedFrame) {
+ let groupedSceneNode = realityEditor.sceneGraph.getSceneNodeById(groupedFrame.uuid);
+ groupRelativePositions[groupedFrame.uuid] = groupedSceneNode.getMatrixRelativeTo(selectedSceneNode);
+ }, true);
+ }
+ }
+
+ }
+
+ });
+
+ // on touch move, continue drawing a lasso. or if you're selecting a grouped frame, move all grouped frames
+ realityEditor.device.registerCallback('onDocumentMultiTouchMove', function(params) {
+ if (realityEditor.gui.settings.toggleStates.groupingEnabled) {
+ // console.log('grouping.js: onDocumentMultiTouchMove', params);
+
+ if (selectingState.active) {
+ continueLasso(params.event.pageX, params.event.pageY);
+ }
+
+ var activeVehicle = realityEditor.device.getEditingVehicle();
+ var isSingleTouch = params.event.touches.length === 1;
+
+ if (activeVehicle && isSingleTouch) {
+ // Any time a frame or node is moved, check if it's part of a group and move all grouped frames/nodes with it
+ let selectedSceneNode = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid);
+ forEachGroupedFrame(activeVehicle, function(groupedFrame) {
+ let groupedSceneNode = realityEditor.sceneGraph.getSceneNodeById(groupedFrame.uuid);
+ let relativePosition = groupRelativePositions[groupedFrame.uuid];
+ groupedSceneNode.setPositionRelativeTo(selectedSceneNode, relativePosition);
+ }, true);
+
+ }
+
+ }
+
+ });
+
+ // on touch up finish the lasso and create a group out of encircled frames. or stop moving grouped frames.
+ realityEditor.device.registerCallback('onDocumentMultiTouchEnd', function(params) {
+ if (realityEditor.gui.settings.toggleStates.groupingEnabled) {
+ console.log('grouping.js: onDocumentMultiTouchEnd', params);
+
+ if (selectingState.active) {
+ selectingState.active = false;
+ closeLasso();
+
+ var selected = getLassoed();
+ selectFrames(selected);
+ // TODO: get selected => select
+ }
+
+ var activeVehicle = realityEditor.device.getEditingVehicle();
+ console.log('onDocumentMultiTouchEnd', params, activeVehicle);
+
+ // check how many touches are still on the canvas / on the frame
+ // if there's still a touch on it (it was being scaled or distance scaled), reset touch offset so vehicle doesn't jump
+ if (realityEditor.device.currentScreenTouches.length > 0) {
+ forEachGroupedFrame(activeVehicle, function(frame) {
+ frame.groupTouchOffset = undefined; // recalculate groupTouchOffset each time
+ });
+ }
+ }
+ });
+
+ // when you stop moving around a frame, clear some state and post the new positions of all grouped frames to the server
+ realityEditor.device.registerCallback('resetEditingState', function(params) {
+ isUnconstrainedEditingGroup = false;
+
+ var activeVehicle = realityEditor.device.getEditingVehicle();
+ if (!activeVehicle) { return; }
+
+ console.log('resetEditingState', params, activeVehicle);
+
+ // clear the groupTouchOffset of each frame in the group
+ // and post the new positions of each frame in the group to the server
+ forEachGroupedFrame(activeVehicle, function(frame) {
+ frame.groupTouchOffset = undefined; // recalculate groupTouchOffset each time
+
+ var memberPositionData = realityEditor.gui.ar.positioning.getPositionData(frame);
+ var memberContent = {};
+ memberContent.x = memberPositionData.x;
+ memberContent.y = memberPositionData.y;
+ memberContent.scale = memberPositionData.scale;
+ if (realityEditor.device.isEditingUnconstrained(activeVehicle)) {
+ memberContent.matrix = memberPositionData.matrix;
+ }
+ memberContent.lastEditor = globalStates.tempUuid;
+
+ var memberUrlEndpoint = realityEditor.network.getURL(objects[frame.objectId].ip, realityEditor.network.getPort(objects[frame.objectId]), '/object/' + frame.objectId + "/frame/" + frame.uuid + "/node/null/size");
+ // + "/node/" + this.editingState.node + routeSuffix;
+ realityEditor.network.postData(memberUrlEndpoint, memberContent);
+ }, false);
+
+ });
+
+ // unconstrained move grouped vehicles if needed by storing their initial matrix offset
+ // TODO: also store this info when starting unconstrained editing via another method, e.g. editing mode
+ realityEditor.device.registerCallback('onFramePulledIntoUnconstrained', function(params) {
+ if (!realityEditor.gui.settings.toggleStates.groupingEnabled) { return; }
+
+ var activeVehicle = params.activeVehicle;
+ forEachGroupedFrame(activeVehicle, function(frame) {
+ // store relative offset
+ var activeVehicleMatrix = realityEditor.gui.ar.positioning.getPositionData(activeVehicle).matrix;
+ var groupedVehicleMatrix = realityEditor.gui.ar.positioning.getPositionData(frame).matrix;
+
+ var startingMatrixOffset = [];
+ realityEditor.gui.ar.utilities.multiplyMatrix(realityEditor.gui.ar.utilities.invertMatrix(activeVehicleMatrix), groupedVehicleMatrix, startingMatrixOffset);
+ frame.startingMatrixOffset = startingMatrixOffset;
+
+ isUnconstrainedEditingGroup = true;
+ }, true);
+ });
+
+ // TODO: this method is a hack, implement in a better way making use of screenExtension module
+ // push/pull grouped vehicles into screens together if needed
+ realityEditor.gui.screenExtension.registerCallback('updateArFrameVisibility', function(params) {
+ if (!realityEditor.gui.settings.toggleStates.groupingEnabled) return;
+
+ var selectedFrame = realityEditor.getFrame(params.objectKey, params.frameKey);
+ if (selectedFrame && selectedFrame.groupID) {
+ var newVisualization = params.newVisualization;
+ forEachGroupedFrame(selectedFrame, function(groupedFrame) {
+
+ if (groupedFrame.visualization === newVisualization) { return; } // don't repeat for the originating frame or ones already transitioned
+
+ groupedFrame.visualization = newVisualization;
+
+ if (newVisualization === 'screen') {
+
+ console.log('pushed grouped frame ' + groupedFrame.uuid + ' into screen');
+
+ realityEditor.gui.ar.draw.hideTransformed(groupedFrame.uuid, groupedFrame, globalDOMCache, cout);
+
+ groupedFrame.ar.x = 0;
+ groupedFrame.ar.y = 0;
+ groupedFrame.begin = [];
+ groupedFrame.ar.matrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+
+ } else if (newVisualization === 'ar') {
+
+ console.log('pull grouped frame ' + groupedFrame.uuid + ' into AR');
+
+ // set to false so it definitely gets re-added and re-rendered
+ groupedFrame.visible = false;
+ groupedFrame.ar.matrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+ groupedFrame.temp = realityEditor.gui.ar.utilities.newIdentityMatrix();
+ groupedFrame.begin = realityEditor.gui.ar.utilities.newIdentityMatrix();
+
+ var activeKey = groupedFrame.uuid;
+ // resize iframe to override incorrect size it starts with so that it matches the screen frame
+ var iframe = globalDOMCache['iframe' + activeKey];
+ var overlay = globalDOMCache[activeKey];
+ var svg = globalDOMCache['svg' + activeKey];
+
+ iframe.style.width = groupedFrame.frameSizeX + 'px';
+ iframe.style.height = groupedFrame.frameSizeY + 'px';
+ iframe.style.left = ((globalStates.height - parseFloat(groupedFrame.frameSizeX)) / 2) + "px";
+ iframe.style.top = ((globalStates.width - parseFloat(groupedFrame.frameSizeY)) / 2) + "px";
+
+ overlay.style.width = iframe.style.width;
+ overlay.style.height = iframe.style.height;
+ overlay.style.left = iframe.style.left;
+ overlay.style.top = iframe.style.top;
+
+ svg.style.width = iframe.style.width;
+ svg.style.height = iframe.style.height;
+ realityEditor.gui.ar.moveabilityOverlay.createSvg(svg);
+
+ // set the correct position for the frame that was just pulled to AR
+
+ // 1. move it so it is centered on the pointer, ignoring touchOffset
+ // var touchPosition = realityEditor.gui.ar.positioning.getMostRecentTouchPosition();
+ var touchPosition = realityEditor.device.currentScreenTouches[0].position;
+ realityEditor.gui.ar.positioning.moveVehicleToScreenCoordinate(groupedFrame, touchPosition.x, touchPosition.y, false);
+
+ /*
+ // 2. convert touch offset from percent scale to actual scale of the frame
+ var convertedTouchOffsetX = (this.screenObject.touchOffsetX) * parseFloat(groupedFrame.width);
+ var convertedTouchOffsetY = (this.screenObject.touchOffsetY) * parseFloat(groupedFrame.height);
+
+ // 3. manually apply the touchOffset to the results so that it gets rendered in the correct place on the first pass
+ groupedFrame.ar.x -= (convertedTouchOffsetX - parseFloat(groupedFrame.width)/2 ) * groupedFrame.ar.scale;
+ groupedFrame.ar.y -= (convertedTouchOffsetY - parseFloat(groupedFrame.height)/2 ) * groupedFrame.ar.scale;
+ */
+
+ // TODO: this causes a bug now with the offset... figure out why it used to be necessary but doesn't help anymore
+ // 4. set the actual touchOffset so that it stays in the correct offset as you drag around
+ // realityEditor.device.editingState.touchOffset = {
+ // x: convertedTouchOffsetX,
+ // y: convertedTouchOffsetY
+ // };
+
+ realityEditor.gui.ar.draw.showARFrame(activeKey);
+
+ // realityEditor.device.beginTouchEditing(groupedFrame.objectId, activeKey);
+
+ }
+
+ sendScreenObject(groupedFrame, groupedFrame.visualization);
+
+ realityEditor.network.updateFrameVisualization(objects[groupedFrame.objectId].ip, groupedFrame.objectId, groupedFrame.uuid, groupedFrame.visualization, groupedFrame.ar);
+
+ }, true);
+
+ }
+
+ });
+
+ // Remove the frame from its group when it gets deleted -- AND delete all frames in the same group
+ realityEditor.device.registerCallback('vehicleDeleted', function(params) {
+ if (!realityEditor.gui.settings.toggleStates.groupingEnabled) { return; }
+
+ var DELETE_ALL_FRAMES_IN_GROUP = true; // can be easily turned off if we don't want that behavior
+ if (params.objectKey && params.frameKey && !params.nodeKey) {
+ if (DELETE_ALL_FRAMES_IN_GROUP) {
+ // in this mode, delete all frames in this group
+ var frameBeingDeleted = realityEditor.getFrame(params.objectKey, params.frameKey);
+ forEachGroupedFrame(frameBeingDeleted, function(groupedFrame) {
+ // in this mode, just remove the deleted frame from its group if it's in one
+ removeFromGroup(groupedFrame.uuid, groupedFrame.objectId);
+ // delete this frame too
+ realityEditor.device.deleteFrame(groupedFrame, groupedFrame.objectId, groupedFrame.uuid);
+ }, true);
+
+ } else {
+ // in this mode, just remove the deleted frame from its group if it's in one
+ removeFromGroup(params.frameKey, params.objectKey);
+ }
+
+ }
+ });
+
+ // adjust distanceScale of grouped frames together so they get set to same amount
+ realityEditor.device.distanceScaling.registerCallback('scaleEditingFrameDistance', function(params) {
+ if (!realityEditor.gui.settings.toggleStates.groupingEnabled) { return; }
+
+ forEachGroupedFrame(params.frame, function(groupedFrame) {
+ // groupedFrame.distanceScale = params.frame.distanceScale;
+ groupedFrame.distanceScale = (groupedFrame.screenZ / realityEditor.device.distanceScaling.getDefaultDistance()) / 0.85;
+ }, true);
+ });
+
+ }
+
+ /**
+ * Emulates realityEditor.gui.screenExtension.sendScreenObject() but for grouped frames with different offsets, etc
+ * @param {Frame} groupedFrame
+ * @param {string} newVisualization - "screen" or "ar"
+ */
+ function sendScreenObject(groupedFrame, newVisualization) {
+ for (var frameKey in realityEditor.gui.screenExtension.visibleScreenObjects) {
+ if (!realityEditor.gui.screenExtension.visibleScreenObjects.hasOwnProperty(frameKey)) continue;
+ var visibleScreenObject = realityEditor.gui.screenExtension.visibleScreenObjects[frameKey];
+
+ // var screenObjectClone = JSON.parse(JSON.stringify(this.screenObject));
+
+ var screenObjectClone = {
+ object: groupedFrame.objectId,
+ frame: groupedFrame.uuid,
+ node: null,
+ touchOffsetX: 0,
+ touchOffsetY: 0,
+ isScreenVisible: (newVisualization === "screen"),
+ scale: groupedFrame.ar.scale
+ };
+
+ // for every visible screen, calculate this touch's exact x,y coordinate within that screen plane
+ var thisFrameFrameCenterScreenPosition = realityEditor.gui.ar.positioning.getScreenPosition(groupedFrame.objectId,groupedFrame.uuid,true,false,false,false,false).center;
+ var point = realityEditor.gui.ar.utilities.screenCoordinatesToTargetXY(visibleScreenObject.object, thisFrameFrameCenterScreenPosition.x, thisFrameFrameCenterScreenPosition.y);
+ // visibleScreenObject.x = point.x;
+ // visibleScreenObject.y = point.y;
+
+ screenObjectClone.x = point.x; //visibleScreenObject.x;
+ screenObjectClone.y = point.y; //visibleScreenObject.y;
+ screenObjectClone.targetScreen = {
+ object: visibleScreenObject.object,
+ frame: visibleScreenObject.frame
+ };
+ screenObjectClone.touches = visibleScreenObject.touches;
+
+ var iframe = globalDOMCache["iframe" + frameKey];
+ if (iframe) {
+ iframe.contentWindow.postMessage(JSON.stringify({
+ screenObject: screenObjectClone
+ }), '*');
+ }
+ }
+ }
+
+ /**
+ * Iterator over all frames in the same group as the activeVehicle
+ * Performs the callback for the activeVehicle too, unless you pass in true for the last argument
+ * @param {Frame} activeVehicle
+ * @param {function} callback
+ * @param {boolean} excludeActive - if true, doesn't trigger the callback for the activeVehicle, only for its co-members
+ */
+ function forEachGroupedFrame(activeVehicle, callback, excludeActive) {
+ if (activeVehicle && activeVehicle.groupID) {
+ var groupMembers = getGroupMembers(activeVehicle.groupID);
+ groupMembers.forEach(function(member) {
+ var frame = realityEditor.getFrame(member.object, member.frame);
+ if (frame) {
+ if (excludeActive && frame.uuid === activeVehicle.uuid) { return; }
+ callback(frame);
+ } else {
+ groupStruct[activeVehicle.groupID].delete(member.frame); // group restruct
+ }
+ });
+ }
+ }
+
+ /**
+ * Gets triggered when the on/off switch is toggled to update realityEditor.gui.settings.toggleStates.groupingEnabled
+ * When toggled off, erase any visuals that should only update when grouping mode is enabled
+ * @param {boolean} isEnabled
+ */
+ function toggleGroupingMode(isEnabled) {
+ let svg = document.getElementById('groupSVG');
+ let lassoSvg = document.getElementById('groupLassoSVG');
+
+ if (!isEnabled) {
+ clearHulls(svg);
+ closeLasso();
+
+ svg.style.display = 'none';
+ lassoSvg.style.display = 'none';
+ } else {
+ svg.style.display = '';
+ lassoSvg.style.display = '';
+ }
+ }
+
+ /**
+ * Sets start point of selection lasso
+ * @param {number} x
+ * @param {number} y
+ */
+ function startLasso(x, y) {
+ // start drawing; set first point; reset lasso
+ points = [[x, y]];
+ if (lasso === null) {
+ lasso = document.getElementById("lasso");
+ }
+
+ lasso.setAttribute("points", x + ", "+y);
+ lasso.setAttribute("stroke", "#00ffff");
+ lasso.setAttribute("fill", "rgba(0,255,255,0.2)");
+
+ globalCanvas.hasContent = true;
+ }
+
+ /**
+ * Adds more points to the selection lasso
+ * @param {number} x
+ * @param {number} y
+ */
+ function continueLasso(x, y) {
+ var lassoPoints = lasso.getAttribute("points");
+ lassoPoints += " "+x+", "+y;
+ lasso.setAttribute("points", lassoPoints);
+ points.push([x, y]);
+ var lassoed = getLassoed().length;
+ if (lassoed > 0) {
+ lasso.setAttribute("fill", "rgba(0,255,255,0.2)");
+ lasso.setAttribute("stroke", "#00ff00");
+ } else {
+ lasso.setAttribute("fill", "rgba(0,255,255,0.2)");
+ lasso.setAttribute("stroke", "#00ffff");
+ }
+ }
+
+ /**
+ * Auto-closes lasso to start point
+ */
+ function closeLasso() {
+ function clearLasso() {
+ lasso.setAttribute("points", "");
+ lasso.classList.remove('groupLassoFadeOut');
+ }
+ if (!lasso) { return; }
+
+ var lassoPoints = lasso.getAttribute("points");
+ var start = points[0];
+ lassoPoints += " " + start[0]+", "+start[1];
+ lasso.setAttribute("points", lassoPoints);
+
+ lasso.classList.add('groupLassoFadeOut');
+
+ setTimeout(clearLasso.bind(this), 300);
+ }
+
+ /**
+ * @return {Array..} - [{object: objectKey, frame: frameKey}] for frames inside lasso
+ */
+ function getLassoed() {
+ var lassoedFrames = []; // array of frames in lasso
+
+ realityEditor.forEachFrameInAllObjects(function(objectKey, frameKey) {
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ if (frame && frame.visualization === 'ar' && frame.location === 'global') {
+ // check if frame in lasso
+ // FIXME: insidePoly doesn't work for crossed over shapes (such as an infinite symbol)
+ var inLasso = realityEditor.gui.ar.utilities.insidePoly([frame.screenX, frame.screenY], points);
+ if (inLasso) {
+ lassoedFrames.push({object: objectKey, frame: frameKey});
+ }
+ }
+ });
+
+ return lassoedFrames;
+ }
+
+ /**
+ * Takes in selected objects and creates groups from them
+ * updates groupStruct as well as server
+ * @param {Array..} selected - [{object: , frame: }]
+ */
+ function selectFrames(selected) {
+ console.log("--select frames--");
+ console.log(selected.length);
+
+ if (selected.length === 0) return;
+
+ // if selected 1, remove from all groups
+ if (selected.length === 1) {
+ var frameKey = selected[0].frame;
+ var objectKey = selected[0].object;
+
+ removeFromGroup(frameKey, objectKey);
+ }
+
+ // if selected >1, make those into a new group
+ else {
+ // see which groups we've selected from
+ var groups = {}; // {groupID.: .}
+ // let frameToObj = {}; // lookup for {frameKey: objectKey}
+ selected.forEach(function(member) {
+ var object = realityEditor.getObject(member.object);
+ var group = object.frames[member.frame].groupID;
+ frameToObj[member.frame] = member.object;
+
+ if (group) {
+ if (group in groups) groups[group].add(member.frame);
+ else groups[group] = new Set([member.frame]);
+ }
+
+ });
+
+ var groupIDs = Object.keys(groups);
+ // if you've selected all of one group and only that group ...
+ if (groupIDs.length === 1 && groups[groupIDs[0]].size === groupStruct[groupIDs[0]].size) {
+ // then remove all from group
+ selected.forEach(function(member) {
+ removeFromGroup(member.frame, member.object);
+ });
+ }
+ // otherwise we'll make a new group ...
+ else {
+ createNewGroup(selected);
+ }
+ }
+
+ drawGroupHulls();
+ }
+
+ /**
+ * checks if frame is in group, and if so, removes from any group
+ * also deals with groups of size 1 and clears them
+ * @param {string} frameKey
+ * @param {string} objectKey
+ */
+ function removeFromGroup(frameKey, objectKey) {
+ var object = realityEditor.getObject(objectKey);
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ var groupID = frame.groupID;
+
+ if (frame === undefined || groupID === undefined) return;
+ if (groupID) {
+ console.log('removing ' + frameKey + 'from any group');
+ groupStruct[groupID].delete(frameKey); // group restruct
+ frame.groupID = null;
+
+ // ungroup group if left with 1 remaining
+ if (groupStruct[groupID].size === 1) {
+ var group = Array.from(groupStruct[groupID]);
+ object.frames[group[0]].groupID = null;
+ groupStruct[groupID].clear();
+ console.log('cleared group ' + groupID);
+ }
+
+ // TODO: send to server
+ realityEditor.network.updateGroupings(object.ip, objectKey, frameKey, null);
+ }
+ }
+
+ /**
+ * adds single frame to group and posts to server
+ * @param {string} frameKey
+ * @param {string} objectKey
+ * @param {string} newGroup
+ */
+ function addToGroup(frameKey, objectKey, newGroup) {
+ console.log('adding to group ' + newGroup);
+ var object = realityEditor.getObject(objectKey);
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ var group = frame.groupID;
+
+ if (group !== null) {
+ removeFromGroup(frameKey, objectKey);
+ }
+
+ frame.groupID = newGroup;
+ if (newGroup in groupStruct) {
+ groupStruct[newGroup].add(frameKey);
+ }
+ else {
+ groupStruct[newGroup] = new Set([frameKey]);
+ }
+ // TODO: send to server
+ realityEditor.network.updateGroupings(object.ip, objectKey, frameKey, newGroup);
+ }
+
+ /**
+ * creates a new group from selected
+ * @param {Array..} selected
+ */
+ function createNewGroup(selected) {
+ // create new groupID
+ var newGroup = "group" + realityEditor.device.utilities.uuidTime();
+ groupStruct[newGroup] = new Set();
+
+ // add each selected to group
+ selected.forEach(function(member) {
+ var frame = realityEditor.getFrame(member.object, member.frame);
+ addToGroup(member.frame, member.object, newGroup);
+ frame.groupID = newGroup;
+ groupStruct[newGroup].add(member.frame);
+ console.log('frame ' + member.frame + ' was added to new group');
+ });
+
+ console.log('grouped in ' + newGroup);
+ }
+
+ /**
+ * Accurately calculates the screen coordinates of the corners of a frame element
+ * This can be used to draw outlines around a frame, e.g. the outline around the group of frames
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {number|undefined} buffer
+ * @return {{upperLeft: upperLeft|{x, y}|*, upperRight: upperRight|{x, y}|*, lowerLeft: lowerLeft|{x, y}|*, lowerRight: lowerRight|{x, y}|*}}
+ */
+ function getFrameCornersScreenCoordinates(objectKey, frameKey, buffer) {
+ if (typeof buffer === 'undefined') buffer = 0;
+ // new method
+ let frame = realityEditor.getFrame(objectKey, frameKey);
+ var halfWidth = parseInt(frame.frameSizeX)/2 + buffer;
+ var halfHeight = parseInt(frame.frameSizeY)/2 + buffer;
+
+ return {
+ upperLeft: realityEditor.sceneGraph.getScreenPosition(frameKey, [-halfWidth, -halfHeight, 0, 1]),
+ upperRight: realityEditor.sceneGraph.getScreenPosition(frameKey, [halfWidth, -halfHeight, 0, 1]),
+ lowerLeft: realityEditor.sceneGraph.getScreenPosition(frameKey, [-halfWidth, halfHeight, 0, 1]),
+ lowerRight: realityEditor.sceneGraph.getScreenPosition(frameKey, [halfWidth, halfHeight, 0, 1]),
+ };
+ }
+
+ /**
+ * gets all members in a group with object and frame keys
+ * @param {string} groupID
+ * @returns {Array.<{object: , frame: }>}
+ */
+ function getGroupMembers(groupID) {
+ if (!(groupID in groupStruct)) return;
+ var members = [];
+ for (var frameKey of groupStruct[groupID]) {
+ var member = {object: frameToObj[frameKey], frame: frameKey};
+ members.push(member);
+ }
+ return members;
+ }
+
+ /**
+ * Completely erases the SVG containing the hulls
+ * @param {SVGElement} svg
+ */
+ function clearHulls(svg) {
+ while (svg.lastChild) {
+ svg.removeChild(svg.firstChild);
+ }
+ }
+
+ /**
+ * iterates through all groups and creates the hulls
+ */
+ function drawGroupHulls() {
+ var svg = document.getElementById("groupSVG");
+
+ clearHulls(svg);
+
+ Object.keys(groupStruct).forEach(function(groupID) {
+ if (groupStruct[groupID].size > 1) {
+ drawHull(svg, groupStruct[groupID], groupID);
+ }
+ });
+
+ function drawHull(svg, group, groupID) {
+ var hullPoints = [];
+
+ // get the corners of frames
+ for (var frameKey of group) { // iterate over the Set
+ var objectKey = frameToObj[frameKey];
+ if (!realityEditor.gui.ar.draw.visibleObjects.hasOwnProperty(objectKey)) continue; // only draw hulls for frames on visible objects
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+
+ // make sure there is an object and frame
+ if (!frame || frame.visualization !== 'ar') continue;
+
+ var bb = getFrameCornersScreenCoordinates(objectKey, frameKey, 50);
+
+ // points.push([x, y]); // pushing center point
+ // pushing corner points
+ if (bb) {
+ Object.keys(bb).forEach(function(corner) {
+ hullPoints.push([bb[corner].x, bb[corner].y]);
+ });
+ }
+ }
+
+ if (hullPoints.length === 0) {
+ return; // if all members are in screen visualization there won't be any hull points to render in AR
+ }
+
+ // create hull points
+ var hullShape = hull(hullPoints, Infinity);
+ var hullString = '';
+ hullShape.forEach(function(pt) {
+ hullString += ' ' + pt[0] + ', ' + pt[1];
+ });
+ hullString += ' ' + hullShape[0][0] + ', ' + hullShape[0][1];
+
+ // draw hull
+ var hullSVG = document.createElementNS(svg.namespaceURI, 'polyline');
+ if (hullString.indexOf("undefined") === -1) {
+ hullSVG.setAttribute("points", hullString);
+ hullSVG.setAttribute("fill", "None");
+ hullSVG.setAttribute("stroke", "#FFF");
+ hullSVG.setAttribute("stroke-width", "5");
+ hullSVG.classList.add("hull");
+ hullSVG.id = groupID;
+ svg.appendChild(hullSVG);
+ }
+ }
+ }
+
+ /**
+ * Should be called whenever a new frame is loaded into the system,
+ * to populate the global groupStruct with any groupID information it contains
+ * @param {string} frameKey
+ * @param {Frame} thisFrame
+ * @todo trigger via subscription, not as a dependency - actually need to do this, then this module will be fully decoupled from the rest of the codebase
+ */
+ function reconstructGroupStruct(frameKey, thisFrame) {
+ // reconstructing groups from frame groupIDs
+ var group = thisFrame.groupID;
+ if (group === undefined) {
+ thisFrame.groupID = null;
+ }
+ else if (group !== null) {
+ if (group in groupStruct) {
+ groupStruct[group].add(frameKey);
+ }
+ else {
+ groupStruct[group] = new Set([frameKey]);
+ }
+ }
+ frameToObj[frameKey] = thisFrame.objectId;
+ }
+
+ exports.initService = initService;
+ exports.toggleGroupingMode = toggleGroupingMode;
+ exports.reconstructGroupStruct = reconstructGroupStruct;
+
+})(realityEditor.gui.ar.grouping);
diff --git a/src/gui/ar/index.js b/src/gui/ar/index.js
new file mode 100644
index 000000000..afc0ba2f0
--- /dev/null
+++ b/src/gui/ar/index.js
@@ -0,0 +1,528 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+
+createNameSpace("realityEditor.gui.ar");
+
+/**
+ * @fileOverview realityEditor.gui.ar.index.js
+ * Various functions related to the AR process, including setting
+ * the projection matrix, and various ways of finding closest frames and nodes.
+ */
+
+/**********************************************************************************************************************
+ **********************************************************************************************************************/
+
+/**
+ * Called from the native iOS Vuforia engine with the projection matrix for rendering to the screen correctly.
+ * Makes some adjustments based on the viewport of the device and notifies the native iOS app when it is done.
+ * @param {Array.} matrix - a 4x4 projection matrix
+ */
+realityEditor.gui.ar.setProjectionMatrix = function(matrix) {
+
+ var corX = 0;
+ var corY = 0;
+ // var scaleAdjusting = 1;
+
+ // iPhone 5(GSM), iPhone 5 (GSM+CDMA)
+ if (globalStates.device === "iPhone5,1" || globalStates.device === "iPhone5,2") {
+ corX = 0;
+ corY = -3;
+ }
+
+ // iPhone 5c (GSM), iPhone 5c (GSM+CDMA)
+ if (globalStates.device === "iPhone5,3" || globalStates.device === "iPhone5,4") {
+ // not yet tested todo add values
+ corX = 0;
+ corY = 0;
+ }
+
+ // iPhone 5s (GSM), iPhone 5s (GSM+CDMA)
+ if (globalStates.device === "iPhone6,1" || globalStates.device === "iPhone6,2") {
+ corX = -3;
+ corY = -1;
+
+ }
+
+ // iPhone 6 plus
+ if (globalStates.device === "iPhone7,1") {
+ // not yet tested todo add values
+ corX = 0;
+ corY = 0;
+ }
+
+ // iPhone 6
+ if (globalStates.device === "iPhone7,2") {
+ corX = -4.5;
+ corY = -6;
+ }
+
+ // iPhone 6s
+ if (globalStates.device === "iPhone8,1") {
+ // not yet tested todo add values
+ corX = 0;
+ corY = 0;
+ }
+
+ // iPhone 6s Plus
+ if (globalStates.device === "iPhone8,2") {
+ corX = -0.3;
+ corY = -1.5;
+ }
+ // iPhone 8
+ if (globalStates.device === "iPhone10,1") {
+ corX = 1;
+ corY = -5;
+ console.log("------------------------------------");
+ // scaleAdjusting = 0.84;
+ }
+
+ // iPad
+ if (globalStates.device === "iPad1,1") {
+ // not yet tested todo add values
+ corX = 0;
+ corY = 0;
+ }
+
+ // iPad 2 (WiFi), iPad 2 (GSM), iPad 2 (CDMA), iPad 2 (WiFi)
+ if (globalStates.device === "iPad2,1" || globalStates.device === "iPad2,2" || globalStates.device === "iPad2,3" || globalStates.device === "iPad2,4") {
+ corX = -31;
+ corY = -5;
+ }
+
+ // iPad Mini (WiFi), iPad Mini (GSM), iPad Mini (GSM+CDMA)
+ if (globalStates.device === "iPad2,5" || globalStates.device === "iPad2,6" || globalStates.device === "iPad2,7") {
+ // not yet tested todo add values
+ corX = 0;
+ corY = 0;
+ }
+
+ // iPad 3 (WiFi), iPad 3 (GSM+CDMA), iPad 3 (GSM)
+ if (globalStates.device === "iPad3,1" || globalStates.device === "iPad3,2" || globalStates.device === "iPad3,3") {
+ corX = -3;
+ corY = -1;
+ }
+ //iPad 4 (WiFi), iPad 4 (GSM), iPad 4 (GSM+CDMA)
+ if (globalStates.device === "iPad3,4" || globalStates.device === "iPad3,5" || globalStates.device === "iPad3,6") {
+ corX = -5;
+ corY = 17;
+ }
+
+ // iPad Air (WiFi), iPad Air (Cellular)
+ if (globalStates.device === "iPad4,1" || globalStates.device === "iPad4,2") {
+ // not yet tested todo add values
+ corX = 0;
+ corY = 0;
+ }
+
+ // iPad mini 2G (WiFi) iPad mini 2G (Cellular)
+ if (globalStates.device === "iPad4,4" || globalStates.device === "iPad4,5") {
+ corX = -11;
+ corY = 6.5;
+ }
+
+ // iPad Pro
+ if (globalStates.device === "iPad6,7") {
+ // TODO: make any small corrections if needed
+ }
+
+ // generate all transformations for the object that needs to be done ASAP
+ var scaleZ = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 2, 0,
+ 0, 0, 0, 1
+ ];
+
+ var multiplier = -1;
+
+ var viewportScaling = [
+ globalStates.height, 0, 0, 0,
+ 0, multiplier * globalStates.width, 0, 0,
+ 0, 0, 1, 0,
+ corX-5, corY+10, 0, 1
+ ];
+
+ // changes for iPhoneX
+ if (globalStates.device === "iPhone10,3") {
+ var scaleRatio = (globalStates.height/globalStates.width) / (568/320);
+
+ // new scale based on aspect ratio of camera feed - just use the size of the old iphone screen
+ viewportScaling[0] = 568 * scaleRatio;
+ viewportScaling[5] = -320 * scaleRatio;
+ }
+
+ var r = [];
+
+ var shouldMatrixBeFlipped = globalStates.realProjectionMatrix[0] !== globalStates.unflippedRealProjectionMatrix[0];
+
+ globalStates.unflippedRealProjectionMatrix = realityEditor.gui.ar.utilities.copyMatrix(matrix);
+ globalStates.realProjectionMatrix = realityEditor.gui.ar.utilities.copyMatrix(matrix);
+ this.utilities.multiplyMatrix(scaleZ, matrix, r);
+ this.utilities.multiplyMatrix(r, viewportScaling, globalStates.projectionMatrix);
+
+ // if setProjectionMatrix happens after onOrientationChanged, flip it if necessary
+ if (shouldMatrixBeFlipped) {
+ realityEditor.gui.ar.updateProjectionMatrix(true);
+ }
+};
+
+/**
+ * Updates the projection matrix to be rotated 180 degrees or 0 degrees based on whether the phone is upside down.
+ * @param {boolean} isFlippedUpsideDown
+ */
+realityEditor.gui.ar.updateProjectionMatrix = function(isFlippedUpsideDown) {
+ var isMatrixAlreadyFlipped = globalStates.realProjectionMatrix[0] !== globalStates.unflippedRealProjectionMatrix[0];
+
+ // rotate if screen is flipped upside down
+ if ((isFlippedUpsideDown && !isMatrixAlreadyFlipped) || (!isFlippedUpsideDown && isMatrixAlreadyFlipped)) {
+ globalStates.realProjectionMatrix[0] *= -1; // to rotate 180 degrees, just flip X and Y coordinates
+ globalStates.realProjectionMatrix[5] *= -1;
+ globalStates.projectionMatrix[0] *= -1; // needs to update both the projection and realProjection matrices
+ globalStates.projectionMatrix[5] *= -1;
+ }
+};
+
+/**
+ * Returns a list of nodes that are visible and within the screen bounds.
+ * @return {Array.<{objectKey: string, frameKey: string, nodeKey: string}>}
+ */
+realityEditor.gui.ar.getVisibleNodes = function() {
+ var visibleNodes = [];
+
+ for (var objectKey in objects) {
+ for (var frameKey in objects[objectKey].frames) {
+ var thisFrame = realityEditor.getFrame(objectKey, frameKey);
+ if (!thisFrame) continue;
+ if (realityEditor.gui.ar.draw.visibleObjects.hasOwnProperty(objectKey)) { // this is a way to check which objects are currently visible
+ // var thisObject = objects[objectKey];
+
+ for (var nodeKey in thisFrame.nodes) {
+ if (!thisFrame.nodes.hasOwnProperty(nodeKey)) continue;
+
+ if (realityEditor.gui.ar.utilities.isNodeWithinScreen(thisFrame, nodeKey)) {
+ visibleNodes.push({
+ objectKey: objectKey,
+ frameKey: frameKey,
+ nodeKey: nodeKey
+ });
+ }
+ }
+ }
+ }
+ }
+ return visibleNodes;
+};
+
+/**
+ * Given a list of visible nodes (generated by this.getVisibleNodes), returns a list of any links to or from them.
+ * @param {Array.<{objectKey: string, frameKey: string, nodeKey: string}>} visibleNodes
+ * @return {Array.<{objectKey: string, frameKey: string, linkKey: string}>}
+ */
+realityEditor.gui.ar.getVisibleLinks = function(visibleNodes) {
+
+ var visibleNodeKeys = visibleNodes.map(function(keys){return keys.nodeKey;});
+
+ var visibleLinks = [];
+
+ for (var objectKey in objects) {
+ for (var frameKey in objects[objectKey].frames) {
+ var thisFrame = realityEditor.getFrame(objectKey, frameKey);
+ if (!thisFrame) continue;
+
+ for (var linkKey in thisFrame.links) {
+ if (!thisFrame.links.hasOwnProperty(linkKey)) continue;
+ var thisLink = thisFrame.links[linkKey];
+
+ var isVisibleNodeA = visibleNodeKeys.indexOf(thisLink.nodeA) > -1;
+ var isVisibleNodeB = visibleNodeKeys.indexOf(thisLink.nodeB) > -1;
+
+ if (isVisibleNodeA || isVisibleNodeB) {
+ visibleLinks.push({
+ objectKey: objectKey,
+ frameKey: frameKey,
+ linkKey: linkKey
+ });
+ }
+ }
+ }
+ }
+
+ console.log("visibleLinks = ", visibleLinks);
+ return visibleLinks;
+};
+
+/**
+ * @desc Object reference
+ **/
+realityEditor.gui.ar.objects = objects;
+
+realityEditor.gui.ar.MAX_DISTANCE = 10000000000;
+
+realityEditor.gui.ar.closestObjectFilters = [];
+
+/**
+ * Allows add-ons to check each objectKey in visible objects and reject them from being considered closest
+ * @param {function} filterFunction
+ */
+realityEditor.gui.ar.injectClosestObjectFilter = function(filterFunction) {
+ this.closestObjectFilters.push(filterFunction);
+}
+
+/**
+ * This function returns the closest visible object relative to the camera.
+ * Priority: 1) closest non-world objects. 2) closest world objects other than localWorld object. 3) local world object
+ * Accepts an optional filter that will be applied to each object key to restrict which objects are considered.
+ * @param {function} optionalFilter - a function used to narrow down which objects to consider. takes in an object key. if it returns false, ignore that object.
+ * @return {Array.} [ObjectKey, null, null]
+ **/
+realityEditor.gui.ar.getClosestObject = function (optionalFilter) {
+ var object = null;
+ var frame = null;
+ var node = null;
+
+ // first looks for visible non-world objects
+ var info = this.closestVisibleObject(function(objectKey) {
+ if (typeof optionalFilter !== 'undefined') {
+ if (!optionalFilter(objectKey)) {
+ return false;
+ }
+ }
+ for (let i = 0; i < realityEditor.gui.ar.closestObjectFilters.length; i++) {
+ if (!realityEditor.gui.ar.closestObjectFilters[i](objectKey)) {
+ return false;
+ }
+ }
+ return (typeof objects[objectKey] !== 'undefined') && !realityEditor.worldObjects.isWorldObjectKey(objectKey);
+ });
+
+ // if no visible non-world objects, get the closest non-local-world object
+ if (!info.objectKey) {
+ info = this.closestVisibleObject(function(objectKey) {
+ if (typeof optionalFilter !== 'undefined') {
+ if (!optionalFilter(objectKey)) {
+ return false;
+ }
+ }
+ return realityEditor.worldObjects.isWorldObjectKey(objectKey) && objectKey !== realityEditor.worldObjects.getLocalWorldId();
+ });
+ }
+
+ // if no non-local-world object, see if the local world object passes the filter and use it as a last resort
+ if (!info.objectKey) {
+ info = this.closestVisibleObject(function(objectKey) {
+ if (typeof optionalFilter !== 'undefined') {
+ if (!optionalFilter(objectKey)) {
+ return false;
+ }
+ }
+ return objectKey === realityEditor.worldObjects.getLocalWorldId();
+ });
+ }
+
+ object = info.objectKey;
+
+ return [object, frame, node];
+};
+
+/**
+ * Reusable function that will return the object closest to the camera that passes whatever conditions you specify.
+ * @param {function|undefined} optionalFilter - function that takes in an object key and returns true or false.
+ * @return {{distance: number, objectKey: string}}
+ */
+realityEditor.gui.ar.closestVisibleObject = function(optionalFilter) {
+ var object = null;
+ var closest = this.MAX_DISTANCE;
+ var distance = this.MAX_DISTANCE;
+
+ for (var objectKey in realityEditor.gui.ar.draw.visibleObjects) {
+ if (typeof optionalFilter !== 'undefined') {
+ if (!optionalFilter(objectKey)) {
+ continue;
+ }
+ }
+
+ // distance is computed from modelViewMatrices rather than un-modified visibleObject matrices to be compatible
+ // with both regular objects and anchor objects
+ distance = realityEditor.sceneGraph.getDistanceToCamera(objectKey);
+
+ if (distance < closest) {
+ object = objectKey;
+ closest = distance;
+ }
+ }
+
+ return {
+ objectKey: object,
+ distance: distance
+ }
+};
+
+/**
+ * @desc This function returns the closest visible frame relative to the camera.
+ * @param filterFunction - optional function applied to each frame. return true if you want to include that frame in the search, false to ignore.
+ * for example, function localARFilter(frame) {return frame.visualization !== 'screen' && frame.location === 'local';}
+ * @return {Array.} [ObjectKey, FrameKey, null]
+ **/
+realityEditor.gui.ar.getClosestFrame = function (filterFunction) {
+ var object = null;
+ var frame = null;
+ var node = null;
+ var closest = 10000000000;
+ var distance = 10000000000;
+
+ for (var objectKey in realityEditor.gui.ar.draw.visibleObjects) {
+ for(var frameKey in this.objects[objectKey].frames) {
+
+ // apply an additional filter, e.g.
+ if (filterFunction) {
+ if (!filterFunction(this.objects[objectKey].frames[frameKey])) continue;
+ }
+
+ distance = realityEditor.sceneGraph.getDistanceToCamera(frameKey);
+ if (distance < closest) {
+ object = objectKey;
+ frame = frameKey;
+ closest = distance;
+ }
+
+ }
+ }
+
+ return [object, frame, node];
+};
+
+/**
+ * @desc This function returns the closest visible node relative to the camera.
+ * @return {Array.} [ObjectKey, FrameKey, NodeKey]
+ **/
+realityEditor.gui.ar.getClosestNode = function () {
+ var object = null;
+ var frame = null;
+ var node = null;
+ var closest = 10000000000;
+ var distance = 10000000000;
+
+ for (var objectKey in realityEditor.gui.ar.draw.visibleObjects) {
+ for(var frameKey in this.objects[objectKey].frames) {
+ for(var nodeKey in this.objects[objectKey].frames[frameKey].nodes) {
+
+ // don't include hidden node types (e.g. dataStore) when finding closest
+ let thisNode = realityEditor.getNode(objectKey, frameKey, nodeKey);
+ if (realityEditor.gui.ar.draw.hiddenNodeTypes.indexOf(thisNode.type) > -1) {
+ break;
+ }
+ // the above check is deprecated: new nodes will have an invisible property
+ if (thisNode.invisible) { break; }
+
+ distance = realityEditor.sceneGraph.getDistanceToCamera(nodeKey);
+ if (distance < closest) {
+ object = objectKey;
+ frame = frameKey;
+ node = nodeKey;
+ closest = distance;
+ }
+ }
+ }
+ }
+ return [object, frame, node];
+};
+
+/**
+ * Returns the frame whose center screen coordinate is closest to the specified screen coordinate.
+ * @param {number} screenX
+ * @param {number} screenY
+ * @return {Array.} [ObjectKey, FrameKey, NodeKey]
+ */
+realityEditor.gui.ar.getClosestFrameToScreenCoordinates = function(screenX, screenY) {
+ var object = null;
+ var frame = null;
+ var node = null;
+ var closest = 10000000000;
+ var distance = 10000000000;
+
+ for (var objectKey in realityEditor.gui.ar.draw.visibleObjects) {
+ for(var frameKey in this.objects[objectKey].frames) {
+ distance = realityEditor.sceneGraph.getDistanceToCamera(frameKey);
+
+ var thisFrame = realityEditor.getFrame(objectKey, frameKey);
+ var dx = screenX - thisFrame.screenX;
+ var dy = screenY - thisFrame.screenY;
+ distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (distance < closest) {
+ object = objectKey;
+ frame = frameKey;
+ closest = distance;
+ }
+
+ }
+ }
+ return [object, frame, node];
+};
+
+/**
+ * If you pass in a frame, returns its distanceScale.
+ * If you pass in a node, returns the distanceScale of the frame it belongs to.
+ * If the frame doesn't have a value, defaults to 1.0
+ * @param {Frame|Node} activeVehicle
+ * @return {number}
+ */
+realityEditor.gui.ar.getDistanceScale = function(activeVehicle) {
+ var keys = realityEditor.getKeysFromVehicle(activeVehicle);
+ if (keys.nodeKey) {
+ // it's a node, return its parent frame's value
+ var parentFrame = realityEditor.getFrame(keys.objectKey, keys.frameKey);
+ if (!parentFrame) { return 1; }
+ return parentFrame.distanceScale || 1;
+ } else {
+ // it's a frame, return its own value
+ return activeVehicle.distanceScale || 1;
+ }
+};
diff --git a/src/gui/ar/lines.js b/src/gui/ar/lines.js
new file mode 100644
index 000000000..b16a09f2b
--- /dev/null
+++ b/src/gui/ar/lines.js
@@ -0,0 +1,622 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+
+createNameSpace("realityEditor.gui.ar.lines");
+
+/**
+ * @fileOverview realityEditor.gui.ar.lines.js
+ * Contains all the functions for rendering different types of links, lines, and circles on the background canvas.
+ * Also contains logic for deleting lines crossed by a cutting line.
+ */
+
+/**********************************************************************************************************************
+ **********************************************************************************************************************/
+
+
+/**
+ * Any modules can add a function that receives a pending link action
+ * (objectKey, frameKey, linkKey, "delete")
+ * abd, if it returns false, prevents the link action from occurring
+ * @type {Array}
+ */
+realityEditor.gui.ar.lines.linkActionFilters = [];
+
+/**
+ * returns true if allowed, false if not allowed, given (objectKey, frameKey, linkKey, "delete")
+ * @param {function} filterFunction
+ */
+realityEditor.gui.ar.lines.registerLinkActionFilter = function(filterFunction) {
+ this.linkActionFilters.push(filterFunction);
+};
+
+/**
+ * Deletes ("cuts") any links who cross the line between (x1, y1) and (x2, y2)
+ * @param {number} x1
+ * @param {number} y1
+ * @param {number} x2
+ * @param {number} y2
+ */
+realityEditor.gui.ar.lines.deleteLines = function(x1, y1, x2, y2) {
+
+ // window.location.href = "of://gotsome";
+ for (var objectKey in objects) {
+ if (!objects.hasOwnProperty(objectKey)) continue;
+ var thisObject = realityEditor.getObject(objectKey);
+ for (var frameKey in objects[objectKey].frames) {
+
+ var thisFrame = realityEditor.getFrame(objectKey, frameKey);
+
+ if (!thisFrame) {
+ continue;
+ }
+
+ // if (!thisFrame.objectVisible) {
+ // continue;
+ // }
+
+ for (var linkKey in thisFrame.links) {
+ if (!thisFrame.links.hasOwnProperty(linkKey)) continue;
+
+ var link = thisFrame.links[linkKey];
+ var frameA = thisFrame;
+ var frameB = realityEditor.getFrame(link.objectB, link.frameB);
+
+ if (!frameA || !frameB || (!frameA.objectVisible && !frameB.objectVisible)) {
+ continue;
+ }
+
+ var nodeA = frameA.nodes[link.nodeA];
+ var nodeB = frameB.nodes[link.nodeB];
+
+ if (!nodeA || !nodeB) {
+ continue;
+ }
+
+ if (this.realityEditor.gui.utilities.checkLineCross(nodeA.screenX, nodeA.screenY, nodeB.screenX, nodeB.screenY, x1, y1, x2, y2, globalCanvas.canvas.width, globalCanvas.canvas.height)) {
+
+ let isActionAllowed = true;
+ this.linkActionFilters.forEach(function(filterFunction) {
+ if (!filterFunction(objectKey, frameKey, linkKey, "delete")) {
+ isActionAllowed = false;
+ }
+ });
+
+ if (isActionAllowed) {
+ delete thisFrame.links[linkKey];
+ this.cout("iam executing link deletion");
+ //todo this is a work around to not crash the server. only temporarly for testing
+ // if(link.logicA === false && link.logicB === false)
+ realityEditor.network.deleteLinkFromObject(thisObject.ip, objectKey, frameKey, linkKey);
+ }
+ }
+ }
+ }
+ }
+
+};
+
+/**
+ * Renders all links who start from a node on the given frame, drawn onto the provided HTML canvas context reference.
+ * @param {Frame} thisFrame
+ * @param {CanvasRenderingContext2D} context
+ */
+realityEditor.gui.ar.lines.drawAllLines = function (thisFrame, context) {
+
+ // if (globalStates.editingMode || (realityEditor.device.editingState.node && realityEditor.device.currentScreenTouches.length > 1)) {
+ // return;
+ // }
+
+ if(!thisFrame) return;
+ for (var linkKey in thisFrame.links) {
+ if (!thisFrame.links.hasOwnProperty(linkKey)) continue;
+
+ var link = thisFrame.links[linkKey];
+ var frameA = thisFrame;
+ var frameB = realityEditor.getFrame(link.objectB, link.frameB);
+ var objectA = realityEditor.getObject(link.objectA);
+ var objectB = realityEditor.getObject(link.objectB);
+ var nodeASize = 0;
+ var nodeBSize = 0;
+
+ if (isNaN(link.ballAnimationCount)) {
+ link.ballAnimationCount = 0;
+ }
+
+ if (!frameA || !frameB) {
+ continue; // should not be undefined
+ }
+
+ var nodeA = frameA.nodes[link.nodeA];
+ var nodeB = frameB.nodes[link.nodeB];
+
+ if (!nodeA || !nodeB) {
+ continue; // should not be undefined
+ }
+
+ // Don't draw off-screen lines
+ if ( (!frameB.objectVisible && !frameA.objectVisible) || (nodeA.screenZ < -200 && nodeB.screenZ < -200) ) {
+ continue;
+ }
+
+ if (!frameB.objectVisible || nodeB.screenZ < -200) {
+
+ if (nodeB.screenZ > -200 && (objectB.memory && Object.keys(objectB.memory).length > 0)) {
+ let memoryPointer = realityEditor.gui.memory.getMemoryPointerWithId(link.objectB); // TODO: frameId or objectId?
+ if (!memoryPointer) {
+ memoryPointer = new realityEditor.gui.memory.MemoryPointer(link, false);
+ }
+
+ nodeB.screenX = memoryPointer.x;
+ nodeB.screenY = memoryPointer.y;
+ nodeB.screenZ = nodeA.screenZ;
+
+ if (memoryPointer.memory.imageLoaded && memoryPointer.memory.image.naturalWidth === 0 && memoryPointer.memory.image.naturalHeight === 0) {
+ nodeB.screenX = nodeA.screenX;
+ nodeB.screenY = -10;
+ delete objectB.memory;
+ } else {
+ memoryPointer.draw();
+ }
+ } else {
+ nodeB.screenX = nodeA.screenX;
+ nodeB.screenY = -10;
+ nodeB.screenZ = nodeA.screenZ;
+ }
+ nodeB.screenZ = nodeA.screenZ;
+ nodeB.screenLinearZ = nodeA.screenLinearZ;
+ nodeBSize = objectA.averageScale;
+ }
+
+ if (!frameA.objectVisible || nodeA.screenZ < 0) {
+ if (nodeA.screenZ > -200 && (objectA.memory && Object.keys(objectA.memory).length > 0)) {
+ let memoryPointer = realityEditor.gui.memory.getMemoryPointerWithId(link.objectA);
+ if (!memoryPointer) {
+ memoryPointer = new realityEditor.gui.memory.MemoryPointer(link, true);
+ }
+
+ nodeA.screenX = memoryPointer.x;
+ nodeA.screenY = memoryPointer.y;
+
+ if (memoryPointer.memory.imageLoaded && memoryPointer.memory.image.naturalWidth === 0 && memoryPointer.memory.image.naturalHeight === 0) {
+ nodeA.screenX = nodeB.screenX;
+ nodeB.screenY = -10;
+ delete objectA.memory;
+ } else {
+ memoryPointer.draw();
+ }
+ } else {
+ nodeA.screenX = nodeB.screenX;
+ nodeA.screenY = -10;
+ nodeA.screenZ = nodeB.screenZ;
+ }
+ nodeA.screenZ = nodeB.screenZ;
+ nodeA.screenLinearZ = nodeB.screenLinearZ;
+ nodeASize = objectB.averageScale
+ }
+
+ if(!nodeASize) nodeASize = objectA.averageScale;
+ if(!nodeBSize) nodeBSize = objectB.averageScale;
+
+ // linearize a non linear zBuffer (see index.js)
+ // It needs to be a scale factor relative to the nnode scale!
+ var nodeAScreenZ = nodeA.screenLinearZ*(nodeA.scale*1.5);
+ var nodeBScreenZ = nodeB.screenLinearZ*(nodeB.scale*1.5);
+
+ var logicA;
+ if (link.logicA == null || link.logicA === false) {
+ logicA = 4;
+ } else {
+ logicA = link.logicA;
+ }
+
+ var logicB;
+ if (link.logicB == null || link.logicB === false) {
+ logicB = 4;
+ } else {
+ logicB = link.logicB;
+ }
+
+ if(typeof nodeA.screenOpacity === 'undefined') nodeA.screenOpacity = 1.0;
+ if(typeof nodeB.screenOpacity === 'undefined') nodeB.screenOpacity = 1.0;
+ var speed = 1;
+ // don't waste resources drawing it if both sides are invisible
+ if (nodeA.screenOpacity > 0 || nodeB.screenOpacity > 0) {
+ // only draw lines in front of camera, otherwise we can get really slow/long lines
+ this.drawLine(context, [nodeA.screenX, nodeA.screenY], [nodeB.screenX, nodeB.screenY], nodeAScreenZ, nodeBScreenZ, link, timeCorrection, logicA, logicB, speed, nodeA.screenOpacity,nodeB.screenOpacity);
+ }
+ }
+ // context.fill();
+
+ globalCanvas.hasContent = true;
+};
+
+/**
+ * Draws a link from its start position to the touch position, if you are currently adding one.
+ * Draws the "cut" line to the touch position, if you are currently drawing one to delete links.
+ */
+realityEditor.gui.ar.lines.drawInteractionLines = function () {
+
+ if (globalStates.editingMode || realityEditor.device.editingState.node) {
+ return;
+ }
+
+ // this function here needs to be more precise
+
+ if (globalProgram.objectA) {
+
+ var objectA = realityEditor.getObject(globalProgram.objectA);
+ var nodeA = realityEditor.getNode(globalProgram.objectA, globalProgram.frameA, globalProgram.nodeA);
+
+ // this is for making sure that the line is drawn out of the screen... Don't know why this got lost somewhere down the road.
+ // linearize a non linear zBuffer
+
+ // map the linearized zBuffer to the final ball size
+ if (!objectA.objectVisible) {
+ nodeA.screenX = globalStates.pointerPosition[0];
+ nodeA.screenY = -10;
+ nodeA.screenZ = 6;
+
+ } else if(nodeA.screenLinearZ) {
+ nodeA.screenZ = nodeA.screenLinearZ*nodeA.scale*1.5;
+ }
+
+ var logicA = globalProgram.logicA;
+ if (globalProgram.logicA === false) {
+ logicA = 4;
+ }
+
+ if(typeof nodeA.screenOpacity === 'undefined') nodeA.screenOpacity = 1.0;
+ var speed = 1;
+ let lineWeight = nodeA.screenZ; // end of line will have same weight as start of line
+ this.drawLine(globalCanvas.context, [nodeA.screenX, nodeA.screenY], [globalStates.pointerPosition[0], globalStates.pointerPosition[1]], lineWeight, lineWeight, globalStates, timeCorrection, logicA, globalProgram.logicSelector, speed, nodeA.screenOpacity, 1);
+ }
+
+ if (globalStates.drawDotLine) { // this is the cutting line
+ this.drawDotLine(globalCanvas.context, [globalStates.drawDotLineX, globalStates.drawDotLineY], [globalStates.pointerPosition[0], globalStates.pointerPosition[1]]);
+ }
+
+ globalCanvas.hasContent = true;
+};
+
+/**********************************************************************************************************************
+ **********************************************************************************************************************/
+
+/**
+ * Draws a link object and animates it over time.
+ * @param {CanvasRenderingContext2D} context - canvas rendering context
+ * @param {[number, number]} lineStartPoint - the [x, y] coordinate of the start of a line
+ * @param {[number, number]} lineEndPoint - the [x, y] coordinate of the end of a line
+ * @param {number} lineStartWeight - width of a line at start (used to fake 3d depth)
+ * @param {number} lineEndWeight - width of a line at end (used to fake 3d depth)
+ * @param {Link} linkObject - the full link data object, including an added ballAnimationCount property
+ * @param {number} timeCorrector - automatically regulates the animation speed according to the frameRate
+ * @param {number} startColor - white for regular links, colored for logic links (0 = Blue, 1 = Green, 2 = Yellow, 3 = Red, 4 = White)
+ * @param {number} endColor - same mapping as startColor
+ * @param {number|undefined} speed - optionally adjusts how quickly the animation moves
+ * @param {number|undefined} lineAlphaStart - the opacity of the start of the line (range: 0-1)
+ * @param {number|undefined} lineAlphaEnd - the opacity of the end of the line (range: 0-1)
+ *
+ *
+ *
+ */
+realityEditor.gui.ar.lines.angle = 0;
+realityEditor.gui.ar.lines.positionDelta = 0;
+realityEditor.gui.ar.lines.length1 = 0;
+realityEditor.gui.ar.lines.length2 = 0;
+realityEditor.gui.ar.lines.lineVectorLength = 0;
+realityEditor.gui.ar.lines.keepColor = 0;
+realityEditor.gui.ar.lines.spacer = 0;
+realityEditor.gui.ar.lines.ratio = 0;
+realityEditor.gui.ar.lines.mathPI = 2*Math.PI;
+realityEditor.gui.ar.lines.newColor = 0;
+realityEditor.gui.ar.lines.ballPosition = 0;
+realityEditor.gui.ar.lines.colors = 0;
+realityEditor.gui.ar.lines.ballSize = 0;
+realityEditor.gui.ar.lines.x__ = 0;
+realityEditor.gui.ar.lines.y__ = 0;
+realityEditor.gui.ar.lines.ballPosition = 0;
+realityEditor.gui.ar.lines.width = globalStates.width;
+realityEditor.gui.ar.lines.height = globalStates.height;
+realityEditor.gui.ar.lines.extendedBorder = 200;
+realityEditor.gui.ar.lines.extendedBorderNegative = -200;
+realityEditor.gui.ar.lines.nodeExistsA = true;
+realityEditor.gui.ar.lines.nodeExistsB = true;
+
+realityEditor.gui.ar.lines.drawLine = function(context, lineStartPoint, lineEndPoint, lineStartWeight, lineEndWeight, linkObject, timeCorrector, startColor, endColor, speed, lineAlphaStart, lineAlphaEnd) {
+ this.nodeExistsA = true;
+ this.nodeExistsB = true;
+
+
+ if (lineStartPoint[0] < this.extendedBorderNegative) {
+ lineStartPoint[0] = this.extendedBorderNegative;
+ this.nodeExistsA = false;
+ }
+ if (lineStartPoint[1] < this.extendedBorderNegative) {
+ lineStartPoint[1] = this.extendedBorderNegative;
+ this.nodeExistsA = false;
+ }
+ if (lineEndPoint[0] < this.extendedBorderNegative) {
+ lineEndPoint[0] = this.extendedBorderNegative;
+ this.nodeExistsB = false;
+ }
+ if (lineEndPoint[1] < this.extendedBorderNegative) {
+ lineEndPoint[1] = this.extendedBorderNegative;
+ this.nodeExistsB = false;
+ }
+ if (lineStartPoint[0] > globalStates.height+this.extendedBorder) {
+ lineStartPoint[0] = globalStates.height+this.extendedBorder;
+ this.nodeExistsA = false;
+ }
+ if (lineStartPoint[1] > globalStates.width+this.extendedBorder) {
+ lineStartPoint[1] = globalStates.width+this.extendedBorder;
+ this.nodeExistsA = false;
+ }
+ if (lineEndPoint[0] > globalStates.height+this.extendedBorder) {
+ lineEndPoint[0] = globalStates.height+this.extendedBorder;
+ this.nodeExistsB = false;
+ }
+ if (lineEndPoint[1] > globalStates.width+this.extendedBorder) {
+ lineEndPoint[1] = globalStates.width+this.extendedBorder;
+ this.nodeExistsB = false;
+ }
+
+ if( !this.nodeExistsB && !this.nodeExistsA){
+ return;
+ }
+ if(!this.nodeExistsB){
+ lineEndWeight = lineStartWeight;
+ }
+ if(!this.nodeExistsA){
+ lineStartWeight = lineEndWeight;
+ }
+
+ lineStartWeight *= realityEditor.device.environment.getLineWidthMultiplier();
+ lineEndWeight *= realityEditor.device.environment.getLineWidthMultiplier();
+
+ if (typeof lineAlphaStart === 'undefined') lineAlphaStart = 1.0;
+ if (typeof lineAlphaEnd === 'undefined') lineAlphaEnd = 1.0;
+ if (!speed) speed = 1;
+ this.angle = Math.atan2((lineStartPoint[1] - lineEndPoint[1]), (lineStartPoint[0] - lineEndPoint[0]));
+ this.positionDelta = 0;
+ this.length1 = lineEndPoint[0] - lineStartPoint[0];
+ this.length2 = lineEndPoint[1] - lineStartPoint[1];
+ this.lineVectorLength = Math.sqrt(this.length1 * this.length1 + this.length2 * this.length2);
+ //this.keepColor = this.lineVectorLength / 6;
+ this.spacer = 2.3;
+ this.ratio = 0;
+ this.newColor = [255,255,255,0];
+
+ // TODO: temporary solution to render lock information for this link
+
+ if (linkObject.lockPassword) {
+ if (linkObject.lockType === "full") {
+ lineAlphaEnd = lineAlphaEnd/4;
+ } else if (linkObject.lockType === "half") {
+ lineAlphaEnd = lineAlphaEnd/4*3;
+ }
+ }
+
+ this.colors = [[0,255,255], // Blue
+ [0,255,0], // Green
+ [255,255,0], // Yellow
+ [255,0,124], // Red
+ [255,255,255]]; // White
+
+ // if startColor/endColor format is an RGB array
+ if (Array.isArray(startColor)) {
+ this.colors.push(startColor);
+ startColor = this.colors.length - 1;
+ }
+ if (Array.isArray(endColor)) {
+ this.colors.push(endColor);
+ endColor = this.colors.length - 1;
+ }
+ // console.log(this.colors, startColor, endColor);
+
+ if (linkObject.ballAnimationCount >= lineStartWeight * this.spacer) linkObject.ballAnimationCount = 0;
+
+ context.beginPath();
+ context.fillStyle = "rgba("+this.newColor+")";
+ context.arc(lineStartPoint[0],lineStartPoint[1], lineStartWeight, 0, 2*Math.PI);
+ context.fill();
+
+ while (this.positionDelta + linkObject.ballAnimationCount < this.lineVectorLength) {
+ this.ballPosition = this.positionDelta + linkObject.ballAnimationCount;
+
+ this.ratio = this.ar.utilities.map(this.ballPosition, 0, this.lineVectorLength, 0, 1);
+ for (var i = 0; i < 3; i++) {
+ this.newColor[i] = (Math.floor(parseInt(this.colors[startColor][i], 10) + (this.colors[endColor][i] - this.colors[startColor][i]) * this.ratio));
+ }
+ this.newColor[3] = (lineAlphaStart + (lineAlphaEnd - lineAlphaStart) * this.ratio);
+
+ this.ballSize = this.ar.utilities.map(this.ballPosition, 0, this.lineVectorLength, lineStartWeight, lineEndWeight);
+
+ this.x__ = lineStartPoint[0] - Math.cos(this.angle) * this.ballPosition;
+ this.y__ = lineStartPoint[1] - Math.sin(this.angle) * this.ballPosition;
+ this.positionDelta += this.ballSize * this.spacer;
+ context.beginPath();
+ context.fillStyle = "rgba("+this.newColor+")";
+ context.arc(this.x__, this.y__, this.ballSize, 0, this.mathPI);
+ context.fill();
+ }
+
+ context.beginPath();
+ context.fillStyle = "rgba("+this.newColor+")";
+ context.arc(lineEndPoint[0],lineEndPoint[1], lineEndWeight, 0, 2*Math.PI);
+ context.fill();
+
+ linkObject.ballAnimationCount += (lineStartWeight * timeCorrector.delta)+speed;
+};
+
+/**
+ * @todo is this used anymore? unclear what it is used for.
+ * @param cxt
+ * @param weight
+ * @param object
+ */
+realityEditor.gui.ar.lines.transform = function (cxt, weight, object){
+ var n = object;
+ if(!n) return;
+ /* var m = n.mostRecentFinalMatrix;
+ var offset = m[15];
+ var xx =n.scale;
+
+ */
+ cxt.beginPath();
+ // cxt.setTransform((m[0]/offset)*xx, (m[1]/offset)*xx, (m[4]/offset)*xx,(m[5]/offset)*xx, n.screenX,n.screenY);
+ cxt.arc(n.screenX,n.screenY, weight, 0, 2*Math.PI);
+ cxt.fill();
+
+
+};
+/**********************************************************************************************************************
+ **********************************************************************************************************************/
+
+/**
+ * Draws the dotted line used to cut links, between the start and end coordinates.
+ * @param {CanvasRenderingContext2D} context
+ * @param {[number, number]} lineStartPoint
+ * @param {[number, number]} lineEndPoint
+ */
+realityEditor.gui.ar.lines.drawDotLine = function(context, lineStartPoint, lineEndPoint) {
+ context.beginPath();
+ context.moveTo(lineStartPoint[0], lineStartPoint[1]);
+ context.lineTo(lineEndPoint[0], lineEndPoint[1]);
+ context.setLineDash([7]);
+ context.lineWidth = 2;
+ context.strokeStyle = "#ff019f";//"#00fdff";
+ context.stroke();
+ context.closePath();
+};
+
+/**
+ * Draws a green, dashed, circular line.
+ * @param {CanvasRenderingContext2D} context
+ * @param {[number, number]} circleCenterPoint
+ * @param {number} radius
+ */
+realityEditor.gui.ar.lines.drawGreen = function(context, circleCenterPoint, radius) {
+ context.beginPath();
+ context.arc(circleCenterPoint[0], circleCenterPoint[1], radius, 0, Math.PI * 2);
+ context.strokeStyle = "#7bff08";
+ context.lineWidth = 2;
+ context.setLineDash([7]);
+ context.stroke();
+ context.closePath();
+
+};
+
+/**
+ * Draws a red, dashed, circular line.
+ * @param {CanvasRenderingContext2D} context
+ * @param {[number, number]} circleCenterPoint
+ * @param {number} radius
+ */
+realityEditor.gui.ar.lines.drawRed = function(context, circleCenterPoint, radius) {
+ context.beginPath();
+ context.arc(circleCenterPoint[0], circleCenterPoint[1], radius, 0, Math.PI * 2);
+ context.strokeStyle = "#ff036a";
+ context.lineWidth = 2;
+ context.setLineDash([7]);
+ context.stroke();
+ context.closePath();
+};
+
+/**
+ * Draws a blue, dashed, circular line.
+ * @param {CanvasRenderingContext2D} context
+ * @param {[number, number]} circleCenterPoint
+ * @param {number} radius
+ */
+realityEditor.gui.ar.lines.drawBlue = function(context, circleCenterPoint, radius) {
+ context.beginPath();
+ context.arc(circleCenterPoint[0], circleCenterPoint[1], radius, 0, Math.PI * 2);
+ context.strokeStyle = "#01fffd";
+ context.lineWidth = 2;
+ context.setLineDash([7]);
+ context.stroke();
+ context.closePath();
+};
+
+/**
+ * Draws a yellow, dashed, circular line.
+ * @param {CanvasRenderingContext2D} context
+ * @param {[number, number]} circleCenterPoint
+ * @param {number} radius
+ */
+realityEditor.gui.ar.lines.drawYellow = function(context, circleCenterPoint, radius) {
+ context.beginPath();
+ context.arc(circleCenterPoint[0], circleCenterPoint[1], radius, 0, Math.PI * 2);
+ context.strokeStyle = "#FFFF00";
+ context.lineWidth = 2;
+ context.setLineDash([7]);
+ context.stroke();
+ context.closePath();
+};
+
+/**
+ * Utility for drawing a line in the provided canvas context with the given coordinates, color, and width.
+ * @param {CanvasRenderingContext2D} context
+ * @param {number} startX
+ * @param {number} startY
+ * @param {number} endX
+ * @param {number} endY
+ * @param {string} color
+ * @param {number} width
+ */
+realityEditor.gui.ar.lines.drawSimpleLine = function(context, startX, startY, endX, endY, color, width) {
+ context.strokeStyle = color;
+ context.lineWidth = width;
+ context.setLineDash([]);
+ context.beginPath();
+ context.moveTo(startX, startY);
+ context.lineTo(endX, endY);
+ context.stroke();
+};
diff --git a/src/gui/ar/meshPath.js b/src/gui/ar/meshPath.js
new file mode 100644
index 000000000..6055942a0
--- /dev/null
+++ b/src/gui/ar/meshPath.js
@@ -0,0 +1,454 @@
+import * as THREE from '../../../thirdPartyCode/three/three.module.js';
+
+let cachedMaterials = {};
+
+let VERT_PATH = Object.freeze({
+ x: 'x',
+ y: 'y',
+ z: 'z',
+ top: 'top',
+ bottom: 'bottom',
+ left: 'left',
+ right: 'right',
+ start: 'start',
+ end: 'end'
+});
+const POSITIONS_PER_POINT = 24; // each point on the path has 8 triangles
+const COMPONENTS_PER_POSITION = 3; // each vertex has 3 position components (x,y,z)
+const COMPONENTS_PER_COLOR = 4; // each color has 4 components (r,g,b,a)
+
+// Vertex shader
+const vertexShader = `
+ attribute vec4 color;
+
+ varying vec4 vColor;
+
+ void main() {
+ vColor = color;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+`;
+
+// Fragment shader
+const fragmentShader = `
+ varying vec4 vColor;
+
+ void main() {
+ gl_FragColor = vec4(vColor.rgb, vColor.a);
+ }
+`;
+
+/**
+ * MeshPath is similar to MeshLine but is an extruded rectangular path where you can also specify color and size
+ * for each of the points on the path. It is aligned so that its top faces the global up vector. Different colors can
+ * be given to its "horizontal" vs its "wall" faces.
+ */
+export class MeshPath extends THREE.Group
+{
+ constructor(path, {widthMm, heightMm, horizontalColor, wallColor, wallBrightness, usePerVertexColors, bottomScale, opacity, colorBlending}) {
+ super();
+
+ this.widthMm = widthMm || 10; // 10mm default
+ this.heightMm = heightMm || 10;
+ this.horizontalColor = horizontalColor || 0xFFFFFF;
+ this.wallColor = wallColor || 0xABABAB;
+ this.wallBrightness = wallBrightness || 0.8; // sides are by default a bit darker than the horizontal, to make more visible
+ this.usePerVertexColors = usePerVertexColors || false;
+ this.bottomScale = bottomScale || 1; // if > 1, bottom of path flares out a bit to make sides more visible
+ this.opacity = opacity || 1;
+ // if true, multiplies perVertexColors by horizontal and wallColors - otherwise vertexColors override the default
+ this.colorBlending = colorBlending || false;
+
+ this.horizontalPositionsBuffer = [];
+ this.wallPositionsBuffer = [];
+ this.horizontalColorsBuffer = [];
+ this.wallColorsBuffer = [];
+
+ this.setPoints(path);
+ }
+
+ resetPoints() {
+ this.horizontalPositionsBuffer = [];
+ this.horizontalColorsBuffer = [];
+ this.wallPositionsBuffer = [];
+ this.wallColorsBuffer = [];
+
+ if (this.horizontalMesh) {
+ this.remove(this.horizontalMesh);
+ }
+
+ if (this.wallMesh) {
+ this.remove(this.wallMesh);
+ }
+
+ if (typeof this.onRemove === 'function') {
+ this.onRemove(); // dispose of geometry to avoid memory leak
+ this.onRemove = null;
+ }
+ }
+
+ /**
+ * @typedef {Vector3} MeshPathPoint
+ * @property {(number[]|THREE.Color)} color - The color of the point [0-255, 0-255, 0-255] (only used if perVertexColors=true)
+ * @property {number} [scale] - The scale of the point (1.0 = default)
+ */
+
+ // call this to build (or rebuild) the mesh given an updated array of [{x,y,z}, ...] values
+ // each point can also have parameters:
+ // - color: [0-255, 0-255, 0-255] (only used if perVertexColors=true)
+ // - scale: float (1.0 = default)
+ /**
+ * Sets the points on the path
+ * @param points {MeshPathPoint[]}
+ */
+ setPoints(points) {
+ this.resetPoints(); // removes the previous mesh from the scene and disposes of its geometry
+
+ this.currentPoints = points;
+ this.currentPoints.forEach(point => {
+ // Convert THREE.Color colors into the correct format
+ if (point.color && point.color.isColor) {
+ point.color = [point.color.r * 255, point.color.g * 255, point.color.b * 255];
+ }
+ if (point.color.length === 3) {
+ point.color.push(255);
+ }
+ });
+
+ if (points.length < 2) return;
+
+ const horizontalGeometry = new THREE.BufferGeometry(); // The horizontal represents the flat top and bottom of the line
+ const wallGeometry = new THREE.BufferGeometry(); // The wall represents the two sides of the line
+ const up = new THREE.Vector3(0,1,0);
+
+ const horizontalMaterial = getMaterial(this.horizontalColor, this.opacity, this.usePerVertexColors, this.colorBlending);
+ const wallMaterial = getMaterial(this.wallColor, this.opacity, this.usePerVertexColors, this.colorBlending);
+
+ for (let i = points.length - 1; i > 0; i--) {
+ const start = points[i];
+ const end = points[i-1];
+ const direction = new THREE.Vector3().subVectors(end, start);
+ const startTaperFactor = (typeof start.scale !== 'undefined') ? start.scale : 1;
+ const endTaperFactor = (typeof end.scale !== 'undefined') ? end.scale : 1;
+ const cross = new THREE.Vector3().crossVectors(direction, up).normalize().multiplyScalar(this.widthMm / 2);
+ // Base can be wider to allow visibility while moving along line
+ const bottomCross = cross.clone().multiplyScalar(this.bottomScale);
+ const vertex = this.createVertexComponents(start, end, cross, bottomCross, startTaperFactor, endTaperFactor);
+
+ let colors = {};
+ colors[VERT_PATH.start] = {};
+ colors[VERT_PATH.end] = {};
+ colors[VERT_PATH.start].horizontal = (typeof start.color !== 'undefined') ? start.color : this.horizontalColor;
+ colors[VERT_PATH.end].horizontal = (typeof end.color !== 'undefined') ? end.color : this.horizontalColor;
+ colors[VERT_PATH.start].wall = (typeof start.color !== 'undefined') ? start.color : this.wallColor;
+ colors[VERT_PATH.end].wall = (typeof end.color !== 'undefined') ? end.color : this.wallColor;
+
+ // First top triangle
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.start, VERT_PATH.left, VERT_PATH.top, colors);
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.start, VERT_PATH.right, VERT_PATH.top, colors);
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.top, colors);
+
+ // Second top triangle
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.start, VERT_PATH.right, VERT_PATH.top, colors);
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.top, colors);
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.top, colors);
+
+ // First bottom triangle
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.start, VERT_PATH.right, VERT_PATH.bottom, colors);
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.start, VERT_PATH.left, VERT_PATH.bottom, colors);
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.bottom, colors);
+
+ // Second bottom triangle
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.start, VERT_PATH.left, VERT_PATH.bottom, colors);
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.bottom, colors);
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.bottom, colors);
+
+ // First left triangle
+ this.addWallVertexHelper(vertex, VERT_PATH.start, VERT_PATH.left, VERT_PATH.bottom, colors);
+ this.addWallVertexHelper(vertex, VERT_PATH.start, VERT_PATH.left, VERT_PATH.top, colors);
+ this.addWallVertexHelper(vertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.bottom, colors);
+
+ // Second left triangle
+ this.addWallVertexHelper(vertex, VERT_PATH.start, VERT_PATH.left, VERT_PATH.top, colors);
+ this.addWallVertexHelper(vertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.top, colors);
+ this.addWallVertexHelper(vertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.bottom, colors);
+
+ // First right triangle
+ this.addWallVertexHelper(vertex, VERT_PATH.start, VERT_PATH.right, VERT_PATH.top, colors);
+ this.addWallVertexHelper(vertex, VERT_PATH.start, VERT_PATH.right, VERT_PATH.bottom, colors);
+ this.addWallVertexHelper(vertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.top, colors);
+
+ // Second right triangle
+ this.addWallVertexHelper(vertex, VERT_PATH.start, VERT_PATH.right, VERT_PATH.bottom, colors);
+ this.addWallVertexHelper(vertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.bottom, colors);
+ this.addWallVertexHelper(vertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.top, colors);
+
+ // Handle bends by adding extra geometry bridging this segment to the next segment
+ if (i > 1) {
+ const nextDirection = new THREE.Vector3().subVectors(points[i-2],end);
+ const nextCross = new THREE.Vector3().crossVectors(nextDirection, up).normalize().multiplyScalar(this.widthMm / 2);
+ const nextBottomCross = nextCross.clone().multiplyScalar(this.bottomScale);
+ const nextVertex = this.createVertexComponents(start, end, nextCross, nextBottomCross, startTaperFactor, endTaperFactor);
+
+ // First top triangle
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.top, colors);
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.top, colors);
+ this.addHorizontalVertexHelper(nextVertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.top, colors);
+
+ // Second top triangle
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.top, colors);
+ this.addHorizontalVertexHelper(nextVertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.top, colors);
+ this.addHorizontalVertexHelper(nextVertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.top, colors);
+
+ // First bottom triangle
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.bottom, colors);
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.bottom, colors);
+ this.addHorizontalVertexHelper(nextVertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.bottom, colors);
+
+ // Second bottom triangle
+ this.addHorizontalVertexHelper(vertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.bottom, colors);
+ this.addHorizontalVertexHelper(nextVertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.bottom, colors);
+ this.addHorizontalVertexHelper(nextVertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.bottom, colors);
+
+ // First left triangle
+ this.addWallVertexHelper(vertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.bottom, colors);
+ this.addWallVertexHelper(vertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.top, colors);
+ this.addWallVertexHelper(nextVertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.bottom, colors);
+
+ // Second left triangle
+ this.addWallVertexHelper(vertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.top, colors);
+ this.addWallVertexHelper(nextVertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.top, colors);
+ this.addWallVertexHelper(nextVertex, VERT_PATH.end, VERT_PATH.left, VERT_PATH.bottom, colors);
+
+ // First right triangle
+ this.addWallVertexHelper(vertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.top, colors);
+ this.addWallVertexHelper(vertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.bottom, colors);
+ this.addWallVertexHelper(nextVertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.top, colors);
+
+ // Second right triangle
+ this.addWallVertexHelper(vertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.bottom, colors);
+ this.addWallVertexHelper(nextVertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.bottom, colors);
+ this.addWallVertexHelper(nextVertex, VERT_PATH.end, VERT_PATH.right, VERT_PATH.top, colors);
+ }
+ }
+
+ horizontalGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(this.horizontalPositionsBuffer), COMPONENTS_PER_POSITION));
+ wallGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(this.wallPositionsBuffer), COMPONENTS_PER_POSITION));
+
+ if (this.usePerVertexColors) {
+ const normalized = true; // maps the uints from 0-255 to 0-1
+ horizontalGeometry.setAttribute('color', new THREE.BufferAttribute(new Uint8Array(this.horizontalColorsBuffer), COMPONENTS_PER_COLOR, normalized));
+ wallGeometry.setAttribute('color', new THREE.BufferAttribute(new Uint8Array(this.wallColorsBuffer), COMPONENTS_PER_COLOR, normalized));
+ }
+
+ const horizontalMesh = new THREE.Mesh(horizontalGeometry, horizontalMaterial);
+ const wallMesh = new THREE.Mesh(wallGeometry, wallMaterial);
+ this.add(horizontalMesh);
+ this.add(wallMesh);
+
+ // can be accessed publicly
+ this.horizontalMesh = horizontalMesh;
+ this.wallMesh = wallMesh;
+
+ this.onRemove = () => {
+ // Since these geometries are not reused, they MUST be disposed to prevent memory leakage
+ if (horizontalGeometry) horizontalGeometry.dispose();
+ if (wallGeometry) wallGeometry.dispose();
+ }
+
+ this.getGeometry = () => {
+ return {
+ horizontal: horizontalGeometry,
+ wall: wallGeometry
+ }
+ }
+ }
+
+ addPoints(points) { // TODO: replace with optimized version that appends to the mesh if performance is an issue
+ this.setPoints(this.currentPoints.concat(points));
+ }
+
+ // internal helper function - adds the vertex information to the horizontalMesh
+ addHorizontalVertexHelper(vertexComponents, startEnd, leftRight, topBottom, colors) {
+ let thisVertex = vertexComponents[startEnd][topBottom][leftRight];
+ this.addHorizontalVertex(thisVertex.x, thisVertex.y, thisVertex.z, colors[startEnd].horizontal);
+ }
+
+ // internal helper function - adds the vertex information to the wallMesh
+ addWallVertexHelper(vertexComponents, startEnd, leftRight, topBottom, colors) {
+ let thisVertex = vertexComponents[startEnd][topBottom][leftRight];
+ this.addWallVertex(thisVertex.x, thisVertex.y, thisVertex.z, colors[startEnd].wall);
+ }
+
+ // internal helper function
+ addHorizontalVertex(x, y, z, color) {
+ this.horizontalPositionsBuffer.push(x, y, z);
+ if (this.usePerVertexColors) {
+ this.horizontalColorsBuffer.push(color[0], color[1], color[2], color[3]);
+ }
+ }
+
+ // internal helper function
+ addWallVertex(x, y, z, color) {
+ this.wallPositionsBuffer.push(x, y, z);
+ if (this.usePerVertexColors) {
+ let r = Math.max(0, color[0] * this.wallBrightness);
+ let g = Math.max(0, color[1] * this.wallBrightness);
+ let b = Math.max(0, color[2] * this.wallBrightness);
+ let a = color[3];
+ this.wallColorsBuffer.push(r, g, b, a);
+ }
+ }
+
+ // internal helper function - constructs all the vertices that we'll need to render the faces of this segment
+ createVertexComponents(start, end, cross, bottomCross, startTaperFactor, endTaperFactor) {
+ let components = {};
+ [VERT_PATH.start, VERT_PATH.end].forEach((startEnd) => {
+ components[startEnd] = {};
+ let point = startEnd === VERT_PATH.start ? start : end;
+ let taperFactor = startEnd === VERT_PATH.start ? startTaperFactor : endTaperFactor;
+ [VERT_PATH.top, VERT_PATH.bottom].forEach((topBottom) => {
+ components[startEnd][topBottom] = {};
+ let heightOffset = topBottom === VERT_PATH.top ? this.heightMm : 0;
+ [VERT_PATH.left, VERT_PATH.right].forEach((leftRight) => {
+ let crossMultiplier = leftRight === VERT_PATH.left ? -1 : 1;
+ components[startEnd][topBottom][leftRight] = {
+ x: point.x + (crossMultiplier * cross.x * taperFactor),
+ y: point.y + (heightOffset * taperFactor),
+ z: point.z + (crossMultiplier * cross.z * taperFactor)
+ }
+ });
+ });
+ });
+ return components;
+ }
+
+ // given a list of the vertex indices defining a face (such as those returned by a raycast intersection),
+ // returns the index of the point on the path that contains that face
+ getPointFromFace(vertexIndices) {
+ let approximatePointIndex = Math.floor(vertexIndices[0] / POSITIONS_PER_POINT);
+ return Math.max(0, Math.min(this.currentPoints.length - 1, (this.currentPoints.length - approximatePointIndex) - 2));
+ }
+
+ /**
+ * Get the index of the point in the currentPoints array that the intersect is closest to
+ * @param {Object} intersect - the intersect object returned by three.js raycasting
+ * @return {number} index of the point in the currentPoints array that the intersect is closest to
+ */
+ getPointFromIntersect(intersect) {
+ const face = intersect.face;
+ return this.getPointFromFace([face.a, face.b, face.c]);
+ }
+
+ // use this to get the indices in the color and position BufferAttributes that correspond to a certain point in the path
+ // geometry is constructed backwards, from length-1 down to 0, so buffer attribute indices are "opposite" what you may expect
+ getBufferIndices(pointIndex, componentsPerIndex) {
+ // if i = length-1, indices = 0-23... if i = length-2, indices = 24-47... if i = length-3, indices = 48-71...
+ // generalized formula: if i = length-N, indices = (24 * (N-1)) to (24 * N - 1)
+ // special case: if i = 0, indices = (24 * (length-1)) to (24 * length - 1 - 12) // last index only has 12 not 24
+
+ const length = this.currentPoints.length;
+ const i = length - pointIndex;
+ const startBufferIndex = (POSITIONS_PER_POINT * componentsPerIndex) * (i-2); // todo: this was off by 1 on my first attempt so i'm subtracting (i-2) instead of (i-1), but i'm not sure why
+ let endBufferIndex = (POSITIONS_PER_POINT * componentsPerIndex) * (i-1) - 1;
+ if (i === length - 1) {
+ endBufferIndex -= (POSITIONS_PER_POINT * componentsPerIndex) * 0.5; // last index has half as many positions
+ }
+
+ let bufferIndices = [];
+ for (let j = startBufferIndex; j <= endBufferIndex; j += componentsPerIndex) {
+ bufferIndices.push(Math.floor(j/componentsPerIndex));
+ }
+ return bufferIndices;
+ }
+
+ // calculates the sum of distances between the two points on the path
+ getDistanceAlongPath(firstIndex, secondIndex) {
+ const smallerIndex = Math.min(firstIndex, secondIndex);
+ const biggerIndex = Math.max(firstIndex, secondIndex);
+ let totalDistance = 0;
+ for (let i = smallerIndex; i < biggerIndex; i++) {
+ let thisPoint = this.currentPoints[i];
+ let nextPoint = this.currentPoints[i+1];
+ let dx = nextPoint.x - thisPoint.x;
+ let dy = nextPoint.y - thisPoint.y;
+ let dz = nextPoint.z - thisPoint.z;
+ let segmentDistance = Math.sqrt(dx * dx + dy * dy + dz * dz);
+ totalDistance += segmentDistance;
+ }
+ return totalDistance;
+ }
+
+ // pass in a range of points to recompute, and it replaces the colorsBuffer entries with recomputed values
+ // note: modify the point.color beforehand, then call this for the color to be applied
+ updateColors(pointIndicesThatNeedUpdate) {
+ if (!this.usePerVertexColors) return; // no effect on single-colored paths
+ if (typeof this.getGeometry === 'undefined') return; // no geometry yet
+
+ let geometry = this.getGeometry();
+ let horizontalColorAttribute = geometry.horizontal.getAttribute('color');
+ let wallColorAttribute = geometry.wall.getAttribute('color');
+ let brightness = this.wallBrightness;
+
+ pointIndicesThatNeedUpdate.forEach(index => {
+ let colorBufferIndices = this.getBufferIndices(index, COMPONENTS_PER_COLOR);
+ colorBufferIndices.forEach(bfrIndex => {
+ let newColor = {
+ r: this.currentPoints[index].color[0],
+ g: this.currentPoints[index].color[1],
+ b: this.currentPoints[index].color[2],
+ a: this.currentPoints[index].color.length === 3 ? 255 : this.currentPoints[index].color[3]
+ }
+ horizontalColorAttribute.setXYZW(bfrIndex, newColor.r, newColor.g, newColor.b, newColor.a);
+ wallColorAttribute.setXYZW(bfrIndex, newColor.r * brightness, newColor.g * brightness, newColor.b * brightness, newColor.a);
+ });
+ })
+ geometry.horizontal.attributes.color.needsUpdate = true;
+ geometry.wall.attributes.color.needsUpdate = true;
+ }
+ // todo: add an updatePositions method similar to updateColors that can be used to update the mesh instead of rebuilding it entirely with setPoints
+}
+
+/**
+ * Lets you reuse materials that share identical properties by generating a hash of those material parameters
+ * @param {number|string} color - hex color
+ * @param {number} opacity
+ * @param {boolean} usePerVertexColors
+ * @returns {string}
+ */
+function getMaterialKey(color, opacity, usePerVertexColors) {
+ return JSON.stringify(color) + JSON.stringify(opacity) + JSON.stringify(usePerVertexColors);
+}
+
+/**
+ * Creates a new material, or returns a cached material, with the provided parameters
+ * @param {number|string} color - used if !usePerVertexColors
+ * @param {number} opacity - defaults to 1
+ * @param {boolean} usePerVertexColors - defaults to false
+ * @param {boolean} colorBlending - defaults to false
+ * @returns {THREE.MeshBasicMaterial}
+ */
+function getMaterial(color, opacity = 1, usePerVertexColors = false, colorBlending = false) {
+ if (usePerVertexColors && !colorBlending) { color = 0xFFFFFF; } // if color isn't white, vertex colors blend
+ let materialKey = getMaterialKey(color, opacity, usePerVertexColors);
+ if (typeof cachedMaterials[materialKey] === 'undefined') {
+ let params = {
+ color: color || 0xFFFFFF
+ };
+ if (opacity < 1) {
+ params.transparent = true
+ params.opacity = opacity
+ }
+ if (usePerVertexColors) {
+ params.vertexColors = true;
+ }
+ // cachedMaterials[materialKey] = new THREE.MeshBasicMaterial(params);
+ cachedMaterials[materialKey] = new THREE.ShaderMaterial({
+ vertexShader: vertexShader,
+ fragmentShader: fragmentShader,
+ transparent: true,
+ side: THREE.DoubleSide
+ });
+ }
+ return cachedMaterials[materialKey]; // allows us to reuse materials that have the exact same params
+}
diff --git a/src/gui/ar/moveabilityOverlay.js b/src/gui/ar/moveabilityOverlay.js
new file mode 100644
index 000000000..b3552a7e4
--- /dev/null
+++ b/src/gui/ar/moveabilityOverlay.js
@@ -0,0 +1,111 @@
+createNameSpace("realityEditor.gui.ar.moveabilityOverlay");
+
+/**
+ * @fileOverview realityEditor.gui.ar.moveabilityOverlay
+ * Draws the green SVG overlay that indicates when you can drag a frame or node.
+ * Draws red lines over a given region when the frame moves behind the z=0 target plane.
+ */
+
+realityEditor.gui.ar.moveabilityOverlay = {};
+realityEditor.gui.ar.moveabilityOverlay.element = document.body;
+realityEditor.gui.ar.moveabilityOverlay.svgNS = {};
+realityEditor.gui.ar.moveabilityOverlay.x = window.innerWidth;
+realityEditor.gui.ar.moveabilityOverlay.y = window.innerHeight;
+
+realityEditor.gui.ar.moveabilityOverlay.createSvg = function(svg){
+ svg.innerHTML ="";
+ var x = parseInt(svg.style.width, 10);
+ var y = parseInt(svg.style.height, 10);
+
+ // if the object is fullscreen, handle differently so we don't convert 100% to 100px)
+ if (svg.style.width[svg.style.width.length-1] === "%") {
+ x = (x/100) * globalStates.height;
+ y = (y/100) * globalStates.width;
+ // return;
+ }
+
+ this.drawBox(svg, svg.namespaceURI, x, y);
+ this.drawNegativeSpace(svg, svg.namespaceURI, x, y, 0+","+0+","+0+","+0+","+0+","+0+","+0+","+0);
+};
+realityEditor.gui.ar.moveabilityOverlay.changeClipping = function(svg,points){
+ svg.getElementById("lineID").setAttribute('points',points);
+};
+
+realityEditor.gui.ar.moveabilityOverlay.drawNegativeSpace = function(svg, svgNS, x,y, points){
+ if(!x) return;
+ var line = document.createElementNS(svgNS,'polyline');
+ line.setAttribute('points',points);
+
+ var defs = svg.appendChild(document.createElementNS(svgNS,'defs'));
+ var clipPath = defs.appendChild(document.createElementNS(svgNS,'clipPath'));
+ clipPath.id = "clippy";
+
+ var lineLement = clipPath.appendChild(line);
+ lineLement.id = "lineID";
+
+ var group = document.createElementNS(svgNS,'g');
+ group.setAttribute('stroke-width',x/400);
+ group.setAttribute('stroke','fff00');
+ group.setAttribute('fill','none');
+ group.setAttribute('clip-path',"url(#clippy)");
+
+ for(var i = 0; i<= x; i = i+10){
+ this.drawMultiLine(group,svgNS, i+","+0+","+i+","+y,100,x,y,"#ed1d7c");
+ }
+ svg.appendChild(group);
+};
+
+
+realityEditor.gui.ar.moveabilityOverlay.drawBox = function(svg, svgNS, x,y){
+ var that = this;
+
+ this.drawMultiLine(svg, svgNS,x/200+","+x/5+","+x/200+","+x/200+","+x/5+","+x/200,20,x,y);
+ this.drawMultiLine(svg,svgNS, (x-x/200)+","+x/5+","+(x-x/200)+","+x/200+","+(x-(x/5))+","+x/200,20,x,y);
+
+ this.drawMultiLine(svg,svgNS, x/200+","+(y-x/5)+","+x/200+","+(y-x/200)+","+x/5+","+(y-x/200),20,x,y);
+ this.drawMultiLine(svg,svgNS, (x-x/200)+","+(y-x/5)+","+(x-x/200)+","+(y-x/200)+","+(x-(x/5))+","+(y-x/200),20,x,y);
+
+ var crossDividerX = Math.round(x/100);
+ var crossDividerY = Math.round(y/100);
+ var crossDistanceX = x/(crossDividerX);
+ var crossDistanceY = y/(crossDividerY);
+
+ if (crossDividerY === 1) {
+ crossDistanceX = x / 2;
+ crossDistanceY = y / 2;
+ this.drawCross(svg,svgNS, crossDistanceX, crossDistanceY,x,y);
+ }
+
+ for(var w = 1; w< crossDividerY; w++) {
+ callX(w)
+ }
+
+ function callX (w){
+ if (crossDividerX === 1) {
+ crossDistanceX = x / 2;
+ that.drawCross(svg,svgNS, crossDistanceX, crossDistanceY,x,y);
+ }
+
+ for (var i = 1; i < crossDividerX; i++) {
+ that.drawCross(svg,svgNS, (crossDistanceX * i), (crossDistanceY*w),x,y);
+ }
+ }
+};
+realityEditor.gui.ar.moveabilityOverlay.drawMultiLine = function(svg,svgNS, points, width,x,y, color){
+ if(!color) color = '#00ff00';
+ if(!width) width = 200;
+
+ var line = document.createElementNS(svgNS,'polyline');
+ line.setAttribute('stroke-width',x/width);
+ line.setAttribute('stroke',color);
+ line.setAttribute('fill','none');
+ line.setAttribute('points',points);
+ svg.appendChild(line);
+};
+
+
+realityEditor.gui.ar.moveabilityOverlay.drawCross = function(svg,svgNS, pX,pY,x,y){
+ this.drawMultiLine(svg,svgNS, (pX)+","+(pY-(x/32))+","+(pX)+","+(pY+(x/32)),100,x,y);
+ this.drawMultiLine(svg,svgNS, (pX+(x/32))+","+(pY)+","+(pX-(x/32))+","+(pY),100,x,y);
+ // drawMultiLine(svg, (x+20)+","+(y+20)+","+(x-20)+","+(y-20));
+};
diff --git a/src/gui/ar/positioning.js b/src/gui/ar/positioning.js
new file mode 100644
index 000000000..1bc8ed74b
--- /dev/null
+++ b/src/gui/ar/positioning.js
@@ -0,0 +1,783 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+
+createNameSpace("realityEditor.gui.ar.positioning");
+
+/**
+ * @fileOverview realityEditor.gui.ar.positioning.js
+ * Contains all functions relating to repositioning or rescaling a frame or node.
+ */
+
+/**
+ * @typedef initialScaleData
+ * @property {number} radius - how far apart in pixels the two touches are to begin with
+ * @property {number} scale - the frame or node's initial scale value before the gesture, to use as a base multiplier
+ */
+realityEditor.gui.ar.positioning.initialScaleData = null;
+
+/**
+ * @type {{currentlyMovingPreservingDistance: boolean, currentlyMovingAlongPlane: boolean, initialDistance: number|null, initialOffset: {}|null}}
+ */
+realityEditor.gui.ar.positioning.tempDraggingState = {
+ // keeps track of which mode of dragging we're performing
+ currentlyMovingAlongPlane: false,
+ currentlyMovingPreservingDistance: false,
+ // state to help preserve distance to tool over the drag and its position relative to the pointerdown
+ initialOffset: null,
+ initialDistance: null
+};
+
+realityEditor.gui.ar.positioning.setVehicleScale = (activeVehicle, scale) => {
+ if (!activeVehicle) return;
+ if (typeof scale !== 'number') return;
+
+ let positionData = realityEditor.gui.ar.positioning.getPositionData(activeVehicle);
+ positionData.scale = Math.max(0.1, scale); // 0.1 is the minimum scale allowed
+ realityEditor.sceneGraph.updatePositionData(activeVehicle.uuid);
+
+ var keys = realityEditor.getKeysFromVehicle(activeVehicle);
+ var propertyPath = activeVehicle.hasOwnProperty('visualization') ? 'ar.scale' : 'scale';
+ realityEditor.network.realtime.broadcastUpdate(keys.objectKey, keys.frameKey, keys.nodeKey, propertyPath, positionData.scale);
+}
+
+/**
+ * Scales the specified frame or node using the first two touches.
+ * The new scale starts at the initial scale and varies linearly with the changing touch radius.
+ * @param {Frame|Node} activeVehicle - the frame or node you are scaling
+ * @param {Object.} centerTouch - the first touch event, where the scale is centered from
+ * @param {Object.} outerTouch - the other touch, where the scale extends to
+ */
+realityEditor.gui.ar.positioning.scaleVehicle = function(activeVehicle, centerTouch, outerTouch) {
+
+ if (!centerTouch || !outerTouch || !centerTouch.x || !centerTouch.y || !outerTouch.x || !outerTouch.y) {
+ console.warn('trying to scale vehicle using improperly formatted touches');
+ return;
+ }
+
+ var dx = centerTouch.x - outerTouch.x;
+ var dy = centerTouch.y - outerTouch.y;
+ var radius = Math.sqrt(dx * dx + dy * dy);
+
+ var positionData = realityEditor.gui.ar.positioning.getPositionData(activeVehicle);
+
+ if (!this.initialScaleData) {
+ this.initialScaleData = {
+ radius: radius,
+ scale: positionData.scale
+ };
+ return;
+ }
+
+ // calculate the new scale based on the radius between the two touches
+ var newScale = this.initialScaleData.scale + (radius - this.initialScaleData.radius) / 300;
+
+ // TODO ben: low priority: re-implement scaling gesture to preserve touch location rather than scaling center
+ // TODO: this only works for frames right now, not nodes (at least not after scaling nodes twice in one gesture)
+ // manually calculate positionData.x and y to keep centerTouch in the same place relative to the vehicle
+ // var overlayDiv = document.getElementById(activeVehicle.uuid);
+ // var touchOffset = realityEditor.device.editingState.touchOffset;
+ // if (overlayDiv && touchOffset) {
+ // var touchOffsetFromCenter = {
+ // x: overlayDiv.clientWidth/2 - touchOffset.x,
+ // y: overlayDiv.clientHeight/2 - touchOffset.y
+ // };
+ // var scaleDifference = Math.max(0.2, newScale) - positionData.scale;
+ // positionData.x += touchOffsetFromCenter.x * scaleDifference;
+ // positionData.y += touchOffsetFromCenter.y * scaleDifference;
+ // }
+
+ realityEditor.gui.ar.positioning.setVehicleScale(activeVehicle, newScale);
+
+ // redraw circles to visualize the new scaling
+ globalCanvas.context.clearRect(0, 0, globalCanvas.canvas.width, globalCanvas.canvas.height);
+
+ // draw a blue circle visualizing the initial radius
+ var circleCenterCoordinates = [centerTouch.x, centerTouch.y];
+ realityEditor.gui.ar.lines.drawBlue(globalCanvas.context, circleCenterCoordinates, this.initialScaleData.radius);
+
+ // draw a red or green circle visualizing the new radius
+ if (radius < this.initialScaleData.radius) {
+ realityEditor.gui.ar.lines.drawRed(globalCanvas.context, circleCenterCoordinates, radius);
+ } else {
+ realityEditor.gui.ar.lines.drawGreen(globalCanvas.context, circleCenterCoordinates, radius);
+ }
+};
+
+/**
+ * Removes the x and y translation offsets from the vehicle so that its position is purely determined by its matrix
+ * This should be done if you want to directly set the position of a tool to a given matrix
+ * @param {Frame|Node} activeVehicle
+ */
+realityEditor.gui.ar.positioning.resetVehicleTranslation = function(activeVehicle) {
+ let positionData = this.getPositionData(activeVehicle);
+ positionData.x = 0;
+ positionData.y = 0;
+
+ // flags the sceneNode as dirty so it gets rendered again with the new x/y position
+ realityEditor.sceneGraph.updatePositionData(activeVehicle.uuid);
+
+ // broadcasts this to the realtime system if it's enabled
+ if (!realityEditor.gui.settings.toggleStates.realtimeEnabled) { return; }
+
+ let keys = realityEditor.getKeysFromVehicle(activeVehicle);
+ let propertyPath = activeVehicle.hasOwnProperty('visualization') ? 'ar.x' : 'x';
+ realityEditor.network.realtime.broadcastUpdate(keys.objectKey, keys.frameKey, keys.nodeKey, propertyPath, positionData.x);
+ propertyPath = activeVehicle.hasOwnProperty('visualization') ? 'ar.y' : 'y';
+ realityEditor.network.realtime.broadcastUpdate(keys.objectKey, keys.frameKey, keys.nodeKey, propertyPath, positionData.y);
+};
+
+/**
+ * Call this when you stop a drag gesture to reset the tempDraggingState
+ */
+realityEditor.gui.ar.positioning.stopRepositioning = function() {
+ this.tempDraggingState.currentlyMovingAlongPlane = false;
+ this.tempDraggingState.currentlyMovingPreservingDistance = false;
+ this.tempDraggingState.initialDistance = null;
+ this.tempDraggingState.initialOffset = null;
+};
+
+/**
+ * Moves the tool to be centered on the screen (x,y) position, keeping it the same distance from the camera as before
+ * @param {Frame|Node} activeVehicle
+ * @param {number} screenX
+ * @param {number} screenY
+ * @param {boolean} useTouchOffset
+ */
+realityEditor.gui.ar.positioning.moveVehiclePreservingDistance = function(activeVehicle, screenX, screenY, useTouchOffset = false) {
+ const utils = realityEditor.gui.ar.utilities;
+
+ let toolNode = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid);
+ let rootNode = realityEditor.sceneGraph.getSceneNodeById('ROOT');
+
+ if (!this.tempDraggingState.initialOffset) {
+ let pointInObject = this.getLocalPointAtScreenXY(activeVehicle, screenX, screenY);
+ let toolOriginLocal = realityEditor.sceneGraph.convertToNewCoordSystem(realityEditor.sceneGraph.getWorldPosition(activeVehicle.uuid), rootNode, toolNode.parent);
+ this.tempDraggingState.initialOffset = utils.subtract([pointInObject.x, pointInObject.y, pointInObject.z], [toolOriginLocal.x, toolOriginLocal.y, toolOriginLocal.z]);
+ let worldPoint = realityEditor.sceneGraph.convertToNewCoordSystem(this.tempDraggingState.initialOffset, toolNode, rootNode);
+ let cameraPoint = realityEditor.sceneGraph.getWorldPosition('CAMERA');
+ let pointToCamera = utils.subtract(worldPoint, [cameraPoint.x, cameraPoint.y, cameraPoint.z]);
+ this.tempDraggingState.initialDistance = utils.magnitude(pointToCamera); // this is the distance from the camera to the point at (toolOrigin + storedOffset)
+ }
+
+ let outputCoordinateSystem = toolNode.parent;
+ let point = realityEditor.sceneGraph.getPointAtDistanceFromCamera(screenX, screenY, this.tempDraggingState.initialDistance, outputCoordinateSystem);
+
+ // recalculate touchOffset if switch reposition modes
+ if (this.tempDraggingState.currentlyMovingAlongPlane) {
+ realityEditor.device.editingState.touchOffset = null;
+ this.tempDraggingState.currentlyMovingAlongPlane = false;
+ }
+ this.tempDraggingState.currentlyMovingPreservingDistance = true;
+
+ let offset = this.computeTouchOffset(toolNode, point, useTouchOffset);
+
+ let positionData = this.getPositionData(activeVehicle);
+ if (positionData.x !== 0 || positionData.y !== 0) {
+ this.resetVehicleTranslation(activeVehicle);
+ }
+
+ // keep the rotation and scale the same but update the translation elements of the matrix
+ let matrixCopy = realityEditor.gui.ar.utilities.copyMatrix(toolNode.localMatrix);
+ matrixCopy[12] = point.x - offset.x;
+ matrixCopy[13] = point.y - offset.y;
+ matrixCopy[14] = point.z - offset.z;
+ toolNode.setLocalMatrix(matrixCopy);
+};
+
+/**
+ * Ray-casts from (screenX, screenY) onto the XY plane of a given tool, and returns the (x,y,z) intersect.
+ * The result is calculated in the tool's parent (object) coordinate system.
+ * @param {Frame|Node} activeVehicle
+ * @param {number} screenX
+ * @param {number} screenY
+ * @returns {{x: number, y: number, z: number}}
+ */
+realityEditor.gui.ar.positioning.getLocalPointAtScreenXY = function(activeVehicle, screenX, screenY) {
+ const utils = realityEditor.gui.ar.utilities;
+
+ let cameraNode = realityEditor.sceneGraph.getCameraNode();
+ let toolNode = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid);
+ let toolPoint = realityEditor.sceneGraph.getWorldPosition(activeVehicle.uuid);
+ let planeOrigin = [toolPoint.x, toolPoint.y, toolPoint.z];
+ let planeNormal = utils.getForwardVector(toolNode.worldMatrix);
+
+ let worldCoordinates = utils.getPointOnPlaneFromScreenXY(planeOrigin, planeNormal, cameraNode, screenX, screenY);
+ let rootCoordinateSystem = cameraNode.parent || realityEditor.sceneGraph.getSceneNodeById('ROOT');
+ return realityEditor.sceneGraph.convertToNewCoordSystem(worldCoordinates, rootCoordinateSystem, toolNode.parent);
+};
+
+/**
+ * Translates the tool along its local XY plane such that it moves to the screen (x,y) position
+ * @param {Frame|Node} activeVehicle
+ * @param {number} screenX
+ * @param {number} screenY
+ * @param {boolean} useTouchOffset - if false, jumps to center on pointer. if true, translates relative to pointerdown position
+ */
+realityEditor.gui.ar.positioning.moveVehicleAlongPlane = function(activeVehicle, screenX, screenY, useTouchOffset = false) {
+ let toolNode = realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid);
+ let localPoint = this.getLocalPointAtScreenXY(activeVehicle, screenX, screenY);
+
+ // recalculate touchOffset if switch reposition modes
+ if (this.tempDraggingState.currentlyMovingPreservingDistance) {
+ realityEditor.device.editingState.touchOffset = null;
+ this.tempDraggingState.currentlyMovingPreservingDistance = false;
+ }
+ this.tempDraggingState.currentlyMovingAlongPlane = true;
+
+ // this makes it so the center of the tool doesn't snap to the pointer location
+ let offset = this.computeTouchOffset(toolNode, localPoint, useTouchOffset);
+
+ // we don't need the separate x and y components anymore
+ let positionData = this.getPositionData(activeVehicle);
+ if (positionData.x !== 0 || positionData.y !== 0) {
+ this.resetVehicleTranslation(activeVehicle);
+ }
+
+ // keep the rotation and scale the same but update the translation elements of the matrix
+ let matrixCopy = realityEditor.gui.ar.utilities.copyMatrix(toolNode.localMatrix);
+ matrixCopy[12] = localPoint.x - offset.x;
+ matrixCopy[13] = localPoint.y - offset.y;
+ matrixCopy[14] = localPoint.z - offset.z;
+ toolNode.setLocalMatrix(matrixCopy);
+};
+
+/**
+ * Prevents the tool from jumping so that its center is on your pointer โ offsets it relative to your cursor.
+ * Returns the difference between the sceneNode's localMatrix origin and the newOrigin.
+ * @param {SceneNode} sceneNode
+ * @param {{x: number, y: number, z: number}} newOrigin
+ * @param {boolean} useTouchOffset - if false, always return (0,0,0)
+ * @returns {{x: number, y: number, z: number}}
+ */
+realityEditor.gui.ar.positioning.computeTouchOffset = function(sceneNode, newOrigin, useTouchOffset) {
+ let editingState = realityEditor.device.editingState;
+ if (!useTouchOffset) {
+ editingState.offset = null;
+ return { x: 0, y: 0, z: 0 };
+ }
+ if (editingState.touchOffset) return editingState.touchOffset; // return existing offset unless it gets reset to null
+ editingState.touchOffset = {
+ x: newOrigin.x - sceneNode.localMatrix[12],
+ y: newOrigin.y - sceneNode.localMatrix[13],
+ z: newOrigin.z - sceneNode.localMatrix[14]
+ }
+ return editingState.touchOffset; // return newly calculated offset at the start of each drag
+}
+
+/**
+ * Determines whether to translate tool along its local plane, or parallel to camera (preserving distance to camera)
+ * @param {Frame|Node} activeVehicle
+ * @returns {boolean}
+ */
+realityEditor.gui.ar.positioning.shouldPreserveDistanceWhileMoving = function(activeVehicle) {
+ // always move 3D tools
+ if (activeVehicle.fullScreen) return true;
+
+ // for now, moving along plane while attached to camera is a bit buggy if offset is included
+ // so force it to use distance-preserving method if in this mode
+ if (realityEditor.device.isEditingUnconstrained(activeVehicle)) return true;
+
+ // preserve distance while moving a 2D tool if the plane that the tool sits on isn't roughly parallel to the camera
+ const DIRECTION_SIMILARITY_THRESHOLD = 0.8;
+ const utils = realityEditor.gui.ar.utilities;
+ let toolDirection = utils.getForwardVector(realityEditor.sceneGraph.getSceneNodeById(activeVehicle.uuid).worldMatrix);
+ let cameraDirection = utils.getForwardVector(realityEditor.sceneGraph.getCameraNode().worldMatrix);
+ let dotProduct = utils.dotProduct(toolDirection, cameraDirection); // 1=parallel, 0=perpendicular
+ return (Math.abs(dotProduct) < DIRECTION_SIMILARITY_THRESHOLD);
+}
+
+/**
+ * Primary method to move a transformed frame or node to the (x,y) point on its plane where the (screenX,screenY) ray cast intersects
+ * @param {Frame|Node} activeVehicle
+ * @param {number} screenX
+ * @param {number} screenY
+ * @param {boolean} useTouchOffset - if false, puts (0,0) coordinate of frame/node at the resulting point.
+ * if true, the first time you call it, it determines the x,y offset to drag the frame/node
+ * from the ray cast without it jumping, and subsequently drags it from that point
+ */
+realityEditor.gui.ar.positioning.moveVehicleToScreenCoordinate = function(activeVehicle, screenX, screenY, useTouchOffset) {
+ if (this.shouldPreserveDistanceWhileMoving(activeVehicle)) {
+ this.moveVehiclePreservingDistance(activeVehicle, screenX, screenY, useTouchOffset);
+ } else {
+ this.moveVehicleAlongPlane(activeVehicle, screenX, screenY, useTouchOffset);
+ }
+};
+
+/**
+ * Because node positions are affected by scale of parent while rendering, divide by scale of parent while dragging
+ * @param {Frame|Node} activeVehicle
+ * @param {{x: number, y: number}} pointReference - object containing the x and y values you want to adjust
+ * @todo: currently not in use. re-enable later once node position dragging gets fixed
+ */
+realityEditor.gui.ar.positioning.applyParentScaleToDragPosition = function(activeVehicle, pointReference) {
+
+ if (!realityEditor.gui.ar.positioning.isVehicleUnconstrainedEditable(activeVehicle)) {
+ // position is affected by parent frame scale
+ var parentFrame = realityEditor.getFrame(activeVehicle.objectId, activeVehicle.frameId);
+ if (parentFrame) {
+ var parentFramePositionData = realityEditor.gui.ar.positioning.getPositionData(parentFrame);
+ pointReference.x /= (parentFramePositionData.scale/globalStates.defaultScale);
+ pointReference.y /= (parentFramePositionData.scale/globalStates.defaultScale);
+ }
+ }
+
+};
+
+/**
+ * Gets the object reference containing 'x', 'y', 'scale', and 'matrix' variables describing this vehicle's position
+ * - frames: return position data within 'ar' property (no need to return 'screen' anymore since that never happens within the editor)
+ * - nodes that aren't unconstrained editable: return the parent frame's matrix but the node's x, y, and scale
+ * - unconstrained editable frames: return their own x, y, scale, and matrix
+ * @param {Frame|Node} activeVehicle
+ * @return {{x: number, y: number, scale: number, matrix: Array., ...}}
+ */
+realityEditor.gui.ar.positioning.getPositionData = function(activeVehicle) {
+ // frames use their AR data
+ if (activeVehicle.hasOwnProperty('visualization')) {
+ return activeVehicle.ar;
+ }
+
+ // nodes have x, y, scale directly as properties
+ return activeVehicle;
+};
+
+/**
+ * Sets the correct matrix for this vehicle's position data to the new value.
+ * @param {Frame|Node} activeVehicle
+ * @param {Array.} newMatrixValue
+ * @param {boolean|undefined} dontBroadcast โ pass true to prevent realtime broadcasting this update
+ * @todo: ensure fully implemented
+ */
+realityEditor.gui.ar.positioning.setPositionDataMatrix = function(activeVehicle, newMatrixValue, dontBroadcast) {
+
+ if (realityEditor.isVehicleAFrame(activeVehicle)) {
+ realityEditor.gui.ar.utilities.copyMatrixInPlace(newMatrixValue, activeVehicle.ar.matrix);
+ } else {
+ realityEditor.gui.ar.utilities.copyMatrixInPlace(newMatrixValue, activeVehicle.matrix);
+ }
+
+ if (!dontBroadcast) {
+ var keys = realityEditor.getKeysFromVehicle(activeVehicle);
+ var propertyPath = activeVehicle.hasOwnProperty('visualization') ? 'ar.matrix' : 'matrix';
+ realityEditor.network.realtime.broadcastUpdate(keys.objectKey, keys.frameKey, keys.nodeKey, propertyPath, newMatrixValue);
+ }
+};
+
+/**
+ * Returns the last position that was touched, by extracting the CSS location of the touch overlay div.
+ * @todo: WARNING this doesn't always work as intended if there are more than one touches on the screen....
+ * @todo: it will jump back and forth between the two fingers depending on which one moved last
+ * @return {{x: number, y: number}}
+ */
+realityEditor.gui.ar.positioning.getMostRecentTouchPosition = function() {
+ var touchX = globalStates.height/2; // defaults to center of screen;
+ var touchY = globalStates.width/2;
+
+ try {
+ var translate3d = overlayDiv.style.transform.split('(')[1].split(')')[0].split(',').map(function(elt){return parseInt(elt);});
+ touchX = translate3d[0];
+ touchY = translate3d[1];
+ } catch (e) {
+ // no touches on screen yet, so defaulting to center
+ }
+
+ return {
+ x: touchX,
+ y: touchY
+ }
+};
+
+/**
+ * Able to unconstrained edit:
+ * - all logic nodes
+ * - all frames
+ * - nodes on local frames
+ * @param {Frame|Node} activeVehicle
+ * @return {boolean}
+ */
+realityEditor.gui.ar.positioning.isVehicleUnconstrainedEditable = function(activeVehicle) {
+
+ if (activeVehicle.type === 'node') {
+ var parentFrame = realityEditor.getFrame(activeVehicle.objectId, activeVehicle.frameId);
+ if (parentFrame) {
+ return parentFrame.location === 'local';
+ }
+ }
+
+ return (typeof activeVehicle.type === 'undefined' || activeVehicle.type === 'ui' || activeVehicle.type === 'logic');
+};
+
+/**
+ * A super-optimized version of realityEditor.gui.ar.positioning.getScreenPosition that specifically computes the
+ * upperLeft, center, and lowerRight screen coordinates of a frame or node using as few arithmetic operations as possible,
+ * using only the final CSS matrix of the vehicle, and its half width and height
+ * Return value includes center even if not needed, because faster to compute lowerRight using center than without it
+ *
+ * @param {Array.} finalMatrix - the CSS transform3d matrix
+ * @param {number} vehicleHalfWidth - get from frameSizeX (scale is already stored separately in the matrix)
+ * @param {number} vehicleHalfHeight - get from frameSizeY
+ * @param {boolean} onlyCenter - if defined, doesn't waste resources computing upperLeft and lowerRight
+ * @return {{ center: {x: number, y: number}, upperLeft: {x: number, y: number}|undefined, lowerRight: {x: number, y: number}|undefined }}
+ */
+realityEditor.gui.ar.positioning.getVehicleBoundingBoxFast = function(finalMatrix, vehicleHalfWidth, vehicleHalfHeight, onlyCenter) {
+
+ // compute the screen coordinates for various points within the frame
+ var screenCoordinates = {};
+
+ // var halfWidth = parseInt(frame.frameSizeX)/2;
+ // var halfHeight = parseInt(frame.frameSizeY)/2;
+
+ // super optimized version of getProjectedCoordinates (including multiplyMatrix4 and perspectiveDivide) for the 0,0 coordinate
+ screenCoordinates.center = {
+ x: (globalStates.height / 2) + (finalMatrix[12] / finalMatrix[15]),
+ y: (globalStates.width / 2) + (finalMatrix[13] / finalMatrix[15])
+ };
+
+ if (typeof onlyCenter === 'undefined') {
+ // perspective divide is more complicated for point not at 0,0 ... but still pretty optimized
+ var perspectiveDivide = finalMatrix[3] * (-1 * vehicleHalfWidth) + finalMatrix[7] * (-1 * vehicleHalfHeight) + finalMatrix[15];
+ screenCoordinates.upperLeft = {
+ x: (globalStates.height / 2) + ((finalMatrix[0] * (-1 * vehicleHalfWidth) + finalMatrix[4] * (-1 * vehicleHalfHeight) + finalMatrix[12]) / perspectiveDivide),
+ y: (globalStates.width / 2) + ((finalMatrix[1] * (-1 * vehicleHalfWidth) + finalMatrix[5] * (-1 * vehicleHalfHeight) + finalMatrix[13]) / perspectiveDivide)
+ };
+
+ // don't calculate lowerRight with expensive matrix multiplications, it can be deduced from center and upperLeft because it is the reflection of upperLeft across the center
+ var dx = screenCoordinates.center.x - screenCoordinates.upperLeft.x;
+ var dy = screenCoordinates.center.y - screenCoordinates.upperLeft.y;
+
+ screenCoordinates.lowerRight = {
+ x: screenCoordinates.center.x + dx,
+ y: screenCoordinates.center.y + dy
+ };
+ }
+
+ return screenCoordinates;
+};
+
+/**
+ * Provides the screen coordinates of the center, upperLeft and lowerRight coordinates of the provided frame
+ * (enough points to determine whether the frame overlaps with any rectangular region of the screen)
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @return {{ center: {x: number, y: number}, upperLeft: {x: number, y: number}, lowerRight: {x: number, y: number} }}
+ */
+realityEditor.gui.ar.positioning.getFrameScreenCoordinates = function(objectKey, frameKey) {
+ return this.getScreenPosition(objectKey, frameKey, true, true, false, false, true);
+};
+
+/**
+ * Calculates the exact screen coordinates corresponding to the center and corner points of the provided frame.
+ * Passing in true or false for the last 5 arguments controls which points to calculate and include in the result.
+ * (if omitted, they default to true to include everything)
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {boolean|undefined} includeCenter
+ * @param {boolean|undefined} includeUpperLeft
+ * @param {boolean|undefined} includeUpperRight
+ * @param {boolean|undefined} includeLowerLeft
+ * @param {boolean|undefined} includeLowerRight
+ * @param {number|undefined} buffer - extra padding to extend corner positions by, defaults to 0
+ */
+realityEditor.gui.ar.positioning.getScreenPosition = function(objectKey, frameKey, includeCenter, includeUpperLeft, includeUpperRight, includeLowerLeft, includeLowerRight, buffer) {
+ if (typeof includeCenter === 'undefined') { includeCenter = true; }
+ if (typeof includeUpperLeft === 'undefined') { includeUpperLeft = true; }
+ if (typeof includeUpperRight === 'undefined') { includeUpperRight = true; }
+ if (typeof includeLowerLeft === 'undefined') { includeLowerLeft = true; }
+ if (typeof includeLowerRight === 'undefined') { includeLowerRight = true; }
+ if (typeof buffer === 'undefined') { buffer = 0; }
+
+ var utils = realityEditor.gui.ar.utilities;
+ var draw = realityEditor.gui.ar.draw;
+
+ // 1. recompute the ModelViewProjection matrix for the target
+ var activeObjectMatrix = [];
+ utils.multiplyMatrix(draw.visibleObjects[objectKey], globalStates.projectionMatrix, activeObjectMatrix);
+
+ // 2. Get the matrix of the frame and compute the composed matrix of the frame relative to the object.
+ // *the order of multiplications is important*
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ var positionData = realityEditor.gui.ar.positioning.getPositionData(frame);
+ var positionDataMatrix = positionData.matrix.length === 16 ? positionData.matrix : utils.newIdentityMatrix();
+ var frameMatrixTemp = [];
+ var frameMatrix = [];
+ utils.multiplyMatrix(positionDataMatrix, activeObjectMatrix, frameMatrixTemp);
+
+ // 4. Scale/translate the final result.
+ var scale = [
+ positionData.scale, 0, 0, 0,
+ 0, positionData.scale, 0, 0,
+ 0, 0, positionData.scale, 0,
+ positionData.x, positionData.y, 0, 1
+ ];
+ utils.multiplyMatrix(scale, frameMatrixTemp, frameMatrix);
+
+ // compute the screen coordinates for various points within the frame
+ var screenCoordinates = {};
+
+ var halfWidth = parseInt(frame.frameSizeX)/2;
+ var halfHeight = parseInt(frame.frameSizeY)/2;
+
+ // start with coordinates in frame-space -> compute coordinates in screen space
+
+ // for each "include..." parameter, add a value to the result with that coordinate
+ if (includeCenter) {
+ var center = [0, 0, 0, 1];
+ screenCoordinates.center = this.getProjectedCoordinates(center, frameMatrix);
+ }
+
+ if (includeUpperLeft) {
+ var upperLeft = [-1 * halfWidth - buffer, -1 * halfHeight - buffer, 0, 1];
+ screenCoordinates.upperLeft = this.getProjectedCoordinates(upperLeft, frameMatrix);
+ }
+
+ if (includeUpperRight) {
+ var upperRight = [halfWidth + buffer, -1 * halfHeight - buffer, 0, 1];
+ screenCoordinates.upperRight = this.getProjectedCoordinates(upperRight, frameMatrix);
+ }
+
+ if (includeLowerLeft) {
+ var lowerLeft = [-1 * halfWidth - buffer, halfHeight + buffer, 0, 1];
+ screenCoordinates.lowerLeft = this.getProjectedCoordinates(lowerLeft, frameMatrix);
+ }
+
+ if (includeLowerRight) {
+ var lowerRight = [halfWidth + buffer, halfHeight + buffer, 0, 1];
+ screenCoordinates.lowerRight = this.getProjectedCoordinates(lowerRight, frameMatrix);
+ }
+
+ return screenCoordinates;
+};
+
+/**
+ * Converts [frameX, frameY, 0, 1] into screen coordinates based on the provided ModelViewProjection matrix
+ * @param {Array.} frameCoordinateVector - a length-4 vector [x, y, 0, 1] of the position in frame space
+ * e.g. [0, 0, 0, 1] represents the center of the frame and [-halfWidth, -halfHeight, 0, 1] represents the top-left
+ * @param {Array.} frameMatrix - 4x4 MVP matrix, composition of the object and frame transformations
+ * @return {{x: number, y: number}}
+ */
+realityEditor.gui.ar.positioning.getProjectedCoordinates = function(frameCoordinateVector, frameMatrix) {
+ var utils = realityEditor.gui.ar.utilities;
+ var projectedCoordinateVector = utils.perspectiveDivide(utils.multiplyMatrix4(frameCoordinateVector, frameMatrix));
+ projectedCoordinateVector[0] += (globalStates.height / 2);
+ projectedCoordinateVector[1] += (globalStates.width / 2);
+ return {
+ x: projectedCoordinateVector[0],
+ y: projectedCoordinateVector[1]
+ };
+};
+
+/**
+ * Instantly moves the frame so it's floating right in front of the camera
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {number} mmInFrontOfCamera - e.g. 400 = 0.4 meters. default 0
+ */
+realityEditor.gui.ar.positioning.moveFrameToCamera = function(objectKey, frameKey, mmInFrontOfCamera) {
+
+ // reset the (x, y) position so it will move to center of screen
+ let frame = realityEditor.getFrame(objectKey, frameKey);
+ if (frame) {
+ frame.ar.x = 0;
+ frame.ar.y = 0;
+ }
+
+ // place it in front of the camera, facing towards the camera
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(frameKey);
+ let cameraNode = realityEditor.sceneGraph.getSceneNodeById('CAMERA');
+ let distanceInFrontOfCamera = mmInFrontOfCamera || 0; // 0.4 meters
+
+ let initialVehicleMatrix = [
+ -1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, -1, 0,
+ 0, 0, -1 * distanceInFrontOfCamera, 1
+ ];
+
+ let additionalRotation = realityEditor.device.environment.getInitialPocketToolRotation();
+ if (additionalRotation) {
+ let temp = [];
+ realityEditor.gui.ar.utilities.multiplyMatrix(additionalRotation, initialVehicleMatrix, temp);
+ initialVehicleMatrix = temp;
+ }
+
+ // needs to be flipped in some environments with different camera systems
+ if (realityEditor.device.environment.isCameraOrientationFlipped()) {
+ initialVehicleMatrix[0] *= -1;
+ initialVehicleMatrix[5] *= -1;
+ initialVehicleMatrix[10] *= -1;
+ }
+
+ sceneNode.setPositionRelativeTo(cameraNode, initialVehicleMatrix);
+
+ setTimeout(function() {
+ realityEditor.network.realtime.broadcastUpdate(objectKey, frameKey, null, 'ar.matrix', sceneNode.localMatrix);
+ }, 300);
+};
+
+/**
+ * Given the final transform3d matrix representing where a frame or node is rendered on the screen,
+ * determines if it is sufficiently outside the viewport to be able to entirely unloaded from view.
+ * The size of the viewport can depend on various factors, e.g. powerSave mode.
+ * @param {string} activeKey - frame/node key to lookup sceneGraph information
+ * @param {Array.} finalMatrix - the CSS transform3d matrix
+ * @param {number} vehicleHalfWidth - get from frameSizeX (scale is already stored separately in the matrix)
+ * @param {number} vehicleHalfHeight - get from frameSizeY
+ * @param {number?} maxDistance - if further away than this, unload. (unit scale: 1000=1meter)
+ * @return {boolean}
+ */
+realityEditor.gui.ar.positioning.canUnload = function(activeKey, finalMatrix, vehicleHalfWidth, vehicleHalfHeight, maxDistance) {
+ // don't bother unloading/reloading on desktop environments, as the camera moves around so quickly that this can cause more overhead than it saves
+ if (!realityEditor.device.environment.isARMode()) return false;
+
+ // // if it's fully behind the viewport, it can be unloaded
+ if (!realityEditor.sceneGraph.isInFrontOfCamera(activeKey)) {
+ return true;
+ }
+
+ // if a distance threshold is provided, unload if it is too far away
+ if (typeof maxDistance !== 'undefined') {
+ if (realityEditor.sceneGraph.getDistanceToCamera(activeKey) > maxDistance) {
+ return true;
+ }
+ }
+
+ // get a rough estimation of screen position so we can see if it overlaps with viewport
+ var frameScreenPosition = this.getVehicleBoundingBoxFast(finalMatrix, vehicleHalfWidth, vehicleHalfHeight);
+ var left = frameScreenPosition.upperLeft.x;
+ var right = frameScreenPosition.lowerRight.x;
+ var top = frameScreenPosition.upperLeft.y;
+ var bottom = frameScreenPosition.lowerRight.y;
+
+ // usually (in powerSave mode) remove if frame is slightly outside screen bounds
+ let viewportBounds = {
+ left: 0,
+ right: globalStates.height,
+ top: 0,
+ bottom: globalStates.width
+ };
+
+ // if not in powerSave mode, be more generous about keeping frames loaded
+ // adds a buffer on each side of the viewport equal to the size of the screen
+ if (!realityEditor.gui.settings.toggleStates.powerSaveMode) {
+ let additionalBuffer = {
+ x: globalStates.height,
+ y: globalStates.width
+ };
+ viewportBounds.left -= additionalBuffer.x;
+ viewportBounds.right += additionalBuffer.x;
+ viewportBounds.top -= additionalBuffer.y;
+ viewportBounds.bottom += additionalBuffer.y;
+ }
+
+ // if it is fully beyond any edge of the viewport, it can be unloaded
+ return bottom < viewportBounds.top || top > viewportBounds.bottom ||
+ right < viewportBounds.left || left > viewportBounds.right;
+};
+
+/**
+ * Constructs a dataset of the positions of the relevant objects and their tools
+ * @param {Object.} objectTypesToSend
+ * @param {boolean} includeToolPositions - defaults to true
+ * @returns {{}}
+ * @example getObjectPositionsOfTypes({'human': true}) returns:
+ * { 'human': { 'objectId1': { matrix: [worldMatrix], worldId: '_WORLD_xyz', tools: { 'toolId1': [localMatrix], 'toolId2': [localMatrix] }}}}
+ */
+realityEditor.gui.ar.positioning.getObjectPositionsOfTypes = function(objectTypesToSend, includeToolPositions = true) {
+ let dataToSend = {};
+ realityEditor.forEachObject((object, objectKey) => {
+ if (objectTypesToSend[object.type]) {
+ if (typeof dataToSend[object.type] === 'undefined') {
+ dataToSend[object.type] = {};
+ }
+
+ // only works if it's localized against a world object
+ if (object.worldId) {
+ let objectSceneNode = realityEditor.sceneGraph.getSceneNodeById(objectKey);
+ let worldSceneNode = realityEditor.sceneGraph.getSceneNodeById(object.worldId);
+ let relativeMatrix = objectSceneNode.getMatrixRelativeTo(worldSceneNode);
+ dataToSend[object.type][objectKey] = {
+ matrix: relativeMatrix,
+ worldId: object.worldId
+ };
+
+ if (includeToolPositions) {
+ dataToSend[object.type][objectKey].tools = {};
+ realityEditor.forEachFrameInObject(objectKey, (_, frameKey) => {
+ let toolSceneNode = realityEditor.sceneGraph.getSceneNodeById(frameKey);
+ dataToSend[object.type][objectKey].tools[frameKey] = toolSceneNode.localMatrix;
+ });
+ }
+ }
+ }
+ });
+ return dataToSend;
+};
+
+// adjusts the screenZ (modelViewProjection[14]) to give values that correctly determine stacking order based on distance
+// to screen. if you don't do this, CSS-3D tools/icons have opposite stacking order compared to how you might expect.
+realityEditor.gui.ar.positioning.getFinalMatrixScreenZ = function(originalScreenZ, thisIsBeingEdited = false, shouldRenderFramesInNodeView = false) {
+ let activeElementZIncrease = thisIsBeingEdited ? 100 : 0;
+
+ // originalScreenZ is lower as it is closer to camera. We want newScreenZ to be higher when it approaches camera.
+ let newScreenZ = 200 + activeElementZIncrease + 1000000 / Math.max(10, originalScreenZ);
+
+ // apply legacy adjustments to z order... these might be adjusted/removed in future...
+
+ // on devices that make elements visible from further away, make sure the z value increases proportionally so it is > 0
+ if (realityEditor.device.environment.variables.distanceScaleFactor > 1) {
+ newScreenZ += realityEditor.device.environment.variables.distanceScaleFactor * 1000;
+ }
+ // put frames all the way in the back if you are in node view
+ if (shouldRenderFramesInNodeView) {
+ newScreenZ = 100;
+ }
+
+ return newScreenZ;
+};
diff --git a/src/gui/ar/utilities.js b/src/gui/ar/utilities.js
new file mode 100644
index 000000000..dcd204e13
--- /dev/null
+++ b/src/gui/ar/utilities.js
@@ -0,0 +1,1438 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.gui.ar.utilities");
+
+/**
+ * @fileOverview realityEditor.gui.ar.utilities.js
+ * Various utility functions, mostly mathematical, for calculating AR geometry.
+ * Includes simply utilities like multiplying and inverting a matrix,
+ * as well as sophisticated algorithms for target-plane intersections and raycasting points onto a plane.
+ */
+
+/**
+ * Updates the timing object with the current timestamp and delta since last frame.
+ * @param {{delta: number, now: number, then: number}} timing - reference to the timing object to modify
+ */
+realityEditor.gui.ar.utilities.timeSynchronizer = function(timing) {
+ timing.now = Date.now();
+ timing.delta = (timing.now - timing.then) / 198;
+ timing.then = timing.now;
+};
+
+/**
+ * Rescales x from the original range (in_min, in_max) to the new range (out_min, out_max)
+ * @example map(5, 0, 10, 100, 200) would return 150, because 5 is halfway between 0 and 10, so it finds the number halfway between 100 and 200
+ *
+ * @param {number} x
+ * @param {number} in_min
+ * @param {number} in_max
+ * @param {number} out_min
+ * @param {number} out_max
+ * @return {number}
+ */
+realityEditor.gui.ar.utilities.map = function(x, in_min, in_max, out_min, out_max) {
+ if (x > in_max) x = in_max;
+ if (x < in_min) x = in_min;
+ return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
+};
+
+/**
+ * @desc This function multiplies one m16 matrix with a second m16 matrix
+ * @param {Array.} m2 - origin matrix to be multiplied with
+ * @param {Array.} m1 - second matrix that multiplies.
+ * @return {Array.} m16 matrix result of the multiplication
+ */
+realityEditor.gui.ar.utilities.multiplyMatrix = function(m2, m1, r) {
+ // var r = [];
+ // Cm1che only the current line of the second mm1trix
+ r[0] = m2[0] * m1[0] + m2[1] * m1[4] + m2[2] * m1[8] + m2[3] * m1[12];
+ r[1] = m2[0] * m1[1] + m2[1] * m1[5] + m2[2] * m1[9] + m2[3] * m1[13];
+ r[2] = m2[0] * m1[2] + m2[1] * m1[6] + m2[2] * m1[10] + m2[3] * m1[14];
+ r[3] = m2[0] * m1[3] + m2[1] * m1[7] + m2[2] * m1[11] + m2[3] * m1[15];
+
+ r[4] = m2[4] * m1[0] + m2[5] * m1[4] + m2[6] * m1[8] + m2[7] * m1[12];
+ r[5] = m2[4] * m1[1] + m2[5] * m1[5] + m2[6] * m1[9] + m2[7] * m1[13];
+ r[6] = m2[4] * m1[2] + m2[5] * m1[6] + m2[6] * m1[10] + m2[7] * m1[14];
+ r[7] = m2[4] * m1[3] + m2[5] * m1[7] + m2[6] * m1[11] + m2[7] * m1[15];
+
+ r[8] = m2[8] * m1[0] + m2[9] * m1[4] + m2[10] * m1[8] + m2[11] * m1[12];
+ r[9] = m2[8] * m1[1] + m2[9] * m1[5] + m2[10] * m1[9] + m2[11] * m1[13];
+ r[10] = m2[8] * m1[2] + m2[9] * m1[6] + m2[10] * m1[10] + m2[11] * m1[14];
+ r[11] = m2[8] * m1[3] + m2[9] * m1[7] + m2[10] * m1[11] + m2[11] * m1[15];
+
+ r[12] = m2[12] * m1[0] + m2[13] * m1[4] + m2[14] * m1[8] + m2[15] * m1[12];
+ r[13] = m2[12] * m1[1] + m2[13] * m1[5] + m2[14] * m1[9] + m2[15] * m1[13];
+ r[14] = m2[12] * m1[2] + m2[13] * m1[6] + m2[14] * m1[10] + m2[15] * m1[14];
+ r[15] = m2[12] * m1[3] + m2[13] * m1[7] + m2[14] * m1[11] + m2[15] * m1[15];
+ // return r;
+};
+
+/**
+ * Utility to subtract one m16 from another
+ * @param {Array.} m1
+ * @param {Array.} m2
+ * @return {Array.} = m1 - m2
+ */
+realityEditor.gui.ar.utilities.subtractMatrix = function(m1, m2) {
+ var r = [];
+ r[0] = m1[0] - m2[0];
+ r[1] = m1[1] - m2[1];
+ r[2] = m1[2] - m2[2];
+ r[3] = m1[3] - m2[3];
+ r[4] = m1[4] - m2[4];
+ r[5] = m1[5] - m2[5];
+ r[6] = m1[6] - m2[6];
+ r[7] = m1[7] - m2[7];
+ r[8] = m1[8] - m2[8];
+ r[9] = m1[9] - m2[9];
+ r[10] = m1[10] - m2[10];
+ r[11] = m1[11] - m2[11];
+ r[12] = m1[12] - m2[12];
+ r[13] = m1[13] - m2[13];
+ r[14] = m1[14] - m2[14];
+ r[15] = m1[15] - m2[15];
+ return r;
+};
+
+/**
+ * @desc multiply m4 matrix with m16 matrix
+ * @param {Array.} m1 - origin m4 matrix
+ * @param {Array.} m2 - m16 matrix to multiply with
+ * @return {Array.} is m16 matrix
+ */
+realityEditor.gui.ar.utilities.multiplyMatrix4 = function(m1, m2) {
+ var r = [];
+ var x = m1[0], y = m1[1], z = m1[2], w = m1[3];
+ r[0] = m2[0] * x + m2[4] * y + m2[8] * z + m2[12] * w;
+ r[1] = m2[1] * x + m2[5] * y + m2[9] * z + m2[13] * w;
+ r[2] = m2[2] * x + m2[6] * y + m2[10] * z + m2[14] * w;
+ r[3] = m2[3] * x + m2[7] * y + m2[11] * z + m2[15] * w;
+ return r;
+};
+
+/**
+ * @desc copies one m16 matrix in to another m16 matrix
+ * @param {Array.}matrix - source matrix
+ * @return {Array.} resulting copy of the matrix
+ */
+realityEditor.gui.ar.utilities.copyMatrix = function(matrix) {
+ if (matrix.length === 0) return [];
+
+ var r = []; //new Array(16);
+ r[0] = matrix[0];
+ r[1] = matrix[1];
+ r[2] = matrix[2];
+ r[3] = matrix[3];
+ r[4] = matrix[4];
+ r[5] = matrix[5];
+ r[6] = matrix[6];
+ r[7] = matrix[7];
+ r[8] = matrix[8];
+ r[9] = matrix[9];
+ r[10] = matrix[10];
+ r[11] = matrix[11];
+ r[12] = matrix[12];
+ r[13] = matrix[13];
+ r[14] = matrix[14];
+ r[15] = matrix[15];
+ return r;
+};
+
+/**
+ * @desc copies one m16 matrix in to another m16 matrix
+ * Use instead of copyMatrix function when speed is very important - this is faster
+ * @param {Array.} m1 - source matrix
+ * @param {Array.} m2 - resulting copy of the matrix
+ */
+realityEditor.gui.ar.utilities.copyMatrixInPlace = function(m1, m2) {
+ m2[0] = m1[0];
+ m2[1] = m1[1];
+ m2[2] = m1[2];
+ m2[3] = m1[3];
+ m2[4] = m1[4];
+ m2[5] = m1[5];
+ m2[6] = m1[6];
+ m2[7] = m1[7];
+ m2[8] = m1[8];
+ m2[9] = m1[9];
+ m2[10] = m1[10];
+ m2[11] = m1[11];
+ m2[12] = m1[12];
+ m2[13] = m1[13];
+ m2[14] = m1[14];
+ m2[15] = m1[15];
+};
+
+/**
+ * Returns a matrix that linearly interpolated each element of two matrices
+ * @param {Array.} existingMatrix - source matrix
+ * @param {Array.} newMatrix - new value
+ * @param {number} alpha - if 0, sets to existing matrix, if 1, sets to new matrix, if 0.5, averages the two
+ * @return {Array.} resulting interpolated matrix
+ */
+realityEditor.gui.ar.utilities.lerpMatrices = function(existingMatrix, newMatrix, alpha) {
+ if (existingMatrix.length !== newMatrix.length) {
+ console.warn('trying to lerp incompatible matrices');
+ return;
+ }
+ if (typeof alpha === 'undefined' || alpha < 0 || alpha > 1) {
+ alpha = 0.5;
+ }
+
+ var r = [];
+ r[0] = newMatrix[0] * alpha + existingMatrix[0] * (1 - alpha);
+ r[1] = newMatrix[1] * alpha + existingMatrix[1] * (1 - alpha);
+ r[2] = newMatrix[2] * alpha + existingMatrix[2] * (1 - alpha);
+ r[3] = newMatrix[3] * alpha + existingMatrix[3] * (1 - alpha);
+ r[4] = newMatrix[4] * alpha + existingMatrix[4] * (1 - alpha);
+ r[5] = newMatrix[5] * alpha + existingMatrix[5] * (1 - alpha);
+ r[6] = newMatrix[6] * alpha + existingMatrix[6] * (1 - alpha);
+ r[7] = newMatrix[7] * alpha + existingMatrix[7] * (1 - alpha);
+ r[8] = newMatrix[8] * alpha + existingMatrix[8] * (1 - alpha);
+ r[9] = newMatrix[9] * alpha + existingMatrix[9] * (1 - alpha);
+ r[10] = newMatrix[10] * alpha + existingMatrix[10] * (1 - alpha);
+ r[11] = newMatrix[11] * alpha + existingMatrix[11] * (1 - alpha);
+ r[12] = newMatrix[12] * alpha + existingMatrix[12] * (1 - alpha);
+ r[13] = newMatrix[13] * alpha + existingMatrix[13] * (1 - alpha);
+ r[14] = newMatrix[14] * alpha + existingMatrix[14] * (1 - alpha);
+ r[15] = newMatrix[15] * alpha + existingMatrix[15] * (1 - alpha);
+ return r;
+};
+
+/**
+ * @desc inverting a matrix
+ * @param {Array.} a origin matrix
+ * @return {Array.} a inverted copy of the origin matrix
+ */
+realityEditor.gui.ar.utilities.invertMatrix = function (a) {
+ var b = [];
+ var c = a[0], d = a[1], e = a[2], g = a[3], f = a[4], h = a[5], i = a[6], j = a[7], k = a[8], l = a[9], o = a[10], m = a[11], n = a[12], p = a[13], r = a[14], s = a[15], A = c * h - d * f, B = c * i - e * f, t = c * j - g * f, u = d * i - e * h, v = d * j - g * h, w = e * j - g * i, x = k * p - l * n, y = k * r - o * n, z = k * s - m * n, C = l * r - o * p, D = l * s - m * p, E = o * s - m * r, q = 1 / (A * E - B * D + t * C + u * z - v * y + w * x);
+ b[0] = (h * E - i * D + j * C) * q;
+ b[1] = ( -d * E + e * D - g * C) * q;
+ b[2] = (p * w - r * v + s * u) * q;
+ b[3] = ( -l * w + o * v - m * u) * q;
+ b[4] = ( -f * E + i * z - j * y) * q;
+ b[5] = (c * E - e * z + g * y) * q;
+ b[6] = ( -n * w + r * t - s * B) * q;
+ b[7] = (k * w - o * t + m * B) * q;
+ b[8] = (f * D - h * z + j * x) * q;
+ b[9] = ( -c * D + d * z - g * x) * q;
+ b[10] = (n * v - p * t + s * A) * q;
+ b[11] = ( -k * v + l * t - m * A) * q;
+ b[12] = ( -f * C + h * y - i * x) * q;
+ b[13] = (c * C - d * y + e * x) * q;
+ b[14] = ( -n * u + p * B - r * A) * q;
+ b[15] = (k * u - l * B + o * A) * q;
+ return b;
+};
+
+/**
+ * Returns the transpose of a 4x4 matrix
+ * @param {Array.} matrix
+ * @return {Array.}
+ */
+realityEditor.gui.ar.utilities.transposeMatrix = function(matrix) {
+ var r = [];
+ r[0] = matrix[0];
+ r[1] = matrix[4];
+ r[2] = matrix[8];
+ r[3] = matrix[12];
+ r[4] = matrix[1];
+ r[5] = matrix[5];
+ r[6] = matrix[9];
+ r[7] = matrix[13];
+ r[8] = matrix[2];
+ r[9] = matrix[6];
+ r[10] = matrix[10];
+ r[11] = matrix[14];
+ r[12] = matrix[3];
+ r[13] = matrix[7];
+ r[14] = matrix[11];
+ r[15] = matrix[15];
+ return r;
+};
+
+/**
+ * Efficient method for multiplying each element in a length 4 array by the same number
+ * @param {Array.} vector4
+ * @param {number} scalar
+ * @return {Array.}
+ */
+realityEditor.gui.ar.utilities.scalarMultiplyVector = function(vector4, scalar) {
+ var r = [];
+ r[0] = vector4[0] * scalar;
+ r[1] = vector4[1] * scalar;
+ r[2] = vector4[2] * scalar;
+ r[3] = vector4[3] * scalar;
+ return r;
+};
+
+/**
+ * Efficient method for multiplying each element in a length 16 array by the same number
+ * @param {Array.} matrix
+ * @param {number} scalar
+ * @return {Array.}
+ */
+realityEditor.gui.ar.utilities.scalarMultiplyMatrix = function(matrix, scalar) {
+ var r = [];
+ r[0] = matrix[0] * scalar;
+ r[1] = matrix[1] * scalar;
+ r[2] = matrix[2] * scalar;
+ r[3] = matrix[3] * scalar;
+ r[4] = matrix[4] * scalar;
+ r[5] = matrix[5] * scalar;
+ r[6] = matrix[6] * scalar;
+ r[7] = matrix[7] * scalar;
+ r[8] = matrix[8] * scalar;
+ r[9] = matrix[9] * scalar;
+ r[10] = matrix[10] * scalar;
+ r[11] = matrix[11] * scalar;
+ r[12] = matrix[12] * scalar;
+ r[13] = matrix[13] * scalar;
+ r[14] = matrix[14] * scalar;
+ r[15] = matrix[15] * scalar;
+ return r;
+};
+
+/**
+ * Divides every element in a vector or matrix by its last element so that the last element becomes 1.
+ * (see explanation of homogeneous coordinates http://robotics.stanford.edu/~birch/projective/node4.html)
+ * @param {Array.} matrix - can have any length (so it works for vectors and matrices)
+ * @return {Array.}
+ */
+realityEditor.gui.ar.utilities.perspectiveDivide = function(matrix) {
+ var lastElement = matrix[matrix.length-1];
+ var r = [];
+ for (var i = 0; i < matrix.length; i++) {
+ r[i] = matrix[i] / lastElement;
+ }
+ return r;
+};
+
+/**
+ * Helper function for printing a matrix in human-readable format
+ * Note that this assumes, row-major order, while CSS 3D matrices actually use column-major
+ * Interpret column-major matrices as the transpose of what is printed
+ * @param {Array.} matrix
+ * @param {number} precision - the number of decimal points to include
+ * @param {boolean} htmlLineBreaks - use html line breaks instead of newline characters
+ * @return {string}
+ */
+realityEditor.gui.ar.utilities.prettyPrintMatrix = function(matrix, precision, htmlLineBreaks) {
+ if (typeof precision === 'undefined') precision = 3;
+
+ var lineBreakSymbol = htmlLineBreaks ? ' ' : '\n';
+
+ return "[ " + matrix[0].toFixed(precision) + ", " + matrix[1].toFixed(precision) + ", " + matrix[2].toFixed(precision) + ", " + matrix[3].toFixed(precision) + ", " + lineBreakSymbol +
+ " " + matrix[4].toFixed(precision) + ", " + matrix[5].toFixed(precision) + ", " + matrix[6].toFixed(precision) + ", " + matrix[7].toFixed(precision) + ", " + lineBreakSymbol +
+ " " + matrix[8].toFixed(precision) + ", " + matrix[9].toFixed(precision) + ", " + matrix[10].toFixed(precision) + ", " + matrix[11].toFixed(precision) + ", " + lineBreakSymbol +
+ " " + matrix[12].toFixed(precision) + ", " + matrix[13].toFixed(precision) + ", " + matrix[14].toFixed(precision) + ", " + matrix[15].toFixed(precision) + " ]";
+};
+
+/**
+ * Returns the dot product of the two vectors
+ */
+realityEditor.gui.ar.utilities.dotProduct = function(v1, v2) {
+ if (v1.length !== v2.length) {
+ console.warn('trying to dot two vectors of different lengths');
+ return 0;
+ }
+ var sum = 0;
+ for (var i = 0; i < v1.length; i++) {
+ sum += v1[i] * v2[i];
+ }
+ return sum;
+};
+
+/**
+ * Utility that returns true if the rectangle formed by topLeft and bottomRight A overlaps B
+ * https://www.geeksforgeeks.org/find-two-rectangles-overlap/
+ *
+ * topLeftA ----------------
+ * | |
+ * | |
+ * | |
+ * ------------ bottomRightA
+ *
+ * topLeftB -----------------
+ * | |
+ * | |
+ * | |
+ * ------------ bottomRightB
+ *
+ * @param {{x: number, y: number}} topLeftA
+ * @param {{x: number, y: number}} bottomRightA
+ * @param {{x: number, y: number}} topLeftB
+ * @param {{x: number, y: number}} bottomRightB
+ * @return {boolean}
+ */
+realityEditor.gui.ar.utilities.areRectsOverlapping = function(topLeftA, bottomRightA, topLeftB, bottomRightB) {
+
+ // can't overlap if one is completely to the left of the other
+ if (topLeftA.x > bottomRightB.x || topLeftB.x > bottomRightA.x) {
+ return false;
+ }
+
+ // can't overlap is one is completely above the other
+ if (topLeftA.y > bottomRightB.y || topLeftB.y > bottomRightA.y) {
+ return false;
+ }
+
+ // must overlap if neither of the above conditions are true
+ return true;
+};
+
+/**
+ * Returns whether or not the given point is inside the polygon formed by the given vertices.
+ * @param {Array.} point - [x,y]
+ * @param {Array.>} vertices - [[x0, y0], [x1, y1], ... ]
+ * @return {boolean}
+ */
+realityEditor.gui.ar.utilities.insidePoly = function(point, vertices) {
+ // ray-casting algorithm based on
+ // http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html
+ // Copyright (c) 2016 James Halliday
+ // The MIT License (MIT)
+
+ var x = point[0], y = point[1];
+
+ if(x <=0 || y <= 0) return false;
+
+ var inside = false;
+ for (var i = 0, j = vertices.length - 1; i < vertices.length; j = i++) {
+ var xi = vertices[i][0], yi = vertices[i][1];
+ var xj = vertices[j][0], yj = vertices[j][1];
+
+ var intersect = ((yi > y) !== (yj > y))
+ && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
+ if (intersect) inside = !inside;
+ }
+
+ return inside;
+};
+
+/**
+ * Returns whether or not the given node's center is within the screen bounds
+ * @param {Frame} thisObject - frame containing the node // TODO: does this work with frames now or does it still expect an object?
+ * @param {string} nodeKey
+ * @return {boolean}
+ */
+realityEditor.gui.ar.utilities.isNodeWithinScreen = function(thisObject, nodeKey) {
+ var thisNode = thisObject.nodes[nodeKey];
+
+ // This is correct, globalStates.height is actually the width (568), while globalStates.width is the height (320)
+ // noinspection JSSuspiciousNameCombination
+ var screenWidth = globalStates.height;
+ // noinspection JSSuspiciousNameCombination
+ var screenHeight = globalStates.width;
+
+ var screenCorners = [
+ [0,0],
+ [screenWidth,0],
+ [screenWidth,screenHeight],
+ [0,screenHeight]
+ ];
+ return this.insidePoly([thisNode.screenX, thisNode.screenY],screenCorners);
+};
+
+/**
+ * Uses isOutsideViewport to determine which frames are currently visible across all visible objects
+ * @return {Array.} - returns frameKeys of all visible frames
+ */
+realityEditor.gui.ar.utilities.getAllVisibleFramesFast = function() {
+
+ var visibleFrameKeys = [];
+
+ var visibleObjects = realityEditor.gui.ar.draw.visibleObjects;
+ for (var objectKey in visibleObjects) {
+ if (objects[objectKey]) {
+ for (var frameKey in objects[objectKey].frames) {
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ if (frame) {
+ if (frame.visualization !== 'ar') { continue; }
+ if (!frame.isOutsideViewport) {
+ visibleFrameKeys.push(frameKey);
+ }
+ }
+ }
+ }
+
+ }
+
+ return visibleFrameKeys;
+};
+
+/**
+ * Efficient calculation to determine which frames are visible within the screen bounds.
+ * Only AR frames are counted. Considered visible if the rectangular bounding-box of the
+ * 3d-transformed div overlaps with the screen bounds at all.
+ * @return {Array. }
+ */
+realityEditor.gui.ar.utilities.getAllVisibleFrames = function() {
+ // TODO currently this function requires to many resources. It can take up to 5ms to just calculate if frames are visible
+ // return true;
+
+ var visibleFrames = [];
+
+ var visibleObjects = realityEditor.gui.ar.draw.visibleObjects;
+ for (var objectKey in visibleObjects) {
+ if (!visibleObjects.hasOwnProperty(objectKey)) continue;
+ realityEditor.forEachFrameInObject(objectKey, function(objectKey, frameKey) {
+
+ var thisFrame = realityEditor.getFrame(objectKey, frameKey);
+
+ if (thisFrame.visualization !== 'ar') {
+ return;
+ }
+
+ if (globalDOMCache['iframe' + frameKey]) {
+
+ // Use the getBoundingClientRect to check approximate overlap between frame bounds and screen bounds
+
+ var upperLeftScreen = {
+ x: 0,
+ y: 0
+ };
+
+ // noinspection JSSuspiciousNameCombination - This is correct, globalStates.height is actually the width
+ var bottomRightScreen = {
+ x: globalStates.height,
+ y: globalStates.width
+ };
+
+ var frameClientRect = globalDOMCache['iframe' + frameKey].getBoundingClientRect();
+
+ var upperLeftFrame = {
+ x: frameClientRect.left,
+ y: frameClientRect.top
+ };
+
+ var bottomRightFrame = {
+ x: frameClientRect.right,
+ y: frameClientRect.bottom
+ };
+
+ if (realityEditor.gui.ar.utilities.areRectsOverlapping(upperLeftScreen, bottomRightScreen, upperLeftFrame, bottomRightFrame)) {
+ visibleFrames.push(frameKey);
+ }
+ }
+
+ });
+ }
+
+ return visibleFrames;
+};
+
+realityEditor.gui.ar.utilities.isValidMatrix4x4 = function(mat) {
+ if (!mat) return false;
+ if (typeof mat !== 'object') return false;
+ if (typeof mat.length !== 'number') return false;
+ if (mat.length !== 16) return false;
+ for (let i = 0; i < 16; i++) {
+ if (typeof mat[i] !== 'number') return false;
+ if (isNaN(mat[i])) return false;
+ }
+ return true;
+}
+
+/**
+ * Helper method for creating a new 4x4 identity matrix
+ * @return {Array.}
+ */
+realityEditor.gui.ar.utilities.newIdentityMatrix = function() {
+ return [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ];
+};
+
+/**
+ * Checks if a 4x4 matrix is the identity matrix.
+ * optimized for the cases when it is not, as that is more common in this application.
+ * @param {Array.} matrix
+ * @param {number|undefined} precision - how many digits to when checking (to prevent small rounding errors)
+ * @return {boolean}
+ */
+realityEditor.gui.ar.utilities.isIdentityMatrix = function(matrix, precision) {
+ precision = precision || 3; // defaults to 3 digits of precision
+ // unrolled loop to be faster at expense of longer function body
+ if (parseFloat(matrix[0].toFixed(precision)) !== 1) {
+ return false;
+ }
+ if (parseFloat(matrix[1].toFixed(precision)) !== 0) {
+ return false;
+ }
+ if (parseFloat(matrix[2].toFixed(precision)) !== 0) {
+ return false;
+ }
+ if (parseFloat(matrix[3].toFixed(precision)) !== 0) {
+ return false;
+ }
+ if (parseFloat(matrix[4].toFixed(precision)) !== 0) {
+ return false;
+ }
+ if (parseFloat(matrix[5].toFixed(precision)) !== 1) {
+ return false;
+ }
+ if (parseFloat(matrix[6].toFixed(precision)) !== 0) {
+ return false;
+ }
+ if (parseFloat(matrix[7].toFixed(precision)) !== 0) {
+ return false;
+ }
+ if (parseFloat(matrix[8].toFixed(precision)) !== 0) {
+ return false;
+ }
+ if (parseFloat(matrix[9].toFixed(precision)) !== 0) {
+ return false;
+ }
+ if (parseFloat(matrix[10].toFixed(precision)) !== 1) {
+ return false;
+ }
+ if (parseFloat(matrix[11].toFixed(precision)) !== 0) {
+ return false;
+ }
+ if (parseFloat(matrix[12].toFixed(precision)) !== 0) {
+ return false;
+ }
+ if (parseFloat(matrix[13].toFixed(precision)) !== 0) {
+ return false;
+ }
+ if (parseFloat(matrix[14].toFixed(precision)) !== 0) {
+ return false;
+ }
+ return parseFloat(matrix[15].toFixed(precision)) === 1; // if it got this far, it's the identity iff the last element is 1
+};
+
+/**
+ * Checks if 2 matrices are equal strictly, without any rounding
+ * @param {Array.} m1
+ * @param {Array.} m2
+ * @return {boolean}
+ */
+realityEditor.gui.ar.utilities.isEqualStrict = function(m1, m2) {
+ if (m1.length !== m2.length) return false;
+ return m1.every((val, index) => val === m2[index]);
+}
+
+/**
+ * Updates the averageScale property of the object by averaging the scale properties of all its frames and nodes
+ * @todo move to another file
+ * @param object
+ */
+realityEditor.gui.ar.utilities.setAverageScale = function(object) {
+ var amount = 0;
+ var sum = 0;
+// if(!object.frames) return;
+
+ if (Object.keys(object.frames).length === 0) {
+ object.averageScale = globalStates.defaultScale;
+ return; // use default scale if there are no existing frames
+ }
+
+ for(var frameKey in object.frames){
+ if(!object.frames.hasOwnProperty(frameKey)) continue;
+ // if(!object.frames[frameKey].ar.size) continue;
+ amount++;
+ sum = sum+ object.frames[frameKey].ar.scale;
+ // if(!object.frames[frameKey].nodes) continue;
+ for(var nodeKey in object.frames[frameKey].nodes){
+ if(!object.frames[frameKey].nodes.hasOwnProperty(nodeKey)) continue;
+ // if(!object.frames[frameKey].nodes) continue;
+ amount++;
+ sum = sum+ object.frames[frameKey].nodes[nodeKey].scale;
+ }
+ }
+ object.averageScale = Math.max(0.01, sum/amount); // TODO: put more thought into minimum scale
+};
+
+/**
+ * Creates and returns a div with the CSS3D transform needed to position it at an image target's origin
+ * @param {string} objectKey
+ * @return {HTMLElement}
+ */
+realityEditor.gui.ar.utilities.getDivWithTargetTransformation = function(objectKey) {
+
+ let matrixComputationDiv = globalDOMCache['matrixComputationDivForObjects'];
+ if (!matrixComputationDiv) {
+ // create it if needed
+ matrixComputationDiv = document.createElement('div');
+ matrixComputationDiv.id = 'matrixComputationDivForObjects';
+ matrixComputationDiv.classList.add('main');
+ matrixComputationDiv.classList.add('ignorePointerEvents');
+
+ // 3D transforms only apply correctly if it's a child of the GUI container (like the rest of the tools/nodes)
+ document.getElementById('GUI').appendChild(matrixComputationDiv);
+ globalDOMCache['matrixComputationDivForObjects'] = matrixComputationDiv;
+ }
+
+ if (matrixComputationDiv.style.display === 'none') {
+ matrixComputationDiv.style.display = '';
+ }
+
+ // the computation is only correct if it has the same width/height as the vehicle's transformed element
+ matrixComputationDiv.style.width = window.innerWidth + 'px';
+ matrixComputationDiv.style.height = window.innerHeight + 'px';
+
+ let untransformedMatrix = realityEditor.sceneGraph.getCSSMatrixWithoutTranslation(objectKey);
+ matrixComputationDiv.style.transform = 'matrix3d(' + untransformedMatrix.toString() + ')';
+
+ return matrixComputationDiv;
+};
+
+/**
+ * tapping on the center of the object matrix should yield (0,0). ranges from [-targetSize/2, targetSize/2]
+ * @param {string} objectKey
+ * @param {number} screenX
+ * @param {number} screenY
+ * @return {{x: number, y: number}}
+ */
+realityEditor.gui.ar.utilities.screenCoordinatesToTargetXY = function(objectKey, screenX, screenY) {
+
+ // set dummy div transform to iframe without x,y,scale
+ let matrixComputationDiv = this.getDivWithTargetTransformation(objectKey);
+ let newPosition = webkitConvertPointFromPageToNode(matrixComputationDiv, new WebKitPoint(screenX, screenY));
+
+ return {
+ x: newPosition.x - window.innerWidth / 2,
+ y: newPosition.y - window.innerHeight / 2
+ }
+};
+
+/**********************************************************************************************************************
+ **********************************************************************************************************************/
+
+(function(exports) {
+
+ /**
+ * Helper function that extracts a 4x4 matrix from the element's CSS matrix3d
+ * @param {HTMLElement} ele
+ * @return {Array.}
+ */
+ function getTransform(ele) {
+ // var st = window.getComputedStyle(ele, null);
+ // tr = st.getPropertyValue("-webkit-transform") ||
+ // st.getPropertyValue("-moz-transform") ||
+ // st.getPropertyValue("-ms-transform") ||
+ // st.getPropertyValue("-o-transform") ||
+ // st.getPropertyValue("transform");
+
+ var tr = ele.style.webkitTransform;
+ if (!tr) {
+ return realityEditor.gui.ar.utilities.newIdentityMatrix();
+ }
+
+ var values = tr.split('(')[1].split(')')[0].split(',');
+
+ var out = [ 0, 0, 0, 1 ];
+ for (var i = 0; i < values.length; ++i) {
+ out[i] = parseFloat(values[i]);
+ }
+
+ return out;
+ }
+
+ exports.getTransform = getTransform;
+
+}(realityEditor.gui.ar.utilities));
+
+/**********************************************************************************************************************
+ **********************************************************************************************************************/
+
+/**
+ * @desc Uses Pythagorean theorem to return the 3D distance to the origin of the transformation matrix.
+ * @param {Array} matrix of the point - should be provided in the format taken from gui.ar.draw.modelViewMatrices
+ * @return {number} distance
+ */
+realityEditor.gui.ar.utilities.distance = function (matrix) {
+ var distance = 1000; // for now give a valid value as a fallback
+ try {
+ if (realityEditor.device.environment.distanceRequiresCameraTransform()) {
+ // calculate distance to camera
+ var matrixToCamera = [];
+ realityEditor.gui.ar.utilities.multiplyMatrix(matrix, realityEditor.sceneGraph.getViewMatrix(), matrixToCamera);
+ matrix = matrixToCamera;
+ }
+ distance = Math.sqrt(Math.pow(matrix[12], 2) + Math.pow(matrix[13], 2) + Math.pow(matrix[14], 2));
+ } catch (e) {
+ console.warn('trying to calculate distance of ', matrix);
+ }
+ return distance;
+};
+
+/**
+ * Returns a matrix containing the inverse rotation of the 4x4 matrix passed in
+ * @param {Array.} m
+ * @return {Array.}
+ */
+realityEditor.gui.ar.utilities.invertRotationMatrix = function(m) {
+ var mInv = [];
+
+ // transpose the first 3x3, identity for the rest
+ mInv[0] = m[0];
+ mInv[1] = m[4];
+ mInv[2] = m[8];
+ mInv[3] = 0;
+ mInv[4] = m[1];
+ mInv[5] = m[5];
+ mInv[6] = m[9];
+ mInv[7] = 0;
+ mInv[8] = m[2];
+ mInv[9] = m[6];
+ mInv[10] = m[10];
+ mInv[11] = 0;
+ mInv[12] = 0;
+ mInv[13] = 0;
+ mInv[14] = 0;
+ mInv[15] = 1;
+
+ return mInv;
+};
+
+/**
+ * Extracts rotation information from a 4x4 transformation matrix
+ * @param {Array.} m - a 4x4 transformation matrix
+ * @author https://answers.unity.com/questions/11363/converting-matrix4x4-to-quaternion-vector3.html
+ */
+realityEditor.gui.ar.utilities.getQuaternionFromMatrix = function(m) {
+
+ // create identity Quaternion structure as a placeholder
+ var q = { x: 0, y: 0, z: 0, w: 1 };
+
+ if (m.length === 0) { return q; } // also works to set m = this.newIdentityMatrix();
+
+ q.w = Math.sqrt( Math.max( 0, 1 + m[0] + m[5] + m[10] ) ) / 2;
+ q.x = Math.sqrt( Math.max( 0, 1 + m[0] - m[5] - m[10] ) ) / 2;
+ q.y = Math.sqrt( Math.max( 0, 1 - m[0] + m[5] - m[10] ) ) / 2;
+ q.z = Math.sqrt( Math.max( 0, 1 - m[0] - m[5] + m[10] ) ) / 2;
+ q.x *= Math.sign( q.x * ( m[6] - m[9] ) );
+ q.y *= Math.sign( q.y * ( m[8] - m[2] ) );
+ q.z *= Math.sign( q.z * ( m[1] - m[4] ) );
+
+ return q;
+};
+
+realityEditor.gui.ar.utilities.convertQuaternionHandedness = function(q) {
+ q.x *= -1;
+ q.y *= -1;
+ q.z *= -1;
+ return q;
+};
+
+// realityEditor.gui.ar.utilities.quaternionMagnitude = function(q) {
+// // var identity = { x: 0, y: 0, z: 0, w: 1 };
+// // qRot = q * inverse(identity); // identity inversed is still identity. identity multiplied by q gives q.
+// var magnitude = Math.sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
+// var pureMagnitude = 2 * Math.atan2(magnitude, q.w);
+// return pureMagnitude;
+// // var mappedMagnitude = (pureMagnitude / (2 * Math.atan2(1, 1)));
+// // return Math.sqrt( Math.max(0, Math.min(1, (mappedMagnitude - 1.0) * 4)) );
+// };
+
+realityEditor.gui.ar.utilities.quaternionToEulerAngles = function(q) { // TODO: rename to getEulerAnglesFromQuaternion to be consistent
+ var phi = Math.atan2(q.z * q.w + q.x * q.y, 0.5 - (q.y * q.y + q.z * q.z));
+ var theta = Math.asin(-2 * (q.y * q.w - q.x * q.z));
+ var psi = Math.atan2(q.y * q.z + q.x * q.w, 0.5 - (q.z * q.z + q.w * q.w));
+ return {
+ phi: phi,
+ theta: theta,
+ psi: psi
+ }
+};
+
+/**
+ * Tells you how much the frame was rotated by twisting the x-axis
+ * @param m
+ * @return {number}
+ */
+realityEditor.gui.ar.utilities.getRotationAboutAxisX = function(m) {
+ var q = this.getQuaternionFromMatrix(m);
+ var angles = this.quaternionToEulerAngles(q);
+ return angles.theta;
+};
+
+/**
+ * Tells you how much the frame was rotated by twisting the y-axis
+ * @param m
+ * @return {number}
+ */
+realityEditor.gui.ar.utilities.getRotationAboutAxisY = function(m) {
+ var q = this.getQuaternionFromMatrix(m);
+ var angles = this.quaternionToEulerAngles(q);
+ return angles.psi;
+};
+
+/**
+ * Tells you how much the frame was rotated by twisting the z-axis
+ * @param m
+ * @return {number}
+ */
+realityEditor.gui.ar.utilities.getRotationAboutAxisZ = function(m) {
+ var q = this.getQuaternionFromMatrix(m);
+ var angles = this.quaternionToEulerAngles(q);
+ return angles.phi;
+};
+
+
+realityEditor.gui.ar.utilities.getMatrixFromQuaternion = function(q) {
+
+ // Matrix(
+ // 1.0f - 2.0f*qy*qy - 2.0f*qz*qz, 2.0f*qx*qy - 2.0f*qz*qw, 2.0f*qx*qz + 2.0f*qy*qw, 0.0f,
+ // 2.0f*qx*qy + 2.0f*qz*qw, 1.0f - 2.0f*qx*qx - 2.0f*qz*qz, 2.0f*qy*qz - 2.0f*qx*qw, 0.0f,
+ // 2.0f*qx*qz - 2.0f*qy*qw, 2.0f*qy*qz + 2.0f*qx*qw, 1.0f - 2.0f*qx*qx - 2.0f*qy*qy, 0.0f,
+ // 0.0f, 0.0f, 0.0f, 1.0f);
+
+ var m = [];
+ m[0] = 1.0 - 2.0 * q.y * q.y - 2.0 * q.z * q.z;
+ m[1] = 2.0 * q.x * q.y - 2.0 * q.z * q.w;
+ m[2] = 2.0 * q.x * q.z + 2.0 * q.y * q.w;
+ m[3] = 0;
+
+ m[4] = 2.0 * q.x * q.y + 2.0 * q.z * q.w;
+ m[5] = 1.0 - 2.0 * q.x * q.x - 2.0 * q.z * q.z;
+ m[6] = 2.0 * q.y * q.z - 2.0 * q.x * q.w;
+ m[7] = 0;
+
+ m[8] = 2.0 * q.x * q.z - 2.0 * q.y * q.w;
+ m[9] = 2.0 * q.y * q.z + 2.0 * q.x * q.w;
+ m[10] = 1.0 - 2.0 * q.x * q.x - 2.0 * q.y * q.y;
+ m[11] = 0;
+
+ m[12] = 0;
+ m[13] = 0;
+ m[14] = 0;
+ m[15] = 1;
+
+ return m;
+};
+
+realityEditor.gui.ar.utilities.normalizeQuaternion = function(q) {
+ var n = 1.0 / Math.sqrt(q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w);
+ q.x *= n;
+ q.y *= n;
+ q.z *= n;
+ q.w *= n;
+ return q;
+};
+
+realityEditor.gui.ar.utilities.invertQuaternion = function(q) {
+ var d = q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w;
+ return {
+ x: q.x/d,
+ y: -q.y/d,
+ z: -q.z/d,
+ w: -q.w/d
+ }
+};
+
+/**
+ * @author https://en.wikipedia.org/wiki/Conversion_between_quaternions_and_Euler_angles
+ * @param {number} pitch
+ * @param {number} roll
+ * @param {number} yaw
+ * @return {q}
+ */
+realityEditor.gui.ar.utilities.getQuaternionFromPitchRollYaw = function(pitch, roll, yaw) {
+
+ // create identity Quaternion structure as a placeholder
+ var q = { x: 0, y: 0, z: 0, w: 1 };
+
+ // Abbreviations for the various angular functions
+ var cy = Math.cos(yaw * 0.5);
+ var sy = Math.sin(yaw * 0.5);
+ var cr = Math.cos(roll * 0.5);
+ var sr = Math.sin(roll * 0.5);
+ var cp = Math.cos(pitch * 0.5);
+ var sp = Math.sin(pitch * 0.5);
+
+ q.w = cy * cr * cp + sy * sr * sp;
+ q.x = cy * sr * cp - sy * cr * sp;
+ q.y = cy * cr * sp + sy * sr * cp;
+ q.z = sy * cr * cp - cy * sr * sp;
+ return q;
+};
+
+/**
+ * Normalizes a 4x4 transformation matrix by dividing by the last element
+ * @param m
+ * @return {Array}
+ */
+realityEditor.gui.ar.utilities.normalizeMatrix = function(m) {
+ var divisor = m[15];
+ return this.scalarMultiplyMatrix(m, (1.0/divisor));
+};
+
+/**
+ * A helper function that extracts the rotation matrix from a 4x4 transformation matrix,
+ * and optionally inverts any combination of the axes of rotation
+ * @param {Array.} matrix
+ * @param {boolean} flipX
+ * @param {boolean} flipY
+ * @param {boolean} flipZ
+ * @return {Array.}
+ */
+realityEditor.gui.ar.utilities.extractRotation = function(matrix, flipX, flipY, flipZ) {
+ var q = realityEditor.gui.ar.utilities.getQuaternionFromMatrix(matrix);
+ if (flipX || flipY || flipZ) {
+ var eulerAngles = realityEditor.gui.ar.utilities.quaternionToEulerAngles(q);
+ if (flipX) {
+ eulerAngles.theta *= -1; // flips first axis of rotation (yaw)
+ }
+ if (flipY) {
+ eulerAngles.psi *= -1; // flips second axis of rotation (pitch)
+ }
+ if (flipZ) {
+ eulerAngles.phi *= -1; // flips third axis of rotation (roll)
+ }
+ q = realityEditor.gui.ar.utilities.getQuaternionFromPitchRollYaw(eulerAngles.theta, eulerAngles.psi, eulerAngles.phi);
+ }
+ return realityEditor.gui.ar.utilities.getMatrixFromQuaternion(q);
+};
+
+/**
+ * A helper function that extracts the rotation matrix from a 4x4 transformation matrix,
+ * and optionally inverts any combination of the axes of rotation
+ * @param {Array.} matrix
+ * @return {Array.}
+ */
+realityEditor.gui.ar.utilities.extractRotationTemp = function(matrix) {
+ var q = realityEditor.gui.ar.utilities.getQuaternionFromMatrix(matrix);
+ return realityEditor.gui.ar.utilities.getMatrixFromQuaternion(this.convertQuaternionHandedness(q));
+};
+
+/**
+ * Helper function that extracts the x,y,z translation elements from a 4x4 transformation matrix,
+ * and optionally inverts any combination of the axes of translation
+ * @param {Array.} matrix
+ * @param {boolean} flipX
+ * @param {boolean} flipY
+ * @param {boolean} flipZ
+ * @return {Array.}
+ */
+realityEditor.gui.ar.utilities.extractTranslation = function(matrix, flipX, flipY, flipZ) {
+ var translationMatrix = realityEditor.gui.ar.utilities.newIdentityMatrix();
+ translationMatrix[12] = matrix[12];
+ translationMatrix[13] = matrix[13];
+ translationMatrix[14] = matrix[14];
+
+ if (flipX) {
+ translationMatrix[12] *= -1; // flips first axis of translation
+ }
+ if (flipY) {
+ translationMatrix[13] *= -1; // flips second axis of translation
+ }
+ if (flipZ) {
+ translationMatrix[14] *= -1; // flips third axis of translation
+ }
+
+ return translationMatrix;
+};
+
+realityEditor.gui.ar.utilities.mToggle_YZ = [
+ 1, 0, 0, 0,
+ 0, 0, 1, 0,
+ 0, 1, 0, 0,
+ 0, 0, 0, 1
+];
+
+/**
+ * @param matrix
+ * @return {*}
+ */
+realityEditor.gui.ar.utilities.convertMatrixHandedness = function(matrix) {
+ var m2 = [];
+ this.multiplyMatrix(this.mToggle_YZ, matrix, m2);
+ return m2;
+};
+
+realityEditor.gui.ar.utilities.makeGroundPlaneRotationX = function(theta) {
+ let c = Math.cos(theta), s = Math.sin(theta);
+ return [
+ 1, 0, 0, 0,
+ 0, c, -s, 0,
+ 0, s, c, 0,
+ 0, 0, 0, 1
+ ];
+};
+
+realityEditor.gui.ar.utilities.makeGroundPlaneRotationY = function(theta) {
+ let c = Math.cos(theta), s = Math.sin(theta);
+ return [
+ c, 0, s, 0,
+ 0, 1, 0, 0,
+ -s, 0, c, 0,
+ 0, 0, 0, 1
+ ];
+};
+
+realityEditor.gui.ar.utilities.tweenMatrix = function(currentMatrix, destination, tweenSpeed) {
+ if (typeof tweenSpeed === 'undefined') { tweenSpeed = 0.5; } // default value
+
+ if (currentMatrix.length !== destination.length) {
+ console.warn('matrices are inequal lengths. cannot be tweened so just assigning current=destination');
+ return realityEditor.gui.ar.utilities.copyMatrix(destination);
+ }
+ if (tweenSpeed <= 0 || tweenSpeed >= 1) {
+ return realityEditor.gui.ar.utilities.copyMatrix(destination);
+ }
+
+ let m = [];
+ for (let i = 0; i < currentMatrix.length; i++) {
+ m[i] = destination[i] * tweenSpeed + currentMatrix[i] * (1.0 - tweenSpeed);
+ }
+ return m;
+}
+
+realityEditor.gui.ar.utilities.animationVectorLinear = function(currentVector, newVector, maxSpeed) {
+ if (typeof maxSpeed === 'undefined') { maxSpeed = 100; } // default value
+
+ if (currentVector.length !== newVector.length) {
+ console.warn('matrices are inequal lengths. cannot be tweened so just assigning current=destination');
+ return JSON.parse(JSON.stringify(newVector));
+ }
+ if (maxSpeed <= 0) {
+ return JSON.parse(JSON.stringify(currentVector));
+ }
+
+ let diff = [];
+ for (let i = 0; i < currentVector.length; i++) {
+ diff[i] = newVector[i] - currentVector[i];
+ }
+ let distanceSquared = 0;
+ for (let i = 0; i < diff.length; i++) {
+ distanceSquared += diff[i] * diff[i];
+ }
+ let distance = Math.sqrt(distanceSquared);
+ if (distance === 0) {
+ return JSON.parse(JSON.stringify(currentVector));
+ }
+
+ let percentMotion = Math.max(0, Math.min(1, maxSpeed / distance));
+ let result = [];
+ for (let i = 0; i < currentVector.length; i++) {
+ result[i] = newVector[i] * percentMotion + currentVector[i] * (1.0 - percentMotion);
+ }
+ return result;
+}
+
+/**
+ * Simple, custom made Matrix data structure for working with transformation matrices
+ *
+ * @param {Array.} array
+ * @param {number|undefined} numRows - can be omitted if matrix is square
+ * @param {number|undefined} numCols - can be omitted if matrix is square
+ * @param {boolean} isRowMajor - by default, we use column-major matrices. pass in true if array is in row-major form
+ * @constructor
+ */
+function Matrix(array, numRows, numCols, isRowMajor) {
+
+ if (typeof numRows === 'undefined' && typeof numCols === 'undefined') {
+ if (array.length > 0 && Math.sqrt(array.length) % 1 === 0) {
+ numRows = Math.sqrt(array.length);
+ numCols = Math.sqrt(array.length);
+ } else {
+ throw new Error('cannot create non-square Matrix without specifying shape!');
+ }
+ } else if (numRows * numCols !== array.length) {
+ throw new Error('invalid shape (' + numRows + ' x ' + numCols + ') to form Matrix from array of length ' + array.length);
+ }
+
+ this.array = array;
+ this.numRows = numRows;
+ this.numCols = numCols;
+ this.isRowMajor = isRowMajor;
+
+ this.isSquare = numRows === numCols;
+
+ // create un-flattened representation of the matrix from the flattened array
+ this.mat = [];
+ if (isRowMajor) {
+ for (let r = 0; r < numRows; r++) {
+ var row = [];
+ for (let c = 0; c < numCols; c++) {
+ row[c] = array[r * numCols + c];
+ }
+ this.mat.push(row);
+ }
+ } else {
+ for (let c = 0; c < numCols; c++) {
+ var col = [];
+ for (let r = 0; r < numRows; r++) {
+ col[r] = array[r * numCols + c];
+ }
+ this.mat.push(col);
+ }
+ }
+}
+
+Matrix.prototype.determinant = function() {
+ if (!this.isSquare) { throw new Error('cannot calculate determinant of non-square Matrix'); }
+
+ // base case
+ if (this.numRows === 2) {
+ return this.mat[0][0] * this.mat[1][1] - this.mat[0][1] * this.mat[1][0];
+ }
+};
+
+Matrix.prototype.arrayIndex = function(row, col) {
+ if (this.isRowMajor) {
+ return row * this.numCols + col;
+ } else {
+ return col * this.numRows + row;
+ }
+};
+
+Matrix.prototype.clone = function() {
+ return new Matrix(this.array, this.numRows, this.numCols, this.isRowMajor);
+};
+
+Matrix.prototype.unflattened = function() {
+ return this.mat;
+};
+
+// polyfill webkit functions on Chrome browser
+if (typeof window.webkitConvertPointFromPageToNode === 'undefined') {
+ polyfillWebkitConvertPointFromPageToNode();
+
+ var ssEl = document.createElement('style'),
+ css = '.or{position:absolute;opacity:0;height:33.333%;width:33.333%;top:0;left:0}.or.r-2{left:33.333%}.or.r-3{left:66.666%}.or.r-4{top:33.333%}.or.r-5{top:33.333%;left:33.333%}.or.r-6{top:33.333%;left:66.666%}.or.r-7{top:66.666%}.or.r-8{top:66.666%;left:33.333%}.or.r-9{top:66.666%;left:66.666%}';
+ ssEl.type = 'text/css';
+ (ssEl.styleSheet) ?
+ ssEl.styleSheet.cssText = css :
+ ssEl.appendChild(document.createTextNode(css));
+ document.getElementsByTagName('head')[0].appendChild(ssEl);
+}
+
+/**
+ * Based off of https://gist.github.com/Yaffle/1145197 with modifications to
+ * support more complex matrices
+ */
+function polyfillWebkitConvertPointFromPageToNode() {
+ const identity = new DOMMatrix([
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ]);
+
+ if (!window.WebKitPoint) {
+ window.WebKitPoint = DOMPoint;
+ }
+
+ function getTransformationMatrix(element) {
+ var transformationMatrix = identity;
+ var x = element;
+
+ while (x !== undefined && x !== x.ownerDocument.documentElement) {
+ var computedStyle = window.getComputedStyle(x);
+ var transform = computedStyle.transform || "none";
+ var c = transform === "none" ? identity : new DOMMatrix(transform);
+
+ transformationMatrix = c.multiply(transformationMatrix);
+ x = x.parentNode;
+ }
+
+ // Normalize current matrix to have m44=1 (w = 1). Math does not work
+ // otherwise because nothing knows how to scale based on w
+ let baseArr = transformationMatrix.toFloat64Array();
+ baseArr = baseArr.map(b => b / baseArr[15]);
+ transformationMatrix = new DOMMatrix(baseArr);
+
+ var w = element.offsetWidth;
+ var h = element.offsetHeight;
+ var i = 4;
+ var left = +Infinity;
+ var top = +Infinity;
+ while (--i >= 0) {
+ var p = transformationMatrix.transformPoint(new DOMPoint(i === 0 || i === 1 ? 0 : w, i === 0 || i === 3 ? 0 : h, 0));
+ if (p.x < left) {
+ left = p.x;
+ }
+ if (p.y < top) {
+ top = p.y;
+ }
+ }
+ var rect = element.getBoundingClientRect();
+ transformationMatrix = identity.translate(window.pageXOffset + rect.left - left, window.pageYOffset + rect.top - top, 0).multiply(transformationMatrix);
+ return transformationMatrix;
+ }
+
+ window.convertPointFromPageToNode = window.webkitConvertPointFromPageToNode = function (element, point) {
+ let mati = getTransformationMatrix(element).inverse();
+ // This involves a lot of math, sorry.
+ // Given $v = M^{-1}p$ we have p.x, p.y, p.w, M^{-1}, and know that v.z
+ // should be equal to 0.
+ // Solving for p.z we get the following:
+ let projectedZ = -(mati.m13 * point.x + mati.m23 * point.y + mati.m43) / mati.m33;
+ return mati.transformPoint(new DOMPoint(point.x, point.y, projectedZ));
+ };
+
+ window.convertPointFromNodeToPage = function (element, point) {
+ return getTransformationMatrix(element).transformPoint(point);
+ };
+}
+
+(function(exports) {
+ function lookAt(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ) {
+ var ev = [eyeX, eyeY, eyeZ];
+ var cv = [centerX, centerY, centerZ];
+ var uv = [upX, upY, upZ];
+
+ var n = normalize(add(ev, negate(cv))); // vector from the camera to the center point
+ var u = normalize(crossProduct(uv, n)); // a "right" vector, orthogonal to n and the lookup vector
+ var v = crossProduct(n, u); // resulting orthogonal vector to n and u, as the up vector isn't necessarily one anymore
+
+ return [u[0], v[0], n[0], 0,
+ u[1], v[1], n[1], 0,
+ u[2], v[2], n[2], 0,
+ dotProduct(negate(u), ev), dotProduct(negate(v), ev), dotProduct(negate(n), ev), 1];
+ }
+
+ function scalarMultiply(A, x) {
+ return [A[0] * x, A[1] * x, A[2] * x];
+ }
+
+ function negate(A) {
+ return [-A[0], -A[1], -A[2]];
+ }
+
+ function add(A, B) {
+ return [A[0] + B[0], A[1] + B[1], A[2] + B[2]];
+ }
+
+ function subtract(A, B) {
+ return [A[0] - B[0], A[1] - B[1], A[2] - B[2]];
+ }
+
+ function magnitude(A) {
+ return Math.sqrt(A[0] * A[0] + A[1] * A[1] + A[2] * A[2]);
+ }
+
+ function normalize(A) {
+ var mag = magnitude(A);
+ return [A[0] / mag, A[1] / mag, A[2] / mag];
+ }
+
+ function crossProduct(A, B) {
+ var a = A[1] * B[2] - A[2] * B[1];
+ var b = A[2] * B[0] - A[0] * B[2];
+ var c = A[0] * B[1] - A[1] * B[0];
+ return [a, b, c];
+ }
+
+ function dotProduct(A, B) {
+ return A[0] * B[0] + A[1] * B[1] + A[2] * B[2];
+ }
+
+ function getRightVector(M) {
+ return normalize([M[0], M[1], M[2]]);
+ }
+
+ function getUpVector(M) {
+ return normalize([M[4], M[5], M[6]]);
+ }
+
+ function getForwardVector(M) {
+ return normalize([M[8], M[9], M[10]]);
+ }
+
+ // see https://www.scratchapixel.com/lessons/3d-basic-rendering/minimal-ray-tracer-rendering-simple-shapes/ray-plane-and-ray-disk-intersection
+ function rayPlaneIntersect(planeOrigin, planeNormal, rayOrigin, rayDirection) {
+ let denom = dotProduct(planeNormal, rayDirection);
+ if (Math.abs(denom) < 0.0001) return null; // plane and ray are ~parallel, so either 0 or infinite intersections
+
+ // solve for parametric variable, t, to figure out where on the ray is the plane intersection
+ let vector = subtract(planeOrigin, rayOrigin);
+ let t = dotProduct(vector, planeNormal) / denom;
+
+ return add(rayOrigin, scalarMultiply(rayDirection, t));
+ }
+
+ /**
+ * Ray-casts from (screenX, screenY) onto the XY plane, and returns the (x,y,z) intersect in ROOT coordinates
+ * @param {number[]} planeOrigin
+ * @param {number[]} planeNormal
+ * @param {SceneNode} cameraNode
+ * @param {number} screenX
+ * @param {number} screenY
+ * @returns {{x: number, y: number, z: number}}
+ */
+ function getPointOnPlaneFromScreenXY(planeOrigin, planeNormal, cameraNode, screenX, screenY) {
+ let rootCoordinateSystem = cameraNode.parent || realityEditor.sceneGraph.getSceneNodeById('ROOT');
+ const SEGMENT_LENGTH = 1000; // arbitrary, just need to calculate one point, so we can compute rayDirection
+ let testPoint = realityEditor.sceneGraph.getPointAtDistanceFromCamera(screenX, screenY, SEGMENT_LENGTH, rootCoordinateSystem);
+
+ let cameraPoint = realityEditor.sceneGraph.getWorldPosition(cameraNode.id);
+ let rayOrigin = [cameraPoint.x, cameraPoint.y, cameraPoint.z];
+ let rayDirection = normalize(subtract([testPoint.x, testPoint.y, testPoint.z], rayOrigin));
+
+ let planeIntersection = rayPlaneIntersect(planeOrigin, planeNormal, rayOrigin, rayDirection);
+ if (!planeIntersection) return undefined; // if plane is parallel to ray
+
+ return {x: planeIntersection[0], y: planeIntersection[1], z: planeIntersection[2]};
+ }
+
+ exports.lookAt = lookAt;
+ exports.scalarMultiply = scalarMultiply;
+ exports.negate = negate;
+ exports.add = add;
+ exports.subtract = subtract;
+ exports.magnitude = magnitude;
+ exports.normalize = normalize;
+ exports.crossProduct = crossProduct;
+ exports.dotProduct = dotProduct;
+ exports.getRightVector = getRightVector;
+ exports.getUpVector = getUpVector;
+ exports.getForwardVector = getForwardVector;
+ exports.rayPlaneIntersect = rayPlaneIntersect;
+ exports.getPointOnPlaneFromScreenXY = getPointOnPlaneFromScreenXY;
+})(realityEditor.gui.ar.utilities);
diff --git a/src/gui/ar/videoPlayback.js b/src/gui/ar/videoPlayback.js
new file mode 100644
index 000000000..567958182
--- /dev/null
+++ b/src/gui/ar/videoPlayback.js
@@ -0,0 +1,633 @@
+/*
+* Created by Daniel Dangond on 10/11/22.
+*
+* Copyright (c) 2022 PTC Inc
+*
+* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/.
+*/
+
+/**
+ * @fileOverview realityEditor.gui.ar.videoPlayback
+ * Provides an API for tools to call in order to play spatial depth video in a scene
+ */
+
+createNameSpace("realityEditor.gui.ar.videoPlayback");
+
+import * as THREE from '../../../thirdPartyCode/three/three.module.js';
+import RVLParser from '../../../thirdPartyCode/rvl/RVLParser.js';
+import {Followable} from './Followable.js';
+
+const videoPlayers = [];
+
+const callbacks = {
+ onVideoCreated: [],
+ onVideoDisposed: [],
+ onVideoPlayed: [],
+ onVideoPaused: [],
+}
+
+realityEditor.gui.ar.videoPlayback.initService = function() {
+ realityEditor.network.addPostMessageHandler('createVideoPlayback', (msgData) => {
+ const videoPlayer = new VideoPlayer(msgData.id, msgData.urls, msgData.frameKey);
+ videoPlayers.push(videoPlayer);
+ callbacks.onVideoCreated.forEach(cb => { cb(videoPlayer); });
+ });
+ realityEditor.network.addPostMessageHandler('disposeVideoPlayback', (msgData) => {
+ const videoPlayer = videoPlayers.find(videoPlayer => videoPlayer.id === msgData.id);
+ videoPlayer.dispose();
+ videoPlayers.splice(videoPlayers.indexOf(videoPlayer), 1);
+ callbacks.onVideoDisposed.forEach(cb => { cb(msgData.id); });
+ });
+ realityEditor.network.addPostMessageHandler('setVideoPlaybackCurrentTime', (msgData) => {
+ videoPlayers.find(videoPlayer => videoPlayer.id === msgData.id).currentTime = msgData.currentTime;
+ });
+ realityEditor.network.addPostMessageHandler('playVideoPlayback', (msgData) => {
+ const videoPlayer = videoPlayers.find(videoPlayer => videoPlayer.id === msgData.id);
+ videoPlayer.play();
+ callbacks.onVideoPlayed.forEach(cb => { cb(videoPlayer); });
+ });
+ realityEditor.network.addPostMessageHandler('pauseVideoPlayback', (msgData) => {
+ const videoPlayer = videoPlayers.find(videoPlayer => videoPlayer.id === msgData.id);
+ videoPlayer.pause();
+ callbacks.onVideoPlayed.forEach(cb => { cb(videoPlayer); });
+ });
+}.bind(realityEditor.gui.ar.videoPlayback);
+
+realityEditor.gui.ar.videoPlayback.onVideoCreated = (cb) => {
+ callbacks.onVideoCreated.push(cb);
+};
+realityEditor.gui.ar.videoPlayback.onVideoDisposed = (cb) => {
+ callbacks.onVideoDisposed.push(cb);
+};
+realityEditor.gui.ar.videoPlayback.onVideoPlayed = (cb) => {
+ callbacks.onVideoPlayed.push(cb);
+};
+realityEditor.gui.ar.videoPlayback.onVideoPaused = (cb) => {
+ callbacks.onVideoPaused.push(cb);
+};
+
+const POINT_CLOUD_VERTEX_SHADER = `
+uniform sampler2D map;
+uniform sampler2D mapDepth;
+uniform float width;
+uniform float height;
+uniform float depthScale;
+uniform float glPosScale;
+uniform float pointSize;
+const float pointSizeBase = 0.0;
+varying vec2 vUv;
+varying vec2 vDepthUv;
+varying vec4 pos;
+const float XtoZ = 1920.0 / 1448.24976; // width over focal length
+const float YtoZ = 1080.0 / 1448.24976;
+void main() {
+ vUv = vec2(position.x / width, position.y / height);
+ vDepthUv = vec2((width - position.x) / width, (height - position.y) / height);
+ vec4 color = texture2D(mapDepth, vDepthUv);
+ float depth = 5000.0 * (color.r + color.g / 255.0 + color.b / (255.0 * 255.0));
+ float z = depth - 0.05;
+ pos = vec4(
+ (position.x / width - 0.5) * z * XtoZ,
+ (position.y / height - 0.5) * z * YtoZ,
+ -z,
+ 1.0);
+ gl_Position = projectionMatrix * modelViewMatrix * pos;
+ // gl_PointSize = pointSizeBase + pointSize * depth * depthScale;
+ gl_PointSize = pointSizeBase + pointSize * depth * depthScale + glPosScale / gl_Position.w;
+}`;
+
+const POINT_CLOUD_FRAGMENT_SHADER = `
+// color texture
+uniform sampler2D map;
+
+// uv (0.0-1.0) texture coordinates
+varying vec2 vUv;
+varying vec2 vDepthUv;
+// Position of this pixel relative to the camera in proper (millimeter) coordinates
+varying vec4 pos;
+
+void main() {
+ // Depth in millimeters
+ float depth = -pos.z;
+
+ // Fade out beginning at 4.5 meters and be gone after 5.0
+ float alphaDepth = clamp(2.0 * (5.0 - depth / 1000.0), 0.0, 1.0);
+
+ // Normal vector of the depth mesh based on pos
+ // Necessary to calculate manually since we're messing with gl_Position in the vertex shader
+ vec3 normal = normalize(cross(dFdx(pos.xyz), dFdy(pos.xyz)));
+
+ // pos.xyz is the ray looking out from the camera to this pixel
+ // dot of pos.xyz and the normal is to what extent this pixel is flat
+ // relative to the camera (alternatively, how much it's pointing at the
+ // camera)
+ // alphaDepth is thrown in here to incorporate the depth-based fade
+ float alpha = abs(dot(normalize(pos.xyz), normal)) * alphaDepth;
+
+ // Sample the proper color for this pixel from the color image
+ vec4 color = texture2D(map, vUv);
+
+ gl_FragColor = vec4(color.rgb, alpha);
+ // gl_FragColor = vec4(color.rgb, 1.0);
+}`;
+
+// TODO: move shaders out of remote-operator-addon ./Shaders.js
+// into jointly accessible location, rather than duplicate code
+const FIRST_PERSON_FRAGMENT_SHADER = `
+// color texture
+uniform sampler2D map;
+
+// uv (0.0-1.0) texture coordinates
+varying vec2 vUv;
+// Position of this pixel relative to the camera in proper (millimeter) coordinates
+varying vec4 pos;
+
+void main() {
+// Sample the proper color for this pixel from the color image
+vec4 color = texture2D(map, vUv);
+
+gl_FragColor = vec4(color.rgb, 1.0);
+}`;
+
+const VideoPlayerStates = {
+ LOADING: 'LOADING', // Loading the recording
+ PAUSED: 'PAUSED', // Video paused, initial state after loading
+ PLAYING: 'PLAYING', // Playing video
+};
+
+const ShaderMode = {
+ SOLID: 'SOLID',
+ FIRST_PERSON: 'FIRST_PERSON',
+};
+
+class VideoPlayer extends Followable {
+ static count = 0;
+
+ /**
+ * @param {string} id
+ * @param {object} urls - Expected to contain keys `color` and `rvl` with
+ * urls pointing to color and depth data, respectively
+ * @param {string|undefined} frameKey - option frame that wants to be
+ * notified about the video playback's state changes
+ */
+ constructor(id, urls, frameKey) {
+ // first we must set up the Followable so that the remote operator
+ // camera system will be able to follow this video...
+ VideoPlayer.count++;
+ let parentNode = realityEditor.sceneGraph.getVisualElement('CameraGroupContainer');
+ if (!parentNode) {
+ let gpNode = realityEditor.sceneGraph.getGroundPlaneNode();
+ let cameraGroupContainerId = realityEditor.sceneGraph.addVisualElement('CameraGroupContainer', gpNode);
+ parentNode = realityEditor.sceneGraph.getSceneNodeById(cameraGroupContainerId);
+ let transformationMatrix = realityEditor.gui.ar.utilities.makeGroundPlaneRotationX(0);
+ transformationMatrix[13] = -1 * realityEditor.gui.ar.areaCreator.calculateFloorOffset();
+ parentNode.setLocalMatrix(transformationMatrix);
+ }
+ // count (e.g. 1) is more user-friendly than the id (e.g. 0.123) or frameKey
+ let menuItemName = `Video Recording ${VideoPlayer.count}`;
+ super(`VideoPlayerFollowable_${id}`, menuItemName, parentNode);
+
+ // then the VideoPlayer can initialize as usual...
+ this.id = id;
+ const onHostedCloudProxy = window.location.origin.includes('toolboxedge.net') ||
+ window.location.origin.includes('spatial.ptc.io');
+ // If not on cloud proxy, use local proxy to download without cross-origin issues
+ this.urls = onHostedCloudProxy ? urls : {
+ color: urls.color.replace(/https:\/\/(toolboxedge\.net|spatial\.ptc\.io)/, `${window.location.origin}/proxy`), // Avoid CORS issues on iOS WebKit by proxying video
+ rvl: urls.rvl.replace(/https:\/\/(toolboxedge\.net|spatial\.ptc\.io)/, `${window.location.origin}/proxy`) // Avoid CORS issues on iOS WebKit by proxying video
+ }; // TODO: test without rvl proxy, don't think it is needed
+ this.frameKey = frameKey;
+ this.state = VideoPlayerStates.LOADING;
+
+ this.floorOffset = realityEditor.gui.ar.areaCreator.calculateFloorOffset();
+ this.phoneParent = new THREE.Group();
+ this.phone = new THREE.Group();
+ this.phone.matrixAutoUpdate = false; // Phone matrix will be set via pose data
+ this.phone.frustumCulled = false;
+ realityEditor.gui.threejsScene.addToScene(this.phoneParent, {worldObjectId: realityEditor.worldObjects.getBestWorldObject().objectId});
+ this.phoneParent.add(this.phone);
+ this.phoneParent.rotateX(Math.PI / 2);
+ // this.phoneParent.position.y = this.floorOffset;
+ this.firstPersonMode = false;
+
+ // add a visual element to show the position of the camera that recorded the video
+ // note: we use the same visual style as the remote operator CameraVis
+ this.cameraMeshGroup = this.createCameraMeshGroup();
+ this.phone.add(this.cameraMeshGroup);
+
+ this.lastRenderTime = -1; // Last rendered frame time (using video time)
+ this.videoLength = 0;
+
+ this.depthCanvas = document.createElement('canvas');
+ this.depthCanvas.width = 256;
+ this.depthCanvas.height = 144;
+ this.depthCanvas.style.backgroundColor = '#FFFFFF';
+ this.depthCanvas.style.display = 'none';
+ this.depthCanvas.imageData = this.depthCanvas.getContext('2d').createImageData(256, 144);
+
+ this.colorVideo = document.createElement('video');
+ this.colorVideo.loop = true;
+ this.colorVideo.playsInline = true;
+ this.colorVideo.muted = true;
+ this.colorVideo.crossOrigin = 'Anonymous';
+ this.colorVideo.style.display = 'none';
+ const source = document.createElement('source');
+ source.src = this.urls.color;
+ source.type = 'video/mp4';
+ this.colorVideo.appendChild(source);
+ this.colorVideo.sourceElement = source;
+ this.colorVideo.load();
+ this.colorVideo.onloadedmetadata = () => {
+ this.colorVideo.loadSuccessful = true;
+ if (this.rvl) {
+ this.pause();
+ }
+ };
+
+ this.shaderMode = ShaderMode.SOLID; // default to the non-first-person shader
+
+ this.manuallyHidden = false;
+
+ this.decoder = new TextDecoder();
+
+ // this.debugBox = new THREE.Mesh(new THREE.BoxGeometry(40, 40, 40), new THREE.MeshNormalMaterial());
+ // this.phone.add(this.debugBox);
+
+ fetch(urls.rvl).then(res => res.arrayBuffer()).then(buf => {
+ this.rvl = new RVLParser(buf);
+ if (this.colorVideo.loadSuccessful) {
+ this.pause();
+ }
+ this.videoLength = this.rvl.getDuration();
+ if (this.frameKey) {
+ realityEditor.network.postMessageIntoFrame(this.frameKey, {onVideoMetadata: {videoLength: this.rvl.getDuration()}, id: this.id});
+ }
+ });
+
+ this.onAnimationFrame = () => this.render();
+ realityEditor.gui.threejsScene.onAnimationFrame(this.onAnimationFrame);
+ }
+
+ /**
+ * Can add this to visualize the position where the video was recorded from
+ */
+ createCameraMeshGroup(color = null) {
+ let cameraMeshGroup = new THREE.Group();
+
+ let id = Math.floor(Math.random() * 10000);
+
+ const geo = new THREE.BoxGeometry(100, 100, 80);
+ if (!color) {
+ let colorId = id;
+ if (typeof id === 'string') {
+ colorId = 0;
+ for (let i = 0; i < id.length; i++) {
+ colorId ^= id.charCodeAt(i);
+ }
+ }
+ let hue = ((colorId / 29) % Math.PI) * 360 / Math.PI;
+ const colorStr = `hsl(${hue}, 100%, 50%)`;
+ this.color = new THREE.Color(colorStr);
+ } else {
+ this.color = color;
+ }
+ this.colorRGB = [
+ 255 * this.color.r,
+ 255 * this.color.g,
+ 255 * this.color.b,
+ ];
+ let cameraMeshGroupMat = new THREE.MeshBasicMaterial({color: this.color});
+ const box = new THREE.Mesh(geo, cameraMeshGroupMat);
+ box.name = 'cameraVisCamera';
+ box.cameraVisId = this.id;
+ cameraMeshGroup.add(box);
+
+ const geoCone = new THREE.ConeGeometry(60, 180, 16, 1);
+ const cone = new THREE.Mesh(geoCone, cameraMeshGroupMat);
+ cone.rotation.x = -Math.PI / 2;
+ cone.rotation.y = Math.PI / 8;
+ cone.position.z = 65;
+ cone.name = 'cameraVisCamera';
+ cone.cameraVisId = this.id;
+ cameraMeshGroup.add(cone);
+
+ return cameraMeshGroup;
+ }
+
+ dispose() {
+ this.phoneParent.parent.remove(this.phoneParent);
+ this.colorVideo.pause();
+ this.colorVideo.sourceElement.remove();
+ this.colorVideo.load();
+ this.rvl = null;
+ realityEditor.gui.threejsScene.removeAnimationCallback(this.onAnimationFrame);
+ }
+
+ get currentTime() {
+ return this.colorVideo.currentTime;
+ }
+
+ set currentTime(currentTime) {
+ if (currentTime > this.videoLength && this.videoLength > 0) {
+ this.colorVideo.currentTime = currentTime % this.videoLength;
+ } else {
+ this.colorVideo.currentTime = currentTime;
+ }
+ }
+
+ play() {
+ this.state = VideoPlayerStates.PLAYING;
+ if (this.frameKey) {
+ realityEditor.network.postMessageIntoFrame(this.frameKey, {onVideoStateChange: this.state, id: this.id, currentTime: this.currentTime});
+ }
+ if (!this.manuallyHidden) {
+ this.pointCloud.visible = true;
+ }
+ this.colorVideo.play().then(() => {/** Empty then() callback to silence warning **/});
+ }
+
+ pause() {
+ this.state = VideoPlayerStates.PAUSED;
+ if (this.frameKey) {
+ realityEditor.network.postMessageIntoFrame(this.frameKey, {onVideoStateChange: this.state, id: this.id, currentTime: this.currentTime});
+ }
+ this.colorVideo.pause();
+ }
+
+ isShown() {
+ return !this.manuallyHidden &&
+ this.pointCloud &&
+ this.pointCloud.visible;
+ }
+
+ show() {
+ this.manuallyHidden = false;
+ if (this.pointCloud) {
+ this.pointCloud.visible = true;
+ }
+ }
+
+ hide() {
+ this.manuallyHidden = true;
+ if (this.pointCloud) {
+ this.pointCloud.visible = false;
+ }
+ }
+
+ render() {
+ if (!this.colorVideo.loadSuccessful || !this.rvl) {
+ return;
+ }
+ if (this.lastRenderTime === this.colorVideo.currentTime) {
+ return; // Do not re-render identical frames, useful when paused
+ }
+ this.lastRenderTime = this.colorVideo.currentTime;
+
+ const rvlFrame = this.rvl.getFrameFromDeltaTimeSeconds(this.colorVideo.currentTime);
+ this.rvl.drawFrame(rvlFrame, this.depthCanvas.getContext('2d'), this.depthCanvas.imageData);
+
+ const rvlPayload = this.decoder.decode(rvlFrame.payload);
+ this.applyMatricesMessage(rvlPayload);
+
+ if (!this.pointCloud) {
+ this.loadPointCloud();
+ } else {
+ this.textures.depth.needsUpdate = true;
+ this.pointCloudMaterial.uniforms.time = window.performance.now();
+ }
+ }
+
+ /**
+ * Loads the point cloud into the scene.
+ */
+ loadPointCloud() {
+ const width = 640;
+ const height = 360;
+
+ const geometry = new THREE.PlaneGeometry(width, height, width / 5, height / 5);
+ geometry.translate(width / 2, height / 2, 0);
+ const material = this.createPointCloudMaterial(this.shaderMode);
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.scale.set(-1, 1, -1);
+ mesh.rotateZ(Math.PI);
+ mesh.frustumCulled = false;
+ this.pointCloud = mesh;
+ this.pointCloud.visible = false; // Make visible once video starts playing to prevent black-screen from load
+ this.phone.add(this.pointCloud);
+ }
+
+ /**
+ * Creates the material used by the point cloud.
+ * @return {*}
+ */
+ createPointCloudMaterial(shaderMode) {
+ const width = 640;
+ const height = 360;
+
+ this.textures = {
+ color: new THREE.VideoTexture(this.colorVideo),
+ depth: new THREE.CanvasTexture(this.depthCanvas)
+ };
+
+ // this.textures.color.center = new THREE.Vector2(0.5, 0.5);
+ // this.textures.color.rotation = Math.PI;
+ // this.textures.color.flipY = false;
+
+ this.textures.color.minFilter = THREE.LinearFilter;
+ this.textures.color.magFilter = THREE.LinearFilter;
+ this.textures.color.generateMipmaps = false;
+ this.textures.depth.minFilter = THREE.LinearFilter;
+ this.textures.depth.magFilter = THREE.LinearFilter;
+ this.textures.depth.generateMipmaps = false;
+
+ this.textures.depth.isVideoTexture = true;
+ this.textures.depth.update = function() {
+ };
+
+ let fragmentShader = shaderMode === ShaderMode.SOLID ? POINT_CLOUD_FRAGMENT_SHADER : FIRST_PERSON_FRAGMENT_SHADER;
+
+ this.pointCloudMaterial = new THREE.ShaderMaterial({
+ uniforms: {
+ time: {value: window.performance.now()},
+ map: {value: this.textures.color},
+ mapDepth: {value: this.textures.depth},
+ width: {value: width},
+ height: {value: height},
+ depthScale: {value: 0.15 / 256}, // roughly 1 / 1920
+ glPosScale: {value: 20000}, // 0.15 / 256}, // roughly 1 / 1920
+ pointSize: { value: 2 * 0.666 },
+ },
+ vertexShader: POINT_CLOUD_VERTEX_SHADER,
+ fragmentShader: fragmentShader,
+ depthTest: true,
+ transparent: true
+ });
+ return this.pointCloudMaterial;
+ }
+
+ // a simplified copy of setShaderMode from remote operator CameraVis.js
+ setShaderMode(shaderMode) {
+ if (shaderMode !== this.shaderMode) {
+ this.shaderMode = shaderMode;
+ this.pointCloudMaterial = this.createPointCloudMaterial(this.shaderMode);
+ this.pointCloud.material = this.pointCloudMaterial;
+ }
+ }
+
+ /* ---------------- Override Followable Functions ---------------- */
+
+ doesOverrideCameraUpdatesInFirstPerson() {
+ return true;
+ }
+
+ onCameraStartedFollowing() {
+ // TODO: we might want to update the shader mode to a more front-legible
+ // form as soon as we start following, but this needs experimenting
+ }
+
+ // make sure the video switches back to volumetric mode when we stop following
+ onCameraStoppedFollowing() {
+ this.firstPersonMode = false;
+ if (this.shaderMode === ShaderMode.FIRST_PERSON) {
+ this.setShaderMode(ShaderMode.SOLID);
+ }
+ }
+
+ // switch the shader mode and hide the camera mesh when fully zoomed in
+ enableFirstPersonMode() {
+ this.firstPersonMode = true;
+ this.cameraMeshGroup.visible = false;
+ if (this.shaderMode === ShaderMode.SOLID) {
+ this.setShaderMode(ShaderMode.FIRST_PERSON);
+ }
+ }
+
+ // switch back the shader mode when not fully zoomed in
+ disableFirstPersonMode() {
+ this.firstPersonMode = false;
+ if (this.shaderMode === ShaderMode.FIRST_PERSON) {
+ this.setShaderMode(ShaderMode.SOLID);
+ }
+ }
+
+ // hide the camera mesh if we get close to it
+ onFollowDistanceUpdated(currentDistance) {
+ this.cameraMeshGroup.visible = currentDistance > 3000;
+ }
+
+ // continually update the Followable sceneNode to the position of the camera
+ updateSceneNode() {
+ // this.sceneNode.setLocalMatrix(this.phone.matrix.elements);
+ }
+
+ /* ---------------- Helper Functions ---------------- */
+
+ applyMatricesMessage(matricesMsg) {
+ const matrices = JSON.parse(matricesMsg);
+ const rootNode = new realityEditor.sceneGraph.SceneNode('ROOT');
+ rootNode.updateWorldMatrix();
+
+ let cameraNode = new realityEditor.sceneGraph.SceneNode('camera');
+ cameraNode.setLocalMatrix(matrices.camera);
+ cameraNode.updateWorldMatrix();
+
+ let gpNode = new realityEditor.sceneGraph.SceneNode('gp');
+ gpNode.needsRotateX = true;
+ let gpRxNode = new realityEditor.sceneGraph.SceneNode('gprotateX');
+ gpRxNode.addTag('rotateX');
+ gpRxNode.setParent(gpNode);
+
+ const c = Math.cos(-Math.PI / 2);
+ const s = Math.sin(-Math.PI / 2);
+ let rxMat = [
+ 1, 0, 0, 0,
+ 0, c, -s, 0,
+ 0, s, c, 0,
+ 0, 0, 0, 1
+ ];
+ gpRxNode.setLocalMatrix(rxMat);
+
+ gpNode.setLocalMatrix(matrices.groundplane);
+ gpNode.updateWorldMatrix();
+
+ let sceneNode = new realityEditor.sceneGraph.SceneNode('scene');
+ sceneNode.setParent(rootNode);
+
+ let initialVehicleMatrix = [
+ -1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, -1, 0,
+ 0, 0, 0, 1,
+ ];
+
+ sceneNode.setPositionRelativeTo(cameraNode, initialVehicleMatrix);
+ sceneNode.updateWorldMatrix();
+
+ let cameraMat = sceneNode.getMatrixRelativeTo(gpRxNode);
+ this.setMatrixFromArray(this.phone.matrix, new Float32Array(cameraMat));
+ this.phone.updateMatrixWorld(true);
+
+ if (this.sceneNode) {
+ this.sceneNode.setLocalMatrix(this.phone.matrix.elements, { recomputeImmediately: true });
+ }
+
+ if (this.firstPersonMode) {
+ let matrix = this.getSceneNodeMatrix();
+ let eye = new THREE.Vector3(0, 0, 0);
+ eye.applyMatrix4(matrix);
+ let target = new THREE.Vector3(0, 0, -1000);
+ target.applyMatrix4(matrix);
+ matrix.lookAt(eye, target, new THREE.Vector3(0, 1, 0));
+ realityEditor.sceneGraph.setCameraPosition(matrix.elements);
+ }
+ }
+
+ getSceneNodeMatrix() {
+ let matrix = this.phone.matrixWorld.clone();
+
+ let initialVehicleMatrix = new THREE.Matrix4().fromArray([
+ -1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, -1, 0,
+ 0, 0, 0, 1,
+ ]);
+ matrix.multiply(initialVehicleMatrix);
+
+ return matrix;
+ }
+
+ /**
+ * Takes in the stored Base64 pose data and parses it back into a matrix.
+ * @param poseBase64 - The stored Base64 pose data.
+ * @return {Float32Array|null} - The original pose data.
+ */
+ getPoseMatrixFromData(poseBase64) {
+ if (!poseBase64) { return null; }
+
+ let byteCharacters = window.atob(poseBase64);
+ const byteNumbers = new Array(byteCharacters.length);
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+ const byteArray = new Uint8Array(byteNumbers);
+ return new Float32Array(byteArray.buffer);
+ }
+
+ /**
+ * Sets a matrix from the values in an array.
+ * @param matrix - The matrix to set the values of.
+ * @param array - The array to copy the values from.
+ */
+ setMatrixFromArray(matrix, array) {
+ matrix.set(
+ array[0], array[4], array[8], array[12],
+ array[1], array[5], array[9], array[13],
+ array[2], array[6], array[10], array[14],
+ array[3], array[7], array[11], array[15]
+ );
+ }
+}
+
+realityEditor.gui.ar.videoPlayback.VideoPlayer = VideoPlayer;
diff --git a/src/gui/buttons.js b/src/gui/buttons.js
new file mode 100644
index 000000000..090f3e01d
--- /dev/null
+++ b/src/gui/buttons.js
@@ -0,0 +1,288 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.gui.buttons");
+
+/**
+ * @fileOverview realityEditor.gui.buttons.js
+ * Manages the model of each button, whereas realityEditor.gui.menus.js manages the view.
+ * Handles touch events and tracks button state for each menu button. Provides default behavior for each button,
+ * and provides an interface for other modules to register callbacks for custom button behavior.
+ */
+
+(function(exports) {
+
+ /**
+ * @typedef {PointerEvent} ButtonEvent
+ * @desc A pointerevent with an additional property containing the button id that was pressed
+ * @property {string} button - the ID of the button that was pressed
+ * @property {boolean|undefined} ignoreIsDown - if included, don't require that the button was pressed down first in order for up event to trigger
+ * (can be used to synthetically trigger button events)
+ */
+
+ /**
+ * @type {Readonly<{GUI: string, LOGIC: string, RESET: string, COMMIT: string, UNCONSTRAINED: string, DISTANCE: string, SETTING: string, LOGIC_SETTING: string, FREEZE: string, LOCK: string, HALF_LOCK: string, UNLOCK: string, RECORD: string, POCKET: string, LOGIC_POCKET: string, BIG_POCKET: string, HALF_POCKET: string, REALITY_GUI: string, REALITY_INFO: string, REALITY_TAG: string, REALITY_SEARCH: string, REALITY_WORK: string}>}
+ */
+ var ButtonNames = Object.freeze(
+ {
+ GUI: 'gui',
+ LOGIC: 'logic',
+ RESET: 'reset',
+ COMMIT: 'commit',
+ UNCONSTRAINED: 'unconstrained',
+ DISTANCE: 'distance',
+ DISTANCE_GREEN: 'distanceGreen',
+ SETTING: 'setting',
+ LOGIC_SETTING: 'logicSetting',
+ FREEZE: 'freeze',
+ LOCK: 'lock',
+ HALF_LOCK: 'halflock',
+ UNLOCK: 'unlock',
+ RECORD: 'record',
+ POCKET: 'pocket',
+ LOGIC_POCKET: 'logicPocket',
+ BIG_POCKET: 'bigPocket',
+ HALF_POCKET: 'halfPocket',
+ REALITY_GUI: 'realityGui',
+ REALITY_INFO: 'realityInfo',
+ REALITY_TAG: 'realityTag',
+ REALITY_SEARCH: 'realitySearch',
+ REALITY_WORK: 'realityWork',
+ BACK: 'back',
+ GROUNDPLANE_RESET: 'groundPlaneReset'
+ });
+
+ /**
+ * @type {Readonly<{UP: string, DOWN: string, ENTERED: string}>}
+ */
+ var ButtonStates = Object.freeze(
+ {
+ UP: 'up',
+ DOWN: 'down',
+ ENTERED: 'entered'
+ });
+
+ /**
+ * Contains the up/down state of every button.
+ * Each key is the name of a button, as defined in the ButtonNames enum.
+ * Each value is that button's ButtonStates.
+ * @type {Object.}
+ */
+ var buttonStates = {};
+
+ /**
+ * Getter returns whether the button is 'up', 'down', or 'entered'
+ * @param {string} buttonName
+ * @return {string}
+ */
+ var getButtonState = function(buttonName) {
+ return buttonStates[buttonName];
+ };
+
+ /**
+ * Utility to set the buttonName button to state DOWN
+ * @param {string} buttonName
+ */
+ var setButtonStateDown = function(buttonName) {
+ buttonStates[buttonName] = ButtonStates.DOWN;
+ };
+
+ /**
+ * Utility to set the buttonName button to state UP
+ * @param {string} buttonName
+ */
+ var setButtonStateUp = function(buttonName) {
+ buttonStates[buttonName] = ButtonStates.UP;
+ };
+
+ /**
+ * Utility to set the buttonName button to state ENTERED
+ * @param {string} buttonName
+ */
+ var setButtonStateEntered = function(buttonName) {
+ buttonStates[buttonName] = ButtonStates.ENTERED;
+ };
+
+ /**
+ * @type {CallbackHandler}
+ */
+ var callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('gui/buttons');
+
+ // uncomment to create placeholders for these functions that get generated automatically at runtime
+ // doesn't make a functional difference, but helps with autocomplete
+
+ // button down events
+ /*
+ var guiButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var logicButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var pocketButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var logicPocketButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var resetButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var commitButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var unconstrainedButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var settingButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var logicSettingButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var freezeButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var lockButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var halflockButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var unlockButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+ var recordButtonDown = function(){ console.warn('function stub should be overridden at runtime'); };
+
+ // button up events
+ var guiButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var logicButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var resetButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var commitButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var unconstrainedButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var settingButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var logicSettingButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var freezeButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var pocketButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var logicPocketButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var lockButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var halflockButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var unlockButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+ var recordButtonUp = function(){ console.warn('function stub should be overridden at runtime'); };
+
+ // button enter events (they are auto-generated for every button, but these are the only ones currently used)
+ var pocketButtonEnter = function(){ console.warn('function stub should be overridden at runtime'); };
+ var bigPocketButtonEnter = function(){ console.warn('function stub should be overridden at runtime'); };
+ var halfPocketButtonEnter = function(){ console.warn('function stub should be overridden at runtime'); };
+
+ // button leave events (they are auto-generated for every button, but these are the only ones currently used)
+ var pocketButtonLeave = function(){ console.warn('function stub should be overridden at runtime'); };
+ */
+
+ /**
+ * Called from device/onLoad to initialize the buttons with assets and event listeners
+ */
+ var initButtons = function() {
+
+ // loop over all buttons (as defined in the ButtonNames enum), and generate default state and event handlers
+ Object.keys(ButtonNames).forEach(function(buttonKey) {
+ var buttonName = ButtonNames[buttonKey];
+
+ // populate the default states for each button
+ buttonStates[buttonName] = ButtonStates.UP;
+
+ // generate onButtonDown functions... they trigger externally-registered callbacks and update the buttonState
+ var functionName = buttonName + 'ButtonDown';
+ /** @param {ButtonEvent} event */
+ exports[functionName] = function(event) {
+ if (event.button !== buttonName) return;
+ callbackHandler.triggerCallbacks(event.button, {buttonName: event.button, newButtonState: 'down'});
+ setButtonStateDown(event.button);
+ };
+
+ // ...generate onButtonUp functions
+ functionName = buttonName + 'ButtonUp';
+ /** @param {ButtonEvent} event */
+ exports[functionName] = function(event) {
+ if (event.button !== buttonName) return;
+ // only works if the tap down originated on the button
+ if (!event.ignoreIsDown && buttonStates[event.button] !== ButtonStates.DOWN) return;
+ callbackHandler.triggerCallbacks(event.button, {buttonName: event.button, newButtonState: 'up'});
+ setButtonStateUp(event.button);
+ };
+
+ // ...generate onButtonEnter functions
+ functionName = buttonName + 'ButtonEnter';
+ /** @param {ButtonEvent} event */
+ exports[functionName] = function(event) {
+ if (event.button !== buttonName) return;
+ callbackHandler.triggerCallbacks(event.button, {buttonName: event.button, newButtonState: 'enter'});
+ setButtonStateEntered(event.button);
+ };
+
+ // ...generate onButtonLeave functions
+ functionName = buttonName + 'ButtonLeave';
+ /** @param {ButtonEvent} event */
+ exports[functionName] = function(event) {
+ if (event.button !== buttonName) return;
+ callbackHandler.triggerCallbacks(event.button, {buttonName: event.button, newButtonState: 'leave'});
+ setButtonStateEntered(event.button);
+ };
+
+ // ensure pointer enter and pointer leave events get triggered
+ var buttonElement = document.getElementById(buttonName + 'Button');
+ var buttonDivElement = document.getElementById(buttonName + 'ButtonDiv');
+ if (buttonElement) {
+ buttonElement.addEventListener('gotpointercapture', function(evt) {
+ evt.target.releasePointerCapture(evt.pointerId);
+ });
+ }
+ if (buttonDivElement) {
+ buttonDivElement.addEventListener('gotpointercapture', function(evt) {
+ evt.target.releasePointerCapture(evt.pointerId);
+ });
+ }
+
+ }.bind(this));
+
+ };
+
+ /**
+ * Adds a callback function that will be invoked when the specified button is pressed
+ * @param {string} buttonName
+ * @param {function} callback
+ */
+ function registerCallbackForButton(buttonName, callback) {
+ if (!callbackHandler) {
+ callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('gui/buttons');
+ }
+ callbackHandler.registerCallback(buttonName, callback);
+ }
+
+ exports.initButtons = initButtons;
+ exports.ButtonNames = ButtonNames;
+ exports.ButtonStates = ButtonStates;
+ exports.registerCallbackForButton = registerCallbackForButton;
+ exports.getButtonState = getButtonState;
+
+})(realityEditor.gui.buttons);
diff --git a/src/gui/crafting/blockMenu.js b/src/gui/crafting/blockMenu.js
new file mode 100644
index 000000000..9897b4ec3
--- /dev/null
+++ b/src/gui/crafting/blockMenu.js
@@ -0,0 +1,417 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2016 Benjamin Reynholds
+ * Modified by Valentin Heun 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.gui.crafting.blockMenu");
+
+(function(exports) {
+
+ var blockTabImage = [];
+ var blockTabImageActive = [];
+
+ /**
+ * Creates the DOM elements for the logic block menu,
+ * load all the blocks and generate their DOM and data models,
+ * and call the callback function when fully loaded.
+ * @param {Function} callback
+ */
+ function initializeBlockMenu(callback) {
+ var logic = globalStates.currentLogic;
+
+ var craftingMenusContainer = document.getElementById('craftingMenusContainer');
+
+ var container = document.createElement('div');
+ container.setAttribute('id', 'menuContainer');
+ // container.style.left = logic.grid.xMargin + 'px';
+ // container.style.top = logic.grid.yMargin + 'px';
+
+ container.classList.add('centerVerticallyAndHorizontally');
+
+ // pre-load any necessary assets
+ if (blockTabImage.length === 0) {
+ // realityEditor.gui.utilities.preload(blockTabImage,
+ // 'png/iconBlocks.png', 'png/iconEvents.png', 'png/iconSignals.png', 'png/iconMath.png', 'png/iconWeb.png'
+ // );
+ realityEditor.gui.utilities.preload(blockTabImage,
+ 'svg/blockMenu/blockMenuDefault.svg', 'svg/blockMenu/blockMenuEvents.svg', 'svg/blockMenu/blockMenuSignals.svg', 'svg/blockMenu/blockMenuMath.svg', 'svg/blockMenu/blockMenuWeb.svg'
+ );
+ }
+
+ if (blockTabImageActive.length === 0) {
+ realityEditor.gui.utilities.preload(blockTabImageActive,
+ 'svg/blockMenu/blockMenuDefaultActive.svg', 'svg/blockMenu/blockMenuEventsActive.svg', 'svg/blockMenu/blockMenuSignalsActive.svg', 'svg/blockMenu/blockMenuMathActive.svg', 'svg/blockMenu/blockMenuWebActive.svg'
+ );
+ }
+
+ // center on iPads
+ // nodeSettingsContainer.style.marginLeft = globalStates.currentLogic.grid.xMargin + 'px';
+ // nodeSettingsContainer.style.marginTop = globalStates.currentLogic.grid.yMargin + 'px';
+
+ // container.style.width = logic.grid.gridWidth + 'px';
+ // container.style.height = logic.grid.gridHeight + 'px';
+
+ // container.style.width = 'calc(' + (100.0 / scaleMultiplier) + 'vw - 62px)';
+ container.style.width = '506px';
+ container.style.height = '320px';
+
+ // change display for desktop programming
+ if (realityEditor.device.environment.shouldDisplayLogicMenuModally()) {
+ container.style.left = 'unset';
+ craftingMenusContainer.style.background = 'rgba(0, 0, 0, 0.5)';
+ craftingMenusContainer.style.backdropFilter = 'blur(3px)';
+ craftingMenusContainer.style.webkitBackdropFilter = 'blur(3px)';
+ craftingMenusContainer.style.background = 'rgba(0, 0, 0, 0.5)';
+ var scaleMultiplier = Math.max(logic.grid.containerHeight / logic.grid.gridHeight, logic.grid.containerWidth / logic.grid.gridWidth);
+ container.style.transformOrigin = '100% 50%';
+ container.style.transform = 'scale(' + scaleMultiplier + ')';
+
+ // TODO: needs some additional styling to look good on modal environments, e.g. to blockIcon
+ }
+
+ craftingMenusContainer.appendChild(container);
+
+ // var settingsContainer = document.createElement('div');
+ // container.appendChild(settingsContainer);
+
+ var menuBlockContainer = document.createElement('div');
+ menuBlockContainer.setAttribute('id', 'menuBlockContainer');
+ container.appendChild(menuBlockContainer);
+
+ var menuSideContainer = document.createElement('div');
+ menuSideContainer.setAttribute('id', 'menuSideContainer');
+ container.appendChild(menuSideContainer);
+
+ var menuCols = 4;
+ var menuNumTabs = 5;
+ logic.guiState.menuSelectedTab = 0;
+ logic.guiState.menuTabDivs = [];
+ logic.guiState.menuIsPointerDown = false;
+ logic.guiState.menuSelectedBlock = null;
+ logic.guiState.menuBlockDivs = [];
+
+ // create menu tabs for block categories
+ for (var i = 0; i < menuNumTabs; i++) {
+ var menuTab = document.createElement('div');
+ menuTab.setAttribute('class', 'menuTab');
+ menuTab.setAttribute('tabIndex', i);
+ menuTab.setAttribute('touch-action', 'none');
+ menuTab.addEventListener('pointerdown', onMenuTabSelected.bind(exports));
+
+ var menuTabIcon = document.createElement('img');
+ menuTabIcon.setAttribute('class', 'menuTabIcon');
+ menuTabIcon.setAttribute('src', blockTabImage[i].src);
+ menuTabIcon.setAttribute('touch-action', 'none');
+ menuTab.appendChild(menuTabIcon);
+
+ logic.guiState.menuTabDivs.push(menuTab);
+ menuSideContainer.appendChild(menuTab);
+ }
+
+ // we use "call" syntax because need to pass "exports" as "this" to the event listeners in callback
+ menuLoadBlocks.call(exports, function(blockData) {
+
+ // when the menu first initializes, create enough rows of placeholder blocks for the menu
+ // to contain all the blocks that exist. when we switch tabs, we'll hide any extras that
+ // aren't needed for the visible category (happens in redisplayBlockSelection)
+ let totalBlockCount = Object.keys(blockData).length;
+ let menuRows = Math.ceil(totalBlockCount / menuCols);
+
+ // load each block from the downloaded json and add it to the appropriate category
+ for (var key in blockData) {
+ if (!blockData.hasOwnProperty(key)) continue;
+ let block = blockData[key];
+
+ var categoryIndex = 0;
+ if (block.category) {
+ categoryIndex = block.category - 1;
+ }
+ var defaultMenu = logic.guiState.menuBlockData[0];
+ var categoryMenu = logic.guiState.menuBlockData[categoryIndex];
+ defaultMenu[key] = block;
+ categoryMenu[key] = block;
+ }
+
+ console.log("menuBlockData = ");
+ console.log(logic.guiState.menuBlockData);
+
+ for (var r = 0; r < menuRows; r++) {
+ var row = document.createElement('div');
+ row.classList.add('menuBlockRow');
+ menuBlockContainer.appendChild(row);
+ for (var c = 0; c < menuCols; c++) {
+ let block = document.createElement('div');
+ block.setAttribute('class', 'menuBlock');
+ block.style.visibility = 'hidden';
+ var blockContents = document.createElement('div');
+ blockContents.setAttribute('class', 'menuBlockContents');
+ blockContents.setAttribute("touch-action", "none");
+ blockContents.addEventListener('pointerdown', onBlockMenuPointerDown.bind(exports));
+ blockContents.addEventListener('pointerup', onBlockMenuPointerUp.bind(exports));
+ blockContents.addEventListener('pointerleave', onBlockMenuPointerLeave.bind(exports));
+ blockContents.addEventListener('gotpointercapture', function(evt) {
+ evt.target.releasePointerCapture(evt.pointerId);
+ });
+ blockContents.addEventListener('pointermove', onBlockMenuPointerMove.bind(exports));
+ block.appendChild(blockContents);
+ logic.guiState.menuBlockDivs.push(block);
+ row.appendChild(block);
+ }
+ }
+ callback();
+ });
+ }
+
+ /**
+ * Remove all the menu block event handlers and DOM elements.
+ */
+ function resetBlockMenu() {
+ if (globalStates.currentLogic) {
+ var guiState = globalStates.currentLogic.guiState;
+ guiState.menuBlockDivs.forEach(function(blockDiv) {
+ blockDiv.firstChild.removeEventListener('pointerdown', onBlockMenuPointerDown);
+ blockDiv.firstChild.removeEventListener('pointerup', onBlockMenuPointerUp);
+ blockDiv.firstChild.removeEventListener('pointerleave', onBlockMenuPointerLeave);
+ blockDiv.addEventListener('gotpointercapture', function(evt) {
+ evt.target.releasePointerCapture(evt.pointerId);
+ });
+ blockDiv.firstChild.removeEventListener('pointermove', onBlockMenuPointerMove);
+ });
+ }
+ var container = document.getElementById('menuContainer');
+ if (container) {
+ while (container.hasChildNodes()) {
+ container.removeChild(container.lastChild);
+ }
+ }
+ }
+
+ /**
+ * Get the JSON data of all available logic blocks on the current logic node's server, and pass it into the callback function when loaded
+ * @param {Function} callback - function that accepts JSON data as first parameter
+ */
+ function menuLoadBlocks(callback) {
+ var keys = this.crafting.eventHelper.getServerObjectLogicKeys(globalStates.currentLogic); // TODO: move to realityEditor.network module
+
+ var urlEndpoint = realityEditor.network.getURL(keys.ip, keys.port, '/availableLogicBlocks');
+ realityEditor.network.getData(null, null, null, urlEndpoint, function (objectKey, frameKey, nodeKey, req) {
+ console.log("did get available blocks", req);
+ callback(req);
+ });
+ }
+
+ /**
+ * Displays the set of logic blocks associated with the category of the tab that was tapped on.
+ * @param {PointerEvent} e
+ */
+ function onMenuTabSelected(e) {
+ e.preventDefault();
+ var guiState = globalStates.currentLogic.guiState;
+ guiState.menuSelectedTab = e.target.tabIndex;
+ if (guiState.menuSelectedTab < 0) guiState.menuSelectedTab = e.target.parentNode.tabIndex;
+ if (guiState.menuSelectedTab < 0) guiState.menuSelectedTab = 0;
+ redisplayTabSelection.call(exports);
+ redisplayBlockSelection.call(exports);
+ }
+
+ /**
+ * Update the visuals for each tab to show which one is selected.
+ */
+ function redisplayTabSelection() {
+
+ // TODO: move into desktop adapter module
+ if (realityEditor.device.environment.shouldDisplayLogicMenuModally()) {
+ document.getElementById("datacraftingCanvas").style.display = '';
+ document.getElementById("blockPlaceholders").style.display = '';
+ document.getElementById("blocks").style.display = '';
+ }
+
+ var guiState = globalStates.currentLogic.guiState;
+ guiState.menuTabDivs.forEach(function(tab) {
+ if (guiState.menuSelectedTab === tab.tabIndex) {
+ tab.setAttribute('class', 'menuTabSelected');
+ tab.querySelector('.menuTabIcon').setAttribute('src', blockTabImageActive[tab.tabIndex].src);
+
+ } else {
+ tab.setAttribute('class', 'menuTab');
+ tab.querySelector('.menuTabIcon').setAttribute('src', blockTabImage[tab.tabIndex].src);
+ }
+ });
+ }
+
+ /**
+ * Update the visuals for each menu block to show the icon image of the block it will add.
+ * Hides excess blocks if this category has fewer than the maximum number.
+ */
+ function redisplayBlockSelection() {
+ var guiState = globalStates.currentLogic.guiState;
+ var blocksObject = guiState.menuBlockData[guiState.menuSelectedTab];
+ var blocksInThisSection = [];
+ for (var key in blocksObject) {
+ blocksInThisSection.push(blocksObject[key]);
+ }
+
+ var blockDiv;
+ // reassign as many divs as needed to the current set of blocks
+ for (let i = 0; i < blocksInThisSection.length; i++) {
+ blockDiv = guiState.menuBlockDivs[i];
+ var thisBlockData = blocksInThisSection[i];
+ blockDiv.blockData = thisBlockData;
+ blockDiv.firstChild.innerHTML = ""; // reset block contents before adding anything
+
+ // load icon and title
+ var iconImage = document.createElement("img");
+ iconImage.classList.add('blockIcon', 'blockIconTinted');
+
+ // wait until image loads to display block
+ iconImage.onload = function(e) {
+ console.log('did load image');
+
+ var parentBlock = e.target.parentElement.parentElement;
+ if (parentBlock) {
+ parentBlock.style.visibility = 'visible';
+ parentBlock.style.display = 'inline-block';
+ }
+ };
+
+ // must come after the onload callback is defined, otherwise won't trigger it
+ iconImage.src = this.crafting.getBlockIcon(globalStates.currentLogic, thisBlockData.type,false).src;
+ blockDiv.firstChild.appendChild(iconImage);
+
+ if (blockDiv.querySelectorAll('.blockTitle').length === 0) {
+ var blockTitle = document.createElement('div');
+ blockTitle.setAttribute('class', 'blockTitle');
+ blockTitle.innerHTML = thisBlockData.name;
+ blockDiv.appendChild(blockTitle);
+ } else {
+ blockDiv.querySelector('.blockTitle').innerHTML = thisBlockData.name;
+ }
+ }
+
+ // clear the remaining block divs
+ for (let i = blocksInThisSection.length; i < guiState.menuBlockDivs.length; i++) {
+ blockDiv = guiState.menuBlockDivs[i];
+ blockDiv.blockData = '';
+ blockDiv.style.display = 'none';
+ }
+ }
+
+ /**
+ * Changes internal state when you tap on a menu block to store which one you selected.
+ * Updates visuals to show it was selected.
+ * (Doesn't add the block yet - waits until pointermove event)
+ * @param {PointerEvent} e
+ */
+ function onBlockMenuPointerDown(e) {
+ e.preventDefault();
+ var guiState = globalStates.currentLogic.guiState;
+ guiState.menuBlockToAdd = null;
+ guiState.menuIsPointerDown = true;
+ guiState.menuSelectedBlock = e.currentTarget;
+ guiState.menuSelectedBlock.parentNode.setAttribute('class', 'menuBlock blockDivMovingAble');
+ guiState.menuBlockToAdd = e.currentTarget.parentNode;
+ }
+
+ /**
+ * Resets internal state and visuals to un-select the menu block that was selected.
+ * @param {PointerEvent} e
+ */
+ function onBlockMenuPointerUp(e) {
+ e.preventDefault();
+ var guiState = globalStates.currentLogic.guiState;
+ guiState.menuIsPointerDown = false; // TODO: this is only difference between this and onBlockMenuPointerLeave?
+ if (guiState.menuSelectedBlock) {
+ guiState.menuSelectedBlock.parentNode.setAttribute('class', 'menuBlock');
+ }
+ guiState.menuSelectedBlock = null;
+ guiState.menuBlockToAdd = null;
+ }
+
+ /**
+ * Resets internal state and visuals to un-select the menu block that was selected.
+ * @param e
+ */
+ function onBlockMenuPointerLeave(e) {
+ e.preventDefault();
+ var guiState = globalStates.currentLogic.guiState;
+ if (guiState.menuIsPointerDown) {
+ if (guiState.menuSelectedBlock) {
+ guiState.menuSelectedBlock.parentNode.setAttribute('class', 'menuBlock');
+ }
+ }
+ guiState.menuSelectedBlock = null;
+ guiState.menuBlockToAdd = null;
+ }
+
+ /**
+ * Actually adds the selected block to the crafting board and hides the menu when you drag on a menu block.
+ * @param {PointerEvent} e
+ */
+ function onBlockMenuPointerMove(e) {
+ e.preventDefault();
+ var guiState = globalStates.currentLogic.guiState;
+ if (guiState.menuBlockToAdd) {
+ if (guiState.menuSelectedBlock) {
+ guiState.menuSelectedBlock.parentNode.setAttribute('class', 'menuBlock');
+ }
+ var blockJSON = guiState.menuBlockToAdd.blockData;
+ var blockRect = guiState.menuBlockToAdd.getBoundingClientRect();
+ var pointerX = blockRect.left + blockRect.width/2;
+ var pointerY = blockRect.top + blockRect.height/2;
+
+ this.crafting.blockMenuHide(); // hide menu before adding block otherwise the touchmove event it triggers will be stopped
+ this.crafting.eventHelper.addBlockFromMenu(blockJSON, pointerX, pointerY); // actually adds it to the crafting board
+ guiState.menuBlockToAdd = null;
+ }
+ }
+
+ exports.initializeBlockMenu = initializeBlockMenu;
+ exports.resetBlockMenu = resetBlockMenu;
+ exports.redisplayTabSelection = redisplayTabSelection;
+ exports.redisplayBlockSelection = redisplayBlockSelection;
+
+}(realityEditor.gui.crafting.blockMenu));
diff --git a/src/gui/crafting/eventHandlers.js b/src/gui/crafting/eventHandlers.js
new file mode 100644
index 000000000..a5e40cd73
--- /dev/null
+++ b/src/gui/crafting/eventHandlers.js
@@ -0,0 +1,305 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2016 Benjamin Reynholds
+ * Modified by Valentin Heun 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.gui.crafting.eventHandlers");
+
+(function(exports) {
+
+ var TS_NONE = "NONE";
+ var TS_TAP_BLOCK = "TAP_BLOCK";
+ var TS_HOLD = "HOLD_BLOCK";
+ var TS_MOVE = "MOVE_BLOCK";
+ var TS_CONNECT = "CONNECT_BLOCK";
+ var TS_CUT = "CUT";
+
+ var touchState = TS_NONE;
+
+ var cutLineStart = null;
+
+ var startTapTime;
+
+ var activeHoldTimer = null;
+
+ function onPointerDown(e) {
+ if (realityEditor.gui.crafting.eventHelper.areAnyMenusOpen()) return;
+
+ // we can assume we are in TS_NONE
+
+ var cell = this.crafting.eventHelper.getCellOverPointer(e.pageX, e.pageY);
+ if (!cell) return; // tapped on menu
+ var contents = this.crafting.eventHelper.getCellContents(cell);
+
+ this.crafting.eventHelper.updateCraftingBackgroundVisibility("down", cell, contents);
+
+ if (contents && !this.crafting.eventHelper.isOutputBlock(contents.block)) {
+
+ touchState = TS_TAP_BLOCK;
+
+ globalStates.currentLogic.guiState.tappedContents = contents;
+
+ startTapTime = Date.now();
+
+ var _this = this;
+ var thisTappedContents = contents;
+
+ activeHoldTimer = setTimeout(function () {
+ _this.crafting.eventHelper.styleBlockForHolding(thisTappedContents, true);
+
+ realityEditor.gui.menus.switchToMenu("bigTrash");
+ //realityEditor.gui.pocket.pocketOnMemoryDeletionStart();
+ }, globalStates.craftingMoveDelay);
+
+ } else {
+
+ touchState = TS_CUT;
+ cutLineStart = {
+ x: e.pageX,
+ y: e.pageY
+ };
+
+ }
+ }
+
+ function onPointerMove(e, setStateMove) {
+ if (realityEditor.gui.crafting.eventHelper.areAnyMenusOpen()) return;
+
+ if (setStateMove) {
+ touchState = TS_MOVE;
+ }
+ var cell = this.crafting.eventHelper.getCellOverPointer(e.pageX, e.pageY);
+ if (!cell) { // moved to sidebar menu
+ if (touchState !== TS_MOVE) {
+ return this.onPointerUp(e);
+ }
+
+ } else {
+ this.crafting.eventHelper.updateCraftingBackgroundVisibility("move", cell, globalStates.currentLogic.guiState.tappedContents);
+ }
+
+ var contents = this.crafting.eventHelper.getCellContents(cell);
+ var tappedContents = globalStates.currentLogic.guiState.tappedContents;
+
+ if (touchState === TS_TAP_BLOCK) {
+
+ // if you moved to a different cell, go to TS_CONNECT
+ if (!this.crafting.eventHelper.areCellsEqual(cell, tappedContents.cell)) {
+ this.crafting.eventHelper.styleBlockForHolding(tappedContents, false);
+ if (this.crafting.eventHelper.canDrawLineFrom(tappedContents)) {
+ touchState = TS_CONNECT;
+ clearTimeout(activeHoldTimer);
+ realityEditor.gui.menus.switchToMenu("crafting");
+ // realityEditor.gui.pocket.pocketOnMemoryDeletionStop();
+
+ } else {
+ touchState = TS_NONE;
+ clearTimeout(activeHoldTimer);
+ realityEditor.gui.menus.switchToMenu("crafting");
+ // realityEditor.gui.pocket.pocketOnMemoryDeletionStop();
+
+ }
+
+ // otherwise if enough time has passed, change to TS_HOLD
+ } else if (!contents.block.isPortBlock) {
+ if (Date.now() - startTapTime > globalStates.craftingMoveDelay) {
+ this.cout("enough time has passed -> HOLD (" + (Date.now() - startTapTime) + ")");
+ touchState = TS_HOLD;
+ clearTimeout(activeHoldTimer);
+ this.crafting.eventHelper.styleBlockForHolding(globalStates.currentLogic.guiState.tappedContents, true);
+ }
+ }
+
+ } else if (touchState === TS_HOLD) {
+
+ // if you moved to a different cell, go to TS_MOVE
+ // remove the block and create a temp block
+
+ touchState = TS_MOVE;
+ this.crafting.eventHelper.convertToTempBlock(tappedContents);
+ this.crafting.eventHelper.moveBlockDomToPosition(tappedContents, e.pageX, e.pageY);
+
+ } else if (touchState === TS_CONNECT) {
+
+ // if you are over an eligible block, create a temp link and re-route grid
+ if (contents && this.crafting.eventHelper.canConnectBlocks(tappedContents, contents)){
+ this.crafting.eventHelper.resetLinkLine();
+ if (!this.crafting.eventHelper.areBlocksTempConnected(tappedContents, contents)) {
+ this.crafting.eventHelper.createTempLink(tappedContents, contents);
+ }
+
+ // if you aren't over an eligible block, draw a line to current position
+ } else {
+ this.crafting.eventHelper.drawLinkLine(tappedContents, e.pageX, e.pageY);
+ }
+
+ } else if (touchState === TS_MOVE) {
+ realityEditor.gui.menus.switchToMenu("bigTrash");
+ // realityEditor.gui.pocket.pocketOnMemoryDeletionStart(); //displays the big trash can icon
+
+ // snap if to grid position if necessary, otherwise just move block to pointer position
+ var didSnap = this.crafting.eventHelper.snapBlockToCellIfPossible(tappedContents, cell, e.pageX, e.pageY); //TODO: move to inside the canPlaceBlockInCell block to avoid redundant checks
+ if (!didSnap) {
+ this.crafting.eventHelper.moveBlockDomToPosition(tappedContents, e.pageX, e.pageY);
+ this.crafting.eventHelper.unhighlightPlaceholderDivs(this.crafting.eventHelper.highlightedPlaceholders);
+ }
+
+ // if you are over an eligible cell, style temp block to highlighted
+ cell = this.crafting.eventHelper.getCellOverPointer(e.pageX, e.pageY);
+ if (this.crafting.eventHelper.canPlaceBlockInCell(tappedContents, cell)) {
+ this.crafting.eventHelper.styleBlockForPlacement(tappedContents, true);
+
+ var cellsOver = globalStates.currentLogic.grid.getCellsOver(cell, tappedContents.block.blockSize, tappedContents.item);
+ cellsOver.forEach(function(thisCell) {
+ realityEditor.gui.crafting.eventHelper.stylePlaceholder({cell: thisCell}, true);
+ });
+
+ // if you aren't over an eligible cell, style temp block to faded
+ } else {
+ this.crafting.eventHelper.styleBlockForPlacement(tappedContents, false);
+ }
+
+ } else if (touchState === TS_CUT) {
+ // draw the cut line from cutLineStart to current position
+ var cutLineEnd = {
+ x: e.pageX,
+ y: e.pageY
+ };
+
+ this.crafting.eventHelper.drawCutLine(cutLineStart, cutLineEnd);
+ }
+ }
+
+ function onPointerUp(e) {
+ if (realityEditor.gui.crafting.eventHelper.areAnyMenusOpen()) return;
+ if (e.target !== e.currentTarget) return; // prevents event bubbling
+
+ var cell = this.crafting.eventHelper.getCellOverPointer(e.pageX, e.pageY);
+ var contents = this.crafting.eventHelper.getCellContents(cell);
+ var tappedContents = globalStates.currentLogic.guiState.tappedContents;
+
+ //this.crafting.eventHelper.toggleDatacraftingExceptPort(tappedContents, true); // always make sure the background shows again
+ this.crafting.eventHelper.updateCraftingBackgroundVisibility("up", cell, globalStates.currentLogic.guiState.tappedContents);
+
+
+ realityEditor.gui.menus.switchToMenu("crafting");
+ // realityEditor.gui.pocket.pocketOnMemoryDeletionStop(); //hides the big trash can icon
+
+ if (touchState === TS_TAP_BLOCK) {
+ // for now -> do nothing
+ // but in the future -> this will open the block settings screen
+ this.crafting.eventHelper.styleBlockForHolding(tappedContents, false);
+ clearTimeout(activeHoldTimer);
+
+ if (!contents.block.isPortBlock) {
+ if (Date.now() - startTapTime < (globalStates.craftingMoveDelay/2)) {
+ this.crafting.eventHelper.openBlockSettings(tappedContents.block);
+ }
+ }
+
+ } else if (touchState === TS_HOLD) {
+ // holding (not moving) a block means haven't left the cell
+ // so do nothing (just put it down)
+ this.crafting.eventHelper.styleBlockForHolding(tappedContents, false);
+
+ } else if (touchState === TS_CONNECT) {
+
+ // if you are over an eligible block, remove temp link and add real link
+ if (contents && this.crafting.eventHelper.canConnectBlocks(tappedContents, contents)) {
+ this.crafting.eventHelper.createLink(tappedContents, contents, globalStates.currentLogic.guiState.tempLink);
+ this.crafting.eventHelper.resetTempLink();
+ } else {
+ this.crafting.eventHelper.resetLinkLine();
+ this.crafting.eventHelper.resetTempLink(); // TODO: decide whether it's better to resetTempLink, or create a permanent link here with the last updated templink
+ }
+
+ } else if (touchState === TS_MOVE) {
+
+ // remove entirely if dragged to menu
+ var isOverSidebar = (e.pageX > window.innerWidth - (realityEditor.gui.crafting.menuBarWidth + 20));
+ if (isOverSidebar) {
+ this.crafting.eventHelper.removeTappedContents(tappedContents);
+ } else {
+ if (this.crafting.eventHelper.canPlaceBlockInCell(tappedContents, cell)) {
+ this.crafting.eventHelper.placeBlockInCell(tappedContents, cell); // move the block to the cell you're over
+ } else {
+ this.crafting.eventHelper.placeBlockInCell(tappedContents, tappedContents.cell); // return the block to its original cell
+ }
+ }
+
+ } else if (touchState === TS_CUT) {
+ this.crafting.eventHelper.cutIntersectingLinks();
+ this.crafting.eventHelper.resetCutLine();
+ }
+
+ globalStates.currentLogic.guiState.tappedContents = null;
+ cutLineStart = null;
+ touchState = TS_NONE;
+ this.cout("pointerUp ->" + touchState);
+ }
+
+ function onLoadBlock(object,frame,logic,block,publicData) {
+ var msg = {
+ object: object,
+ frame: frame,
+ logic: logic,
+ block: block,
+ publicData: JSON.parse(publicData)
+ };
+
+ document.getElementById('blockSettingsContainer').contentWindow.postMessage(
+ JSON.stringify(msg), '*');
+ }
+
+ exports.onPointerDown = onPointerDown;
+ exports.onPointerMove = onPointerMove;
+ exports.onPointerUp = onPointerUp;
+ exports.onLoadBlock = onLoadBlock;
+
+})(realityEditor.gui.crafting.eventHandlers);
+
+
diff --git a/src/gui/crafting/eventHelper.js b/src/gui/crafting/eventHelper.js
new file mode 100644
index 000000000..12f337bdb
--- /dev/null
+++ b/src/gui/crafting/eventHelper.js
@@ -0,0 +1,906 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2016 Benjamin Reynholds
+ * Modified by Valentin Heun 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.gui.crafting.eventHelper");
+
+realityEditor.gui.crafting.eventHelper.highlightedPlaceholders = {};
+
+// done
+realityEditor.gui.crafting.eventHelper.getCellOverPointer = function(pointerX, pointerY) {
+ if(globalStates.currentLogic) {
+ var grid = globalStates.currentLogic.grid;
+ // returns cell if position is within grid bounds, null otherwise
+ return grid.getCellFromPointerPosition(pointerX, pointerY);
+ }
+};
+
+realityEditor.gui.crafting.eventHelper.getCellPlaceholderDiv = function(cell) {
+ var col = cell.location.col;
+ var row = cell.location.row;
+ if (col % 2 === 0 && row % 2 === 0) {
+ var rowContainer = document.querySelectorAll('.blockPlaceholderRow')[row];
+ // console.log(rowContainer);
+ return rowContainer.childNodes[col/2];
+ } else {
+ console.warn('trying to get a placeholder div that isn\'t a valid cell location');
+ return null;
+ }
+};
+
+// done
+realityEditor.gui.crafting.eventHelper.getCellContents = function(cell) {
+ // use grid methods to get block/item overlapping this cell
+ if (cell) {
+ var block = cell.blockAtThisLocation();
+ if (block) {
+ var item = cell.itemAtThisLocation();
+ return {
+ block: block,
+ item: item,
+ cell: cell
+ };
+ }
+ }
+ return null;
+};
+
+realityEditor.gui.crafting.eventHelper.areCellsEqual = function(cell1, cell2) {
+ if (!cell1 || !cell2) return false;
+ return (cell1.location.col === cell2.location.col)
+ && (cell1.location.row === cell2.location.row);
+};
+
+realityEditor.gui.crafting.eventHelper.areBlocksEqual = function(block1, block2) {
+ return (block1.globalId === block2.globalId);
+};
+
+realityEditor.gui.crafting.eventHelper.convertToTempBlock = function(contents) {
+ contents.block.isTempBlock = true;
+ this.updateTempLinkOutlinesForBlock(contents);
+};
+
+realityEditor.gui.crafting.eventHelper.moveBlockDomToPosition = function(contents, pointerX, pointerY) {
+ var grid = globalStates.currentLogic.grid;
+ var domElement = this.getDomElementForBlock(contents.block);
+
+ if (!domElement) return;
+
+ var blockOutlinePadding = 10; // wrapping the div with corners/outline adds the remaining width to match the cell size
+
+ domElement.style.left = pointerX - this.offsetForItem(contents.item) + blockOutlinePadding/2 + 'px';
+ domElement.style.top = pointerY - grid.blockRowHeight/2 + blockOutlinePadding/2 + 'px';
+};
+
+realityEditor.gui.crafting.eventHelper.snapBlockToCellIfPossible = function(contents, cell, pointerX, pointerY) {
+ var grid = globalStates.currentLogic.grid;
+ if (this.canPlaceBlockInCell(contents, cell)) {
+ var dX = Math.abs(pointerX - grid.getCellCenterX(cell)) / (grid.blockColWidth/2);
+ var dY = Math.abs(pointerY - grid.getCellCenterY(cell)) / (grid.blockRowHeight/2);
+ var shouldSnap = ((dX * dX + dY * dY) < 0.5) && (!this.areCellsEqual(contents.cell, cell)); // only snaps to grid if tighter bound is overlapped
+ if (shouldSnap) {
+ this.moveBlockDomToPosition(contents, grid.getCellCenterX(cell), grid.getCellCenterY(cell));
+ return true;
+ }
+ }
+ return false;
+};
+
+realityEditor.gui.crafting.eventHelper.offsetForItem = function(item) {
+ var grid = globalStates.currentLogic.grid;
+ return grid.blockColWidth/2 + item * (grid.blockColWidth + grid.marginColWidth);
+};
+
+realityEditor.gui.crafting.eventHelper.canConnectBlocks = function(contents1, contents2) {
+ return !this.areBlocksEqual(contents1.block, contents2.block)
+ && (contents2.block.activeInputs[contents2.item] === true);
+};
+
+realityEditor.gui.crafting.eventHelper.canDrawLineFrom = function(contents) {
+ return (contents.block.activeOutputs[contents.item] === true);
+};
+
+realityEditor.gui.crafting.eventHelper.areBlocksTempConnected = function(contents1, contents2) {
+ var tempLink = globalStates.currentLogic.tempLink;
+ if (!tempLink) return false;
+
+ return this.areBlocksEqual(this.crafting.grid.blockWithID(tempLink.nodeA, globalStates.currentLogic), contents1.block) &&
+ this.areBlocksEqual(this.crafting.grid.blockWithID(tempLink.nodeB, globalStates.currentLogic), contents2.block) &&
+ tempLink.logicA === contents1.item &&
+ tempLink.logicB === contents2.item;
+};
+
+realityEditor.gui.crafting.eventHelper.canPlaceBlockInCell = function(tappedContents, cell) {
+ var grid = globalStates.currentLogic.grid;
+ if (!cell || !tappedContents) return false;
+ var cellsOver = grid.getCellsOver(cell, tappedContents.block.blockSize, tappedContents.item);
+ var canPlaceBlock = true;
+ cellsOver.forEach( function(cell) {
+ if (!cell || !cell.canHaveBlock() || (cell.blockAtThisLocation() && !cell.blockAtThisLocation().isTempBlock && !cell.blockAtThisLocation().isPortBlock)) {
+ canPlaceBlock = false;
+ }
+ });
+ return canPlaceBlock;
+};
+
+realityEditor.gui.crafting.eventHelper.stylePlaceholder = function(contents, isAble) {
+ var placeholderDiv = this.getCellPlaceholderDiv(contents.cell);
+ if (placeholderDiv && isAble) {
+ placeholderDiv.classList.add('blockDivMovingAbleBorder');
+
+ this.highlightedPlaceholders[JSON.stringify(contents.cell.location)] = placeholderDiv;
+
+ if (contents.cell.location.row !== 0 && contents.cell.location.row !== 6) {
+ realityEditor.gui.moveabilityCorners.removeCornersFromDiv(placeholderDiv);
+ }
+ } else if (placeholderDiv) {
+ this.unhighlightPlaceholderDivs(this.highlightedPlaceholders);
+ }
+};
+
+realityEditor.gui.crafting.eventHelper.removeStyleFromPlaceholderDivs = function(highlightedPlaceholders) {
+ if (!highlightedPlaceholders || Object.keys(highlightedPlaceholders).length === 0) { return; }
+ console.log('remove style from placeholder divs', highlightedPlaceholders);
+
+ for (var locationString in highlightedPlaceholders) {
+ var div = highlightedPlaceholders[locationString];
+ div.classList.remove('blockDivMovingAbleBorder');
+ // var location = JSON.parse(locationString);
+ // if (location.row !== 0 && location.row !== 6) {
+ // realityEditor.gui.moveabilityCorners.wrapDivWithCorners(div, 0, true);
+ // }
+
+ realityEditor.gui.moveabilityCorners.removeOutlineFromDiv(div);
+ realityEditor.gui.moveabilityCorners.removeCornersFromDiv(div);
+
+ // delete highlightedPlaceholders[locationString];
+ }
+};
+
+realityEditor.gui.crafting.eventHelper.unhighlightPlaceholderDivs = function(highlightedPlaceholders) {
+ if (!highlightedPlaceholders || Object.keys(highlightedPlaceholders).length === 0) { return; }
+
+ for (var locationString in highlightedPlaceholders) {
+ var div = highlightedPlaceholders[locationString];
+ div.classList.remove('blockDivMovingAbleBorder');
+ var location = JSON.parse(locationString);
+ if (location.row !== 0 && location.row !== 6) {
+ // realityEditor.gui.moveabilityCorners.wrapDivWithCorners(div, 0, true);
+ realityEditor.gui.moveabilityCorners.wrapDivWithCorners(div, 0, true, {opacity: 0.5});
+ }
+
+ delete highlightedPlaceholders[locationString];
+ }
+};
+
+realityEditor.gui.crafting.eventHelper.styleBlockAsPlaced = function(contents, isPlaced) {
+ var domElement = this.getDomElementForBlock(contents.block);
+ if (isPlaced) {
+ // realityEditor.gui.moveabilityCorners.wrapDivInOutline(domElement, 5, true);
+ console.log('really add outline here...');
+ realityEditor.gui.moveabilityCorners.wrapDivInOutline(domElement, 8, true, null, -4, 3);
+
+ this.removeStyleFromPlaceholderDivs(this.highlightedPlaceholders);
+
+ } else {
+ realityEditor.gui.moveabilityCorners.removeOutlineFromDiv(domElement);
+ console.log('also remove outline here.');
+ }
+};
+
+realityEditor.gui.crafting.eventHelper.styleBlockForHolding = function(contents, startHold) {
+ var domElement = this.getDomElementForBlock(contents.block);
+ if (!domElement) return;
+ if (startHold) {
+ domElement.setAttribute('class','blockDivHighlighted');
+ domElement.firstChild.lastChild.setAttribute('class','blockMoveDiv blockDivMovingAble');
+ console.log('remove outline (if there is one)');
+ realityEditor.gui.moveabilityCorners.removeOutlineFromDiv(domElement);
+ realityEditor.gui.moveabilityCorners.wrapDivWithCorners(domElement, 8, true, null, -4);
+ this.styleBlockAsPlaced(contents, false);
+ } else {
+ domElement.setAttribute('class','blockDivPlaced');
+ domElement.firstChild.lastChild.setAttribute('class','blockMoveDiv');
+ realityEditor.gui.moveabilityCorners.removeCornersFromDiv(domElement);
+ realityEditor.gui.moveabilityCorners.wrapDivInOutline(domElement, 8, true, null, -4, 3);
+ // TODO: add outline here?
+ console.log('add outline for placement');
+ }
+ this.stylePlaceholder(contents, startHold); // placeholder behind this cell lights up when you pick it up to show you can place it back
+};
+
+realityEditor.gui.crafting.eventHelper.styleBlockForPlacement = function(contents, shouldHighlight) {
+ var domElement = this.getDomElementForBlock(contents.block);
+ if (!domElement) return;
+ if (shouldHighlight) {
+ domElement.setAttribute('class','blockDivHighlighted');
+ domElement.firstChild.lastChild.setAttribute('class','blockMoveDiv blockDivMovingAble');
+ // realityEditor.gui.moveabilityCorners.wrapDivWithCorners(domElement, 5, true);
+ } else {
+ domElement.setAttribute('class','blockDivHighlighted');
+ domElement.firstChild.lastChild.setAttribute('class','blockMoveDiv blockDivMovingUnable');
+ // realityEditor.gui.moveabilityCorners.wrapDivWithCorners(domElement, 5, true);
+ }
+};
+
+realityEditor.gui.crafting.eventHelper.updateCraftingBackgroundVisibility = function(event, cell, contents) {
+
+ if (event === "down" || event === "move") {
+
+ var currentBlock = cell.blockAtThisLocation();
+ if (currentBlock && currentBlock.isPortBlock) {
+ this.toggleDatacraftingExceptPort(contents, false);
+ } else {
+ this.toggleDatacraftingExceptPort(contents, true);
+ }
+
+ } else if (event === "up") {
+
+ this.toggleDatacraftingExceptPort(contents, true);
+
+ }
+
+};
+
+realityEditor.gui.crafting.eventHelper.visibilityCounter = null;
+realityEditor.gui.crafting.eventHelper.toggleDatacraftingExceptPort = function(tappedContents, shouldShow) {
+ var _this = this;
+ if (shouldShow !== globalStates.currentLogic.guiState.isCraftingBackgroundShown) {
+ console.log("show datacrafting background? " + shouldShow);
+
+ var craftingBoard = document.getElementById("craftingBoard");
+ var datacraftingCanvas = document.getElementById("datacraftingCanvas");
+ var blockPlaceholders = document.getElementById("blockPlaceholders");
+ var blocks = document.getElementById("blocks");
+
+ if (shouldShow) {
+
+ if(this.visibilityCounter){
+ clearTimeout( this.visibilityCounter);
+ craftingBoard.className = "craftingBoardBlur";
+ this.visibilityCounter = null;
+ // force the dom to re-render
+ datacraftingCanvas.style.display = "inline";
+
+ // animate opacity from 0 -> 1
+ blockPlaceholders.childNodes.forEach( function(blockPlaceholderRow, rowIndex) {
+ if (!(rowIndex === 0 || rowIndex === 6)) {
+ blockPlaceholderRow.setAttribute("class", "blockPlaceholderRow visibleFadeIn");
+ }
+ });
+ blocks.childNodes.forEach( function(blockDom) {
+ blockDom.setAttribute("class", "blockDivPlaced visibleFadeIn");
+ });
+ }
+
+ } else {
+
+ var tappedBlock;
+ if (tappedContents) {
+ tappedBlock = tappedContents.block;
+ }
+
+ this.visibilityCounter = setTimeout( function(){
+ craftingBoard.className = "craftingBoardClear";
+ datacraftingCanvas.style.display = "none";
+
+ // animate opacity from 1 -> 0
+ blockPlaceholders.childNodes.forEach( function(blockPlaceholderRow, rowIndex) {
+ if (!(rowIndex === 0 || rowIndex === 6)) {
+ blockPlaceholderRow.setAttribute("class", "blockPlaceholderRow invisibleFadeOut");
+ }
+ });
+
+ blocks.childNodes.forEach( function(blockDom) {
+ var block = realityEditor.gui.crafting.getBlockForDom(blockDom);
+ var isTappedBlock = tappedBlock && _this.areBlocksEqual(tappedBlock, block);
+ if (!(block.y === 0 || block.y === 3 || isTappedBlock)) {
+ blockDom.setAttribute("class", "blockDivPlaced invisibleFadeOut");
+ }
+ });
+ }, globalStates.craftingMoveDelay * 2); // takes twice as long to unblur background as it does to pick up a block
+ }
+
+ globalStates.currentLogic.guiState.isCraftingBackgroundShown = shouldShow;
+ }
+};
+
+// todo why is isInOutBlock in grid by isPortBlock in here?
+/**
+ * Ensures that the "in" and "out" blocks don't get uploaded to server, they are just for connecting the board to other nodes
+ * @param {Block} block
+ * @return {boolean}
+ */
+realityEditor.gui.crafting.eventHelper.shouldUploadBlock = function(block) {
+ return !this.crafting.grid.isInOutBlock(block.globalId);// && !block.isPortBlock; //&& !(block.x === -1 || block.y === -1)
+};
+
+//realityEditor.gui.crafting.eventHelper.shouldUploadBlockLink = function(blockLink) {
+// if (!blockLink) return false;
+// //return (!this.crafting.grid.isEdgePlaceholderLink(blockLink)); // add links to surrounding instead of uploading itself
+//};
+
+/**
+ * Returns all identifiers necessary to make an API request for the provided logic object
+ * @param logic - logic object
+ * @param block - optional param, if included it includes the block key in the return value
+ * @returns {ip, objectKey, frameKey, logicKey, (optional) blockKey}
+ */
+realityEditor.gui.crafting.eventHelper.getServerObjectLogicKeys = function(logic, block) {
+
+ for (var objectKey in objects) {
+ if (!objects.hasOwnProperty(objectKey)) continue;
+ for (var frameKey in objects[objectKey].frames) {
+ if (!objects[objectKey].frames.hasOwnProperty(frameKey)) continue;
+ if (objects[objectKey].frames[frameKey].nodes.hasOwnProperty(logic.uuid)) {
+ var keys = {
+ ip: objects[objectKey].ip,
+ port: realityEditor.network.getPort(objects[objectKey]),
+ objectKey: objectKey,
+ frameKey: frameKey,
+ logicKey: logic.uuid
+ };
+ if (block) {
+ for (var blockKey in logic.blocks){
+ if(logic.blocks[blockKey] === block) { // TODO: give each block an id property to avoid search
+ keys.blockKey = blockKey;
+ }
+ }
+ }
+ return keys;
+ }
+ }
+ }
+ return null;
+};
+
+realityEditor.gui.crafting.eventHelper.placeBlockInCell = function(contents, cell) {
+ var grid = globalStates.currentLogic.grid;
+ if (cell) {
+ var prevCell = this.crafting.grid.getCellForBlock(grid, contents.block, contents.item);
+ var newCellsOver = grid.getCellsOver(cell, contents.block.blockSize, contents.item);
+
+ this.styleBlockAsPlaced(contents, true);
+
+ // remove corners/outlines from placeholders underneath newCellsOver, if needed
+ newCellsOver.forEach(function(cell) {
+ var placeholderDiv = realityEditor.gui.crafting.eventHelper.getCellPlaceholderDiv(cell);
+ realityEditor.gui.moveabilityCorners.removeOutlineFromDiv(placeholderDiv);
+ realityEditor.gui.moveabilityCorners.removeCornersFromDiv(placeholderDiv);
+ });
+
+ // if it's being moved to the top or bottom rows, delete the invisible port block underneath
+ // this also saves the links connected to those port blocks so we can add them to the new block
+ var portLinkData = this.removePortBlocksIfNecessary(newCellsOver);
+
+ contents.block.x = Math.floor((cell.location.col / 2) - (contents.item));
+ contents.block.y = (cell.location.row / 2);
+ contents.block.isTempBlock = false;
+
+ if (this.shouldUploadBlock(contents.block)) {
+ var keys = this.getServerObjectLogicKeys(globalStates.currentLogic);
+ this.realityEditor.network.postNewBlockPosition(keys.ip, keys.objectKey, keys.frameKey, keys.logicKey, contents.block.globalId, {x: contents.block.x, y: contents.block.y});
+ }
+
+ // if (realityEditor.gui.crafting.eventHelper.areCellsEqual(cell, prevCell)) {
+ this.crafting.removeBlockDom(contents.block); // remove do so it can be re-rendered in the correct place
+ // }
+
+ var _this = this;
+ portLinkData.forEach( function(linkData) {
+
+ var nodeA = _this.crafting.grid.blockWithID(linkData.nodeA, globalStates.currentLogic);
+ var nodeB = _this.crafting.grid.blockWithID(linkData.nodeB, globalStates.currentLogic);
+
+ // if we deleted a link from the top row, add it to this block if possible
+ if (nodeB && !nodeA) {
+ if (contents.block.activeOutputs[linkData.logicA] === true) {
+ _this.crafting.grid.addBlockLink(contents.block.globalId, linkData.nodeB, linkData.logicA, linkData.logicB, true);
+ }
+ // if we deleted a link to the bottom row, add it to this block if possible
+ } else if (nodeA && !nodeB) {
+ if (contents.block.activeInputs[linkData.logicB] === true) {
+ _this.crafting.grid.addBlockLink(linkData.nodeA, contents.block.globalId, linkData.logicA, linkData.logicB, true);
+ }
+ }
+ });
+
+ if (contents.block.y === 0 || contents.block.y === 3) {
+ this.crafting.grid.updateInOutLinks(contents.block.globalId);
+ }
+
+ // if it's being moved away from the top or bottom rows, re-add the invisible port block underneath
+ if (prevCell) {
+ var prevCellsOver = grid.getCellsOver(prevCell, contents.block.blockSize, contents.item);
+ this.replacePortBlocksIfNecessary(prevCellsOver);
+ }
+
+ this.convertTempLinkOutlinesToLinks(contents);
+
+ contents = null;
+
+ } else {
+ this.removeTappedContents(contents);
+ }
+ this.crafting.updateGrid(globalStates.currentLogic.grid);
+};
+
+realityEditor.gui.crafting.eventHelper.removePortBlocksIfNecessary = function(cells) {
+ var portLinkData = [];
+ var _this = this;
+ cells.forEach( function(cell, i) {
+ if (cell) {
+ var existingBlock = cell.blockAtThisLocation();
+ if (existingBlock && existingBlock.isPortBlock) {
+ if (_this.isInputBlock(existingBlock)) {
+ var outgoingLinks = _this.getOutgoingLinks(existingBlock);
+ outgoingLinks.forEach(function(link) {
+ portLinkData.push({
+ nodeA: null,
+ nodeB: link.nodeB,
+ logicA: i,
+ logicB: link.logicB
+ });
+ });
+ } else if (_this.isOutputBlock(existingBlock)) {
+ var incomingLinks = _this.getIncomingLinks(existingBlock);
+ incomingLinks.forEach(function(link) {
+ portLinkData.push({
+ nodeA: link.nodeA,
+ nodeB: null,
+ logicA: link.logicA,
+ logicB: i
+ });
+ });
+ }
+ _this.crafting.grid.removeBlock(globalStates.currentLogic, existingBlock.globalId);
+ }
+ }
+ });
+ return portLinkData;
+};
+
+// todo hasOwnProperty
+realityEditor.gui.crafting.eventHelper.getOutgoingLinks = function(block) {
+ var outgoingLinks = [];
+ for (var linkKey in globalStates.currentLogic.links) {
+ var link = globalStates.currentLogic.links[linkKey];
+ if (link.nodeA === block.globalId) {
+ outgoingLinks.push(link);
+ }
+ }
+ return outgoingLinks;
+};
+
+// todo hasOwnProperty
+realityEditor.gui.crafting.eventHelper.getIncomingLinks = function(block) {
+ var incomingLinks = [];
+ for (var linkKey in globalStates.currentLogic.links) {
+ var link = globalStates.currentLogic.links[linkKey];
+ if (link.nodeB === block.globalId) {
+ incomingLinks.push(link);
+ }
+ }
+ return incomingLinks;
+};
+
+realityEditor.gui.crafting.eventHelper.replacePortBlocksIfNecessary = function(cells) {
+ var _this = this;
+ cells.forEach( function(cell) {
+ if (cell && !cell.blockAtThisLocation()) {
+ if (cell.location.row === 0 || cell.location.row === globalStates.currentLogic.grid.size-1) {
+ var width = 1;
+ var privateData = {};
+ var publicData = {};
+ var activeInputs = (cell.location.row === 0) ? [false, false, false, false] : [true, false, false, false];
+ var activeOutputs = (cell.location.row === 0) ? [true, false, false, false] : [false, false, false, false];
+ var nameInput = ["","","",""];
+ var nameOutput = ["","","",""];
+ var blockPos = _this.crafting.grid.convertGridPosToBlockPos(cell.location.col, cell.location.row);
+ var inOrOut = blockPos.y === 0 ? "In" : "Out";
+ var type = "default";
+ var name = "edgePlaceholder" + inOrOut + blockPos.x;
+ var globalId = name;
+ var blockJSON = _this.crafting.utilities.toBlockJSON(type, name, width, privateData, publicData, activeInputs, activeOutputs, nameInput, nameOutput);
+ _this.crafting.grid.addBlock(blockPos.x, blockPos.y, blockJSON, globalId, true);
+ }
+ }
+ });
+};
+
+// todo hasOwnProperty
+realityEditor.gui.crafting.eventHelper.updateTempLinkOutlinesForBlock = function(contents) {
+ for (var linkKey in globalStates.currentLogic.links) {
+ var link = globalStates.currentLogic.links[linkKey];
+ if (link.nodeB === contents.block.globalId) {
+ globalStates.currentLogic.guiState.tempIncomingLinks.push({
+ nodeA: link.nodeA,
+ logicA: link.logicA,
+ logicB: link.logicB
+ });
+
+ } else if (link.nodeA === contents.block.globalId) {
+ globalStates.currentLogic.guiState.tempOutgoingLinks.push({
+ logicA: link.logicA,
+ nodeB: link.nodeB,
+ logicB: link.logicB
+ });
+ }
+ }
+
+ this.crafting.grid.removeLinksForBlock(globalStates.currentLogic, contents.block.globalId);
+};
+
+realityEditor.gui.crafting.eventHelper.convertTempLinkOutlinesToLinks = function(contents) {
+ var _this = this;
+ globalStates.currentLogic.guiState.tempIncomingLinks.forEach( function(linkData) {
+ if (_this.blocksExist(linkData.nodeA, contents.block.globalId)) {
+
+ if (!_this.crafting.grid.isInOutBlock(linkData.nodeA)) {
+ // add regular link back
+ _this.crafting.grid.addBlockLink(linkData.nodeA, contents.block.globalId, linkData.logicA, linkData.logicB, true);
+
+ } else {
+
+ // create separate links from in->edge and edge->block
+ var x = linkData.nodeA.slice(-1);
+ var placeholderBlockExists = !!(globalStates.currentLogic.blocks[_this.edgePlaceholderName(true, x)]);
+ if (placeholderBlockExists) {
+ _this.crafting.grid.addBlockLink(linkData.nodeA, _this.edgePlaceholderName(true, x), linkData.logicA, linkData.logicB, true);
+ _this.crafting.grid.addBlockLink(_this.edgePlaceholderName(true, x), contents.block.globalId, linkData.logicA, linkData.logicB, true);
+ }
+
+ }
+
+ }
+ });
+
+ globalStates.currentLogic.guiState.tempOutgoingLinks.forEach( function(linkData) {
+ if (_this.blocksExist(linkData.nodeB, contents.block.globalId)) {
+
+ if (!_this.crafting.grid.isInOutBlock(linkData.nodeB)) {
+ // add regular link back
+ _this.crafting.grid.addBlockLink(contents.block.globalId, linkData.nodeB, linkData.logicA, linkData.logicB, true);
+
+ } else {
+ // create separate links from block->edge and edge->out
+ var x = linkData.nodeB.slice(-1);
+ var placeholderBlockExists = !!(globalStates.currentLogic.blocks[_this.edgePlaceholderName(false, x)]);
+ if (placeholderBlockExists) {
+ _this.crafting.grid.addBlockLink(contents.block.globalId, _this.edgePlaceholderName(false, x), linkData.logicA, linkData.logicB, true);
+ _this.crafting.grid.addBlockLink(_this.edgePlaceholderName(false, x), linkData.nodeB, linkData.logicA, linkData.logicB, true);
+ }
+ }
+ }
+ });
+
+ this.resetTempLinkOutlines();
+};
+
+realityEditor.gui.crafting.eventHelper.edgePlaceholderName = function(isInBlock, x) {
+ return isInBlock ? "edgePlaceholderIn" + x : "edgePlaceholderOut" + x;
+};
+
+realityEditor.gui.crafting.eventHelper.blocksExist = function(block1ID, block2ID) {
+ var blocks = globalStates.currentLogic.blocks;
+ return !!(blocks[block1ID]) && !!(blocks[block2ID]);
+};
+
+realityEditor.gui.crafting.eventHelper.resetTempLinkOutlines = function() {
+ globalStates.currentLogic.guiState.tempIncomingLinks = [];
+ globalStates.currentLogic.guiState.tempOutgoingLinks = [];
+};
+
+realityEditor.gui.crafting.eventHelper.removeTappedContents = function(contents) {
+ var grid = globalStates.currentLogic.grid;
+ this.resetTempLinkOutlines();
+ this.crafting.grid.removeBlock(globalStates.currentLogic, contents.block.globalId);
+
+ // replace port blocks if necessary
+ var prevCell = this.crafting.grid.getCellForBlock(grid, contents.block, contents.item);
+ if (prevCell) {
+ var prevCellsOver = grid.getCellsOver(prevCell, contents.block.blockSize, contents.item);
+ this.replacePortBlocksIfNecessary(prevCellsOver);
+ }
+
+ contents = null;
+ this.crafting.updateGrid(globalStates.currentLogic.grid);
+};
+
+realityEditor.gui.crafting.eventHelper.createTempLink = function(contents1, contents2) {
+ var newTempLink = this.crafting.grid.addBlockLink(contents1.block.globalId, contents2.block.globalId, contents1.item, contents2.item, false);
+ this.crafting.grid.setTempLink(newTempLink);
+ this.crafting.updateGrid(globalStates.currentLogic.grid);
+};
+
+realityEditor.gui.crafting.eventHelper.resetTempLink = function() {
+ this.crafting.grid.setTempLink(null);
+ this.crafting.updateGrid(globalStates.currentLogic.grid);
+};
+
+realityEditor.gui.crafting.eventHelper.drawLinkLine = function(contents, endX, endY) {
+ var grid = globalStates.currentLogic.grid;
+ var tempLine = globalStates.currentLogic.guiState.tempLine;
+ // actual drawing happens in index.js loop, we just need to set endpoint here
+ var startX = grid.getCellCenterX(contents.cell);
+ var startY = grid.getCellCenterY(contents.cell);
+ var hsl = contents.cell.getColorHSL();
+ var lineColor = 'hsl(' + hsl.h + ', '+ hsl.s +'%,'+ hsl.l +'%)';
+ tempLine.start = {
+ x: startX,
+ y: startY
+ };
+ tempLine.end = {
+ x: endX,
+ y: endY
+ };
+ tempLine.color = lineColor;
+};
+
+realityEditor.gui.crafting.eventHelper.resetLinkLine = function() {
+ var tempLine = globalStates.currentLogic.guiState.tempLine;
+ tempLine.start = null;
+ tempLine.end = null;
+ tempLine.color = null;
+};
+
+realityEditor.gui.crafting.eventHelper.drawCutLine = function(start, end) {
+ var cutLine = globalStates.currentLogic.guiState.cutLine;
+ // actual drawing happens in index.js loop, we just need to set endpoint here
+ cutLine.start = start;
+ cutLine.end = end;
+};
+
+realityEditor.gui.crafting.eventHelper.resetCutLine = function() {
+ var cutLine = globalStates.currentLogic.guiState.cutLine;
+ cutLine.start = null;
+ cutLine.end = null;
+};
+
+realityEditor.gui.crafting.eventHelper.createLink = function(contents1, contents2, tempLink) {
+ var addedLink = this.crafting.grid.addBlockLink(contents1.block.globalId, contents2.block.globalId, contents1.item, contents2.item, true);
+ if (addedLink && tempLink) {
+ addedLink.route = tempLink.route; // copy over the route rather than recalculating everything
+ addedLink.ballAnimationCount = tempLink.ballAnimationCount;
+ }
+};
+
+// todo hasOwnProperty
+realityEditor.gui.crafting.eventHelper.cutIntersectingLinks = function() {
+ var cutLine = globalStates.currentLogic.guiState.cutLine;
+ if (!cutLine || !cutLine.start || !cutLine.end) return;
+ var didRemoveAnyLinks = false;
+ for (var linkKey in globalStates.currentLogic.links) {
+ var didIntersect = false;
+ var blockLink = globalStates.currentLogic.links[linkKey];
+ var points = globalStates.currentLogic.grid.getPointsForLink(blockLink);
+ for (var j = 1; j < points.length; j++) {
+ var start = points[j - 1];
+ var end = points[j];
+ if (this.gui.utilities.checkLineCross(start.screenX, start.screenY, end.screenX, end.screenY, cutLine.start.x, cutLine.start.y, cutLine.end.x, cutLine.end.y)) {
+ didIntersect = true;
+ }
+ if (didIntersect) {
+ this.crafting.grid.removeBlockLink(linkKey);
+ didRemoveAnyLinks = true;
+ }
+ }
+ }
+ if (didRemoveAnyLinks) {
+ this.crafting.updateGrid(globalStates.currentLogic.grid);
+ }
+};
+
+realityEditor.gui.crafting.eventHelper.getDomElementForBlock = function(block) {
+ if (block.isPortBlock) return;
+ return globalStates.currentLogic.guiState.blockDomElements[block.globalId];
+};
+
+realityEditor.gui.crafting.eventHelper.generateBlockGlobalId = function() {
+ return "block" + this.realityEditor.device.utilities.uuidTime();
+};
+
+realityEditor.gui.crafting.eventHelper.isInputBlock = function(block) {
+ return block.isPortBlock && block.y === 0;
+};
+
+realityEditor.gui.crafting.eventHelper.isOutputBlock = function(block) {
+ return block.isPortBlock && !this.isInputBlock(block);
+};
+
+realityEditor.gui.crafting.eventHelper.addBlockFromMenu = function(blockJSON, pointerX, pointerY) {
+ var globalId = this.generateBlockGlobalId();
+ var addedBlock = this.crafting.grid.addBlock(-1, -1, blockJSON, globalId); // TODO: only upload after you've placed it
+ this.crafting.addDomElementForBlock(addedBlock, globalStates.currentLogic.grid, true);
+
+ globalStates.currentLogic.guiState.tappedContents = {
+ block: addedBlock,
+ item: 0,
+ cell: null
+ };
+
+ this.crafting.eventHandlers.onPointerMove({
+ pageX: pointerX,
+ pageY: pointerY
+ }, true);
+};
+
+//temporarily hide all other datacrafting divs. redisplay them when menu hides
+realityEditor.gui.crafting.eventHelper.changeDatacraftingDisplayForMenu = function(newDisplay) {
+ document.getElementById("datacraftingCanvas").style.display = newDisplay;
+ document.getElementById("blockPlaceholders").style.display = newDisplay;
+ document.getElementById("blocks").style.display = newDisplay;
+ document.getElementById("datacraftingEventDiv").style.display = newDisplay;
+
+ if (newDisplay === 'none') {
+ document.getElementById("craftingMenusContainer").style.display = '';
+ } else {
+ document.getElementById("craftingMenusContainer").style.display = 'none';
+ }
+};
+
+realityEditor.gui.crafting.eventHelper.areAnyMenusOpen = function() {
+ return document.getElementById('craftingMenusContainer').style.display !== 'none';
+};
+
+realityEditor.gui.crafting.eventHelper.openBlockSettings = function(block) {
+
+ this.changeDatacraftingDisplayForMenu('none');
+
+ var keys = this.getServerObjectLogicKeys(globalStates.currentLogic, block);
+ var settingsUrl = realityEditor.network.getURL(keys.ip,keys.port, '/logicBlock/' + block.type + "/index.html");
+ var craftingMenusContainer = document.getElementById('craftingMenusContainer');
+ var blockSettingsContainer = document.createElement('iframe');
+ blockSettingsContainer.setAttribute('id', 'blockSettingsContainer');
+ blockSettingsContainer.setAttribute('class', 'settingsContainer');
+
+ // center on iPad
+ blockSettingsContainer.classList.add('centerVerticallyAndHorizontally');
+ // var scaleMultiplier = Math.max(globalStates.currentLogic.grid.containerHeight / globalStates.currentLogic.grid.gridHeight, globalStates.currentLogic.grid.containerWidth / globalStates.currentLogic.grid.gridWidth);
+ // blockSettingsContainer.style.transform = 'scale(' + scaleMultiplier + ')';
+ blockSettingsContainer.style.left = 0;
+
+ blockSettingsContainer.setAttribute("onload", "realityEditor.gui.crafting.eventHandlers.onLoadBlock('" + keys.objectKey + "','" + keys.frameKey + "','" + keys.logicKey + "','" + keys.blockKey + "','" + JSON.stringify(block.publicData) + "')");
+ blockSettingsContainer.src = settingsUrl;
+
+ craftingMenusContainer.appendChild(blockSettingsContainer);
+
+ realityEditor.gui.menus.buttonOn(["logicSetting"]);
+};
+
+realityEditor.gui.crafting.eventHelper.hideBlockSettings = function() {
+
+ var wasBlockSettingsOpen = false;
+ var container = document.getElementById('blockSettingsContainer');
+ if (container) {
+
+ this.changeDatacraftingDisplayForMenu('');
+
+ container.parentNode.removeChild(container);
+ wasBlockSettingsOpen = true;
+ }
+ return wasBlockSettingsOpen;
+};
+
+realityEditor.gui.crafting.eventHelper.openNodeSettings = function() {
+ if (document.getElementById('menuContainer') && document.getElementById('menuContainer').style.display !== "none") {
+ return;
+ }
+
+ var logic = globalStates.currentLogic;
+
+ // 1. temporarily hide all other datacrafting divs. redisplay them when menu hides
+ this.changeDatacraftingDisplayForMenu('none');
+
+ // 2. create and display the settings container
+
+ var nodeSettingsContainer = document.createElement('iframe');
+ nodeSettingsContainer.setAttribute('id', 'nodeSettingsContainer');
+ nodeSettingsContainer.setAttribute('class', 'settingsContainer');
+
+ nodeSettingsContainer.classList.add('centerVerticallyAndHorizontally');
+
+ // center on iPads
+ // nodeSettingsContainer.style.marginLeft = logic.grid.xMargin + 'px';
+ // nodeSettingsContainer.style.marginTop = logic.grid.yMargin + 'px';
+
+ // nodeSettingsContainer.style.width = globalStates.currentLogic.grid.gridWidth + 'px';
+ // nodeSettingsContainer.style.height = globalStates.currentLogic.grid.gridHeight + 'px';
+
+ // var scaleMultiplier = Math.max(logic.grid.containerHeight / logic.grid.gridHeight, logic.grid.containerWidth / logic.grid.gridWidth);
+ // nodeSettingsContainer.style.transform = 'scale(' + scaleMultiplier + ')';
+
+ nodeSettingsContainer.style.left = 0;
+
+ // nodeSettingsContainer.setAttribute("onload", "realityEditor.gui.crafting.eventHandlers.onLoadBlock('" + keys.objectKey + "','" + keys.frameKey + "','" + keys.logicKey + "','" + keys.blockKey + "','" + JSON.stringify(block.publicData) + "')");
+ nodeSettingsContainer.src = 'src/gui/crafting/nodeSettings.html';
+
+ nodeSettingsContainer.onload = function() {
+
+ var keys = realityEditor.gui.crafting.eventHelper.getServerObjectLogicKeys(logic);
+
+ var logicNodeData = {
+
+ version: realityEditor.getObject(keys.objectKey).integerVersion,
+ ip: keys.ip,
+ httpPort: keys.port,
+ port:keys.port,
+
+ objectKey: keys.objectKey,
+ frameKey: keys.frameKey,
+ nodeKey: keys.logicKey,
+
+ objectName: realityEditor.getObject(keys.objectKey).name,
+ logicName: logic.name,
+
+ iconImageState: logic.iconImage,
+ autoImagePath: realityEditor.gui.crafting.getSrcForAutoIcon(logic)
+ };
+
+ nodeSettingsContainer.contentWindow.postMessage(JSON.stringify(logicNodeData), '*');
+
+ };
+
+ var craftingMenusContainer = document.getElementById('craftingMenusContainer');
+ craftingMenusContainer.appendChild(nodeSettingsContainer);
+
+ realityEditor.gui.menus.switchToMenu("crafting", ["logicSetting"], null);
+};
+
+realityEditor.gui.crafting.eventHelper.hideNodeSettings = function() {
+ var wasBlockSettingsOpen = false;
+ var container = document.getElementById('nodeSettingsContainer');
+ if (container) {
+ container.parentNode.removeChild(container);
+ //temporarily hide all other datacrafting divs. redisplay them when menu hides
+ this.changeDatacraftingDisplayForMenu('');
+
+ wasBlockSettingsOpen = true;
+ }
+ return wasBlockSettingsOpen;
+};
diff --git a/src/gui/crafting/grid.js b/src/gui/crafting/grid.js
new file mode 100644
index 000000000..4f3b9db37
--- /dev/null
+++ b/src/gui/crafting/grid.js
@@ -0,0 +1,1224 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2016 Benjamin Reynholds
+ * Modified by Valentin Heun 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.gui.crafting.grid");
+
+(function(exports) {
+
+///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+// Data Structures - Constructors
+///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+// the grid is the overall data structure for managing block locations and calculating routes between them
+ function Grid(containerWidth, containerHeight, gridWidth, gridHeight, logicID) {
+
+ this.size = 7; // number of rows and columns
+
+ // TODO: these four properties are almost never used, except for sizing the settings and block menu - decide if necessary
+ this.containerWidth = containerWidth;
+ this.containerHeight = containerHeight;
+ this.gridWidth = gridWidth;
+ this.gridHeight = gridHeight;
+
+ this.xMargin = (containerWidth - gridWidth) / 2;
+ this.yMargin = (containerHeight - gridHeight) / 2;
+
+ this.blockColWidth = 2 * (gridWidth / 11);
+ this.blockRowHeight = gridHeight / 5.2; //(gridHeight / 7);
+ this.marginColWidth = (gridWidth / 11);
+ this.marginRowHeight = gridHeight / (5.2/0.4); //this.blockRowHeight;
+
+ this.cells = []; // array of [Cell] objects
+
+ // initialize list of cells using the size of the grid
+ for (var row = 0; row < this.size; row++) {
+ for (var col = 0; col < this.size; col++) {
+ var cellLocation = new CellLocation(col, row);
+ var cell = new Cell(cellLocation);
+ this.cells.push(cell);
+ }
+ }
+
+ this.logicID = logicID; // the Logic Node associated with this Grid
+ }
+
+// the cell has a location in the grid, possibly an associated Block object
+// and a list of which routes pass through the cell
+ function Cell(location) {
+ this.location = location; // CellLocation
+ this.routeSegments = []; // [RouteSegment]
+ }
+
+ function CellLocation(col,row) {
+ this.col = col;
+ this.row = row;
+ this.offsetX = 0;
+ this.offsetY = 0;
+ }
+
+// the route contains the corner points and the list of all cells it passes through
+ function Route(initialCellLocations) {
+ this.cellLocations = []; // [CellLocation]
+ this.allCells = []; // [Cell]
+
+ if (initialCellLocations !== undefined) {
+ var that = this;
+ initialCellLocations.forEach( function(location) {
+ that.addLocation(location.col,location.row);
+ });
+ }
+ this.pointData = null; // list of [{screenX, screenY}]
+ }
+
+// contains useful data for keeping track of how a route passes through a cell
+ function RouteSegment(route, containsHorizontal, containsVertical) {
+ this.route = route;
+ this.containsHorizontal = containsHorizontal;
+ this.containsVertical = containsVertical;
+ this.isStart = false;
+ this.isEnd = false;
+ }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+// Data Structures - Methods
+///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+// -- CELL METHODS -- //
+
+ Cell.prototype.canHaveBlock = function() {
+ return (this.location.col % 2 === 0) && (this.location.row % 2 === 0);
+ };
+
+ Cell.prototype.isMarginCell = function() {
+ return this.location.row % 2 === 0 && this.location.col % 2 === 1;
+ };
+
+// utility - gets the hue for cells in a given column
+ Cell.prototype.getColorHSL = function() {
+ var blockColumn = Math.floor(this.location.col / 2);
+ var colorMap = { blue: {h: 180, s:100, l:60}, green: {h: 122, s:100, l:60}, yellow: {h: 59, s:100, l:60}, red: {h:333, s:100, l:60} };
+ var colorName = ['blue','green','yellow','red'][blockColumn];
+ return colorMap[colorName];
+ };
+
+// utility - counts the number of horizontal routes in a cell
+ Cell.prototype.countHorizontalRoutes = function() {
+ return this.routeSegments.filter(function(value) { return value.containsHorizontal; }).length;
+ };
+
+// utility - counts the number of vertical routes in a cell
+// optionally excludes start or endpoints so that routes starting in a
+// block cell don't count as overlapping routes ending in a block cell
+ Cell.prototype.countVerticalRoutes = function(excludeStartPoints, excludeEndPoints) {
+ return this.routeSegments.filter(function(value) {
+ return value.containsVertical && !((value.isStart && excludeStartPoints) || (value.isEnd && excludeEndPoints));
+ }).length;
+ };
+
+// utility - checks whether the cell has a vertical route tracker for the given route
+ Cell.prototype.containsVerticalSegmentOfRoute = function(route) {
+ var containsVerticalSegment = false;
+ this.routeSegments.forEach( function(routeSegment) {
+ if (routeSegment.route === route && routeSegment.containsVertical) {
+ containsVerticalSegment = true;
+ }
+ });
+ return containsVerticalSegment;
+ };
+
+// utility - checks whether the cell has a horizontal route tracker for the given route
+ Cell.prototype.containsHorizontalSegmentOfRoute = function(route) {
+ var containsHorizontalSegment = false;
+ this.routeSegments.forEach( function(routeSegment) {
+ if (routeSegment.route === route && routeSegment.containsHorizontal) {
+ containsHorizontalSegment = true;
+ }
+ });
+ return containsHorizontalSegment;
+ };
+
+ Cell.prototype.blockAtThisLocation = function() {
+ if (this.isMarginCell()) {
+ var blockPosBefore = convertGridPosToBlockPos(this.location.col-1, this.location.row);
+ var blockPosAfter = convertGridPosToBlockPos(this.location.col+1, this.location.row);
+ var blockBefore = getBlockOverlappingPosition(blockPosBefore.x, blockPosBefore.y);
+ var blockAfter = getBlockOverlappingPosition(blockPosAfter.x, blockPosAfter.y);
+ if (blockBefore && blockAfter && realityEditor.gui.crafting.eventHelper.areBlocksEqual(blockBefore, blockAfter)) {
+ return blockBefore;
+ }
+ } else if (this.canHaveBlock()) {
+ var blockPos = convertGridPosToBlockPos(this.location.col, this.location.row);
+ return getBlockOverlappingPosition(blockPos.x, blockPos.y);
+ }
+ };
+
+ Cell.prototype.itemAtThisLocation = function() {
+ var block = this.blockAtThisLocation();
+ var blockGridPos = convertBlockPosToGridPos(block.x, block.y);
+ var itemCol = this.location.col - blockGridPos.col;
+ return convertGridPosToBlockPos(itemCol, blockGridPos.row).x;
+ };
+
+// -- ROUTE METHODS -- //
+
+// adds a new corner location to a route
+ Route.prototype.addLocation = function(col, row) {
+ var skip = false;
+ this.cellLocations.forEach(function(cellLocation) {
+ if (cellLocation.col === col && cellLocation.row === row) { // implicitly prevent duplicate points from being added
+ skip = true;
+ }
+ });
+ if (!skip) {
+ this.cellLocations.push(new CellLocation(col, row));
+ }
+ };
+
+// utility - outputs how far a route travels left/right and up/down, for
+// use in choosing the order of routes so that they usually don't cross
+ Route.prototype.getOrderPreferences = function() {
+ var lastCell = this.cellLocations[this.cellLocations.length-1];
+ var firstCell = this.cellLocations[0];
+ return {
+ horizontal: lastCell.col - firstCell.col,
+ vertical: lastCell.row - firstCell.row
+ };
+ };
+
+ Route.prototype.getXYPositionAtPercentage = function(percent) {
+ var pointData = this.pointData;
+ if (percent >= 0 && percent <= 1) {
+ var indexBefore = 0;
+ for (var i = 1; i < pointData.points.length; i++) {
+ var nextPercent = pointData.percentages[i];
+ if (nextPercent > percent) {
+ indexBefore = i-1;
+ break;
+ }
+ }
+
+ var x1 = pointData.points[indexBefore].screenX;
+ var y1 = pointData.points[indexBefore].screenY;
+ var x2 = pointData.points[indexBefore+1].screenX;
+ var y2 = pointData.points[indexBefore+1].screenY;
+
+ var percentOver = percent - pointData.percentages[indexBefore];
+ var alpha = percentOver / (pointData.percentages[indexBefore+1] - pointData.percentages[indexBefore]);
+ var x = (1 - alpha) * x1 + alpha * x2;
+ var y = (1 - alpha) * y1 + alpha * y2;
+
+ return {
+ screenX: x,
+ screenY: y
+ };
+
+ } else {
+ return null;
+ }
+ };
+
+// -- GRID METHODS -- //
+
+// -- GRID UTILITIES -- //
+
+ /**
+ * Performs a search through all objects and frames in the system to find a logic node that matches this grid's logicID
+ * @return {Logic|undefined}
+ */
+ Grid.prototype.parentLogic = function() {
+
+ for (var objectKey in objects) {
+ var object = objects[objectKey];
+ for (var frameKey in object.frames) {
+ var frame = object.frames[frameKey];
+ for (var logicKey in frame.nodes) {
+ if (frame.nodes[logicKey].type === "logic") {
+ if (frame.nodes[logicKey].uuid === this.logicID) {
+ return frame.nodes[logicKey];
+ }
+ }
+ }
+ }
+ }
+ console.warn("ERROR: DIDN'T FIND LOGIC NODE FOR THIS GRID");
+ };
+
+ /**
+ * Given a block link, gets the the actual points to draw on the screen to draw all of the line segments
+ * @param blockLink
+ * @return {Array.<{screenX: number, screenY: number}>} the x,y coordinates of corners for a link so that they can be rendered
+ */
+ Grid.prototype.getPointsForLink = function(blockLink) {
+ var points = [];
+ if (blockLink.route !== null) {
+ var that = this;
+ blockLink.route.cellLocations.forEach( function(location) {
+ var screenX = that.getColumnCenterX(location.col) + location.offsetX;
+ var screenY = that.getRowCenterY(location.row) + location.offsetY;
+ points.push({
+ "screenX": screenX,
+ "screenY": screenY
+ });
+ });
+
+ }
+ return points;
+ };
+
+// utility - calculates the total width and height of the grid using the sizes of the cells
+ Grid.prototype.getPixelDimensions = function() {
+ var width = Math.ceil(this.size/2) * this.blockColWidth + Math.floor(this.size/2) * this.marginColWidth;
+ var height = Math.ceil(this.size/2) * this.blockRowHeight + Math.floor(this.size/2) * this.marginRowHeight;
+ return {
+ "width": width,
+ "height": height
+ };
+ };
+
+// utility - gets a cell at a given grid location
+ Grid.prototype.getCell = function(col, row) {
+ if (row >= 0 && row < this.size && col >= 0 && col < this.size) {
+ return this.cells[row * this.size + col];
+ }
+ };
+
+// utility - gets width of cell, which differs for cols with blocks vs margins
+ Grid.prototype.getCellWidth = function(col) {
+ return (col % 2 === 0) ? this.blockColWidth : this.marginColWidth;
+ };
+
+// utility - gets height of cell, which differs for rows with blocks vs margins
+ Grid.prototype.getCellHeight = function(row) {
+ return (row % 2 === 0) ? this.blockRowHeight : this.marginRowHeight;
+ };
+
+// utility - gets x position of cell //TODO: update with grid margin
+ Grid.prototype.getCellCenterX = function(cell) {
+ var leftEdgeX = 0;
+ if (cell.location.col % 2 === 0) { // this is a block cell
+ leftEdgeX = (cell.location.col / 2) * (this.blockColWidth + this.marginColWidth);
+ return this.xMargin + leftEdgeX + this.blockColWidth/2;
+
+ } else { // this is a margin cell
+ leftEdgeX = Math.ceil(cell.location.col / 2) * this.blockColWidth + Math.floor(cell.location.col / 2) * this.marginColWidth;
+ return this.xMargin + leftEdgeX + this.marginColWidth/2;
+ }
+ };
+
+// utility - gets y position of cell //TODO: update with grid margin
+ Grid.prototype.getCellCenterY = function(cell) {
+ var topEdgeY = 0;
+ if (cell.location.row % 2 === 0) { // this is a block cell
+ topEdgeY = (cell.location.row / 2) * (this.blockRowHeight + this.marginRowHeight);
+ return this.yMargin + topEdgeY + this.blockRowHeight/2;
+
+ } else { // this is a margin cell
+ topEdgeY = Math.ceil(cell.location.row / 2) * this.blockRowHeight + Math.floor(cell.location.row / 2) * this.marginRowHeight;
+ return this.yMargin + topEdgeY + this.marginRowHeight/2;
+ }
+ };
+
+// utility - gets x position for a column
+ Grid.prototype.getColumnCenterX = function(col) {
+ return this.getCellCenterX(this.getCell(col,0));
+ };
+
+// utility - gets y position for a row
+ Grid.prototype.getRowCenterY = function(row) {
+ return this.getCellCenterY(this.getCell(0,row));
+ };
+
+// utility - true iff cells are in same row
+ Grid.prototype.areCellsHorizontal = function(cell1, cell2) {
+ if (cell1 && cell2) {
+ return cell1.location.row === cell2.location.row;
+ }
+ return false;
+ };
+
+// utility - true iff cells are in same column
+ Grid.prototype.areCellsVertical = function(cell1, cell2) {
+ if (cell1 && cell2) {
+ return cell1.location.col === cell2.location.col;
+ }
+ return false;
+ };
+
+// utility - if cells are in a line horizontally or vertically, returns all the cells in between them
+ Grid.prototype.getCellsBetween = function(cell1, cell2) {
+ var cellsBetween = [];
+ if (this.areCellsHorizontal(cell1, cell2)) {
+ var minCol = Math.min(cell1.location.col, cell2.location.col);
+ var maxCol = Math.max(cell1.location.col, cell2.location.col);
+ cellsBetween.push.apply(cellsBetween, this.cells.filter( function(cell) {
+ return cell.location.row === cell1.location.row && cell.location.col > minCol && cell.location.col < maxCol;
+ }));
+
+ } else if (this.areCellsVertical(cell1, cell2)) {
+ var minRow = Math.min(cell1.location.row, cell2.location.row);
+ var maxRow = Math.max(cell1.location.row, cell2.location.row);
+ cellsBetween.push.apply(cellsBetween, this.cells.filter( function(cell) {
+ return cell.location.col === cell1.location.col && cell.location.row > minRow && cell.location.row < maxRow;
+ }));
+ }
+ return cellsBetween;
+ };
+
+// utility - true iff a cell between the start and end actually contains a block
+ Grid.prototype.areBlocksBetween = function(startCell, endCell) {
+ var blocksBetween = this.getCellsBetween(startCell, endCell).filter( function(cell) {
+ return cell.blockAtThisLocation() !== undefined;
+ });
+ return blocksBetween.length > 0;
+ };
+
+// utility - looks vertically below a location until it finds a block, or null if none in that column
+ Grid.prototype.getFirstBlockBelow = function(col, row) {
+ for (var r = row+1; r < this.size; r++) {
+ var cell = this.getCell(col,r);
+ if (cell.blockAtThisLocation()) {
+ return cell.blockAtThisLocation();
+ }
+ }
+ return null;
+ };
+
+// utility - for a given cell in a route, looks at the previous and next cells in the route to
+// figure out if the cell contains a vertical path, horizontal path, or both (it's a corner)
+ Grid.prototype.getLineSegmentDirections = function(prevCell,currentCell,nextCell) {
+ var containsHorizontal = false;
+ var containsVertical = false;
+ if (this.areCellsHorizontal(currentCell, prevCell) ||
+ this.areCellsHorizontal(currentCell, nextCell)) {
+ containsHorizontal = true;
+ }
+
+ if (this.areCellsVertical(currentCell, prevCell) ||
+ this.areCellsVertical(currentCell, nextCell)) {
+ containsVertical = true;
+ }
+ return {
+ horizontal: containsHorizontal,
+ vertical: containsVertical
+ };
+ };
+
+// resets the number of "horizontal" or "vertical" segments contained to 0 for all cells
+ Grid.prototype.resetCellRouteCounts = function() {
+ this.cells.forEach(function(cell) {
+ cell.routeSegments = [];
+ });
+ };
+
+
+
+ Grid.prototype.getCellsOver = function (firstCell,blockWidth,itemSelected,includeMarginCells) {
+ var cells = [];
+ var increment = includeMarginCells ? 1 : 2;
+ for (var col = firstCell.location.col; col < firstCell.location.col + 2 * blockWidth - 1; col += increment) {
+ cells.push(this.getCell(col - (itemSelected * 2), firstCell.location.row))
+ }
+ return cells;
+ };
+
+ Grid.prototype.getCellFromPointerPosition = function(xCoord, yCoord) {
+ var col;
+ var row;
+
+ xCoord -= this.xMargin;
+ yCoord -= this.yMargin;
+
+ var colPairIndex = xCoord / (this.blockColWidth + this.marginColWidth);
+ var fraction = colPairIndex - Math.floor(colPairIndex);
+
+ if (fraction <= this.blockColWidth / (this.blockColWidth + this.marginColWidth)) {
+ col = Math.floor(colPairIndex) * 2;
+ } else {
+ col = Math.floor(colPairIndex) * 2 + 1;
+ }
+
+ var rowPairIndex = yCoord / (this.blockRowHeight + this.marginRowHeight);
+ fraction = rowPairIndex - Math.floor(rowPairIndex);
+
+ if (fraction <= this.blockRowHeight / (this.blockRowHeight + this.marginRowHeight)) {
+ row = Math.floor(rowPairIndex) * 2;
+ } else {
+ row = Math.floor(rowPairIndex) * 2 + 1;
+ }
+
+ return this.getCell(col, row);
+ };
+
+ Grid.prototype.forEachLink = function(action) {
+ var logic = this.parentLogic();
+ for (var linkKey in logic.links) {
+ if (!logic.links.hasOwnProperty(linkKey)) continue;
+ if (isInOutLink(logic.links[linkKey])) continue; // ignore in/out links for processing
+ action(logic.links[linkKey]);
+ }
+ if (logic.guiState.tempLink) {
+ action(logic.guiState.tempLink);
+ }
+ };
+
+ Grid.prototype.allLinks = function() {
+ var linksArray = [];
+ this.forEachLink(function(link) {
+ linksArray.push(link);
+ });
+ return linksArray;
+ };
+
+// -- GRID ROUTING ALGORITHM -- //
+
+// *** main method for routing ***
+// first, calculates the routes (which cells they go thru)
+// next, offsets each so that they don't visually overlap
+// lastly, prepares points so that they can be easily rendered
+ Grid.prototype.recalculateAllRoutes = function() {
+ console.log("reculculate all routes!");
+ var that = this;
+
+ that.resetCellRouteCounts();
+
+ this.forEachLink( function(link) {
+ that.calculateLinkRoute(link);
+ });
+ var overlaps = that.determineMaxOverlaps();
+ that.calculateOffsets(overlaps); // todo: still some minor bugs in the offset function
+
+ this.forEachLink( function(link) {
+ var points = that.getPointsForLink(link);
+ link.route.pointData = preprocessPointsForDrawing(points);
+ });
+ };
+
+// given a link, calculates all the corner points between the start block and end block,
+// and sets the route of the link to contain the corner points and all the cells between
+ Grid.prototype.calculateLinkRoute = function(link) {
+
+ var logic = this.parentLogic();
+
+ var nodeA = blockWithID(link.nodeA, logic);
+ var nodeB = blockWithID(link.nodeB, logic);
+
+ var startLocation = convertBlockPosToGridPos(nodeA.x + link.logicA, nodeA.y);
+ var endLocation = convertBlockPosToGridPos(nodeB.x + link.logicB, nodeB.y);
+ var route = new Route([startLocation]);
+
+ // by default lines loop around the right of blocks, except for last column or if destination is to left of start
+ var sideToApproachOn = 1; // to the right
+ if (endLocation.col < startLocation.col || startLocation.col === 6) {
+ sideToApproachOn = -1; // to the left
+ }
+
+ if (startLocation.row < endLocation.row) {
+ // simplifies edge case when block is directly below by skipping rest of points
+ var areBlocksBetweenInStartColumn = this.areBlocksBetween(this.getCell(startLocation.col, startLocation.row), this.getCell(startLocation.col, endLocation.row));
+
+ if (startLocation.col !== endLocation.col || areBlocksBetweenInStartColumn) {
+
+ // first point continues down vertically as far as it can go without hitting another block
+ var firstBlockBelow = this.getFirstBlockBelow(startLocation.col, startLocation.row);
+ var rowToDrawDownTo = endLocation.row-1;
+ if (firstBlockBelow) {
+ var firstBlockRowBelow = convertBlockPosToGridPos(firstBlockBelow.x, firstBlockBelow.y).row;
+ rowToDrawDownTo = Math.min(firstBlockRowBelow-1, rowToDrawDownTo);
+ }
+ route.addLocation(startLocation.col, rowToDrawDownTo);
+
+ if (rowToDrawDownTo < endLocation.row-1) {
+ // second point goes horizontally to the side of the start column
+ route.addLocation(startLocation.col+sideToApproachOn, rowToDrawDownTo);
+ // fourth point goes vertically to the side of the end column
+ route.addLocation(startLocation.col+sideToApproachOn, endLocation.row-1);
+ }
+
+ // fifth point goes horizontally until it is directly above center of end block
+ route.addLocation(endLocation.col, endLocation.row-1);
+ }
+
+ } else {
+
+ if (startLocation.row < this.size-1) { // first point is vertically below the start, except for bottom row
+ route.addLocation(startLocation.col, startLocation.row+1);
+ route.addLocation(startLocation.col + sideToApproachOn, startLocation.row+1);
+ } else { // start from side of bottom row
+ route.addLocation(startLocation.col + sideToApproachOn, startLocation.row);
+ }
+
+ // different things happen if destination is top row or not...
+ if (endLocation.row > 0) {
+ // if not top row, next point is above and to the side of the destination
+ route.addLocation(startLocation.col + sideToApproachOn, endLocation.row-1);
+ // last point is directly vertically above the end block
+ route.addLocation(endLocation.col, endLocation.row-1);
+
+ } else { // if it's going to the top row, approach from the side rather than above it
+
+ // if there's nothing blocking the line from getting to the side of the end block, last point goes there
+ var cellsBetween = this.getCellsBetween(this.getCell(startLocation.col, 0), this.getCell(endLocation.col, endLocation.row));
+ var blocksBetween = cellsBetween.filter(function(cell){
+ return cell.blockAtThisLocation() !== undefined;
+ });
+ if (blocksBetween.length === 0) {
+ route.addLocation(startLocation.col + sideToApproachOn, 0);
+
+ } else { // final exception! if there are blocks horizontally between start and end in top row, go under and up
+ // first extra point stops below top row in the column next to the start block, creating a vertical line
+ route.addLocation(startLocation.col + sideToApproachOn, 1);
+ // next extra point goes horizontally over to the column of the last block
+ route.addLocation(endLocation.col - sideToApproachOn, 1);
+ // final extra point goes vertically up to the direct side of the end block
+ route.addLocation(endLocation.col - sideToApproachOn, 0);
+ }
+ }
+ }
+
+ route.addLocation(endLocation.col, endLocation.row);
+ route.allCells = this.calculateAllCellsContainingRoute(route);
+ link.route = route;
+ };
+
+// Given the corner points for a route, finds all the cells in between, and labels each with
+// "horizontal", "vertical", or both depending on which way the route goes thru that cell
+ Grid.prototype.calculateAllCellsContainingRoute = function(route) {
+ var allCells = [];
+ for (var i=0; i < route.cellLocations.length; i++) {
+
+ var prevCell = null;
+ var currentCell = null;
+ var nextCell = null;
+
+ currentCell = this.getCell(route.cellLocations[i].col, route.cellLocations[i].row);
+ if (i > 0) {
+ prevCell = this.getCell(route.cellLocations[i-1].col, route.cellLocations[i-1].row);
+ }
+ if (i < route.cellLocations.length-1) {
+ nextCell = this.getCell(route.cellLocations[i+1].col, route.cellLocations[i+1].row);
+ }
+ var segmentDirections = this.getLineSegmentDirections(prevCell, currentCell, nextCell);
+
+ var routeSegment = new RouteSegment(route, segmentDirections.horizontal, segmentDirections.vertical); // corners have both vertical and horizontal. end point has only vertical //todo: except for top/bottom row?
+ if (prevCell === null) {
+ routeSegment.isStart = true;
+ }
+ if (nextCell === null) {
+ routeSegment.isEnd = true;
+ }
+ currentCell.routeSegments.push(routeSegment);
+ allCells.push(currentCell); // add endpoint cell for each segment
+
+ var cellsBetween = this.getCellsBetween(currentCell, nextCell);
+ var areNextHorizontal = this.areCellsHorizontal(currentCell, nextCell);
+ var areNextVertical = !areNextHorizontal; // mutually exclusive
+ cellsBetween.forEach( function(cell) {
+ var routeSegment = new RouteSegment(route, areNextHorizontal, areNextVertical);
+ cell.routeSegments.push(routeSegment);
+ });
+ allCells.push.apply(allCells, cellsBetween);
+ }
+ return allCells;
+ };
+
+// counts how many routes overlap eachother in each row and column, and sorts them, so that
+// they can be displaced around the center of the row/column and not overlap one another
+ Grid.prototype.determineMaxOverlaps = function() {
+
+ var logic = this.parentLogic();
+
+ var colRouteOverlaps = [];
+ var horizontallySortedLinks;
+ for (var c = 0; c < this.size; c++) {
+ var thisColRouteOverlaps = [];
+ // for each route in column
+ var that = this;
+
+ // decreases future overlaps of links in the grid by sorting them left/right
+ // so that links going to the left don't need to cross over links going to the right
+ horizontallySortedLinks = this.allLinks().sort(function(link1, link2){
+ var p1 = link1.route.getOrderPreferences();
+ var p2 = link2.route.getOrderPreferences();
+ var horizontalOrder = p1.horizontal - p2.horizontal;
+ var verticalOrder = p1.vertical - p2.vertical;
+
+ var block1A = blockWithID(link1.nodeA, logic);
+ var block1B = blockWithID(link1.nodeB, logic);
+ var block2A = blockWithID(link2.nodeA, logic);
+ var block2B = blockWithID(link2.nodeB, logic);
+
+ var startCellLocation1 = convertBlockPosToGridPos(block1A.x, block1A.y);
+ var endCellLocation1 = convertBlockPosToGridPos(block1B.x, block1B.y);
+
+ var startCellLocation2 = convertBlockPosToGridPos(block2A.x, block2A.y);
+ var endCellLocation2 = convertBlockPosToGridPos(block2B.x, block2B.y);
+
+ // special case if link stays in same column as the start block
+ var dCol1 = endCellLocation1.col - startCellLocation1.col;
+ var dCol2 = endCellLocation2.col - startCellLocation2.col;
+
+ if (p1.vertical >= 0 && p2.vertical >= 0) {
+ if (dCol1 === 0 && dCol2 === 0) { // in start col, bottom -> last
+ return verticalOrder;
+ }
+ if (dCol1 === 0 && dCol2 !== 0) { // lines to right of start col -> last, those to left -> first
+ return -1 * dCol2;
+ }
+ var diagonalOrder;
+ if (dCol1 > 0 && dCol2 > 0) { // to right of start col, topright diagonal bands -> last
+ diagonalOrder = horizontalOrder - verticalOrder;
+ if (diagonalOrder === 0) { // within same diagonal band, top -> last
+ return -1 * verticalOrder;
+ } else {
+ return diagonalOrder;
+ }
+ }
+ if (dCol1 < 0 && dCol2 < 0) { // to left of start col, bottomright diagonal bands -> last
+ diagonalOrder = horizontalOrder + verticalOrder;
+ if (diagonalOrder === 0) { // within same diagonal band, bottom -> last
+ return verticalOrder;
+ } else {
+ return diagonalOrder;
+ }
+ }
+ }
+
+ // by default, if it doesn't fit into one of those special cases, just sort by horizontal distance
+ return horizontalOrder;
+ //return 10 * (p1.horizontal - p2.horizontal) + 1 * (Math.abs(p2.vertical) - Math.abs(p1.vertical));
+ });
+
+ horizontallySortedLinks.forEach( function(link) {
+ // filter a list of cells containing that route and that column
+ var routeCellsInThisCol = link.route.allCells.filter(function(cell){return cell.location.col === c;});
+ if (routeCellsInThisCol.length > 0) { // does this route contain this column?
+ var maxOverlappingVertical = 0;
+ // get the max vertical overlap of those cells
+ // only need to do this step for columns not rows because it has to do with vertical start/end points in block cells
+ var firstCellInRoute = that.getCell(link.route.cellLocations[0].col,link.route.cellLocations[0].row);
+ var lastCellInRoute = that.getCell(link.route.cellLocations[link.route.cellLocations.length-1].col, link.route.cellLocations[link.route.cellLocations.length-1].row);
+ routeCellsInThisCol.forEach(function(cell) {
+ var excludeStartPoints = (cell === lastCellInRoute);
+ var excludeEndPoints = (cell === firstCellInRoute);
+ maxOverlappingVertical = Math.max(maxOverlappingVertical, cell.countVerticalRoutes(excludeStartPoints,excludeEndPoints)); //todo: should we also keep references to the routes this overlaps?
+ });
+ // store value in a data structure for that col,route pair
+ thisColRouteOverlaps.push({
+ route: link.route, // column index can be determined from position in array
+ maxOverlap: maxOverlappingVertical
+ });
+ }
+ });
+ colRouteOverlaps.push(thisColRouteOverlaps);
+ }
+
+ var rowRouteOverlaps = [];
+ // for each route in column
+ for (var r = 0; r < this.size; r++) {
+ var thisRowRouteOverlaps = [];
+ this.allLinks().sort(function(link1, link2){
+ // vertically sorts them so that links starting near horizontal center of block are below those
+ // starting near edges, so they don't overlap. requires that we sort horizontally before vertically
+ var centerIndex = Math.ceil((horizontallySortedLinks.length-1)/2);
+ var index1 = horizontallySortedLinks.indexOf(link1);
+ var distFromCenter1 = Math.abs(index1 - centerIndex);
+ var index2 = horizontallySortedLinks.indexOf(link2);
+ var distFromCenter2 = Math.abs(index2 - centerIndex);
+ return distFromCenter2 - distFromCenter1;
+ //return 10 * (p1.vertical - p2.vertical) + 1 * (Math.abs(p2.horizontal) - Math.abs(p1.horizontal));
+
+ }).forEach( function(link) {
+ var routeCellsInThisRow = link.route.allCells.filter(function(cell){return cell.location.row === r;});
+ if (routeCellsInThisRow.length > 0) { // does this route contain this column?
+ var maxOverlappingHorizontal = 0;
+ routeCellsInThisRow.forEach(function(cell) {
+ maxOverlappingHorizontal = Math.max(maxOverlappingHorizontal, cell.countHorizontalRoutes());
+ });
+ thisRowRouteOverlaps.push({
+ route: link.route, // column index can be determined from position in array
+ maxOverlap: maxOverlappingHorizontal
+ });
+ }
+ });
+ rowRouteOverlaps.push(thisRowRouteOverlaps);
+ }
+ return {
+ colRouteOverlaps: colRouteOverlaps,
+ rowRouteOverlaps: rowRouteOverlaps
+ };
+ };
+
+// After routes have been calculated and overlaps have been counted, determines the x,y offset for
+// each point so that routes don't overlap one another and are spaced evenly within the cells
+ Grid.prototype.calculateOffsets = function(overlaps) {
+ var colRouteOverlaps = overlaps.colRouteOverlaps;
+ var rowRouteOverlaps = overlaps.rowRouteOverlaps;
+
+ var that = this;
+ var maxOffset;
+ var minOffset;
+ var routeOverlaps;
+ var numRoutesProcessed;
+
+ for (var c = 0; c < this.size; c++) {
+ maxOffset = 0.5 * this.getCellWidth(c);
+ minOffset = -1 * maxOffset;
+ routeOverlaps = colRouteOverlaps[c];
+ numRoutesProcessed = new Array(this.size).fill(0);
+
+ var numRoutesProcessedExcludingStart = new Array(this.size).fill(0);
+ var numRoutesProcessedExcludingEnd = new Array(this.size).fill(0);
+
+ routeOverlaps.forEach( function(routeOverlap) {
+ var route = routeOverlap.route;
+ var maxOverlap = routeOverlap.maxOverlap;
+
+ var firstCellInRoute = that.getCell(route.cellLocations[0].col, route.cellLocations[0].row);
+ var lastCellInRoute = that.getCell(route.cellLocations[route.cellLocations.length-1].col, route.cellLocations[route.cellLocations.length-1].row);
+
+ var lineNumber = 0;
+ route.allCells.filter(function(cell){return cell.location.col === c;}).forEach( function(cell) {
+ var numProcessed = 0;
+
+ if (cell === firstCellInRoute) {
+ // exclude endpoints... use numRoutesProcessedExcludingEnd
+ numProcessed = numRoutesProcessedExcludingEnd[cell.location.row];
+ } else if (cell === lastCellInRoute) {
+ // exclude startpoints... use numRoutesProcessedExcludingStart
+ numProcessed = numRoutesProcessedExcludingStart[cell.location.row];
+ } else {
+ numProcessed = numRoutesProcessed[cell.location.row];
+ }
+
+ if (cell.containsVerticalSegmentOfRoute(route)) {
+ lineNumber = Math.max(lineNumber, numProcessed);
+ }
+ });
+ lineNumber += 1;
+
+ // todo: use maxOverlap of any route in this cell? or does maxOverlap already take care of that?
+ var numPartitions = maxOverlap + 1;
+ var width = maxOffset - minOffset;
+ var spacing = width/(numPartitions);
+ var offsetX = minOffset + lineNumber * spacing;
+ if (maxOverlap === 0) offsetX = 0; // edge case - never adjust lines that don't overlap anything
+
+ route.cellLocations.filter(function(location){return location.col === c;}).forEach( function(location) {
+ location.offsetX = offsetX;
+ });
+
+ route.allCells.filter(function(cell){return cell.location.col === c}).forEach( function(cell) {
+ if (cell !== firstCellInRoute) {
+ // exclude endpoints... use numRoutesProcessedExcludingEnd
+ numRoutesProcessedExcludingStart[cell.location.row] += 1;
+ }
+
+ if (cell !== lastCellInRoute) {
+ // exclude startpoints... use numRoutesProcessedExcludingStart
+ numRoutesProcessedExcludingEnd[cell.location.row] += 1;
+ }
+
+ if (cell.containsVerticalSegmentOfRoute(route)) {
+ numRoutesProcessed[cell.location.row] += 1;
+ }
+ });
+ });
+ }
+
+ for (var r = 0; r < this.size; r++) {
+ maxOffset = 0.5 * this.getCellHeight(r);
+ minOffset = -1 * maxOffset;
+ routeOverlaps = rowRouteOverlaps[r];
+ numRoutesProcessed = new Array(this.size).fill(0);
+
+ routeOverlaps.forEach( function(routeOverlap) {
+ var route = routeOverlap.route;
+ var maxOverlap = routeOverlap.maxOverlap;
+
+ var lineNumber = 0;
+ route.allCells.filter(function(cell){return cell.location.row === r;}).forEach( function(cell) {
+ if (cell.containsHorizontalSegmentOfRoute(route)) {
+ lineNumber = Math.max(lineNumber, numRoutesProcessed[cell.location.col]);
+ }
+ });
+ lineNumber += 1; // actual number is one bigger than the number of routes processed
+ // note: line number should never exceed maxOverlap... something went wrong if it did...
+
+ // todo: use maxOverlap of any route in this cell? causes more things to shift but would make more correct
+ var numPartitions = maxOverlap + 1;
+ var width = maxOffset - minOffset;
+ var spacing = width/(numPartitions);
+ var offsetY = minOffset + lineNumber * spacing;
+ if (maxOverlap === 0) offsetY = 0; // edge case - never adjust lines that don't overlap anything
+
+ route.cellLocations.filter(function(location){return location.row === r;}).forEach( function(location) {
+ location.offsetY = offsetY;
+ });
+
+ route.allCells.filter(function(cell){return cell.location.row === r}).forEach( function(cell) {
+ if (cell.containsHorizontalSegmentOfRoute(route)) {
+ numRoutesProcessed[cell.location.col] += 1;
+ }
+ });
+ });
+ }
+ };
+
+ ////////////////////////////////////////////////////////////////////////////////
+// MISC FUNCTIONS FOR WORKING WITH CELLS, BLOCKS, GRID
+////////////////////////////////////////////////////////////////////////////////
+
+ function getBlock(x,y) {
+ for (var blockKey in globalStates.currentLogic.blocks) {
+ if (!globalStates.currentLogic.blocks.hasOwnProperty(blockKey)) continue;
+ var block = globalStates.currentLogic.blocks[blockKey];
+ if (block.x === x && block.y === y) {
+ return block;
+ }
+ }
+ return null;
+ }
+
+ function getCellForBlock(grid, block, item) {
+ var gridPos = convertBlockPosToGridPos(block.x + item, block.y);
+ return grid.getCell(gridPos.col, gridPos.row);
+ }
+
+ function getBlockPixelWidth(block, grid) {
+ var numBlockCols = block.blockSize;
+ var numMarginCols = block.blockSize - 1;
+ return grid.blockColWidth * numBlockCols + grid.marginColWidth * numMarginCols;
+ }
+
+// gets a block overlapping the cell at this x,y location
+ function getBlockOverlappingPosition(x, y) {
+ // check if block of size >= 1 is at (x, y)
+ var block = getBlock(x,y);
+ if (block && block.blockSize >= 1) {
+ return block;
+ }
+ // else check if block of size >= 2 is at (x-1, y)
+ block = getBlock(x-1,y);
+ if (block && block.blockSize >= 2) {
+ return block;
+ }
+ // else check if block of size >= 3 is at (x-2, y)
+ block = getBlock(x-2,y);
+ if (block && block.blockSize >= 3) {
+ return block;
+ }
+
+ // else check if block of size == 4 is at (x-3, y)
+ block = getBlock(x-3,y);
+ if (block && block.blockSize >= 4) {
+ return block;
+ }
+ return null;
+ }
+
+ function isBlockOutsideGrid(block, grid) {
+ var maxPosition = Math.ceil(grid.size/2); // 4
+ return (block.x < 0 || block.y < 0 || block.y > maxPosition || (block.x + (block.blockSize-1)) > maxPosition);
+ }
+
+ function convertGridPosToBlockPos(col, row) {
+ return {
+ x: Math.floor(col/2),
+ y: Math.floor(row/2)
+ };
+ }
+
+ function convertBlockPosToGridPos(x, y) {
+ return new CellLocation(x * 2, y * 2);
+ }
+
+ function addBlockLink(nodeA, nodeB, logicA, logicB, addToLogic) {
+ if (nodeA && nodeB) {
+ var blockLink = new BlockLink();
+ blockLink.nodeA = nodeA;
+ blockLink.nodeB = nodeB;
+ blockLink.logicA = logicA;
+ blockLink.logicB = logicB;
+
+ var linkKey = isFixedNameLink(nodeA, nodeB) ? "blockLink-" + nodeA + "-" + nodeB : "blockLink" + realityEditor.device.utilities.uuidTime();
+
+ blockLink.globalId = linkKey;
+
+ if (addToLogic) {
+ if (!doesLinkAlreadyExist(blockLink)) {
+ globalStates.currentLogic.links[linkKey] = blockLink;
+ }
+ uploadLinkIfNecessary(blockLink, linkKey);
+ }
+
+ return blockLink;
+
+ }
+ return null;
+ }
+
+ function isFixedNameLink(nodeA, nodeB) {
+ return ( (isInOutBlock(nodeA) || isEdgePlaceholderBlock(nodeA)) &&
+ (isInOutBlock(nodeB) || isEdgePlaceholderBlock(nodeB)) );
+ }
+
+ function uploadLinkIfNecessary(blockLink, linkKey) {
+ var keys = realityEditor.gui.crafting.eventHelper.getServerObjectLogicKeys(globalStates.currentLogic);
+ realityEditor.network.postNewBlockLink(keys.ip, keys.objectKey, keys.frameKey, keys.logicKey, linkKey, blockLink);
+ }
+
+ function blockWithID(globalID, logic) {
+ return logic.blocks[globalID];
+ }
+
+ function addBlock(x,y,blockJSON,globalId,isEdgeBlock) {
+ var block = new Block();
+
+ block.type = blockJSON.type;
+ block.name = blockJSON.name;
+ block.x = x;
+ block.y = y;
+ block.blockSize = blockJSON.blockSize;
+ block.globalId = globalId;
+ block.checksum = null; // TODO: implement this!!
+ block.privateData = blockJSON.privateData;
+ block.publicData = blockJSON.publicData;
+ block.activeInputs = blockJSON.activeInputs;
+ block.activeOutputs = blockJSON.activeOutputs;
+ block.nameInput = blockJSON.nameInput;
+ block.nameOutput = blockJSON.nameOutput;
+ block.iconImage = null; //TODO: implement this!!
+ if (isEdgeBlock) block.isPortBlock = true;
+
+ globalStates.currentLogic.blocks[block.globalId] = block;
+
+ if (block.y === 0 || block.y === 3) {
+ updateInOutLinks(globalId);
+ }
+
+ if (realityEditor.gui.crafting.eventHelper.shouldUploadBlock(block)) {
+ var keys = realityEditor.gui.crafting.eventHelper.getServerObjectLogicKeys(globalStates.currentLogic);
+ realityEditor.network.postNewBlock(keys.ip, keys.objectKey, keys.frameKey, keys.logicKey, block.globalId, block);
+ }
+
+ return block;
+ }
+
+ function updateInOutLinks(addedBlockId) {
+ var addedBlock = globalStates.currentLogic.blocks[addedBlockId];
+
+ var namePrefix = addedBlock.y === 0 ? "in" : "out";
+
+ // for each item in added block, remove previous link and add new one to this
+ for (var i = 0; i < addedBlock.blockSize; i++) {
+
+ var itemX = addedBlock.x + i;
+ var inOutName = namePrefix + itemX;
+
+ // remove previous link involving that in/out block
+ for (var key in globalStates.currentLogic.links) {
+ if (!globalStates.currentLogic.links.hasOwnProperty(key)) continue;
+
+ var link = globalStates.currentLogic.links[key];
+ if (link.nodeA === inOutName || link.nodeB === inOutName) {
+ removeBlockLink(key);
+ }
+ }
+
+ if (addedBlock.y === 0) {
+ addBlockLink(inOutName, addedBlock.globalId, 0, i, true);
+ } else {
+ addBlockLink(addedBlock.globalId, inOutName, i, 0, true);
+ }
+
+ }
+ }
+
+ function isEdgePlaceholderLink(link) {
+ return (isEdgePlaceholderBlock(link.nodeA) || isEdgePlaceholderBlock(link.nodeB));
+ }
+
+ function isEdgePlaceholderBlock(blockID) {
+ var re = /^(edgePlaceholder(In|Out))\d$/;
+ return re.test(blockID);
+ }
+
+ function isInOutLink(link) {
+ return (isInOutBlock(link.nodeA) || isInOutBlock(link.nodeB));
+ }
+
+ function isInOutBlock(blockID) {
+ var re = /^(in|out)\d$/;
+ return re.test(blockID);
+ }
+
+ function setTempLink(newTempLink) {
+ if (!doesLinkAlreadyExist(newTempLink)) {
+ globalStates.currentLogic.guiState.tempLink = newTempLink;
+ }
+ }
+
+ function removeBlockLink(linkKey) {
+ //if (realityEditor.gui.crafting.eventHelper.shouldUploadBlockLink(globalStates.currentLogic.links[linkKey])) {
+ var keys = realityEditor.gui.crafting.eventHelper.getServerObjectLogicKeys(globalStates.currentLogic);
+ realityEditor.network.deleteBlockLinkFromObject(keys.ip, keys.objectKey, keys.frameKey, keys.logicKey, linkKey);
+ //} else {
+ // deleteSurroundingBlockLinksFromServer(linkKey);
+ //}
+ delete globalStates.currentLogic.links[linkKey];
+ }
+
+ function removeBlock(logic, blockID) {
+ removeLinksForBlock(logic, blockID);
+ var domElement = logic.guiState.blockDomElements[blockID];
+ if (domElement) {
+ domElement.parentNode.removeChild(domElement);
+ }
+ if (realityEditor.gui.crafting.eventHelper.shouldUploadBlock(logic.blocks[blockID])) {
+ var keys = realityEditor.gui.crafting.eventHelper.getServerObjectLogicKeys(logic);
+ realityEditor.network.deleteBlockFromObject(keys.ip, keys.objectKey, keys.frameKey, keys.logicKey, blockID);
+ }
+ delete logic.guiState.blockDomElements[blockID];
+ delete logic.blocks[blockID];
+ }
+
+ function removeLinksForBlock(logic, blockID) {
+ for (var linkKey in logic.links) {
+ if (!logic.links.hasOwnProperty(linkKey)) continue;
+ var link = logic.links[linkKey];
+ if (link.nodeA === blockID || link.nodeB === blockID) {
+ //if (realityEditor.gui.crafting.eventHelper.shouldUploadBlockLink(link)) {
+ var keys = realityEditor.gui.crafting.eventHelper.getServerObjectLogicKeys(logic);
+ realityEditor.network.deleteBlockLinkFromObject(keys.ip, keys.objectKey, keys.frameKey, keys.logicKey, linkKey);
+ //} else {
+ // deleteSurroundingBlockLinksFromServer(linkKey);
+ //}
+ delete logic.links[linkKey];
+ }
+ }
+ }
+
+ function edgeBlockLinkKey(link) {
+ return "blockLink-" + link.nodeA + "-" + link.logicA + "-" + link.nodeB + "-" + link.logicB;
+ }
+
+ function doesLinkAlreadyExist(blockLink) {
+ if (!blockLink) return false;
+ for (var linkKey in globalStates.currentLogic.links) {
+ if (!globalStates.currentLogic.links.hasOwnProperty(linkKey)) continue;
+ var thatBlockLink = globalStates.currentLogic.links[linkKey];
+ if (areBlockLinksEqual(blockLink, thatBlockLink)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function areBlockLinksEqual(blockLink1, blockLink2) {
+ if (blockLink1.nodeA === blockLink2.nodeA && blockLink1.logicA === blockLink2.logicA) {
+ if (blockLink1.nodeB === blockLink2.nodeB && blockLink1.logicB === blockLink2.logicB) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+// points is an array like [{screenX: x1, screenY: y1}, ...]
+// calculates useful pointData for drawing lines with varying color/weight/etc,
+// by determining how far along the line each corner is located (as a percentage)
+ function preprocessPointsForDrawing(points) { //... only ever used here.. could just inline it
+ // adds up the total length the route points travel
+ var lengths = []; // size = lines.length-1
+ for (var i = 1; i < points.length; i++) {
+ var dx = points[i].screenX - points[i-1].screenX;
+ var dy = points[i].screenY - points[i-1].screenY;
+ lengths.push(Math.sqrt(dx * dx + dy * dy));
+ }
+ var totalLength = lengths.reduce(function(a,b){return a + b;}, 0);
+ // calculates the percentage along the path of each point
+ var prevPercent = 0.0;
+ var percentages = [prevPercent];
+ percentages.push.apply(percentages, lengths.map(function(length){ prevPercent += length/totalLength; return prevPercent; }));
+
+ return {
+ points: points,
+ totalLength: totalLength,
+ lengths: lengths,
+ percentages: percentages
+ };
+ }
+
+ // constructors
+ exports.Grid = Grid;
+ exports.Cell = Cell;
+ exports.CellLocation = CellLocation;
+ exports.Route = Route;
+ exports.RouteSegment = RouteSegment;
+ // misc functions
+ //exports.getBlock = getBlock;
+ exports.getCellForBlock = getCellForBlock;
+ exports.getBlockPixelWidth = getBlockPixelWidth;
+ exports.isBlockOutsideGrid = isBlockOutsideGrid;
+ exports.convertGridPosToBlockPos = convertGridPosToBlockPos;
+ exports.convertBlockPosToGridPos = convertBlockPosToGridPos;
+ exports.addBlockLink = addBlockLink;
+ exports.blockWithID = blockWithID;
+ exports.addBlock = addBlock;
+ exports.updateInOutLinks = updateInOutLinks;
+ exports.isEdgePlaceholderLink = isEdgePlaceholderLink;
+ exports.isEdgePlaceholderBlock = isEdgePlaceholderBlock;
+ exports.isInOutBlock = isInOutBlock;
+ exports.setTempLink = setTempLink;
+ exports.removeBlockLink = removeBlockLink;
+ exports.removeBlock = removeBlock;
+ exports.removeLinksForBlock = removeLinksForBlock;
+ // todo: change so doesn't need to be public
+ exports.edgeBlockLinkKey = edgeBlockLinkKey;
+
+}(realityEditor.gui.crafting.grid));
diff --git a/src/gui/crafting/index.js b/src/gui/crafting/index.js
new file mode 100644
index 000000000..c9d3356d6
--- /dev/null
+++ b/src/gui/crafting/index.js
@@ -0,0 +1,862 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2016 Benjamin Reynholds
+ * Modified by Valentin Heun 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.gui.crafting");
+
+realityEditor.gui.crafting.blockIconCache = {};
+realityEditor.gui.crafting.menuBarWidth = 62;
+realityEditor.gui.crafting.blockColorMap = ["#00FFFF", "#00FF00", "#FFFF00", "#FF007C"];
+
+// since all the connectedColors links have the same shape, we can animate them with the same object
+realityEditor.gui.crafting.reusableLinkObject = {
+ ballAnimationCount: 0,
+ route: {
+ pointData: {
+ points: [] // list of [{screenX, screenY}] will get populated in render function
+ }
+ }
+};
+
+realityEditor.gui.crafting.initService = function() {
+ realityEditor.gui.buttons.registerCallbackForButton('gui', hideCraftingOnButtonUp);
+ realityEditor.gui.buttons.registerCallbackForButton('logic', hideCraftingOnButtonUp);
+
+ function hideCraftingOnButtonUp(params) {
+ if (params.newButtonState === 'up') {
+ realityEditor.gui.crafting.craftingBoardHide();
+ }
+ }
+};
+
+realityEditor.gui.crafting.updateGrid = function(grid) {
+ console.log("update grid!");
+
+ var previousLogic = globalStates.currentLogic;
+
+ var logic = grid.parentLogic();
+
+ if (logic) {
+ globalStates.currentLogic = logic;
+
+ // *** this does all the backend work ***
+ grid.recalculateAllRoutes();
+
+ // this could just happen on open/close but we'll update each time in case another user updates the links
+ realityEditor.gui.crafting.recalculateConnectedColors(logic);
+
+ // UPDATE THE UI IF OPEN
+ var blockContainer = document.getElementById('blocks');
+
+ if (globalStates.currentLogic && grid.parentLogic() && (grid.parentLogic().uuid === globalStates.currentLogic.uuid) && blockContainer) {
+
+ // reset domElements
+ for (var domKey in logic.guiState.blockDomElements) {
+ let blockDomElement = logic.guiState.blockDomElements[domKey];
+
+ // remove dom elements if their blocks are gone or needs to be reset
+ if (this.shouldRemoveBlockDom(blockDomElement)) {
+ blockDomElement.parentNode.removeChild(blockDomElement);
+ delete logic.guiState.blockDomElements[domKey];
+ }
+ }
+
+ // add new domElement for each block that needs one
+ for (var blockKey in logic.blocks) {
+ var block = logic.blocks[blockKey];
+ if (block.isPortBlock) continue; // don't render invisible input/output blocks
+
+ if (realityEditor.gui.crafting.grid.isBlockOutsideGrid(block, grid) && !block.isPortBlock) { // don't render blocks offscreen
+ continue;
+ }
+
+ // only add if the block doesn't already have one
+ let blockDomElement = logic.guiState.blockDomElements[block.globalId];
+ if (!blockDomElement) {
+ this.addDomElementForBlock(block, grid);
+ }
+
+ }
+ }
+ }
+
+ globalStates.currentLogic = previousLogic;
+};
+
+realityEditor.gui.crafting.forceRedraw = function(logic) {
+ var _this = this;
+ for (var key in logic.blocks) {
+ if (!logic.blocks.hasOwnProperty(key)) continue;
+ if (logic.blocks[key].isPortBlock) continue;
+ _this.removeBlockDom(logic.blocks[key]);
+ }
+ this.updateGrid(logic.grid);
+ this.redrawDataCrafting();
+};
+
+ // todo: pass in logic instead of using currentLogic
+realityEditor.gui.crafting.removeBlockDom = function(block) {
+ var blockDomElement = this.eventHelper.getDomElementForBlock(block);
+ if (blockDomElement) {
+ blockDomElement.parentNode.removeChild(blockDomElement);
+ delete globalStates.currentLogic.guiState.blockDomElements[block.globalId];
+ }
+};
+
+ // todo: pass in logic instead of using currentLogic
+realityEditor.gui.crafting.shouldRemoveBlockDom = function(blockDomElement) {
+ return (this.getBlockForDom(blockDomElement) === null); // remove the dom if there isn't a corresponding block
+};
+
+ // todo: pass in logic instead of using currentLogic
+realityEditor.gui.crafting.getBlockForDom = function(blockDomElement) {
+ if (!globalStates.currentLogic) return null;
+ for (var blockKey in globalStates.currentLogic.blocks) {
+ var block = globalStates.currentLogic.blocks[blockKey];
+ if (globalStates.currentLogic.guiState.blockDomElements[block.globalId] === blockDomElement) {
+ return block;
+ }
+ }
+ return null;
+};
+
+ // todo: pass in logic instead of using currentLogic
+realityEditor.gui.crafting.addDomElementForBlock = function(block, grid, isTempBlock) {
+ var blockDomElement = document.createElement('div');
+ blockDomElement.setAttribute('class','blockDivPlaced');
+
+ var blockContents = document.createElement('div');
+ blockContents.setAttribute('class', 'menuBlockContents');
+ blockContents.setAttribute("touch-action", "none");
+ blockDomElement.appendChild(blockContents);
+
+ var iconImage = null;
+
+ // add icon and title to block
+ if (block.name) {
+
+ // show image full width and height of block if able to find
+ var blockIcon = this.getBlockIcon(globalStates.currentLogic, block.type, true);
+ if (blockIcon) {
+ iconImage = document.createElement("img");
+ iconImage.classList.add('blockIcon', 'blockIconPlaced');
+ iconImage.src = blockIcon.src;
+ blockContents.appendChild(iconImage);
+
+ // Show name if there isn't an image to show
+ } else {
+ var blockTitle = document.createElement('div');
+ blockTitle.setAttribute('class', 'blockTitle');
+ blockTitle.innerHTML = block.name;
+ blockContents.appendChild(blockTitle);
+ }
+
+ /* var blockTitle2 = document.createElement('div');
+ blockTitle2.setAttribute('class', 'blockTitle');
+ blockTitle2.innerHTML = " "+block.name+" ";
+ // blockTitle2.style.backgroundColor = "rgba(0,0,0,0.5)";
+ blockTitle2.style.width = blockContents.style.width;
+ blockContents.appendChild(blockTitle2);
+*/
+
+ // add a transparent div on top to display stripes when moving the block
+ var moveDiv = document.createElement("div");
+ moveDiv.setAttribute('class', 'blockMoveDiv');
+ blockContents.appendChild(moveDiv);
+ }
+ blockDomElement.style.display = 'inline-block';
+
+ var blockOutlinePadding = 10; // wrapping the div with corners/outline adds the remaining width to match the cell size
+
+ // if we're adding a temp block, it doesn't have associated cells it can use to calculate position. we need to remember to set position to pointer afterwards
+ if (!isTempBlock) { //TODO: is there a way to set position for new blocks consistently?
+ var firstCell = this.grid.getCellForBlock(grid, block, 0);
+ var firstCellCenterX = grid.getCellCenterX(firstCell);
+ blockDomElement.style.left = firstCellCenterX - grid.blockColWidth/2 + blockOutlinePadding/2 + 'px';
+ blockDomElement.style.top = grid.getCellCenterY(firstCell) - grid.blockRowHeight/2 + blockOutlinePadding/2 + 'px';
+ }
+
+ blockDomElement.style.width = this.grid.getBlockPixelWidth(block,grid) - blockOutlinePadding + 'px';
+ blockDomElement.style.height = grid.blockRowHeight - blockOutlinePadding + 'px';
+
+ if (iconImage) {
+ // iconImage.style.width = blockDomElement.style.width;
+ // iconImage.style.height = (parseInt(blockDomElement.style.height) - 10) + 'px';
+ iconImage.style.marginLeft = '-5px';
+ // iconImage.style.marginTop = '-2px';
+ }
+
+ var blockContainer = document.getElementById('blocks');
+ blockContainer.appendChild(blockDomElement);
+
+ var guiState = globalStates.currentLogic.guiState;
+ guiState.blockDomElements[block.globalId] = blockDomElement;
+
+ // adds outlines to blocks placed in cells, but not when in the process of dropping in from the menu
+ if (block.x !== -1 && block.y !== -1) {
+ realityEditor.gui.moveabilityCorners.wrapDivInOutline(blockDomElement, 8, true, null, -4, 3);
+ } else {
+ realityEditor.gui.moveabilityCorners.wrapDivWithCorners(blockDomElement, 8, true, null, -4);
+ }
+};
+
+realityEditor.gui.crafting.getBlockIcon = function(logic, blockName, labelSwitch) {
+ // if(!label) label = false;
+ var keys = this.eventHelper.getServerObjectLogicKeys(logic);
+
+ if (realityEditor.gui.crafting.blockIconCache[keys.logicKey] === undefined) {
+ realityEditor.gui.crafting.blockIconCache[keys.logicKey] = {};
+ }
+
+ // download icon to cache if not already there
+ if (realityEditor.gui.crafting.blockIconCache[keys.logicKey][blockName] === undefined) {
+ var icon = new Image();
+ icon.src = realityEditor.network.getURL(keys.ip, keys.port, '/logicBlock/' + blockName + "/icon.svg");
+ realityEditor.gui.crafting.blockIconCache[keys.logicKey][blockName] = icon;
+
+ var label = new Image();
+ label.src = realityEditor.network.getURL(keys.ip, keys.port, '/logicBlock/' + blockName + "/label.svg");
+ realityEditor.gui.crafting.blockIconCache[keys.logicKey][blockName+"label"] = label;
+ }
+
+ // otherwise just directly return from cache
+ if(labelSwitch === false) {
+ return realityEditor.gui.crafting.blockIconCache[keys.logicKey][blockName];
+ }
+ else {
+ return realityEditor.gui.crafting.blockIconCache[keys.logicKey][blockName+"label"];
+ }
+
+};
+
+realityEditor.gui.crafting.getSrcForCustomIcon = function(logic) {
+ if (logic.nodeMemoryCustomIconSrc) {
+ return logic.nodeMemoryCustomIconSrc;
+ }
+ var keys = realityEditor.gui.crafting.eventHelper.getServerObjectLogicKeys(logic);
+ if (keys) {
+ return realityEditor.network.getURL(keys.ip, keys.port, '/logicNodeIcon/' + realityEditor.getObject(keys.objectKey).name + "/" + keys.logicKey + ".jpg");
+
+ }
+};
+
+realityEditor.gui.crafting.getSrcForAutoIcon = function(logic) {
+ var validBlockIDs = Object.keys(logic.blocks).filter(function(id) {
+ return !realityEditor.gui.crafting.grid.isInOutBlock(id) &&
+ !realityEditor.gui.crafting.grid.isEdgePlaceholderBlock(id);
+ });
+ console.log(validBlockIDs);
+ if (validBlockIDs.length > 0) {
+ var firstBlock = logic.blocks[validBlockIDs[0]];
+ console.log(firstBlock.type);
+ return this.getBlockIcon(logic, firstBlock.type, false).src; // false specifies menu icon instead of label icon
+ }
+ return null;
+};
+
+/**
+ * Returns either the preset iconImage for this logic node, or the icon of its first visible block
+ * @param {Logic} logic
+ */
+realityEditor.gui.crafting.getLogicNodeIcon = function(logic) {
+ if (logic.iconImage === 'custom') {
+ return this.getSrcForCustomIcon(logic);
+ } else if (logic.iconImage === 'auto') {
+ return this.getSrcForAutoIcon(logic);
+ } else {
+ return null;
+ }
+
+ // if (logic.iconImage) {
+ // return logic.iconImage;
+ // } else {
+ // var validBlockIDs = Object.keys(logic.blocks).filter(function(id) {
+ // return !realityEditor.gui.crafting.grid.isInOutBlock(id) &&
+ // !realityEditor.gui.crafting.grid.isEdgePlaceholderBlock(id);
+ // });
+ // console.log(validBlockIDs);
+ // if (validBlockIDs.length > 0) {
+ // var firstBlock = logic.blocks[validBlockIDs[0]];
+ // console.log(firstBlock.type);
+ // return this.getBlockIcon(logic, firstBlock.type, false).src;
+ // }
+ // }
+ // return null;
+};
+
+// updates datacrafting visuals each frame
+// renders all the links for a datacrafting grid, draws cut line if present, draws temp block if present
+realityEditor.gui.crafting.redrawDataCrafting = function() {
+ if (!globalStates.currentLogic) return;
+ var grid = globalStates.currentLogic.grid;
+ var _this = this;
+
+ var canvas = document.getElementById("datacraftingCanvas");
+ var ctx = canvas.getContext('2d');
+ ctx.clearRect(0,0,canvas.width,canvas.height);
+
+ grid.forEachLink( function(link) {
+ // var startCell = _this.grid.getCellForBlock(grid, _this.grid.blockWithID(link.nodeA, globalStates.currentLogic), link.logicA);
+ // var endCell = _this.grid.getCellForBlock(grid, _this.grid.blockWithID(link.nodeB, globalStates.currentLogic), link.logicB);
+ // _this.drawDataCraftingLine(ctx, link, 5, startCell.getColorHSL(), endCell.getColorHSL(), timeCorrection);
+
+ // var blueColor = {h: 180, s:100, l:60};
+ // _this.drawDataCraftingLine(ctx, link, 3, blueColor, blueColor, timeCorrection);
+ _this.drawDataCraftingLineDashed(ctx, link);
+ });
+
+ var cutLine = globalStates.currentLogic.guiState.cutLine;
+ if (cutLine.start && cutLine.end) {
+ this.realityEditor.gui.ar.lines.drawSimpleLine(ctx, cutLine.start.x, cutLine.start.y, cutLine.end.x, cutLine.end.y, "#FFFFFF", 3);
+ }
+
+ var tempLine = globalStates.currentLogic.guiState.tempLine;
+ if (tempLine.start && tempLine.end) {
+ var blueColor = {h: 180, s:100, l:60};
+ var lineColor = 'hsl('+blueColor.h+','+blueColor.s+'%,'+blueColor.l+'%)';
+ // this.realityEditor.gui.ar.lines.drawSimpleLine(ctx, tempLine.start.x, tempLine.start.y, tempLine.end.x, tempLine.end.y, tempLine.color, 3);
+ this.realityEditor.gui.ar.lines.drawSimpleLine(ctx, tempLine.start.x, tempLine.start.y, tempLine.end.x, tempLine.end.y, lineColor, 3);
+ }
+
+ let connectedInputColors = globalStates.currentLogic.guiState.connectedInputColors;
+ let connectedOutputColors = globalStates.currentLogic.guiState.connectedOutputColors;
+ let numReusableUpdates = connectedInputColors.filter(function(value) { return value; }).length +
+ connectedOutputColors.filter(function(value) { return value; }).length;
+
+ // draw links from top of screen for any of the connected input colors
+ connectedInputColors.forEach(function(isConnected, index) {
+ if (!isConnected) { return; } // only draw connected lines
+ let linkX = grid.getColumnCenterX(index * 2);
+ let endY = grid.getRowCenterY(0);
+ _this.reusableLinkObject.route.pointData.points = [{screenX: linkX, screenY: 0}, {screenX: linkX, screenY: endY}];
+ _this.drawDataCraftingLineDashed(ctx, realityEditor.gui.crafting.reusableLinkObject, numReusableUpdates);
+ });
+
+ // draw links to bottom of screen for any of the connected input colors
+ connectedOutputColors.forEach(function(isConnected, index) {
+ if (!isConnected) { return; } // only draw connected lines
+ let linkX = grid.getColumnCenterX(index * 2);
+ let startY = grid.getRowCenterY(6);
+ _this.reusableLinkObject.route.pointData.points = [{screenX: linkX, screenY: startY}, {screenX: linkX, screenY: window.innerHeight}];
+ _this.drawDataCraftingLineDashed(ctx, realityEditor.gui.crafting.reusableLinkObject, numReusableUpdates);
+ });
+
+ var tappedContents = globalStates.currentLogic.guiState.tappedContents;
+ if (tappedContents) {
+ var domElement = this.eventHelper.getDomElementForBlock(tappedContents.block);
+ if (!domElement) return;
+
+ globalStates.currentLogic.guiState.tempIncomingLinks.forEach( function(linkData) {
+ var startCell = _this.grid.getCellForBlock(grid, _this.grid.blockWithID(linkData.nodeA, globalStates.currentLogic), linkData.logicA);
+ if (!startCell && _this.grid.isInOutBlock(linkData.nodeA)) {
+ var col = linkData.nodeA.slice(-1) * 2;
+ startCell = grid.getCell(col, 0);
+ }
+ var startX = grid.getCellCenterX(startCell);
+ var startY = grid.getCellCenterY(startCell);
+
+ var xOffset = 0.5 * grid.blockColWidth + (grid.blockColWidth + grid.marginColWidth) * linkData.logicB;
+ var endX = parseInt(domElement.style.left) + xOffset;
+ var endY = parseInt(domElement.style.top) + domElement.clientHeight/2;
+ // var startColor = startCell.getColorHSL();
+ // var lineColor = 'hsl('+startColor.h+','+startColor.s+'%,'+startColor.l+'%)';
+ var blueColor = {h: 180, s:100, l:60};
+ var lineColor = 'hsl('+blueColor.h+','+blueColor.s+'%,'+blueColor.l+'%)';
+
+ _this.realityEditor.gui.ar.lines.drawSimpleLine(ctx, startX, startY, endX, endY, lineColor, 2);
+ });
+
+ globalStates.currentLogic.guiState.tempOutgoingLinks.forEach( function(linkData) {
+ var xOffset = 0.5 * grid.blockColWidth + (grid.blockColWidth + grid.marginColWidth) * linkData.logicA;
+ var startX = parseInt(domElement.style.left) + xOffset;
+ var startY = parseInt(domElement.style.top) + domElement.clientHeight/2;
+
+ var endCell = _this.grid.getCellForBlock(grid, _this.grid.blockWithID(linkData.nodeB, globalStates.currentLogic), linkData.logicB);
+ if (!endCell && _this.grid.isInOutBlock(linkData.nodeB)) {
+ var col = linkData.nodeB.slice(-1) * 2;
+ endCell = grid.getCell(col, 6);
+ }
+ var endX = grid.getCellCenterX(endCell);
+ var endY = grid.getCellCenterY(endCell);
+ // var endColor = endCell.getColorHSL();
+ // var lineColor = 'hsl('+endColor.h+','+endColor.s+'%,'+endColor.l+'%)';
+ var blueColor = {h: 180, s:100, l:60};
+ var lineColor = 'hsl('+blueColor.h+','+blueColor.s+'%,'+blueColor.l+'%)';
+
+ _this.realityEditor.gui.ar.lines.drawSimpleLine(ctx, startX, startY, endX, endY, lineColor, 2);
+ });
+ }
+};
+
+/**
+ * Draws a blue dashed animated line along the route specified in the linkObject
+ * @param {CanvasRenderingContext2D} context
+ * @param {BlockLink} linkObject - contains route with points, and ballAnimationCount for animating
+ * @param {number?} numSharingLinkObject - optional param makes animation work at correct speed if the same
+ * ballAnimationCount is being shared by multiple links being rendered
+ */
+realityEditor.gui.crafting.drawDataCraftingLineDashed = function(context, linkObject, numSharingLinkObject) {
+ if (typeof numSharingLinkObject === 'undefined') { numSharingLinkObject = 1; }
+
+ // context.save();
+ // start a dashed line
+ var lineLength = 6;
+ var gapLength = 8;
+ var totalLength = lineLength + gapLength;
+ context.setLineDash([lineLength, gapLength]);
+ context.beginPath();
+ context.strokeStyle = 'cyan';
+ context.lineWidth = 3;
+
+ // animate the line
+ var numFramesForAnimationLoop = 30 * numSharingLinkObject;
+ linkObject.ballAnimationCount += totalLength / numFramesForAnimationLoop;
+ if (linkObject.ballAnimationCount >= totalLength) {
+ linkObject.ballAnimationCount = 0;
+ }
+ context.lineDashOffset = -1 * linkObject.ballAnimationCount;
+
+ // draw it from start point -> corner -> corner -> ... -> end point
+ var points = linkObject.route.pointData.points;
+ context.moveTo(points[0].screenX, points[0].screenY);
+ for (var i = 1; i < points.length; i++) {
+ var nextPoint = points[i];
+ context.lineTo(nextPoint.screenX, nextPoint.screenY);
+ }
+ context.stroke();
+ // context.restore();
+};
+
+realityEditor.gui.crafting.drawDataCraftingLine = function(context, linkObject, lineStartWeight, startColor, endColor) {
+ var spacer = 3;
+
+ var DEBUG_BLUE = true;
+ if (DEBUG_BLUE) {
+ startColor.h = 180;
+ endColor.h = 180;
+ }
+
+ var pointData = linkObject.route.pointData;
+
+ // var blueToRed = (startColor.h === 180) && (endColor.h === 333);
+ // var redToBlue = (startColor.h === 333) && (endColor.h === 180);
+
+ var percentIncrement = (lineStartWeight * spacer)/pointData.totalLength;
+
+ if (linkObject.ballAnimationCount >= 2*percentIncrement) {
+ linkObject.ballAnimationCount = 0;
+ }
+
+ var hue = startColor;
+ // var transitionColorRight = (endColor.h - startColor.h > 180 || blueToRed);
+ // var transitionColorLeft = (endColor.h - startColor.h < -180 || redToBlue);
+
+ for (var i = 0; i < 1.0; i += 2*percentIncrement) {
+ var percentageStart = i + linkObject.ballAnimationCount;
+ var positionStart = linkObject.route.getXYPositionAtPercentage(percentageStart);
+
+ var percentageEnd = i+percentIncrement + linkObject.ballAnimationCount;
+ var positionEnd = linkObject.route.getXYPositionAtPercentage(percentageEnd);
+
+ if (positionStart !== null && positionEnd !== null) {
+ // if (transitionColorRight) {
+ // // looks better to go down rather than up
+ // hue = ((1.0 - percentage) * startColor.h + percentage * (endColor.h - 360)) % 360;
+ // } else if (transitionColorLeft) {
+ // // looks better to go up rather than down
+ // hue = ((1.0 - percentage) * startColor.h + percentage * (endColor.h + 360)) % 360;
+ // } else {
+ // hue = (1.0 - percentage) * startColor.h + percentage * endColor.h;
+ // }
+ hue = startColor.h;
+ context.beginPath();
+ context.strokeStyle = 'hsl(' + hue + ', 100%, 60%)';
+ context.lineWidth = 3;
+ context.moveTo(positionStart.screenX, positionStart.screenY);
+ context.lineTo(positionEnd.screenX, positionEnd.screenY);
+ context.stroke();
+ }
+ }
+
+ var numFramesForAnimationLoop = 10;
+ linkObject.ballAnimationCount += percentIncrement/numFramesForAnimationLoop;
+};
+
+/**
+ * @desc
+ **/
+
+realityEditor.gui.crafting.craftingBoardVisible = function(objectKey, frameKey, nodeKey) {
+
+ globalStates.freezeStateBeforeCrafting = globalStates.freezeButtonState;
+ globalStates.freezeButtonState = true;
+ realityEditor.app.setPause();
+ globalStates.pocketButtonState = true;
+
+ this.cout("craftingBoardVisible for object: " + objectKey + ", frame: " + frameKey + " and node: "+nodeKey);
+
+ globalStates.guiState = "logic";
+ document.getElementById("craftingBoard").style.visibility = "visible";
+ document.getElementById("craftingBoard").style.display = "inline";
+
+ realityEditor.gui.menus.switchToMenu("crafting", ["freeze"], null);
+
+ if (DEBUG_DATACRAFTING) { // TODO: BEN DEBUG - turn off debugging!
+
+ var logic = new Logic();
+ this.initializeDataCraftingGrid(logic);
+
+ } else {
+
+ var nodeLogic = objects[objectKey].frames[frameKey].nodes[nodeKey];
+ if (!nodeLogic.guiState) {
+ console.log("adding new LogicGUIState");
+ nodeLogic.guiState = new LogicGUIState();
+ }
+ this.initializeDataCraftingGrid(nodeLogic);
+ }
+};
+
+/**
+ * @desc
+ **/
+
+realityEditor.gui.crafting.craftingBoardHide = function() {
+
+ if(globalStates.currentLogic) {
+ //realityEditor.gui.menus.switchToMenu("logic", null, ["freeze"]);
+
+ //globalStates.freezeButtonState = false;
+ var memoryBackground = document.querySelector('.memoryBackground');
+ memoryBackground.innerHTML = '';
+
+ if (globalStates.freezeButtonState && !globalStates.freezeStateBeforeCrafting) {
+
+ realityEditor.gui.menus.buttonOff(["freeze"]);
+ globalStates.freezeButtonState = false;
+ realityEditor.app.setResume();
+
+ } else if (!globalStates.freezeButtonState && globalStates.freezeStateBeforeCrafting) {
+
+ realityEditor.gui.menus.buttonOn(["freeze"]);
+ globalStates.freezeButtonState = true;
+ realityEditor.app.setPause();
+ }
+
+ // update the icon image of the current logic node in case it was based on the blocks
+ realityEditor.gui.ar.draw.updateLogicNodeIcon(globalStates.currentLogic);
+ }
+
+ // remove the block menu if it's showing
+ this.blockMenu.resetBlockMenu();
+ // reset side menu buttons
+ realityEditor.gui.menus.switchToMenu("logic", null, ["setting","pocket"]);
+
+ // hide the crafting board div
+ document.getElementById("craftingBoard").style.visibility = "hidden";
+ document.getElementById("craftingBoard").style.display = "none";
+ // reset the contents of the crafting board div so that another node's logic can be fresh loaded into it
+ this.resetCraftingBoard();
+};
+
+/**
+ * @desc
+ **/
+
+realityEditor.gui.crafting.blockMenuVisible = function() {
+ if (document.getElementById('nodeSettingsContainer') && document.getElementById('nodeSettingsContainer').style.display !== "none") {
+ return;
+ }
+
+ realityEditor.gui.menus.switchToMenu("crafting", ["logicPocket"], null);
+
+ // hide block settings if necessary
+ var blockSettingsContainer = document.getElementById('blockSettingsContainer');
+ if (blockSettingsContainer) {
+ realityEditor.gui.buttons.settingButtonUp({button: "setting", ignoreIsDown: true});
+ }
+
+ this.eventHelper.changeDatacraftingDisplayForMenu('none');
+
+ // create the menu if it doesn't already exist, otherwise just show it
+ var existingMenu = document.getElementById('menuContainer');
+ if (existingMenu) {
+ existingMenu.style.display = 'inline';
+ this.blockMenu.redisplayTabSelection();
+ } else {
+ this.blockMenu.initializeBlockMenu(function() {
+ this.blockMenu.redisplayTabSelection(); // wait for callback to ensure menu fully loaded
+ this.blockMenu.redisplayBlockSelection();
+ }.bind(this));
+ }
+};
+
+/**
+ * @desc
+ **/
+
+realityEditor.gui.crafting.blockMenuHide = function() {
+
+ var existingMenu = document.getElementById('menuContainer');
+ if (existingMenu && existingMenu.style.display !== 'none') {
+ existingMenu.style.display = 'none';
+ //temporarily hide all other datacrafting divs. redisplay them when menu hides
+ this.eventHelper.changeDatacraftingDisplayForMenu('');
+
+ if (!globalStates.pocketButtonState) {
+ globalStates.pocketButtonState = true;
+ //document.getElementById('pocketButton').src = pocketButtonImage[4].src;
+ realityEditor.gui.menus.switchToMenu("crafting", null, ["logicPocket"]);
+ }
+ }
+
+};
+
+
+realityEditor.gui.crafting.addDatacraftingEventListeners = function() {
+ if (globalStates.currentLogic) {
+ var datacraftingEventDiv = document.getElementById('datacraftingEventDiv');
+ if (!datacraftingEventDiv) return;
+
+ realityEditor.device.utilities.addBoundListener(datacraftingEventDiv, 'pointerdown', this.eventHandlers.onPointerDown, this.eventHandlers);
+ realityEditor.device.utilities.addBoundListener(document, 'pointermove', this.eventHandlers.onPointerMove, this.eventHandlers);
+ realityEditor.device.utilities.addBoundListener(datacraftingEventDiv, 'pointerup', this.eventHandlers.onPointerUp, this.eventHandlers);
+ realityEditor.device.utilities.addBoundListener(datacraftingEventDiv, 'pointercancel', this.eventHandlers.onPointerUp, this.eventHandlers);
+
+ }
+};
+
+realityEditor.gui.crafting.removeDatacraftingEventListeners = function() {
+ if (globalStates.currentLogic) {
+ var datacraftingEventDiv = document.getElementById('datacraftingEventDiv');
+ if (!datacraftingEventDiv) return;
+
+ realityEditor.device.utilities.removeBoundListener(datacraftingEventDiv, 'pointerdown', this.eventHandlers.onPointerDown);
+ realityEditor.device.utilities.removeBoundListener(document, 'pointermove', this.eventHandlers.onPointerMove);
+ realityEditor.device.utilities.removeBoundListener(datacraftingEventDiv, 'pointerup', this.eventHandlers.onPointerUp);
+ realityEditor.device.utilities.removeBoundListener(datacraftingEventDiv, 'pointercancel', this.eventHandlers.onPointerUp);
+
+ }
+};
+
+realityEditor.gui.crafting.resetCraftingBoard = function() {
+ this.removeDatacraftingEventListeners();
+ this.resetTempLogicState(globalStates.currentLogic);
+ var container = document.getElementById('craftingBoard');
+ while (container.hasChildNodes()) {
+ container.removeChild(container.lastChild);
+ }
+ globalStates.currentLogic = null;
+};
+
+realityEditor.gui.crafting.resetTempLogicState = function(logic) {
+ if (logic) {
+ delete logic.guiState;
+ logic.guiState = new LogicGUIState();
+ }
+};
+
+// should only be called once to initialize a blank datacrafting interface and data model
+realityEditor.gui.crafting.initializeDataCraftingGrid = function(logic) {
+ globalStates.currentLogic = logic;
+
+ var container = document.getElementById('craftingBoard');
+ container.className = "craftingBoardBlur";
+
+ var containerWidth = container.clientWidth - realityEditor.gui.crafting.menuBarWidth;
+ var containerHeight = container.clientHeight;
+
+ var GRID_ASPECT_RATIO = CRAFTING_GRID_WIDTH / CRAFTING_GRID_HEIGHT;
+
+ var gridWidth = Math.max(CRAFTING_GRID_WIDTH, containerWidth * 0.8);
+ var gridHeight = Math.max(CRAFTING_GRID_HEIGHT, containerHeight * 0.8);
+
+ var newAspectRatio = gridWidth / gridHeight;
+
+ if (newAspectRatio < GRID_ASPECT_RATIO) {
+ gridHeight = gridWidth / GRID_ASPECT_RATIO;
+ } else if (newAspectRatio > GRID_ASPECT_RATIO) {
+ gridWidth = gridHeight * GRID_ASPECT_RATIO;
+ }
+
+ // initializes the data model for the datacrafting board
+ logic.grid = new this.grid.Grid(containerWidth, containerHeight, gridWidth, gridHeight, logic.uuid);
+
+ var datacraftingCanvas = document.createElement('canvas');
+ datacraftingCanvas.setAttribute('id', 'datacraftingCanvas');
+ container.appendChild(datacraftingCanvas);
+
+ // var dimensions = logic.grid.getPixelDimensions(); // no longer gives the pixel dimensions we need
+ datacraftingCanvas.width = containerWidth;
+ datacraftingCanvas.style.width = containerWidth;
+ datacraftingCanvas.height = containerHeight;
+ datacraftingCanvas.style.height = containerHeight;
+
+ // holds the colored background blocks
+ var blockPlaceholdersContainer = document.createElement('div');
+ blockPlaceholdersContainer.setAttribute('id', 'blockPlaceholders');
+ blockPlaceholdersContainer.style.position = 'absolute';
+ blockPlaceholdersContainer.style.left = logic.grid.xMargin + 'px';
+ blockPlaceholdersContainer.style.top = logic.grid.yMargin + 'px';
+ container.appendChild(blockPlaceholdersContainer);
+
+ for (var rowNum = 0; rowNum < logic.grid.size; rowNum++) {
+
+ if (rowNum % 2 === 0) {
+
+ let rowDiv = document.createElement('div');
+ rowDiv.setAttribute("class", "blockPlaceholderRow");
+ rowDiv.style.height = logic.grid.blockRowHeight;
+ blockPlaceholdersContainer.appendChild(rowDiv);
+
+ for (var colNum = 0; colNum < logic.grid.size; colNum++) {
+ if (colNum % 2 === 0) {
+ var blockPlaceholder = document.createElement('div');
+ rowDiv.appendChild(blockPlaceholder);
+
+ var className = (colNum === logic.grid.size - 1) ? "blockPlaceholderLastCol" : "blockPlaceholder";
+ blockPlaceholder.setAttribute("class", className);
+
+ blockPlaceholder.style.width = (gridWidth * (2/11)) + 'px';
+ blockPlaceholder.style.marginRight = (gridWidth * (1/11)) + 'px';
+
+ if (rowNum === 0 || rowNum === 6) {
+ blockPlaceholder.style.border = "3px solid " + realityEditor.gui.crafting.blockColorMap[colNum / 2] + "55"; //rgb(45, 255, 254);"
+ var labelContainer = document.createElement("div");
+ labelContainer.setAttribute("class", "blockPlaceholderLabel");
+ var label = document.createElement("div");
+ label.style.color = 'cyan';
+ label.innerHTML = (rowNum === 0) ? "IN" : "OUT";
+ labelContainer.appendChild(label);
+ blockPlaceholder.appendChild(labelContainer);
+ } else {
+ realityEditor.gui.moveabilityCorners.wrapDivWithCorners(blockPlaceholder, 0, true, {opacity: 0.5});
+ }
+ }
+ }
+
+ } else {
+
+ let rowDiv = document.createElement('div');
+ rowDiv.setAttribute("class", "blockPlaceholderRow");
+ rowDiv.style.height = logic.grid.marginRowHeight;
+ blockPlaceholdersContainer.appendChild(rowDiv);
+
+ }
+ }
+
+ this.initLogicInOutBlocks();
+
+ var portCells = logic.grid.cells.filter(function(cell) {
+ return cell.canHaveBlock() && (cell.location.row === 0 || cell.location.row === logic.grid.size-1);
+ });
+ this.eventHelper.replacePortBlocksIfNecessary(portCells);
+
+ // add a container where the real blocks will eventually be added
+ var blocksContainer = document.createElement('div');
+ blocksContainer.setAttribute('id', 'blocks');
+ container.appendChild(blocksContainer);
+
+ // an invisible div on top captures all the touch events and handles them properly
+ var datacraftingEventDiv = document.createElement('div');
+ datacraftingEventDiv.setAttribute('id', 'datacraftingEventDiv');
+ datacraftingEventDiv.setAttribute("touch-action", "none");
+ container.appendChild(datacraftingEventDiv);
+
+ var craftingMenusContainer = document.createElement('div');
+ craftingMenusContainer.id = 'craftingMenusContainer';
+ craftingMenusContainer.style.width = containerWidth + 'px';
+ craftingMenusContainer.style.height = containerHeight + 'px';
+ craftingMenusContainer.style.position = 'relative';
+ craftingMenusContainer.style.left = '0';
+ craftingMenusContainer.style.top = '0';
+ // craftingMenusContainer.style.pointerEvents = 'none';
+ craftingMenusContainer.style.display = 'none';
+ container.appendChild(craftingMenusContainer);
+
+ this.updateGrid(logic.grid);
+ this.addDatacraftingEventListeners();
+};
+
+realityEditor.gui.crafting.initLogicInOutBlocks = function() {
+ for (var y = -1; y <= 4; y+= 5) {
+ var namePrefix = y === -1 ? "in" : "out";
+ for (var x = 0; x <= 3; x++) {
+ var type = namePrefix;
+ var name = namePrefix + x;
+ var activeInputs = (y === -1) ? [false, false, false, false] : [true, false, false, false];
+ var activeOutputs = (y === -1) ? [true, false, false, false] : [false, false, false, false];
+ var blockJSON = this.utilities.toBlockJSON(type, name, 1, {}, {}, activeInputs, activeOutputs, ["","","",""], ["","","",""]);
+ var globalId = name;
+ this.grid.addBlock(x, y, blockJSON, globalId, true);
+ }
+ }
+};
+
+/**
+ * Updates this logic node's connectedInputColors and connectedOutputColors by looking at all links on all objects
+ * that either start or end at this logic node and seeing which color they are connected to.
+ * Resulting format is something like [true, false, false, true] - meaning blue and red are connected on outside
+ * @param {Logic} logic
+ */
+realityEditor.gui.crafting.recalculateConnectedColors = function(logic) {
+ let connectedLinks = realityEditor.getLinksToAndFromNode(logic.uuid);
+
+ let connectedInputs = connectedLinks.linksToNode.map(function(link) {
+ return link.logicB; // the port number of the end of the link
+ });
+ let connectedOutputs = connectedLinks.linksFromNode.map(function(link) {
+ return link.logicA; // the port number of the start of the link
+ });
+
+ // 0 = blue, 1 = green, 2 = yellow, 3 = red
+ [0, 1, 2, 3].forEach(function(index) {
+ logic.guiState.connectedInputColors[index] = connectedInputs.includes(index);
+ logic.guiState.connectedOutputColors[index] = connectedOutputs.includes(index);
+ });
+};
diff --git a/src/gui/crafting/nodeSettings.html b/src/gui/crafting/nodeSettings.html
new file mode 100644
index 000000000..3d3bb506d
--- /dev/null
+++ b/src/gui/crafting/nodeSettings.html
@@ -0,0 +1,478 @@
+
+
+
+
+ Title
+
+
+
+
+
+
+
+
+
+
+
+
+
+
LOGIC0
+
+
+
+ Rename Node
+
+
+
+ No Icon
+ Auto Icon
+ Custom Icon
+
+ Upload File
+
+
+
+
(uses icon of first block placed)
+
+
+
+
+
+
+
diff --git a/src/gui/crafting/utilities.js b/src/gui/crafting/utilities.js
new file mode 100644
index 000000000..f9b4d6ccb
--- /dev/null
+++ b/src/gui/crafting/utilities.js
@@ -0,0 +1,156 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2016 Benjamin Reynholds
+ * Modified by Valentin Heun 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.gui.crafting.utilities");
+
+realityEditor.gui.crafting.utilities.toBlockJSON = function(type, name, blockSize, privateData, publicData, activeInputs, activeOutputs, nameInput, nameOutput) {
+ return {
+ type: type,
+ name: name,
+ blockSize: blockSize,
+ privateData: privateData,
+ publicData: publicData,
+ activeInputs: activeInputs,
+ activeOutputs: activeOutputs,
+ nameInput: nameInput,
+ nameOutput: nameOutput
+ };
+};
+
+realityEditor.gui.crafting.utilities.convertBlockLinkToServerFormat = function(blockLink) {
+ var serverLink = {};
+
+ var keysToSkip = ["route"]; //, "nodeA", "nodeB"
+ for (var key in blockLink) {
+ if (!blockLink.hasOwnProperty(key)) continue;
+ if (keysToSkip.indexOf(key) > -1) continue;
+ serverLink[key] = blockLink[key];
+ }
+
+ serverLink["route"] = null;
+
+ return serverLink;
+};
+
+// strips away unnecessary data from logic node that can be easily regenerated
+realityEditor.gui.crafting.utilities.convertLogicToServerFormat = function(logic) {
+
+ var logicServer = {};
+
+ var keysToSkip = ["guiState", "grid", "blocks", "links"];
+ for (let key in logic) {
+ if (!logic.hasOwnProperty(key)) continue;
+ if (keysToSkip.indexOf(key) > -1) continue;
+ logicServer[key] = logic[key];
+ }
+
+ // VERY IMPORTANT: otherwise the node will think it's already loaded
+ // and won't load from the server next time you open the app
+ logicServer["loaded"] = false;
+ logicServer["visible"] = false;
+
+ // don't upload in/out blocks, those are always the same and live in the editor?
+ logicServer["blocks"] = {};
+ // logicServer["blockData"] = {}; // TODO: did I hide a bug by adding this line
+ for (let key in logic.blocks) {
+ if (!logic.blocks.hasOwnProperty(key)) continue;
+ if (!this.crafting.grid.isInOutBlock(key)) {
+ // logicServer.blockData[key] = logic.blocks[key]; // TODO: this used to cause a bug
+ logicServer.blocks[key] = logic.blocks[key]; // TODO: this used to cause a bug
+ }
+ }
+
+ // TODO: make sure this doesn't cause bugs somewhere else
+ logicServer["links"] = {};
+ for (let key in logic.links) {
+ if (!logic.links.hasOwnProperty(key)) continue;
+ logicServer.links[key] = this.convertBlockLinkToServerFormat(logic.links[key]);
+ }
+
+ return logicServer;
+};
+
+/*
+// todo hasOwnProperty
+// convert links from in/out -> block not in edge row into 2 links, one from in/out->edge and another from edge->block
+// this puts the data in a format that is convenient for the UI while keeping the server data efficient
+realityEditor.gui.crafting.utilities.convertLinksFromServer = function(logic) {
+
+ // add block/link methods haven't been generalized to work on any logic,
+ // it currently relies on currentLogic, so we need to set/reset that around this method // todo: generalize these logic methods so this hack isn't necessary
+ var priorLogic = globalStates.currentLogic;
+ globalStates.currentLogic = logic;
+
+ for (var linkKey in logic.links) {
+ var link = logic.links[linkKey];
+
+ if (this.crafting.grid.isInOutBlock(link.nodeA) && logic.blocks[link.nodeB] && logic.blocks[link.nodeB].y !== 0) {
+ // create separate links from in->edge and edge->block
+ var x = link.nodeA.slice(-1);
+ this.crafting.grid.addBlockLink(link.nodeA, this.crafting.eventHelper.edgePlaceholderName(true, x), link.logicA, link.logicB, true);
+ this.crafting.grid.addBlockLink(this.crafting.eventHelper.edgePlaceholderName(true, x), link.nodeB, link.logicA, link.logicB, true);
+
+ delete logic.links[linkKey];
+
+ } else if (this.crafting.grid.isInOutBlock(link.nodeB) && logic.blocks[link.nodeA] && logic.blocks[link.nodeA].y !== 3) {
+
+ // create separate links from block->edge and edge->out
+ var x = link.nodeB.slice(-1);
+ this.crafting.grid.addBlockLink(link.nodeA, this.crafting.eventHelper.edgePlaceholderName(false, x), link.logicA, link.logicB, true);
+ this.crafting.grid.addBlockLink(this.crafting.eventHelper.edgePlaceholderName(false, x), link.nodeB, link.logicA, link.logicB, true);
+
+ delete logic.links[linkKey];
+ }
+ }
+
+ // restore prior state
+ globalStates.currentLogic = priorLogic;
+
+};
+*/
diff --git a/src/gui/dropdown.js b/src/gui/dropdown.js
new file mode 100644
index 000000000..2d3eed0f1
--- /dev/null
+++ b/src/gui/dropdown.js
@@ -0,0 +1,339 @@
+createNameSpace("realityEditor.gui.dropdown");
+
+/**
+ * @fileOverview realityEditor.gui.dropdown
+ * This exports a class that can be used to create dropdown menus (e.g. the one used to select a Reality Zone)
+ * The dropdown can be expanded or collapsed by clicking on the top div
+ * "Selectables" (items in the dropdown list) can be added or removed, and clicking on one updates the current selection
+ * A callback can be passed in the constructor to listen to changes in the dropdown selection and state.
+ */
+
+(function(exports) {
+
+ /**
+ * @typedef {Object} DropdownTextStates
+ * @description Which text to show on the top-level UI when the dropdown is in each possible state
+ * @property {string} collapsedUnselected - what to show when you haven't chosen anything yet, and it's minimized
+ * @property {string} expandedEmpty - what to show when it's not minimized, but there are currently no options to choose from
+ * @property {string} expandedOptions - what to show when it's not minimized and there are options to choose from
+ * @property {string} selected - what to show when you've selected an option (it will minimize itself when this happens, too)
+ * for this state, appends text from selected item: this.selectedText + this.selected.element.innerHTML
+ */
+
+ /**
+ * @typedef {Readonly<{collapsedUnselected: number, expandedEmpty: number, expandedOptions: number, selected: number}>} DropdownState
+ * @description enum used to keep track of current state of the drop down menu
+ */
+
+ /**
+ * Constructor for a new drop down menu with all the state, logic, UI, and callbacks
+ * @param {string} id - the div id
+ * @param {DropdownTextStates} textStates - which text the div should display in each state
+ * @param {Object} css - a JSON object with any additional styles to apply to the div (e.g. left, top, etc)
+ * @param {HTMLElement} parent - the DOM element to add this to (e.g. document.body)
+ * @param {boolean} isCollapsed - by default should it be collapsed (minimized - only show title) or expanded (show all items)
+ * @param {function} onSelectionChanged - callback triggered when you select an item in the list
+ * includes argument: {index: index, element: selectableDom}
+ * @param {function} onExpandedChanged - callback triggered when dropdown is expanded or collapsed
+ * includes boolean argument: isExpanded
+ * @constructor
+ */
+ function Dropdown(id, textStates, css, parent, isCollapsed, onSelectionChanged, onExpandedChanged) {
+ this.id = id;
+ this.text = '';
+ this.css = css;
+
+ this.dom = null;
+ this.textDiv = null;
+ this.selectables = [];
+ this.isCollapsed = isCollapsed;
+
+ this.selected = null;
+
+ this.onSelectionChanged = onSelectionChanged;
+ this.onExpandedChanged = onExpandedChanged;
+
+ this.isAnimating = false;
+
+ this.states = Object.freeze({
+ collapsedUnselected: 0,
+ expandedEmpty: 1,
+ expandedOptions: 2,
+ selected: 3
+ });
+ this.setTextStates(textStates.collapsedUnselected, textStates.expandedEmpty, textStates.expandedOptions, textStates.selected);
+
+ this.addDomToParent(parent);
+
+ if (this.isCollapsed) {
+ this.updateState(this.states.collapsedUnselected);
+ } else {
+ this.updateState(this.states.expandedEmpty);
+ }
+ }
+
+ /**
+ * Sets each of the text variables based on a field from a DropdownTextStates object
+ * @param {string} collapsedUnselected
+ * @param {string} expandedEmpty
+ * @param {string} expandedOptions
+ * @param {string} selected
+ */
+ Dropdown.prototype.setTextStates = function(collapsedUnselected, expandedEmpty, expandedOptions, selected) {
+ this.collapsedUnselectedText = collapsedUnselected;
+ this.expandedEmptyText = expandedEmpty;
+ this.expandedOptionsText = expandedOptions;
+ this.selectedText = selected;
+ };
+
+ /**
+ * Updates the dropdown text based on the current state and the textStates set during the constructor.
+ * When expanded, also includes the total number of items in the list in parentheses.
+ * @param {DropdownState} newState
+ */
+ Dropdown.prototype.updateState = function(newState) {
+ this.state = newState;
+
+ if (this.state === this.states.collapsedUnselected) {
+ this.setText(this.collapsedUnselectedText, true);
+ } else if (this.state === this.states.expandedEmpty) {
+ this.setText(this.expandedEmptyText);
+ } else if (this.state === this.states.expandedOptions) {
+ this.setText(this.expandedOptionsText);
+ } else if (this.state === this.states.selected) {
+ this.setText(this.selectedText + this.selected.element.innerHTML, true);
+ }
+ };
+
+ /**
+ * Creates the divs for this menu, attaches click listeners, and renders it for the correct initial state
+ * @return {HTMLElement|undefined}
+ */
+ Dropdown.prototype.createDom = function() {
+ if (this.dom) return;
+
+ this.dom = document.createElement('div');
+ this.dom.id = this.id;
+ this.dom.classList.add('dropdownContainer');
+ this.dom.classList.add('containerCollapsed');
+
+ this.textDiv = document.createElement('div');
+ this.textDiv.classList.add('dropdownText');
+ this.textDiv.innerHTML = this.text;
+ this.dom.appendChild(this.textDiv);
+
+ for (var propKey in this.css) {
+ if (!this.css.hasOwnProperty(propKey)) continue;
+ this.dom.style[propKey] = this.css[propKey];
+ }
+
+ this.textDiv.addEventListener('click', function() {
+ this.toggleExpansion();
+ }.bind(this));
+
+ if (this.isCollapsed) {
+ this.collapse();
+ } else {
+ this.expand();
+ }
+
+ return this.dom;
+ };
+
+ /**
+ * Creates the DOM elements for the menu if needed, and adds them to the provided parent element
+ * @param {HTMLElement} parentElement
+ */
+ Dropdown.prototype.addDomToParent = function(parentElement) {
+ this.createDom();
+ parentElement.appendChild(this.dom);
+ };
+
+ /**
+ * Adds a new item to the dropdown menu list. Creates its DOM element and renders it in the list if needed.
+ * @param {string} id - div id for the menu item
+ * @param {string} text - human-readable text to display for the menu item
+ */
+ Dropdown.prototype.addSelectable = function(id, text) {
+ var selectableDom = document.createElement('div');
+ selectableDom.classList.add('dropdownSelectable');
+ selectableDom.id = id;
+ selectableDom.innerText = text;
+
+ var index = this.selectables.length;
+ selectableDom.dataset.index = index;
+
+ if (this.isCollapsed) {
+ selectableDom.classList.add('dropdownCollapsed');
+ } else {
+ selectableDom.classList.add('dropdownExpanded');
+ }
+
+ this.selectables.push(selectableDom);
+
+ if (this.state === this.states.expandedEmpty || this.state === this.states.expandedOptions) {
+ this.updateState(this.states.expandedOptions);
+ }
+
+ selectableDom.addEventListener('click', function() {
+
+ if (this.isAnimating) { return; }
+
+ if (this.selected && this.selected.element) {
+ // remove style from previously selected dom
+ this.selected.element.classList.remove('dropdownSelected');
+
+ // if clicked the currently selected element again, deselect it
+ if (this.selected.element === selectableDom) {
+ this.selected = null;
+
+ this.updateState(this.states.expandedOptions);
+
+ if (this.onSelectionChanged) {
+ this.onSelectionChanged(this.selected);
+ }
+ return;
+ }
+ }
+
+ // select the new element and restyle it
+ this.selected = {
+ index: index,
+ element: selectableDom
+ };
+ selectableDom.classList.add('dropdownSelected');
+
+ // this.setText('Connected to ' + selectableDom.innerHTML, true);
+ this.collapse();
+
+ if (this.onSelectionChanged) {
+ this.onSelectionChanged(this.selected);
+ }
+
+ }.bind(this));
+
+ this.dom.appendChild(selectableDom);
+ };
+
+ /**
+ * Sets the text of the top-level menu element.
+ * Also includes the total number of items in parentheses unless "true" passed into last argument.
+ * @param {string} newText
+ * @param {boolean|undefined} hideSelectableCount
+ */
+ Dropdown.prototype.setText = function(newText, hideSelectableCount) {
+ this.text = newText;
+ this.textDiv.innerHTML = newText;
+ if (!hideSelectableCount) {
+ this.textDiv.innerHTML += ' (' + this.selectables.length + ')';
+ }
+ };
+
+ /**
+ * Minimize the menu so that it doesn't show the list of options, only the selected item
+ * (or whatever text was set for the top-level element).
+ * Animates the transition based on getExpansionSpeed(), and triggers any registered callbacks.
+ */
+ Dropdown.prototype.collapse = function() {
+ if (this.isAnimating) { return; }
+
+ this.isAnimating = true;
+ this.isCollapsed = true;
+ this.selectables.forEach(function(element) { // collapses from the bottom up, in an animated fashion
+ setTimeout(function() {
+ element.classList.remove('dropdownExpanded');
+ element.classList.add('dropdownCollapsed');
+ }, (((this.selectables.length-1) - element.dataset.index) * this.getExpansionSpeed()));
+ }.bind(this));
+ this.dom.classList.add('containerCollapsed');
+
+ if (this.selected) {
+ this.updateState(this.states.selected);
+ } else {
+ this.updateState(this.states.collapsedUnselected);
+ }
+
+ if (this.onExpandedChanged) {
+ this.onExpandedChanged(!this.isCollapsed);
+ }
+
+ setTimeout(function() {
+ this.isAnimating = false;
+ }.bind(this), this.selectables.length * this.getExpansionSpeed());
+ };
+
+ /**
+ * How many milliseconds to wait before expanding/collapsing the next item, once the previous item was collapsed.
+ * The more items there are in total, the shorter the time in between each.
+ * @return {number}
+ */
+ Dropdown.prototype.getExpansionSpeed = function() {
+ return 200 / (this.selectables.length+1);
+ };
+
+ /**
+ * Expands the menu to show the full list of items you can select (similar but opposite of this.collapse)
+ */
+ Dropdown.prototype.expand = function() {
+ if (this.isAnimating) { return; }
+
+ this.isAnimating = true;
+ this.isCollapsed = false;
+ this.selectables.forEach(function(element) { // expands from the top down, in an animated fashion
+ setTimeout(function() {
+ element.classList.remove('dropdownCollapsed');
+ element.classList.add('dropdownExpanded');
+ }, (element.dataset.index * this.getExpansionSpeed()));
+ }.bind(this));
+ this.dom.classList.remove('containerCollapsed');
+
+ if (this.selected) {
+ this.updateState(this.states.selected);
+ } else if (this.selectables.length === 0) {
+ this.updateState(this.states.expandedEmpty);
+ } else {
+ this.updateState(this.states.expandedOptions);
+ }
+
+ if (this.onExpandedChanged) {
+ this.onExpandedChanged(!this.isCollapsed);
+ }
+
+ setTimeout(function() {
+ this.isAnimating = false;
+ }.bind(this), this.selectables.length * this.getExpansionSpeed());
+ };
+
+ /**
+ * Collapses the menu if it's expanded, or expands it if it's collapsed
+ */
+ Dropdown.prototype.toggleExpansion = function() {
+ if (this.isCollapsed) {
+ this.expand();
+ } else {
+ this.collapse();
+ }
+ };
+
+ /**
+ * De-selects whichever element is selected, if any
+ */
+ Dropdown.prototype.resetSelection = function() {
+ if (this.selected && this.selected.element) {
+ // remove style from previously selected dom
+ this.selected.element.classList.remove('dropdownSelected');
+
+ // if clicked the currently selected element again, deselect it
+ this.selected = null;
+
+ this.updateState(this.states.expandedOptions);
+
+ // if (this.onSelectionChanged) {
+ // this.onSelectionChanged(this.selected);
+ // }
+ }
+ };
+
+ exports.Dropdown = Dropdown;
+
+})(realityEditor.gui.dropdown);
diff --git a/src/gui/envelopeIcons.js b/src/gui/envelopeIcons.js
new file mode 100644
index 000000000..cded292c7
--- /dev/null
+++ b/src/gui/envelopeIcons.js
@@ -0,0 +1,290 @@
+/**
+ * This is used to render temporarily icons for envelopes that have received a
+ * "blur" event to remove their 2D UI layer but keep their 3D fullscreen content
+ * on the screen; we add simple image divs floating at the envelope origin so
+ * that clicking on them can restore "focus" to that envelope.
+ */
+class EnvelopeIconRenderer {
+ constructor() {
+ this.knownEnvelopes = {};
+ this.arUtilities = realityEditor.gui.ar.utilities;
+
+ this.callbacks = {
+ onIconStartDrag: [],
+ onIconStopDrag: []
+ };
+
+ this.dragState = {
+ pointerDown: false,
+ didStartDrag: false,
+ target: {
+ icon: null,
+ objectId: null,
+ frameId: null
+ },
+ draggedIcon: null
+ };
+
+ this.onVehicleDeleted = this.onVehicleDeleted.bind(this);
+ this.onPointerMove = this.onPointerMove.bind(this);
+ this.onIconPointerDown = this.onIconPointerDown.bind(this);
+ this.onIconPointerUp = this.onIconPointerUp.bind(this);
+ this.onIconPointerOut = this.onIconPointerOut.bind(this);
+ this.resetDrag = this.resetDrag.bind(this);
+ }
+
+ initService() {
+ this.gui = document.getElementById('GUI');
+
+ realityEditor.device.registerCallback('vehicleDeleted', this.onVehicleDeleted); // deleted using userinterface
+ realityEditor.network.registerCallback('vehicleDeleted', this.onVehicleDeleted); // deleted using server
+
+ document.addEventListener('pointercancel', this.resetDrag);
+ document.addEventListener('pointerup', this.resetDrag);
+ document.addEventListener('pointermove', this.onPointerMove);
+
+ realityEditor.gui.ar.draw.addUpdateListener(() => {
+ Object.values(this.knownEnvelopes).forEach(envelope => {
+ this.updateEnvelope(envelope);
+ });
+ });
+ }
+
+ onVehicleDeleted(event) {
+ if (!event.objectKey || !event.frameKey || event.nodeKey) {
+ return;
+ }
+
+ this.removeEnvelopeIcon(event.frameKey);
+
+ delete this.knownEnvelopes[event.frameKey];
+ }
+
+ onEnvelopeRegistered(envelope) {
+ this.knownEnvelopes[envelope.frame] = envelope;
+ }
+
+ onOpen(envelope) {
+ this.knownEnvelopes[envelope.frame].isOpen = true;
+ this.knownEnvelopes[envelope.frame].hasFocus = true;
+ }
+
+ onClose(envelope) {
+ this.knownEnvelopes[envelope.frame].isOpen = false;
+ this.knownEnvelopes[envelope.frame].hasFocus = false;
+ }
+
+ onFocus(envelope) {
+ this.knownEnvelopes[envelope.frame].hasFocus = true;
+ }
+
+ onBlur(envelope) {
+ this.knownEnvelopes[envelope.frame].hasFocus = false;
+ }
+
+ updateEnvelope(envelope) {
+ if (envelope.isOpen && !envelope.hasFocus) {
+ this.renderEnvelopeIcon(envelope.object, envelope.frame);
+ } else {
+ this.removeEnvelopeIcon(envelope.frame);
+ }
+ }
+
+ removeEnvelopeIcon(frameId) {
+ if (!globalDOMCache['envelopeIcon_' + frameId]) return;
+ this.gui.removeChild(globalDOMCache['envelopeIcon_' + frameId]);
+ globalDOMCache['envelopeIcon_' + frameId] = null;
+ }
+
+ renderEnvelopeIcon(objectId, frameId) {
+ // lazily instantiate the envelope icon if it doesn't already exist
+ let iconDiv = globalDOMCache['envelopeIcon_' + frameId];
+ let frame = realityEditor.getFrame(objectId, frameId);
+ if (!iconDiv) {
+ let object = realityEditor.getObject(objectId);
+ let name = frame.src;
+ let port = realityEditor.network.getPort(object);
+ let path = '/frames/' + name + '/icon-foreground.svg';
+ let src = realityEditor.network.getURL(object.ip, port, path);
+ iconDiv = this.createIconDiv(frameId, src);
+ let icon = iconDiv.querySelector('.minimizedEnvelopeIcon');
+ icon.dataset.objectId = objectId;
+ icon.dataset.frameId = frameId;
+ icon.addEventListener('pointerdown', this.onIconPointerDown);
+ icon.addEventListener('pointerup', this.onIconPointerUp);
+ icon.addEventListener('pointercancel', this.onIconPointerUp);
+ icon.addEventListener('pointerout', this.onIconPointerOut);
+ }
+
+ // We ALWAYS want the icon to face the camera, so don't need to check if frame.alwaysFaceCamera is true
+ // let finalMatrix = this.arUtilities.copyMatrix(realityEditor.sceneGraph.getCSSMatrix(frameId));
+ let finalMatrix = [];
+ let modelMatrix = realityEditor.sceneGraph.getModelMatrixLookingAt(frameId, 'CAMERA');
+ let modelViewMatrix = [];
+ this.arUtilities.multiplyMatrix(modelMatrix, realityEditor.sceneGraph.getViewMatrix(), modelViewMatrix);
+
+ // In AR mode, we need to use this lookAt method, because camera up vec doesn't always match scene up vec
+ if (realityEditor.device.environment.isARMode()) {
+ this.arUtilities.multiplyMatrix(modelViewMatrix, globalStates.projectionMatrix, finalMatrix);
+ } else {
+ // the lookAt method isn't perfect โ it has a singularity as you approach top or bottom
+ // so let's correct the scale and remove the rotation โ this works on desktop because camera up = scene up
+ let scale = realityEditor.sceneGraph.getSceneNodeById(frameId).getVehicleScale();
+ let constructedModelViewMatrix = [
+ scale, 0, 0, 0,
+ 0, -scale, 0, 0,
+ 0, 0, scale, 0,
+ modelViewMatrix[12], modelViewMatrix[13], modelViewMatrix[14], 1
+ ];
+ this.arUtilities.multiplyMatrix(constructedModelViewMatrix, globalStates.projectionMatrix, finalMatrix);
+ }
+
+ finalMatrix[14] = realityEditor.gui.ar.positioning.getFinalMatrixScreenZ(finalMatrix[14]);
+
+ // normalize the matrix and clear the last column, to avoid some browser-specific bugs
+ let normalizedMatrix = realityEditor.gui.ar.utilities.normalizeMatrix(finalMatrix);
+ normalizedMatrix[3] = 0;
+ normalizedMatrix[7] = 0;
+ normalizedMatrix[11] = 0;
+
+ // if tool is rendering while it should be behind the camera, visually hide it (for now)
+ if (normalizedMatrix[14] < 0) {
+ iconDiv.classList.add('elementBehindCamera');
+ } else {
+ iconDiv.classList.remove('elementBehindCamera');
+ }
+
+ iconDiv.style.transform = 'matrix3d(' + normalizedMatrix.toString() + ')';
+ }
+
+ createIconDiv(frameId, src, isCopy) {
+ let container = document.createElement('div');
+ if (!isCopy) {
+ container.id = 'envelopeIcon_' + frameId;
+ globalDOMCache['envelopeIcon_' + frameId] = container;
+ }
+ container.classList.add('main', 'visibleFrameContainer', 'minimizedEnvelopeContainer');
+ this.gui.appendChild(container);
+
+ let icon = document.createElement('img');
+ icon.src = src;
+ icon.classList.add('minimizedEnvelopeIcon', 'tool-color-gradient');
+ icon.setAttribute('frameId', frameId);
+ container.appendChild(icon);
+
+ return container;
+ }
+
+ resetDrag() {
+ let draggedIcon = this.dragState.draggedIcon;
+ // if we have a draggedIcon, remove it
+ if (draggedIcon && draggedIcon.parentElement) {
+ let boundingRect = draggedIcon.getBoundingClientRect();
+ let x = parseInt(draggedIcon.style.left) + boundingRect.width/2;
+ let y = parseInt(draggedIcon.style.top) + boundingRect.height/2;
+
+ // delete the associated tool if the icon is over the trash zone
+ if (realityEditor.device.isPointerInTrashZone(x, y)) {
+ // delete it
+ let frame = realityEditor.getFrame(this.dragState.target.objectId, this.dragState.target.frameId);
+ if (frame) {
+ realityEditor.device.tryToDeleteSelectedVehicle(frame);
+ }
+ }
+ draggedIcon.parentElement.removeChild(draggedIcon);
+ }
+
+ this.dragState = {
+ pointerDown: false,
+ didStartDrag: false,
+ target: {
+ icon: null,
+ objectId: null,
+ frameId: null
+ },
+ draggedIcon: null
+ }
+
+ this.callbacks.onIconStopDrag.forEach(cb => cb());
+ }
+
+ setDragTarget(objectId, frameId) {
+ this.dragState.target.icon = document.getElementById('envelopeIcon_' + frameId); // this.getIcon(frameId);
+ this.dragState.target.objectId = objectId;
+ this.dragState.target.frameId = frameId;
+ }
+
+ onIconPointerDown(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) return;
+ const iconElt = event.target;
+ this.setDragTarget(iconElt.dataset.objectId, iconElt.dataset.frameId);
+ this.dragState.pointerDown = true;
+ }
+
+ onIconPointerUp(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) return;
+ this.dragState.pointerDown = false;
+ let frameId = event.currentTarget.getAttribute('frameId');
+ if (frameId) {
+ realityEditor.envelopeManager.focusEnvelope(frameId);
+ }
+ }
+
+ onIconPointerOut(event) {
+ // this.hoveredFrameId = null;
+
+ const iconElt = event.target;
+ if (this.dragState.pointerDown) {
+ if (this.dragState.target.frameId &&
+ this.dragState.target.frameId === iconElt.dataset.frameId) {
+ this.activateDrag();
+ }
+ }
+ }
+
+ activateDrag() {
+ if (this.dragState.didStartDrag) return;
+ this.dragState.didStartDrag = true;
+
+ //create ghost of button
+ let target = this.dragState.target;
+ // let draggedIcon = this.createIconImg(target.objectId, target.frameId);
+
+ let object = objects[target.objectId];
+ let frame = object.frames[target.frameId];
+ let port = realityEditor.network.getPort(object);
+ let path = '/frames/' + frame.src + '/icon-foreground.svg';
+ let src = realityEditor.network.getURL(object.ip, port, path);
+ let draggedIcon = this.createIconDiv(target.frameId, src, true);
+ let iconImg = draggedIcon.querySelector('.minimizedEnvelopeIcon');
+ // iconImg.classList.remove('tool-color-gradient');
+ iconImg.style.transform = 'scale(0.25)';
+
+ draggedIcon.style.opacity = '.75';
+ draggedIcon.style.pointerEvents = 'none';
+ document.body.appendChild(draggedIcon);
+ this.dragState.draggedIcon = draggedIcon;
+
+ this.callbacks.onIconStartDrag.forEach(cb => cb());
+ }
+
+ onPointerMove(event) {
+ if (!this.dragState.pointerDown) return;
+ if (!this.dragState.didStartDrag) return;
+ if (!this.dragState.draggedIcon) return;
+ if (realityEditor.device.isMouseEventCameraControl(event)) return;
+
+ let boundingRect = this.dragState.draggedIcon.getBoundingClientRect();
+
+ this.dragState.draggedIcon.style.left = `${event.pageX - boundingRect.width/2}px`;
+ this.dragState.draggedIcon.style.top = `${event.pageY - boundingRect.height/2}px`;
+
+ if (realityEditor.device.isPointerInTrashZone(event.pageX, event.pageY)) {
+ overlayDiv.classList.add('overlayNegative');
+ } else {
+ overlayDiv.classList.remove('overlayNegative');
+ }
+ }
+}
+
+realityEditor.gui.envelopeIconRenderer = new EnvelopeIconRenderer();
diff --git a/src/gui/glRenderer.js b/src/gui/glRenderer.js
new file mode 100644
index 000000000..42c600294
--- /dev/null
+++ b/src/gui/glRenderer.js
@@ -0,0 +1,498 @@
+createNameSpace("realityEditor.gui.glRenderer");
+
+(function(exports) {
+ let workerIds = {};
+ let nextWorkerId = 1;
+ let toolIdToProxy = {};
+ let proxies = [];
+ let rendering = false;
+
+ const MAX_PROXIES = 32; // maximum number that can be safely rendered each frame
+
+ /**
+ * Mediator between the worker iframe and the gl implementation
+ */
+ class WorkerGLProxy {
+ /**
+ * @param {Element} worker - worker iframe
+ * @param {WebGLContext} gl
+ * @param {number|string} workerId - unique identifier of worker
+ * @param {string} toolId - unique identifier of associated tool
+ */
+ constructor(worker, gl, workerId, toolId) {
+ this.worker = worker;
+ this.gl = gl;
+ this.workerId = workerId;
+ this.toolId = toolId;
+
+ this.uncloneables = {};
+
+ this.commandBuffer = [];
+ this.previousCommandBuffer = [];
+ this.lastUseProgram = null;
+ this.lastActiveTexture = {
+ name: 'activeTexture',
+ args: [this.gl.TEXTURE0],
+ };
+ this.lastTargettedBinds = {};
+ this.lastTextureBinds = {};
+ this.lastCapabilities = {};
+ this.lastBindVertexArray = {
+ name: 'bindVertexArray',
+ args: [null],
+ };
+ this.buffering = false;
+
+ this.onMessage = this.onMessage.bind(this);
+ window.addEventListener('message', this.onMessage);
+
+ this.frameEndListener = null;
+ }
+
+ onMessage(e) {
+ const message = e.data;
+ if (message.workerId !== this.workerId) {
+ return;
+ }
+
+ if (this.frameEndListener && message.isFrameEnd) {
+ this.frameEndListener(true);
+ return;
+ }
+
+ if (this.buffering) {
+ this.commandBuffer.push(message);
+ return;
+ }
+
+ const res = this.executeCommand(message);
+
+ if (message.wantsResponse) {
+ this.worker.postMessage({
+ id: message.id,
+ result: res,
+ }, '*');
+ }
+ }
+
+ executeCommand(message) {
+ if (message.messages) {
+ for (let bufferedMessage of message.messages) {
+ this.executeOneCommand(bufferedMessage);
+ }
+ } else {
+ this.executeOneCommand(message);
+ }
+ }
+
+ executeOneCommand(message) {
+ for (let i = 0; i < message.args.length; i++) {
+ let arg = message.args[i];
+ if (arg && arg.fakeClone) {
+ message.args[i] = this.uncloneables[arg.index];
+ }
+ }
+
+ if (!this.gl[message.name] && !message.name.startsWith('extVao-')) {
+ return;
+ }
+
+ if (message.name === 'clear') {
+ return;
+ }
+
+ if (message.name === 'useProgram') {
+ this.lastUseProgram = message;
+ }
+
+ if (message.name === 'activeTexture') {
+ this.lastActiveTexture = message;
+ }
+
+ if (message.name === 'bindVertexArray') {
+ this.lastBindVertexArray = message;
+ }
+
+ const targettedBinds = {
+ // Note that all targetted binds should be stored using a VAO
+
+ // bindAttribLocation: true,
+ // bindBuffer: true,
+ // bindFramebuffer: true,
+ // bindRenderbuffer: true,
+
+ // bindTexture: true, // can't be here because of activeTexture nonsense
+ // pixelStorei: true,
+ // texParameterf: true, // 2 hmm
+ // texParameteri: true, // 2
+ // texImage2D: true,
+ };
+
+ if (message.name === 'disable' || message.name === 'enable') {
+ let capaId = message.args[0];
+ if (!this.lastCapabilities.hasOwnProperty(capaId)) {
+ let isEnabled = this.gl.isEnabled(capaId);
+ this.lastCapabilities[capaId] = isEnabled;
+ }
+ let isReturnToDefault =
+ (this.lastCapabilities[capaId] && message.name === 'enable') ||
+ ((!this.lastCapabilities[capaId]) && message.name === 'disable');
+ if (isReturnToDefault) {
+ delete this.lastCapabilities[capaId];
+ }
+ }
+
+ if (targettedBinds.hasOwnProperty(message.name)) {
+ this.lastTargettedBinds[message.name + '-' + message.args[0]] = message;
+ }
+ if (message.name === 'bindTexture') {
+ if (message.args[1]) {
+ let activeTexture = this.lastActiveTexture.args[0];
+ if (!this.lastTextureBinds[activeTexture]) {
+ this.lastTextureBinds[activeTexture] = {};
+ }
+ this.lastTextureBinds[activeTexture][message.name + '-' + message.args[0]] = message;
+ } else {
+ console.warn('bindTexture target undefined', message);
+ }
+ }
+
+ let res;
+
+ if (message.name.startsWith('extVao-')) {
+ let fnName = message.name.split('-')[1]; // e.g. createVertexArrayOES
+ fnName = fnName.replace(/OES$/, '');
+ res = this.gl[fnName].apply(this.gl, message.args);
+ } else {
+ res = this.gl[message.name].apply(this.gl, message.args);
+ }
+
+ if (typeof res === 'object') {
+ this.uncloneables[message.id] = res;
+ res = {fakeClone: true, index: message.id};
+ }
+ return res;
+ }
+
+ logCommandBuffer() {
+ let program = [];
+ for (let command of this.commandBuffer) {
+ let messages = command.messages || [command];
+ for (let message of messages) {
+ let args = message.args.map(arg => {
+ // if (arg.hasOwnProperty('0') && typeof arg !== 'string') {}
+ if (typeof arg === 'object' && arg) {
+ // let arrayArg = [];
+ // for (let a of Array.from(arg)) {
+ // arrayArg.push(typeof a);
+ // }
+ if (arg.length || arg[0]) {
+ arg = [(typeof arg[0]) || 'object', arg.length || Object.keys(arg).length];
+ } else {
+ return arg.toString();
+ }
+ }
+ return JSON.stringify(arg);
+ });
+ program.push(`gl.${message.name}(${args.join(', ')})`);
+ }
+ }
+ let frame = program.join('\n');
+ if (!window.lastFrames) {
+ window.lastFrames = {};
+ }
+ if (!window.lastFrames[frame]) {
+ window.lastFrames[frame] = true;
+
+ console.log(`frame workerId=${this.workerId}`);
+ console.log(frame);
+ }
+ }
+
+ executeFrameCommands() {
+ this.buffering = false;
+
+ let setup = [];
+ if (this.lastBindVertexArray) {
+ setup.push(this.lastBindVertexArray);
+ }
+ if (this.lastUseProgram) {
+ setup.push(this.lastUseProgram);
+ }
+ for (let activeTexture in this.lastTextureBinds) {
+ setup.push({
+ name: 'activeTexture',
+ args: [parseInt(activeTexture)],
+ });
+ for (let command of Object.values(this.lastTextureBinds[activeTexture])) {
+ setup.push(command);
+ }
+ }
+ if (this.lastActiveTexture) {
+ setup.push(this.lastActiveTexture);
+ }
+ if (this.lastTargettedBinds) {
+ for (let command of Object.values(this.lastTargettedBinds)) {
+ setup.push(command);
+ }
+ }
+ let teardown = [];
+ for (let capaId in this.lastCapabilities) {
+ let val = this.lastCapabilities[capaId];
+ teardown.push({
+ name: val ? 'enable' : 'disable',
+ args: [parseInt(capaId)],
+ });
+ }
+ this.commandBuffer = setup.concat(this.commandBuffer).concat(teardown);
+
+ for (let message of this.commandBuffer) {
+ this.executeCommand(message);
+ }
+ // this.logCommandBuffer();
+ this.previousCommandBuffer = this.commandBuffer;
+ this.commandBuffer = [];
+ }
+
+ /**
+ * Execute last successful frame's command buffer
+ */
+ executePreviousFrameCommands() {
+ for (let message of this.previousCommandBuffer) {
+ this.executeCommand(message);
+ }
+ }
+
+ getFrameCommands() {
+ this.buffering = true;
+ this.commandBuffer = [];
+ this.worker.postMessage({name: 'frame', time: Date.now()}, '*');
+ return new Promise((res) => {
+ this.frameEndListener = res;
+ });
+ }
+
+ remove() {
+ this.frameEndListener = null;
+ window.removeEventListener('message', this.onMessage);
+ }
+ }
+
+ let canvas;
+ let gl;
+ const functions = [];
+ const constants = {};
+ let lastRender = Date.now();
+
+ function initService() {
+ // canvas = globalCanvas.canvas;
+ canvas = document.querySelector('#glcanvas');
+ canvas.width = globalStates.height;
+ canvas.height = globalStates.width;
+ canvas.style.width = canvas.width + 'px';
+ canvas.style.height = canvas.height + 'px';
+ gl = canvas.getContext('webgl2');
+
+ realityEditor.device.layout.onWindowResized(({width, height}) => {
+ canvas.style.width = width + 'px';
+ canvas.style.height = height + 'px';
+ // note: don't need to update canvas.width and height, just style.width and height
+ // because there's no mechanism for sending the new canvas pixel dimensions to the proxied frame
+ });
+
+ // If we don't have a GL context, give up now
+
+ if (!gl) {
+ alert('Unable to initialize WebGL2. Your browser or machine may not support it.');
+ return;
+ }
+
+ for (let key in gl) {
+ switch (typeof gl[key]) {
+ case 'function':
+ functions.push(key);
+ break;
+ case 'number':
+ constants[key] = gl[key];
+ break;
+ }
+ if (key === 'canvas') {
+ constants[key] = {
+ width: gl[key].width,
+ height: gl[key].height,
+ };
+ }
+ }
+
+ setTimeout(() => {
+ requestAnimationFrameIfNotPending();
+ }, 500);
+ setInterval(watchpuppy, 1000);
+
+ realityEditor.device.registerCallback('vehicleDeleted', onVehicleDeleted);
+ realityEditor.network.registerCallback('vehicleDeleted', onVehicleDeleted);
+ }
+
+ /**
+ * Returns n random elements from the array. Fast and non-destructive.
+ * @author https://stackoverflow.com/a/19270021
+ * @param {Array} arr
+ * @param {number} n
+ * @return {Array}
+ */
+ function getRandom(arr, n) {
+ var result = new Array(n),
+ len = arr.length,
+ taken = new Array(len);
+ if (n > len)
+ throw new RangeError("getRandom: more elements taken than available");
+ while (n--) {
+ var x = Math.floor(Math.random() * len);
+ result[n] = arr[x in taken ? taken[x] : x];
+ taken[x] = --len in taken ? taken[len] : len;
+ }
+ return result;
+ }
+
+ /**
+ * If there are too many proxies, chooses a random subset of them
+ * @return {Array}
+ */
+ function getSafeProxySubset(proxiesToConsider) {
+ if (proxiesToConsider.length < MAX_PROXIES) {
+ return proxiesToConsider;
+ } else {
+ // choose N random elements from the proxies array
+ return getRandom(proxiesToConsider, MAX_PROXIES);
+ }
+ }
+
+ async function renderFrame() {
+ if (rendering) {
+ console.error('renderFrame called during another renderFrame');
+ return;
+ }
+ rendering = true;
+ let proxiesToConsider = [];
+ function makeWatchdog() {
+ return new Promise((res) => {
+ setTimeout(res, 100, false);
+ });
+ }
+ proxies.forEach(function(thisProxy) {
+ let toolId = thisProxy.toolId;
+ let element = globalDOMCache['object' + toolId];
+ if (element && window.getComputedStyle(element).display !== 'none') {
+ proxiesToConsider.push(thisProxy);
+ }
+ });
+
+ let proxiesToBeRenderedThisFrame = getSafeProxySubset(proxiesToConsider);
+
+ // Get all the commands from the worker iframes
+ let prommies = proxiesToBeRenderedThisFrame.map(proxy => Promise.race([makeWatchdog(), proxy.getFrameCommands()]));
+ let res = await Promise.all(prommies);
+ if (!res) {
+ console.warn('glRenderer watchdog is barking');
+ requestAnimationFrameIfNotPending();
+ return;
+ }
+
+ gl.clearColor(0.0, 0.0, 0.0, 0.0); // Clear to black, fully opaque
+ gl.clearDepth(1.0); // Clear everything
+ gl.enable(gl.DEPTH_TEST); // Enable depth testing
+ gl.depthFunc(gl.LEQUAL); // Near things obscure far things
+
+ // Clear the canvas before we start drawing on it.
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
+
+ // Execute all pending commands for this frame
+ for (let i = 0; i < proxiesToBeRenderedThisFrame.length; i++) {
+ let proxy = proxiesToBeRenderedThisFrame[i];
+ if (!res[i]) {
+ console.warn('dropped proxy frame due to large delay', proxy);
+ proxy.executePreviousFrameCommands();
+ continue;
+ }
+ proxy.executeFrameCommands();
+ }
+
+ lastRender = Date.now();
+ rendering = false;
+ animationFrameRequest = null;
+ requestAnimationFrameIfNotPending();
+ }
+
+ let animationFrameRequest = null;
+ function requestAnimationFrameIfNotPending() {
+ if (animationFrameRequest) {
+ return;
+ }
+ animationFrameRequest = requestAnimationFrame(renderFrame);
+ }
+
+ function watchpuppy() {
+ if (lastRender + 3000 < Date.now()) {
+ requestAnimationFrameIfNotPending();
+ }
+ }
+
+ function generateWorkerIdForTool(toolId) {
+ // generate workerIds incrementally
+ workerIds[toolId] = nextWorkerId;
+ nextWorkerId += 1;
+ return workerIds[toolId];
+ }
+
+ function addWebGlProxy(toolId) {
+ if (toolIdToProxy.hasOwnProperty(toolId)) {
+ console.error('overwriting webglproxy for tool', toolId);
+ removeWebGlProxy(toolId);
+ }
+ const worker = globalDOMCache['iframe' + toolId].contentWindow;
+ let proxy = new WorkerGLProxy(worker, gl, generateWorkerIdForTool(toolId), toolId);
+ proxies.push(proxy);
+ toolIdToProxy[toolId] = proxy;
+
+ worker.postMessage(JSON.stringify({
+ workerId: workerIds[toolId]
+ }), '*');
+
+ const {width, height} = globalStates;
+
+ setTimeout(() => {
+ worker.postMessage({
+ name: 'bootstrap',
+ functions,
+ constants,
+ width: height,
+ height: width,
+ }, '*');
+ }, 200);
+ }
+
+ function removeWebGlProxy(toolId) {
+ let proxy = toolIdToProxy[toolId];
+ let index = proxies.indexOf(proxy);
+ if (index !== -1) {
+ proxies.splice(index, 1);
+ }
+ proxy.remove();
+ delete workerIds[toolId];
+ delete toolIdToProxy[toolId];
+ }
+
+ function onVehicleDeleted(params) {
+ if (params.objectKey && params.frameKey && !params.nodeKey) { // only react to frames, not nodes
+ if (typeof toolIdToProxy[params.frameKey] !== 'undefined') {
+ removeWebGlProxy(params.frameKey);
+ }
+ }
+ }
+
+ exports.initService = initService;
+ exports.addWebGlProxy = addWebGlProxy;
+ exports.removeWebGlProxy = removeWebGlProxy;
+ exports.renderFrame = renderFrame;
+
+})(realityEditor.gui.glRenderer);
diff --git a/src/gui/memory/index.js b/src/gui/memory/index.js
new file mode 100644
index 000000000..bdaa40c5d
--- /dev/null
+++ b/src/gui/memory/index.js
@@ -0,0 +1,694 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2016 James Hobin
+ * Modified by Valentin Heun 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/**
+ * Memory Bar
+ *
+ * Allows user creation and selection of memories (images of objects that allow interaction).
+ * Sends of://memorize and of://remember/?data=%d. Receives receiveThumbnail with
+ * memory image thumbnail.
+ */
+
+createNameSpace("realityEditor.gui.memory");
+
+(function(exports) {
+
+var imageCache = {};
+var knownObjects = {};
+try {
+ knownObjects = JSON.parse(window.localStorage.getItem('realityEditor.memory.knownObject') || '{}');
+} catch(e) {
+ console.warn('Defaulting knownObjects due to data corruption');
+}
+
+var currentMemory = {
+ id: null,
+ matrix: null,
+ cameraMatrix: null,
+ projectionMatrix: null,
+ image: null,
+ thumbnailImage: null,
+ imageUrl: null,
+ thumbnailImageUrl: null
+};
+
+function MemoryContainer(element) {
+ this.element = element;
+ this.image = null;
+ this.backgroundImage = null;
+ this.memory = null;
+ this.dragging = false;
+ this.dragTimer = null;
+ this.imageLoaded = false;
+
+ this.onTransitionEnd = this.onTransitionEnd.bind(this);
+ this.onPointerUp = this.onPointerUp.bind(this);
+ this.onPointerEnter = this.onPointerEnter.bind(this);
+ this.onPointerLeave = this.onPointerLeave.bind(this);
+ this.onTouchStart = this.onTouchStart.bind(this);
+ this.onTouchMove = this.onTouchMove.bind(this);
+ this.onTouchEnd = this.onTouchEnd.bind(this);
+
+ this.element.addEventListener('pointerup', this.onPointerUp);
+ this.element.addEventListener('pointerenter', this.onPointerEnter);
+ this.element.addEventListener('pointerleave', this.onPointerLeave);
+}
+
+MemoryContainer.prototype.set = function(obj) {
+ this.obj = obj;
+ var urlBase = realityEditor.network.getURL(obj.ip, realityEditor.network.getPort(obj), '/obj/' + obj.name + '/memory/');
+ var image = urlBase + 'memory.jpg';
+
+ this.backgroundImage = document.createElement('img');
+ this.backgroundImage.classList.add('memoryBackgroundImage');
+ this.backgroundImage.setAttribute('touch-action', 'none');
+
+ var that = this;
+ this.backgroundImage.onload = function() {
+ that.imageLoaded = true;
+ };
+
+ this.backgroundImage.src = image;
+
+ var thumbnail = urlBase + 'memoryThumbnail.jpg';
+
+ // load matrices into thumbnail from the memory stored in the object
+ var objectMatrix = obj.memory || realityEditor.gui.ar.utilities.newIdentityMatrix();
+ var cameraMatrix = obj.memoryCameraMatrix || realityEditor.gui.ar.utilities.newIdentityMatrix();
+
+ // if (obj.memory && obj.memory.matrix) {
+ // objectMatrix = obj.memory.matrix;
+ // }
+
+ this.memory = {
+ id: obj.objectId,
+ image: image,
+ thumbnail: thumbnail,
+ matrix: objectMatrix, //obj.memory.matrix
+ cameraMatrix: cameraMatrix,
+ projectionMatrix: globalStates.projectionMatrix
+ };
+ this.element.dataset.objectId = this.memory.id;
+
+ if (!this.image) {
+ var cachedImage = imageCache[thumbnail];
+ if (cachedImage && !cachedImage.parentNode && cachedImage.src === thumbnail) {
+ this.image = cachedImage;
+ this.createImage();
+ } else {
+ this.createImage();
+ this.image.src = thumbnail;
+ }
+ }
+
+ imageCache[thumbnail] = this.image;
+};
+
+MemoryContainer.prototype.clear = function() {
+ this.obj = null;
+ this.memory = null;
+ this.removeImage();
+ delete this.element.dataset.objectId;
+};
+
+MemoryContainer.prototype.removeImage = function() {
+ this.image.removeEventListener('touchstart', this.onTouchStart);
+ this.image.removeEventListener('touchmove', this.onTouchMove);
+ this.image.removeEventListener('touchend', this.onTouchEnd);
+ this.image.removeEventListener('pointerenter', this.onPointerEnter);
+ this.image.removeEventListener('pointerleave', this.onPointerLeave);
+ this.image.parentNode.removeChild(this.image);
+ this.image = null;
+ this.imageLoaded = false;
+};
+
+MemoryContainer.prototype.onTouchStart = function(event) {
+
+ if (!realityEditor.gui.pocket.pocketShown()) { // we use the same memory container for pointers and pocket buttons - prevent certain events if in pointer
+ return;
+ }
+
+ this.lastTouch = {
+ left: event.touches[0].clientX,
+ top: event.touches[0].clientY
+ };
+
+ this.dragTimer = setTimeout(function() {
+ this.startDragging();
+ }.bind(this), 100);
+};
+
+MemoryContainer.prototype.startDragging = function() {
+ if (!this.memory || !this.image) {
+ return;
+ }
+ this.dragging = true;
+
+ var rect = this.image.getBoundingClientRect();
+ this.image.classList.add('memoryDragging');
+ this.image.style.transform = 'translate3d(' + rect.left + 'px,' + rect.top + 'px, 1200px)';
+
+ this.image.parentNode.removeChild(this.image);
+ document.querySelector('.memoryDragContainer').appendChild(this.image);
+
+ this.dragDelta = {
+ top: rect.top - this.lastTouch.top,
+ left: rect.left - this.lastTouch.left
+ };
+
+ var isBar = barContainers.indexOf(this) >= 0;
+
+ if (isBar) {
+ realityEditor.gui.menus.switchToMenu("bigTrash");
+ //realityEditor.gui.pocket.pocketOnMemoryDeletionStart();
+ } else {
+ realityEditor.gui.menus.switchToMenu("bigPocket");
+ // realityEditor.gui.pocket.pocketOnMemoryCreationStart();
+ }
+};
+
+MemoryContainer.prototype.onTouchMove = function() {
+ var touch = {
+ left: event.touches[0].clientX,
+ top: event.touches[0].clientY
+ };
+
+ if (this.dragging) {
+ var top = touch.top + this.dragDelta.top + 'px';
+ var left = touch.left + this.dragDelta.left + 'px';
+ this.image.style.transform = 'translate3d(' + left + ',' + top + ', 1200px)';
+ }
+};
+
+MemoryContainer.prototype.stopDragging = function() {
+ if (!this.dragging) {
+ return;
+ }
+ this.dragging = false;
+
+ var isBar = barContainers.indexOf(this) >= 0;
+
+ if (isBar) {
+ realityEditor.gui.menus.switchToMenu("main");
+ //realityEditor.gui.pocket.pocketOnMemoryDeletionStop();
+ } else {
+ realityEditor.gui.menus.switchToMenu("main");
+ //realityEditor.gui.pocket.pocketOnMemoryCreationStop();
+ }
+
+ var imageRect = this.image.getBoundingClientRect();
+
+ this.image.style.transform = '';
+ this.image.classList.remove('memoryDragging');
+ this.image.parentNode.removeChild(this.image);
+ this.element.appendChild(this.image);
+
+ if (isBar) {
+ var rightMostContainer = barContainers[barContainers.length - 1];
+ if (imageRect.left - this.dragDelta.left > rightMostContainer.element.getBoundingClientRect().right) {
+ this.clear();
+ return;
+ }
+ }
+
+ var containerRect = this.element.getBoundingClientRect();
+
+ if (isBar) {
+ // Move requested
+ if (imageRect.right < containerRect.left || imageRect.left > containerRect.right) {
+ let newContainer = getBarContainerAtLeft(imageRect.left);
+ if (newContainer) {
+ newContainer.set(this.obj);
+ this.clear();
+ }
+ }
+ } else {
+ // Move into bar
+ if (imageRect.top < memoryBarHeight) {
+ let newContainer = getBarContainerAtLeft(imageRect.left);
+ if (newContainer) {
+ addKnownObject(this.obj.objectId);
+ newContainer.set(this.obj);
+ }
+ } else {
+ // Didn't move into bar, pocket should close
+ realityEditor.gui.pocket.pocketHide();
+ }
+ }
+};
+
+MemoryContainer.prototype.onPointerUp = function() {
+ this.element.classList.remove('selectedContainer');
+ realityEditor.gui.pocket.highlightAvailableMemoryContainers(false);
+
+ this.cancelRemember();
+
+ if (this.dragTimer) {
+ clearTimeout(this.dragTimer);
+ this.dragTimer = null;
+ }
+
+ if (activeThumbnail) {
+
+ // var objId = potentialObjects[0];
+ barContainers.forEach(function(container) {
+ if (container.memory && container.memory.id === currentMemory.id) {
+ container.clear();
+ }
+ });
+
+ // pendingMemorizations[objId || ''] = this;
+
+ event.stopPropagation();
+
+ // addObjectMemory(realityEditor.getObject(currentMemory.id));
+ // this.set(realityEditor.getObject(currentMemory.id));
+
+ realityEditor.gui.menus.switchToMenu("main");
+
+ if (!this.image) {
+ this.createImage();
+ }
+ this.image.src = activeThumbnail;
+
+ overlayDiv.style.backgroundImage = 'none';
+ overlayDiv.classList.remove('overlayMemory');
+ overlayDiv.style.display = 'none';
+ activeThumbnail = '';
+
+ this.set(realityEditor.getObject(currentMemory.id));
+
+ } else if (this.dragging) {
+ return;
+ } else {
+ this.remember();
+ }
+};
+
+MemoryContainer.prototype.onPointerEnter = function() {
+ if (overlayDiv.classList.contains('overlayMemory')) {
+ // highlight if it's empty and this memory can be placed
+ if (!this.element.dataset.objectId) {
+ this.element.classList.add('selectedContainer');
+ }
+ return;
+ }
+ if (this.dragTimer) {
+ return;
+ }
+ this.beginRemember();
+};
+
+MemoryContainer.prototype.onPointerLeave = function() {
+ this.element.classList.remove('selectedContainer');
+ if (overlayDiv.classList.contains('overlayMemory')) {
+ return;
+ }
+ if (this.dragTimer) {
+ return;
+ }
+ this.cancelRemember();
+};
+
+MemoryContainer.prototype.onTouchEnd = function() {
+ // Defer stopping to the next event loop when onPointerUp will have already
+ // occurred.
+ setTimeout(function() {
+ this.stopDragging();
+ }.bind(this), 0);
+};
+
+MemoryContainer.prototype.beginRemember = function() {
+ if (this.element.classList.contains('remembering')) {
+ return;
+ }
+ if (this.element.classList.contains('memoryPointer')) {
+ this.element.classList.add('remembering');
+ this.element.addEventListener('transitionend', this.onTransitionEnd);
+ } else {
+ this.remember();
+ }
+};
+
+MemoryContainer.prototype.cancelRemember = function() {
+ if (!this.element.classList.contains('remembering')) {
+ return;
+ }
+ this.element.removeEventListener('transitionend', this.onTransitionEnd);
+ this.element.classList.remove('remembering');
+};
+
+MemoryContainer.prototype.onTransitionEnd = function() {
+ this.element.removeEventListener('transitionend', this.onTransitionEnd);
+ this.element.classList.remove('remembering');
+ this.remember();
+};
+
+
+MemoryContainer.prototype.remember = function() {
+ if (!this.memory && !this.image) {
+ return;
+ }
+
+ if (globalStates.guiState === 'node' && globalStates.drawDotLine) {
+ return;
+ }
+
+ realityEditor.gui.pocket.pocketHide();
+
+ if (this.backgroundImage) {
+ var memoryBackground = document.querySelector('.memoryBackground');
+ memoryBackground.innerHTML = '';
+ memoryBackground.appendChild(this.backgroundImage);
+ }
+
+ realityEditor.gui.menus.switchToMenu('main', ['freeze'], null);
+ globalStates.freezeButtonState = true;
+
+ // TODO: unload visible objects (besides WORLD_OBJECTs) first?
+ Object.keys(realityEditor.gui.ar.draw.visibleObjects).filter(function(objectKey) {
+ return objectKey.indexOf('WORLD_OBJECT') === -1;
+ }).forEach(function(nonWorldObjectKey) {
+ delete realityEditor.gui.ar.draw.visibleObjectsCopy[nonWorldObjectKey];
+ delete realityEditor.gui.ar.draw.visibleObjects[nonWorldObjectKey];
+ });
+
+ realityEditor.sceneGraph.setCameraPosition(this.memory.cameraMatrix);
+
+ realityEditor.gui.ar.draw.visibleObjectsCopy[this.memory.id] = this.memory.matrix;
+ realityEditor.gui.ar.draw.visibleObjects[this.memory.id] = this.memory.matrix;
+
+ // also set sceneGraph localMatrix
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(this.memory.id);
+ if (sceneNode) {
+ sceneNode.setLocalMatrix(this.memory.matrix);
+ }
+
+ // TODO: load in temporary projection matrix too?
+};
+
+MemoryContainer.prototype.remove = function() {
+ this.element.parentNode.removeChild(this.element);
+ this.element.removeEventListener('pointerup', this.onPointerUp);
+ this.element.removeEventListener('pointerenter', this.onPointerEnter);
+ this.element.removeEventListener('pointerleave', this.onPointerLeave);
+ this.removeImage();
+};
+
+MemoryContainer.prototype.createImage = function() {
+ if (!this.image) {
+ this.image = document.createElement('img');
+ }
+ if (!this.image.parentNode) {
+ this.element.appendChild(this.image);
+ }
+ this.image.setAttribute('touch-action', 'none');
+ this.image.classList.add('memory');
+ this.image.addEventListener('touchstart', this.onTouchStart);
+ this.image.addEventListener('touchmove', this.onTouchMove);
+ this.image.addEventListener('touchend', this.onTouchEnd);
+ this.image.addEventListener('pointerenter', this.onPointerEnter);
+ this.image.addEventListener('pointerleave', this.onPointerLeave);
+
+};
+
+
+var activeThumbnail = '';
+var barContainers = [];
+var pendingMemorizations = {};
+var memoryBarHeight = 80;
+var numMemoryContainers = 4;
+
+function getBarContainerAtLeft(left) {
+ // Assumes bar containers are in order of DOM insertion
+ for (var i = 0; i < barContainers.length; i++) {
+ var barContainer = barContainers[i];
+ var barRect = barContainer.element.getBoundingClientRect();
+ if (left > barRect.left && left < barRect.right) {
+ return barContainer;
+ }
+ }
+ return null;
+}
+
+function url(href) {
+ return 'url(' + href + ')';
+}
+
+function initMemoryBar() {
+ var memoryBar = document.querySelector('.memoryBar');
+ for (var i = 0; i < numMemoryContainers; i++) {
+ var memoryContainer = document.createElement('div');
+ memoryContainer.classList.add('memoryContainer');
+ memoryContainer.setAttribute('touch-action', 'none');
+ memoryBar.appendChild(memoryContainer);
+
+ var container = new MemoryContainer(memoryContainer);
+ barContainers.push(container);
+ }
+}
+
+function removeMemoryBar() {
+ barContainers.forEach(function(container) {
+ container.remove();
+ });
+ barContainers = [];
+}
+
+function createMemory() {
+ overlayDiv.classList.add('overlayMemory');
+
+ console.log('create memory');
+
+ realityEditor.app.getSnapshot("L", "realityEditor.gui.memory.receiveScreenshot");
+ realityEditor.app.getSnapshot("S", "realityEditor.gui.memory.receiveScreenshotThumbnail");
+
+ currentMemory.id = realityEditor.gui.ar.getClosestObject()[0];
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(currentMemory.id);
+ if (sceneNode) {
+ currentMemory.matrix = realityEditor.gui.ar.utilities.copyMatrix(sceneNode.localMatrix);
+ } else {
+ currentMemory.matrix = realityEditor.gui.ar.utilities.copyMatrix(realityEditor.gui.ar.draw.visibleObjects[currentMemory.id]);
+ }
+ let cameraNode = realityEditor.sceneGraph.getSceneNodeById('CAMERA');
+ currentMemory.cameraMatrix = realityEditor.gui.ar.utilities.copyMatrix(cameraNode.localMatrix);
+ currentMemory.projectionMatrix = globalStates.projectionMatrix;
+
+ addKnownObject(currentMemory.id);
+
+ realityEditor.gui.menus.switchToMenu("bigPocket");
+ // realityEditor.gui.pocket.pocketOnMemoryCreationStart();
+}
+
+function receiveScreenshot(base64String) {
+ var blob = realityEditor.device.utilities.b64toBlob(base64String, 'image/jpeg');
+ var blobUrl = URL.createObjectURL(blob);
+
+ currentMemory.image = blob;
+ currentMemory.imageUrl = blobUrl;
+}
+
+function receiveScreenshotThumbnail(base64String) {
+ var blob = realityEditor.device.utilities.b64toBlob(base64String, 'image/jpeg');
+ var blobUrl = URL.createObjectURL(blob);
+
+ currentMemory.thumbnailImage = blob;
+ currentMemory.thumbnailImageUrl = blobUrl;
+
+ receiveThumbnail(currentMemory.thumbnailImageUrl);
+
+ uploadImageToServer(currentMemory.thumbnailImage);
+}
+
+function receiveThumbnail(thumbnailUrl) {
+ if (overlayDiv.classList.contains('overlayMemory')) {
+ overlayDiv.style.backgroundImage = url(thumbnailUrl);
+ activeThumbnail = thumbnailUrl;
+ }
+
+
+}
+
+function addObjectMemory(obj) {
+ if (!obj.memory || Object.keys(obj.memory).length === 0) {
+ return;
+ }
+
+ var freeMemory;
+ if (pendingMemorizations.hasOwnProperty(obj.objectId)) {
+ freeMemory = pendingMemorizations[obj.objectId];
+ delete pendingMemorizations[obj.objectId];
+ } else {
+ if (!knownObjects[obj.objectId]) {
+ console.warn('staying away from memories of a strange object');
+ return;
+ }
+ freeMemory = barContainers.filter(function(container) {
+ // Container either doesn't have a memory or the memory is of this object
+ return !container.memory || container.memory.id === obj.objectId;
+ })[0];
+
+ if (!freeMemory) {
+ console.warn('There are no free memory slots');
+ return;
+ }
+ }
+
+ barContainers.forEach(function(container) {
+ if (container.memory && container.memory.id === obj.objectId) {
+ container.clear();
+ }
+ });
+
+ addKnownObject(obj.objectId);
+ freeMemory.set(obj);
+}
+
+function addKnownObject(objectId) {
+ knownObjects[objectId] = true;
+ window.localStorage.setItem('realityEditor.memory.knownObject', JSON.stringify(knownObjects));
+}
+
+
+function getMemoryWithId(id) {
+ for (var i = 0; i < barContainers.length; i++) {
+ var barContainer = barContainers[i];
+ if (barContainer.memory && barContainer.memory.id === id) {
+ return barContainer;
+ }
+ }
+ return null;
+}
+
+function memoryCanCreate() {
+ // Exactly one visible object
+
+ var visibleObjectKeys = Object.keys(realityEditor.gui.ar.draw.visibleObjects);
+ visibleObjectKeys.splice(visibleObjectKeys.indexOf(realityEditor.worldObjects.getLocalWorldId()), 1); // remove the local world object, its server cant support memories
+
+ // For now, also remove all world objects, regardless of which server they come from
+ visibleObjectKeys = visibleObjectKeys.filter(function(objectKey) {
+ return objectKey.indexOf('WORLD_OBJECT') === -1;
+ });
+
+ if (visibleObjectKeys.length !== 1) {
+ return false;
+ }
+ if (globalStates.freezeButtonState) {
+ return false;
+ }
+ if (realityEditor.gui.pocket.pocketShown()) {
+ return false;
+ }
+ if (globalStates.settingsButtonState) {
+ return false;
+ }
+ if (globalStates.editingMode || realityEditor.device.getEditingVehicle()) {
+ return false;
+ }
+ // if (realityEditor.gui.screenExtension.areAnyScreensVisible()) {
+ // return false;
+ // }
+ if (globalStates.guiState === 'ui') {
+ return true;
+ }
+ // if (globalStates.guiState === 'node' && !globalProgram.objectA) { // TODO: shouldn't this draw dot line?
+ // return true;
+ // }
+ return false;
+}
+
+function uploadImageToServer() {
+ // Create a new FormData object.
+ var formData = new FormData();
+ formData.append('memoryThumbnailImage', currentMemory.thumbnailImage);
+ formData.append('memoryImage', currentMemory.image);
+ formData.append('memoryInfo', JSON.stringify(currentMemory.matrix));
+ formData.append('memoryCameraInfo', JSON.stringify(currentMemory.cameraMatrix));
+ formData.append('memoryProjectionInfo', JSON.stringify(currentMemory.projectionMatrix));
+
+ // Set up the request.
+ var xhr = new XMLHttpRequest();
+
+ var postUrl = realityEditor.network.getURL(objects[currentMemory.id].ip, realityEditor.network.getPort(objects[currentMemory.id]), '/object/' + currentMemory.id + "/memory");
+
+ // Open the connection.
+ xhr.open('POST', postUrl, true);
+
+ // Set up a handler for when the request finishes.
+ xhr.onload = function () {
+ if (xhr.status === 200) {
+ // File(s) uploaded.
+ console.log('successful upload');
+ setTimeout(function() {
+ console.log('successfully uploaded thumbnail image to server');
+ }, 1000);
+ } else {
+ console.log('error uploading');
+ }
+ };
+
+ // Send the Data.
+ xhr.send(formData);
+}
+
+exports.initMemoryBar = initMemoryBar;
+exports.removeMemoryBar = removeMemoryBar;
+exports.receiveThumbnail = receiveThumbnail;
+exports.addObjectMemory = addObjectMemory;
+exports.MemoryContainer = MemoryContainer;
+exports.getMemoryWithId = getMemoryWithId;
+exports.memoryCanCreate = memoryCanCreate;
+exports.createMemory = createMemory;
+
+exports.receiveScreenshot = receiveScreenshot;
+exports.receiveScreenshotThumbnail = receiveScreenshotThumbnail;
+
+
+}(realityEditor.gui.memory));
diff --git a/src/gui/memory/nodeMemories.js b/src/gui/memory/nodeMemories.js
new file mode 100644
index 000000000..aeb721f23
--- /dev/null
+++ b/src/gui/memory/nodeMemories.js
@@ -0,0 +1,335 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Ben Reynolds on 11/10/17.
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/**
+ * Logic Node Memory Bar
+ *
+ * Allows user creation and selection of Logic Node memories (templates of pre-programmed Logic Nodes which the user can create instances of).
+ */
+
+createNameSpace("realityEditor.gui.memory.nodeMemories");
+
+realityEditor.gui.memory.nodeMemories.states = {
+ memories: [],
+ dragEventListeners: [],
+ upEventListeners: []
+};
+
+// load any stored Logic Node memories from browser's local storage, and create DOM elements to visualize them
+realityEditor.gui.memory.nodeMemories.initMemoryBar = function() {
+
+ this.states.memories = JSON.parse(window.localStorage.getItem('realityEditor.memory.nodeMemories.states.memories') || '[]');
+
+ var memoryBar = document.querySelector('.nodeMemoryBar');
+ for (var i = 0; i < 5; i++) {
+ var memoryContainer = document.createElement('div');
+ memoryContainer.classList.add('memoryContainer');
+ memoryContainer.classList.add('nodeMemoryContainer');
+ memoryContainer.setAttribute('touch-action', 'none');
+ memoryContainer.style.position = 'relative';
+
+ var memoryNode = document.createElement('div');
+ memoryNode.classList.add('memoryNode');
+ memoryNode.style.visibility = 'hidden';
+ memoryContainer.appendChild(memoryNode);
+
+ memoryBar.appendChild(memoryContainer);
+ }
+
+ this.renderMemories();
+};
+
+// Save a Logic Node to a given index (must be between 1-5 as of now)
+realityEditor.gui.memory.nodeMemories.addMemoryAtIndex = function(logicNodeObject, index) {
+
+ // a Logic Node can only exist in one pocket at a time - remove it from previous if being added to another
+ var previousIndex = this.getIndexOfLogic(logicNodeObject);
+ if (previousIndex !== index) {
+ this.states.memories[previousIndex] = null;
+ }
+
+ // additional step to save the publicData and privateData of the blocks in the pocket,
+ // because this data usually only resides on the server
+ var keys = realityEditor.gui.crafting.eventHelper.getServerObjectLogicKeys(logicNodeObject);
+ realityEditor.network.updateNodeBlocksSettingsData(keys.ip, keys.objectKey, keys.frameKey, keys.logicKey);
+
+ var iconSrc;
+ if (logicNodeObject.iconImage === 'custom' || logicNodeObject.iconImage === 'auto') {
+ iconSrc = realityEditor.gui.crafting.getLogicNodeIcon(logicNodeObject);
+ }
+
+ // convert logic node to a serializable object and assign it a new UUID
+ if (index >= 0 && index < 5) {
+ var simpleLogic = this.realityEditor.gui.crafting.utilities.convertLogicToServerFormat(logicNodeObject);
+ simpleLogic.uuid = realityEditor.device.utilities.uuidTime();
+ if (iconSrc) {
+ if (logicNodeObject.iconImage === 'custom') {
+ simpleLogic.nodeMemoryCustomIconSrc = iconSrc;
+ } else {
+ simpleLogic.nodeMemoryAutoIconSrc = iconSrc;
+ }
+ }
+ this.states.memories[index] = simpleLogic;
+ }
+
+ this.renderMemories();
+ this.saveNodeMemories();
+};
+
+// saves each pocket logic node to the browser's local storage.
+// also does a second pass to ensure all links are serializable. // TODO: this shouldn't be necessary. Fix bug before it gets here.
+realityEditor.gui.memory.nodeMemories.saveNodeMemories = function() {
+
+ // TODO: shouldn't need to do this each time if i correctly do it when the node gets added to the memory
+ this.states.memories.forEach(function(logicNode) {
+ if (logicNode && logicNode.hasOwnProperty('links')) {
+ for (var linkKey in logicNode.links) {
+ if (!logicNode.links.hasOwnProperty(linkKey)) continue;
+ if (logicNode.links[linkKey].route) {
+ console.log("eliminating routes");
+ logicNode.links[linkKey] = realityEditor.gui.crafting.utilities.convertBlockLinkToServerFormat(logicNode.links[linkKey]);
+ }
+ }
+ }
+ });
+
+ window.localStorage.setItem('realityEditor.memory.nodeMemories.states.memories', JSON.stringify(this.states.memories));
+};
+
+// Draws each saved Logic Node inside each pocket container DOM element
+realityEditor.gui.memory.nodeMemories.renderMemories = function() {
+
+ var memoryBar = document.querySelector('.nodeMemoryBar');
+ this.states.memories.forEach( function(logicNodeObject, i) {
+
+ // reset contents
+ var memoryContainer = memoryBar.children[i];
+
+ var memoryNode;
+ Array.from(memoryContainer.children).forEach(function(child) {
+ if (child.classList.contains('memoryNode')) {
+ memoryNode = child;
+ } else {
+ memoryContainer.removeChild(child);
+ }
+ });
+
+ // memoryContainer.innerHTML = '';
+ memoryContainer.style.backgroundImage = '';
+ memoryNode.style.backgroundImage = '';
+ memoryNode.style.backgroundPositionX = '';
+ memoryNode.style.backgroundPositionY = '';
+ memoryNode.style.visibility = 'hidden';
+ memoryContainer.onclick = '';
+
+ // stop if there isn't anything to render
+ if (!logicNodeObject) return;
+
+ memoryNode.style.visibility = 'visible';
+
+ var iconToUse = 'none';
+
+ // display contents. currently this is a generic node image and the node's name // TODO: give custom icons
+ // memoryContainer.style.backgroundImage = 'url(/svg/logicNode.svg)';
+ if (typeof logicNodeObject.nodeMemoryCustomIconSrc !== 'undefined') {
+ memoryNode.style.backgroundImage = 'url(' + logicNodeObject.nodeMemoryCustomIconSrc + ')';
+ memoryNode.style.backgroundSize = 'cover';
+ iconToUse = 'custom';
+ } else if (typeof logicNodeObject.nodeMemoryAutoIconSrc !== 'undefined') {
+ memoryNode.style.backgroundImage = 'url(' + logicNodeObject.nodeMemoryAutoIconSrc + ')';
+ memoryNode.style.backgroundSize = 'contain';
+ iconToUse = 'auto';
+ memoryNode.style.backgroundPositionX = 'center';
+ memoryNode.style.backgroundPositionY = '10px';
+ }
+
+ if (iconToUse !== 'custom') {
+ var nameText = document.createElement('div');
+ nameText.style.position = 'absolute';
+ if (iconToUse === 'auto') {
+ nameText.style.top = 'calc(20vw - 55px)'
+ nameText.style.fontSize = '10px';
+ } else {
+ nameText.style.top = '33px';
+ }
+ nameText.style.width = '100%';
+ nameText.style.textAlign = 'center';
+ nameText.innerHTML = logicNodeObject.name;
+ memoryContainer.appendChild(nameText);
+ }
+
+ });
+
+ this.resetEventHandlers();
+};
+
+// ensure there is a single drag handler on each memory container when the pocket is opened, so that they can only be dragged once.
+// the handler will be removed after you start dragging the node. this re-adds removed handlers when you re-open the pocket.
+realityEditor.gui.memory.nodeMemories.resetEventHandlers = function() {
+
+ var memoryBar = document.querySelector('.nodeMemoryBar');
+
+ var nodeMemories = realityEditor.gui.memory.nodeMemories;
+ var dragEventListeners = nodeMemories.states.dragEventListeners;
+ var upEventListeners = nodeMemories.states.upEventListeners;
+
+ Array.from(memoryBar.children).forEach(function(memoryContainer, i) {
+
+ if (dragEventListeners[i]) {
+ memoryContainer.removeEventListener('pointermove', dragEventListeners[i], false);
+ dragEventListeners[i] = null;
+ }
+
+ if (upEventListeners[i]) {
+ memoryContainer.removeEventListener('pointerup', upEventListeners[i], false);
+ upEventListeners[i] = null;
+ }
+
+ var overlay = document.getElementById('overlay');
+ if (overlay.storedLogicNode) { // TODO: make it faster by only adding the type of listeners it needs right now (but make sure to add the others when they become needed)
+ nodeMemories.addUpListener(memoryContainer, nodeMemories.states.memories[i], i);
+ } else {
+ nodeMemories.addDragListener(memoryContainer, nodeMemories.states.memories[i], i);
+ }
+ });
+
+ var pocket = document.querySelector('.pocket');
+ pocket.removeEventListener('pointerup', nodeMemories.touchUpHandler, false);
+ pocket.addEventListener('pointerup', nodeMemories.touchUpHandler, false);
+};
+
+realityEditor.gui.memory.nodeMemories.touchUpHandler = function() {
+ if (overlayDiv.storedLogicNode) {
+ var overlay = document.getElementById('overlay');
+ overlay.storedLogicNode = null;
+ overlayDiv.classList.remove('overlayLogicNode');
+ overlayDiv.innerHTML = '';
+ realityEditor.gui.memory.nodeMemories.renderMemories();
+ }
+ realityEditor.gui.menus.switchToMenu("main");
+};
+
+// hide the pocket and add a new logic node to the closest visible object, and start dragging it to move under the finger
+realityEditor.gui.memory.nodeMemories.addDragListener = function(memoryContainer, logicNodeObject, i) {
+
+ var nodeMemories = realityEditor.gui.memory.nodeMemories;
+
+ // store each event listener in an array so that we can cancel them all later
+ nodeMemories.states.dragEventListeners[i] = function() {
+
+ if (!logicNodeObject) {
+ console.log('cant add a logic node from here because there isnt one saved');
+ return;
+ }
+
+ if (document.getElementById('overlay').storedLogicNode) {
+ console.log("don't trigger drag events - we are carrying a logic node to save");
+ return;
+ }
+
+ console.log('pointermove on memoryContainer for logic node ' + logicNodeObject.name);
+
+ realityEditor.gui.pocket.pocketHide();
+ console.log("move " + logicNodeObject.name + " to pointer position");
+
+ var addedElement = realityEditor.gui.pocket.createLogicNode(logicNodeObject);
+
+ var logicNodeSize = 220; // TODO: dont hard-code this - it is set within the iframe
+
+ realityEditor.device.editingState.touchOffset = {
+ x: logicNodeSize/2,
+ y: logicNodeSize/2
+ };
+
+ realityEditor.device.beginTouchEditing(addedElement.objectKey, addedElement.frameKey, addedElement.logicNode.uuid);
+
+ realityEditor.gui.menus.switchToMenu("bigTrash");
+
+ // remove the touch event listener so that it doesn't fire twice and create two Logic Nodes by accident
+ memoryContainer.removeEventListener('pointermove', nodeMemories.states.dragEventListeners[i], false);
+ nodeMemories.states.dragEventListeners[i] = null;
+ };
+
+ memoryContainer.addEventListener('pointermove', nodeMemories.states.dragEventListeners[i], false);
+};
+
+// if there is a pending logic node attached to the overlay waiting to be saved, store it in this memoryContainer
+realityEditor.gui.memory.nodeMemories.addUpListener = function(memoryContainer, previousLogicNodeObject, i) {
+
+ var nodeMemories = realityEditor.gui.memory.nodeMemories;
+
+ // store each event listener in an array so that we can cancel them all later
+ nodeMemories.states.upEventListeners[i] = function() {
+
+ var overlay = document.getElementById("overlay");
+ if (overlay.storedLogicNode) {
+ console.log("add logic node " + overlay.storedLogicNode.name + " to memory container " + i + "(replacing " + (previousLogicNodeObject ? previousLogicNodeObject.name : "nothing") + ")");
+
+ nodeMemories.addMemoryAtIndex(overlay.storedLogicNode, i);
+
+ overlay.storedLogicNode = null; // TODO: add an up listener everywhere to remove this
+ overlayDiv.classList.remove('overlayLogicNode');
+ overlayDiv.innerHTML = '';
+
+ nodeMemories.renderMemories();
+ }
+
+ // remove the touch event listener so that it doesn't fire twice and create two Logic Nodes by accident
+ memoryContainer.removeEventListener('pointerup', nodeMemories.states.upEventListeners[i], false);
+ nodeMemories.states.upEventListeners[i] = null;
+ };
+ memoryContainer.addEventListener('pointerup', nodeMemories.states.upEventListeners[i], false);
+
+};
+
+// helper method to find out which pocket this Logic Node has already been saved into. Uses "name" to match
+// TODO: can cause overlaps if different programs have same name, but better than ID because each ID must be unique... is there a better solution?
+realityEditor.gui.memory.nodeMemories.getIndexOfLogic = function(logic) {
+ return this.states.memories.map( function(logicNodeObject) {
+ if (logicNodeObject) {
+ return logicNodeObject.name;
+ }
+ return null;
+ }).indexOf(logic.name);
+};
diff --git a/src/gui/memory/pointer.js b/src/gui/memory/pointer.js
new file mode 100644
index 000000000..c427f4663
--- /dev/null
+++ b/src/gui/memory/pointer.js
@@ -0,0 +1,255 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2016 James Hobin
+ * Modified by Valentin Heun 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.gui.memory");
+
+(function(exports) {
+
+var pointers = {};
+
+var requestAnimationFrame = window.requestAnimationFrame ||
+ window.webkitRequestAnimationFrame || function(cb) {setTimeout(cb, 17);};
+
+/**
+ * @constructor
+ * @param {Object} link - Link object with information regarding connection
+ * @param {Object} isObjectA - Whether this pointer is based on ObjectA or ObjectB
+ * using it as a basis for its position.
+ */
+function MemoryPointer(link, isObjectA) {
+ this.link = link;
+ this.isObjectA = isObjectA;
+
+ this.object = null;
+ this.connectedNode = null;
+ this.connectedNodeKey = null;
+ this.connectedFrame = null;
+ this.connectedObject = null;
+ this.frameId = null;
+
+ if (this.isObjectA) {
+ this.object = objects[this.link.objectA];
+ this.frameId = this.link.frameA;
+ this.connectedObject = objects[this.link.objectB];
+ this.connectedNodeKey = this.link.nodeB;
+ this.connectedFrame = this.connectedObject.frames[this.link.frameB];
+ } else {
+ this.object = objects[this.link.objectB];
+ this.frameId = this.link.frameB;
+ this.connectedObject = objects[this.link.objectA];
+ this.connectedNodeKey = this.link.nodeA;
+ this.connectedFrame = this.connectedObject.frames[this.link.frameA];
+ }
+ this.connectedNode = this.connectedFrame.nodes[this.connectedNodeKey];
+
+ this.element = document.createElement('div');
+ this.element.classList.add('memoryContainer');
+ this.element.classList.add('memoryPointer');
+ this.element.setAttribute('touch-action', 'none');
+
+ document.querySelector('.memoryPointerContainer').appendChild(this.element);
+
+ this.memory = new realityEditor.gui.memory.MemoryContainer(this.element);
+ this.memory.set(this.object);
+
+ // TODO could calculate center of mass of other points and select location opposite that
+ var baseDistance = (this.connectedNode.screenLinearZ || 5) * 30;
+ var baseTheta = Math.random() * 2 * Math.pi;
+ this.x = Math.cos(baseTheta) * baseDistance;
+ this.y = Math.sin(baseTheta) * baseDistance;
+
+ this.alive = true;
+ this.lastDraw = Date.now();
+ this.idleTimeout = 250;
+
+ this.beginForceSimulation();
+
+ this.update = this.update.bind(this);
+ this.update();
+
+ pointers[this.object.objectId] = this;
+}
+
+MemoryPointer.prototype.update = function() {
+ var object = this.object;
+ var connectedObject = this.connectedObject;
+ if (!this.alive) {
+ this.remove();
+ return;
+ }
+ if (!object || !connectedObject) {
+ this.remove();
+ return;
+ }
+ if (object.objectVisible) {
+ this.remove();
+ return;
+ }
+ if (globalStates.guiState !== 'node' && globalStates.guiState !== 'logic') { // don't remove when in crafting board either
+ // Remove if no longer in connection-drawing mode
+ this.remove();
+ return;
+ }
+ if (this.lastDraw + this.idleTimeout < Date.now()) {
+ this.remove();
+ return;
+ }
+
+ this.updateForceSimulation();
+ requestAnimationFrame(this.update);
+};
+
+MemoryPointer.prototype.beginForceSimulation = function() {
+ this.simNode = {
+ id: 'this',
+ x: this.x,
+ y: this.y
+ };
+ this.forceNodes = [this.simNode];
+
+ for (var nodeKey in this.connectedFrame.nodes) {
+ var node = this.connectedFrame.nodes[nodeKey];
+ this.forceNodes.push({
+ id: nodeKey,
+ fx: node.screenX - this.connectedNode.screenX,
+ fy: node.screenY - this.connectedNode.screenY
+ });
+ }
+
+ var links = [{
+ source: this.connectedNodeKey,
+ target: 'this'
+ }];
+
+ this.force = d3.forceSimulation()
+ .force('link', d3.forceLink().distance(80).id(function(d) { return d.id; }))
+ .force('charge', d3.forceManyBody().strength(-80));
+
+ this.force.nodes(this.forceNodes);
+
+ this.force.force('link')
+ .links(links);
+
+ this.force.stop();
+};
+
+MemoryPointer.prototype.updateForceSimulation = function() {
+ for (var i = 0; i < this.forceNodes.length; i++) {
+ var forceNode = this.forceNodes[i];
+ if (forceNode.id === 'this') {
+ continue;
+ }
+ var node = this.connectedFrame.nodes[forceNode.id];
+ if (!node) {
+ continue;
+ }
+ forceNode.fx = node.screenX - this.connectedNode.screenX;
+ forceNode.fy = node.screenY - this.connectedNode.screenY;
+ }
+
+ this.force.alpha(1);
+ this.force.force('link').distance((this.connectedNode.screenLinearZ || 5) * (20*this.connectedObject.averageScale));
+ this.force.tick();
+
+ this.x = this.simNode.x + this.connectedNode.screenX;
+ this.y = this.simNode.y + this.connectedNode.screenY;
+};
+
+
+
+MemoryPointer.prototype.draw = function() {
+ if (!this.alive) {
+ return;
+ }
+
+ this.lastDraw = Date.now();
+ var scale = (this.connectedNode.screenLinearZ*this.connectedObject.averageScale) /10;
+
+ var tol = 60 * scale;
+
+ var connectedNodeIsOffscreen = (this.connectedNode.screenX < -tol) ||
+ (this.connectedNode.screenY < -tol) ||
+ (this.connectedNode.screenX > globalStates.height + tol) ||
+ (this.connectedNode.screenY > globalStates.width + tol);
+
+ if (!connectedNodeIsOffscreen) {
+ if (this.x < tol) {
+ this.x = tol;
+ }
+ if (this.y < tol) {
+ this.y = tol;
+ }
+ if (this.x > globalStates.height - tol) {
+ this.x = globalStates.height - tol;
+ }
+ if (this.y > globalStates.width - tol) {
+ this.y = globalStates.width - tol;
+ }
+ }
+
+ this.element.style.transform = 'translate3d(' + this.x + 'px,' + this.y + 'px, 2px) scale(' + scale + ')';
+};
+
+MemoryPointer.prototype.remove = function() {
+ this.alive = false;
+ this.memory.remove();
+ delete pointers[this.object.objectId];
+};
+
+function getMemoryPointerWithId(id) {
+ if (pointers[id] && !pointers[id].alive) {
+ delete pointers[id];
+ }
+ return pointers[id];
+}
+
+exports.MemoryPointer = MemoryPointer;
+exports.getMemoryPointerWithId = getMemoryPointerWithId;
+
+}(realityEditor.gui.memory));
diff --git a/src/gui/menus.js b/src/gui/menus.js
new file mode 100644
index 000000000..b451c80a0
--- /dev/null
+++ b/src/gui/menus.js
@@ -0,0 +1,659 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.gui.menus");
+
+(function(exports) {
+
+ /**
+ * An array of length <= this.historySteps containing the most recently visited menu names so that you can go back to them
+ * @type {Array.} - each element is a key in this.menus
+ */
+ var history = [];
+
+ /**
+ * How many steps to keep track of that you can undo using the back button
+ * @type {number}
+ */
+ var historySteps = 5;
+
+ /**
+ * A set of all possible buttons, which will be populated with their DOM elements
+ * @type {{back: {}, bigPocket: {}, bigTrash: {}, halfTrash: {}, halfPocket: {}, freeze: {}, logicPocket: {}, logicSetting: {}, gui: {}, logic: {}, pocket: {}, reset: {}, commit: {}, setting: {}, unconstrained: {}, distance: {}, lock: {}, halflock: {}, unlock: {}, record: {}, realityGui: {}, realityInfo: {}, realityTag: {}, realitySearch: {}, realityWork: {}}}
+ */
+ var buttons = {
+ back: {},
+ bigPocket: {},
+ bigTrash: {},
+ halfTrash: {},
+ halfPocket: {},
+ freeze:{},
+ logicPocket:{},
+ logicSetting:{},
+ gui: {},
+ logic: {},
+ pocket: {},
+ reset: {},
+ commit: {},
+ setting: {},
+ unconstrained: {},
+ distance: {},
+ distanceGreen: {},
+ lock:{},
+ halflock:{},
+ unlock:{},
+ record: {},
+ groundPlaneReset: {},
+ // reality UI
+ realityGui : {},
+ realityInfo : {},
+ realityTag: {},
+ realitySearch : {},
+ realityWork : {}
+ };
+
+ /**
+ * A set of all possible menus, where a menu is a set of buttons that should appear when that menu is active, and what color it should be.
+ * @type {Object.>}
+ */
+ var menus = {
+ default: {gui: "blue", logic: "blue", pocket: "blue", setting: "blue", freeze: "blue"},
+ main: {gui: "blue", logic: "blue", pocket: "blue", setting: "blue", freeze: "blue"},
+ logic: {gui: "blue", logic: "blue", pocket: "blue", setting: "blue", freeze: "blue"},
+ gui: {gui: "blue", logic: "blue", pocket: "blue", setting: "blue", freeze: "blue"},
+ setting: {gui: "blue", logic: "blue", pocket: "blue", setting: "blue", freeze: "blue"},
+ groundPlane: {gui: "blue", logic: "blue", pocket: "blue", setting: "blue", freeze: "blue", groundPlaneReset: "blue"},
+ editing: {gui: "blue", logic: "blue", pocket: "blue", setting: "blue", freeze: "blue", commit: "blue", reset: "blue", unconstrained: "blue", distance: "blue"},
+ crafting: {back: "blue", logicPocket: "green", logicSetting: "blue", freeze: "blue"},
+ bigTrash: {bigTrash: "red", distanceGreen: "green"},
+ bigPocket: {bigPocket: "green"},
+ trashOrSave: {halfTrash: "red", halfPocket: "green"},
+ locking: {gui: "blue", logic: "blue", pocket: "blue", setting: "blue", freeze: "blue", unlock:"blue", halflock:"blue", lock:"blue"},
+ lockingEditing: {gui: "blue", logic: "blue", pocket: "blue", setting: "blue", freeze: "blue", unlock:"blue", halflock:"blue", lock:"blue", reset: "blue", unconstrained: "blue", distance: "blue"},
+ realityInfo: {realityGui: "blue", realityInfo: "blue", realityTag: "blue", realitySearch: "blue", setting:"blue", realityWork: "blue"},
+ reality: {realityGui: "blue", realityTag: "blue", realitySearch: "blue", setting:"blue", realityWork: "blue"},
+ settingReality: {realityGui: "blue", realityTag: "blue", realitySearch: "blue", setting:"blue", realityWork: "blue"},
+ videoRecording: {gui: "blue", logic: "blue", pocket: "blue", setting: "blue", freeze: "blue", record:"blue"},
+ videoRecordingEditing: {gui: "blue", logic: "blue", pocket: "blue", setting: "blue", freeze: "blue", record:"blue", commit:"blue", reset: "blue", unconstrained: "blue", distance: "blue"}
+ };
+
+ /**
+ * Returns whether the menu button of the provided name is visible (included) in the current menu state.
+ * @param {string} buttonName
+ * @return {boolean}
+ */
+ function getVisibility(buttonName) {
+ return (buttons[buttonName].item.style.visibility !== "hidden");
+ }
+
+ /**
+ * Gathers references to the DOM elements for a certain button. (they are created already in the HTML file)
+ * @param {string} buttonName
+ * @return {{item: HTMLElement, overlay: HTMLElement | null | *, bg: HTMLElement | Element | *, state: string[]}}
+ */
+ function getElementsForButton(buttonName) {
+ var svgElement = document.getElementById(buttonName+"Button");
+ var overlayElement = document.getElementById(buttonName+"ButtonDiv");
+ var bgElement;
+
+ // special case - logic and gui share one svgElement containing both buttons // TODO: change this, make each separate
+ if (buttonName === "logic" || buttonName === "gui") {
+ svgElement = document.getElementById("mainButton");
+ if (buttonName === "gui") {
+ bgElement = svgElement.getElementById("bg0");
+ } else {
+ bgElement = svgElement.getElementById("bg1");
+ }
+ } else {
+ bgElement = svgElement.getElementById("bg");
+ }
+
+ var buttonObject = { // TODO: rename properties with better names
+ item: svgElement,
+ overlay: overlayElement,
+ bg: bgElement,
+ state: ["",""]
+ };
+
+ buttonObject.overlay.button = buttonName;
+ return buttonObject;
+ }
+
+ /**
+ * Registers the DOM elements for each possible menu button, and adds the touch event listeners.
+ */
+ function init() {
+ document.querySelector('#UIButtons').style.display = '';
+
+ addButtonEventListeners();
+ registerButtonCallbacks();
+ }
+
+ /**
+ * Attaches pointer event listeners to each button which will trigger UI effects in the menus module and model/control effects in the buttons module
+ */
+ function addButtonEventListeners() {
+ for (var buttonName in buttons) {
+ // populate buttons set with references to each DOM element
+ buttons[buttonName] = getElementsForButton(buttonName);
+ // add pointer up/down/enter/leave events
+ addEventListenersForButton(buttonName);
+ }
+ }
+
+ /**
+ * Needs to be in its own function to form a closure on the scope of buttonName
+ * @param {string} buttonName
+ */
+ function addEventListenersForButton(buttonName) {
+ // add event listeners to each button to trigger custom behavior in gui/buttons.js
+ if(buttons[buttonName].overlay) {
+
+ buttons[buttonName].overlay.addEventListener("pointerdown", function (event) {
+ // console.log(buttonName + ' down');
+
+ var mutableEvent = realityEditor.network.frameContentAPI.getMutablePointerEventCopy(event);
+
+ mutableEvent.button = this.button; // points to the buttonObject.overlay.button property, which = buttonName
+ realityEditor.gui.buttons[buttonName + 'ButtonDown'](mutableEvent);
+ }, false);
+
+ buttons[buttonName].overlay.addEventListener("pointerup", function (event) {
+ // console.log(buttonName + ' up');
+
+ // Note: if you don't trigger the _x_ButtonDown for button named _x_, you will need to trigger _x_ButtonUp with
+ // event.ignoreIsDown=true because otherwise it won't register that you intended to press it
+
+ var mutableEvent = realityEditor.network.frameContentAPI.getMutablePointerEventCopy(event);
+
+ mutableEvent.button = this.button;
+ // pointerUp(event);
+
+ // these functions get generated automatically at runtime
+ realityEditor.gui.buttons[buttonName + 'ButtonUp'](mutableEvent);
+
+ sendInterfaces(mutableEvent.button);
+
+ }, false);
+
+ buttons[buttonName].overlay.addEventListener("pointerenter", function (event) {
+ // console.log(buttonName + ' enter');
+
+ var mutableEvent = realityEditor.network.frameContentAPI.getMutablePointerEventCopy(event);
+
+ mutableEvent.button = this.button;
+ // pointerEnter(event);
+ realityEditor.gui.buttons[buttonName + 'ButtonEnter'](mutableEvent);
+ buttonActionEnter(mutableEvent);
+
+ }, false);
+
+ buttons[buttonName].overlay.addEventListener("pointerleave", function (event) {
+ // console.log(buttonName + ' leave');
+
+ var mutableEvent = realityEditor.network.frameContentAPI.getMutablePointerEventCopy(event);
+
+ mutableEvent.button = this.button;
+ // pointerLeave(event);
+ realityEditor.gui.buttons[buttonName + 'ButtonLeave'](mutableEvent);
+ buttonActionLeave(mutableEvent);
+
+ }, false);
+
+ }
+ }
+
+ /**
+ * register callbacks for buttons
+ * TODO: move non-menu actions to other modules
+ */
+ function registerButtonCallbacks() {
+
+ realityEditor.gui.buttons.registerCallbackForButton('gui', function(params) {
+ if (params.newButtonState === 'up') {
+ // updates the button visuals to highlight only the GUI button
+ buttonOff(["logic","logicPocket","logicSetting","setting","pocket"]);
+ buttonOn(["gui"]);
+ // update the global gui state
+ globalStates.guiState = "ui";
+
+ if (DEBUG_DATACRAFTING) { // TODO: BEN DEBUG - turn off debugging!
+ // var logic = new Logic();
+ // realityEditor.gui.crafting.initializeDataCraftingGrid(logic);
+ realityEditor.gui.crafting.craftingBoardVisible(Object.keys(objects)[0], Object.keys(objects)[0], Object.keys(objects)[0]);
+ }
+
+ }
+ }.bind(this));
+
+ realityEditor.gui.buttons.registerCallbackForButton('logic', function(params) {
+ if (params.newButtonState === 'up') {
+ buttonOff(["gui","logicPocket","logicSetting","setting","pocket"]);
+ buttonOn(["logic"]);
+
+ globalStates.guiState = "node";
+ }
+ }.bind(this));
+
+ realityEditor.gui.buttons.registerCallbackForButton('reset', function(params) {
+ if (params.newButtonState === 'up') {
+ switchToMenu("editing", null, ["reset"]);
+ }
+ }.bind(this));
+
+ realityEditor.gui.buttons.registerCallbackForButton('commit', function(params) {
+ if (params.newButtonState === 'up') {
+ switchToMenu("editing", null, ["commit"]);
+ }
+ }.bind(this));
+
+ realityEditor.gui.buttons.registerCallbackForButton('unconstrained', function(params) {
+ if (params.newButtonState === 'up') {
+ // TODO: decide whether to keep this here
+ if (globalStates.unconstrainedPositioning === true) {
+ switchToMenu("editing", null, ["unconstrained"]);
+ globalStates.unconstrainedPositioning = false;
+ } else {
+ switchToMenu("editing", ["unconstrained"], null);
+ globalStates.unconstrainedPositioning = true;
+ }
+ }
+ }.bind(this));
+
+ realityEditor.gui.buttons.registerCallbackForButton('distance', function(params) {
+ console.log('registered in buttons module', params.newButtonState, globalStates.distanceEditingMode);
+ if (params.newButtonState === 'up') {
+ // TODO: decide whether to keep this here or move to distanceScaling.js
+ if (globalStates.distanceEditingMode === true) {
+ switchToMenu("editing", null, ["distance"]);
+ globalStates.distanceEditingMode = false;
+ } else {
+ switchToMenu("editing", ["distance"], null);
+ globalStates.distanceEditingMode = true;
+ }
+ }
+ }.bind(this));
+
+ realityEditor.gui.buttons.registerCallbackForButton('freeze', function(params) {
+ if (params.newButtonState === 'up') {
+ // TODO: decide whether to keep this here
+ if (globalStates.freezeButtonState === true) {
+ buttonOff(["freeze"]);
+ globalStates.freezeButtonState = false;
+ var memoryBackground = document.querySelector('.memoryBackground');
+ memoryBackground.innerHTML = '';
+ realityEditor.app.setResume();
+
+ } else {
+ buttonOn(["freeze"]);
+ globalStates.freezeButtonState = true;
+ realityEditor.app.setPause();
+ }
+ }
+ }.bind(this));
+
+ realityEditor.gui.buttons.registerCallbackForButton('record', function(params) {
+ if (params.newButtonState === 'up') {
+ // TODO: move to record module... but need to know whether on or off?
+ var didStartRecording = realityEditor.device.videoRecording.toggleRecording();
+
+ if(!didStartRecording) {
+ buttonOff(["record"]);
+ } else {
+ buttonOff(["record"]);
+ }
+ }
+ }.bind(this));
+
+ realityEditor.gui.buttons.registerCallbackForButton('back', function(params) {
+ if (params.newButtonState === 'up') {
+ console.log('back button pressed');
+
+ buttonOff(["back"]);
+
+ if (history.length > 0) {
+ console.log("history: " + history);
+ history.pop();
+ var lastMenu = history[history.length - 1];
+ switchToMenu(lastMenu, null, null); // TODO: history should auto-remember which buttons should be highlighted
+ adjustAfterBackButton(lastMenu);
+ }
+ }
+ }.bind(this));
+
+
+ var settingTimer = null;
+ var wasTimed = false;
+
+ function settingButtonCallback(params) {
+ if (params.newButtonState === 'down' && params.buttonName === 'setting') { // only works for setting, not logicSetting
+
+ // TODO: decide whether to keep this here
+ settingTimer = setTimeout(function(){
+ wasTimed = true;
+
+ realityEditor.gui.menus.buttonOff(["setting"]);
+
+ if (!globalStates.editingMode) {
+ realityEditor.device.setEditingMode(true);
+ switchToMenu("editing", null, null);
+ } else {
+ realityEditor.device.setEditingMode(false);
+ switchToMenu("main", null, null);
+ // TODO: ben switch to groundplane if necessary
+ }
+
+ }, 200);
+
+
+ } else if (params.newButtonState === 'up') { // works for setting or logicSetting
+
+ // TODO: decide whether to keep this here
+ if (settingTimer) {
+ clearTimeout(settingTimer);
+ }
+
+ if (wasTimed) {
+ wasTimed = false;
+ return;
+ }
+
+ if (globalStates.guiState === "logic") {
+ console.log(" LOGIC SETTINGS PRESSED ");
+ var wasBlockSettingsOpen = realityEditor.gui.crafting.eventHelper.hideBlockSettings();
+ switchToMenu("crafting", null, ["logicSetting"]);
+ if (!wasBlockSettingsOpen) {
+ var wasNodeSettingsOpen = realityEditor.gui.crafting.eventHelper.hideNodeSettings();
+ if (!wasNodeSettingsOpen) {
+ console.log("Open Node Settings");
+ realityEditor.gui.crafting.eventHelper.openNodeSettings();
+ }
+ }
+ return;
+ }
+
+ if (globalStates.settingsButtonState === true) {
+
+ realityEditor.gui.settings.hideSettings();
+
+ buttonOff(["setting"]);
+
+ overlayDiv.style.display = "inline";
+
+ if (globalStates.editingMode) {
+ switchToMenu("editing", null, null);
+ }
+ // TODO: ben switch to groundplane if necessary
+
+ }
+ else {
+ realityEditor.gui.settings.showSettings();
+ }
+
+ }
+ }
+
+ realityEditor.gui.buttons.registerCallbackForButton('setting', settingButtonCallback.bind(this));
+ realityEditor.gui.buttons.registerCallbackForButton('logicSetting', settingButtonCallback.bind(this));
+
+ // Retail Button Callbacks
+
+ realityEditor.gui.buttons.registerCallbackForButton('realityGui', function(params) {
+ if (params.newButtonState === 'up') {
+ buttonOff(["realityGui", "realityInfo", "realityTag", "realitySearch", "realityWork"]);
+ switchToMenu("realityInfo", ["realityGui"], null);
+ }
+ }.bind(this));
+
+ realityEditor.gui.buttons.registerCallbackForButton('realityInfo', function(params) {
+ if (params.newButtonState === 'up') {
+ buttonOff(["realityTag", "realitySearch", "realityWork"]);
+ switchToMenu("realityInfo", ["realityInfo", "realityGui"], null);
+ }
+ }.bind(this));
+
+ realityEditor.gui.buttons.registerCallbackForButton('realityTag', function(params) {
+ if (params.newButtonState === 'up') {
+ buttonOff(["realityGui", "realityInfo", "realityTag", "realitySearch", "realityWork"]);
+ switchToMenu("reality", ["realityTag"], null);
+ }
+ }.bind(this));
+
+ realityEditor.gui.buttons.registerCallbackForButton('realityWork', function(params) {
+ if (params.newButtonState === 'up') {
+ buttonOff(["realityGui", "realityInfo", "realityTag", "realitySearch", "realityWork"]);
+ switchToMenu("reality", ["realityWork"], null);
+ }
+ }.bind(this));
+ }
+
+ /**
+ * Remove the oldest history item and add this menu as the newest
+ * @param {string} menuName
+ */
+ function addToHistory(menuName) {
+ if (history.length >= historySteps) {
+ history.shift();
+ }
+ history.push(menuName);
+ }
+
+ /**
+ * Switches to the menu of name menuDiv and activates (highlight) a subset of its buttons, and deactivates another subset
+ * @param {string} newMenuName
+ * @param {Array.|null} buttonsToHighlight
+ * @param {Array.|null} buttonsToUnhighlight
+ */
+ function switchToMenu(newMenuName, buttonsToHighlight, buttonsToUnhighlight) {
+ if (realityEditor.device.environment.variables.overrideMenusAndButtons) {
+ return;
+ }
+
+ // handle null parameters gracefully
+ buttonsToHighlight = buttonsToHighlight || [];
+ buttonsToUnhighlight = buttonsToUnhighlight || [];
+
+ // show correct combination of sub-menus
+ if ((newMenuName === "main" || newMenuName === "gui" ||newMenuName === "logic") && !globalStates.settingsButtonState) {
+ if (globalStates.editingMode && realityEditor.gui.settings.toggleStates.videoRecordingEnabled) {
+ newMenuName = "videoRecordingEditing"
+ } else if (globalStates.editingMode && globalStates.lockingMode) {
+ newMenuName = "lockingEditing";
+ } else if (globalStates.editingMode) {
+ newMenuName = "editing";
+ } else if (realityEditor.gui.settings.toggleStates.videoRecordingEnabled) {
+ newMenuName = "videoRecording"
+ } else if (globalStates.lockingMode) {
+ newMenuName = "locking";
+ }
+ }
+
+ // update history so back button works
+ addToHistory(newMenuName);
+
+ // show and hide all buttons so that only the ones included in this.menus[menuDiv] are visible
+ for (var buttonName in buttons) {
+ if (buttonName in menus[newMenuName]) {
+ buttons[buttonName].item.style.visibility = "visible";
+ buttons[buttonName].overlay.style.visibility = "visible";
+ } else {
+ buttons[buttonName].item.style.visibility = "hidden";
+ buttons[buttonName].overlay.style.visibility = "hidden";
+ }
+ }
+
+ // highlights any buttons included in buttonsToHighlight,
+ buttonsToHighlight.forEach( function(buttonName) {
+ if (buttonName in menus[newMenuName]) {
+ highlightButton(buttonName, true);
+ }
+ });
+
+ // and un-highlights any included in buttonsToUnhighlight
+ buttonsToUnhighlight.forEach( function(buttonName) {
+ if (buttonName in menus[newMenuName]) {
+ highlightButton(buttonName, false);
+ }
+ });
+ }
+
+ /**
+ * Changes the background color for the provided button to be active or inactive
+ * @param {string} buttonName
+ * @param {boolean} shouldHighlight
+ */
+ function highlightButton(buttonName, shouldHighlight) {
+ if (buttonName in buttons) {
+ var buttonBackground = buttons[buttonName].bg;
+ buttonBackground.classList.add( (shouldHighlight ? 'active' : 'inactive') );
+ buttonBackground.classList.remove( (shouldHighlight ? 'inactive' : 'active') );
+ }
+ }
+
+ /**
+ * Highlights a specific button, without changing which menu is active.
+ * @param {Array.} buttonArray
+ */
+ function buttonOn(buttonArray) {
+ buttonArray.forEach( function(buttonName) {
+ highlightButton(buttonName, true);
+ });
+ }
+
+ /**
+ * Remove the highlight from a specific button, without changing which menu is active.
+ * @param {Array.} buttonArray
+ */
+ function buttonOff(buttonArray) { // TODO: accept string argument if only one changing, array if multiple?
+ buttonArray.forEach( function(buttonName) {
+ highlightButton(buttonName, false);
+ });
+ }
+
+ /**
+ * Triggers any side effects when the back button is pressed and you arrive at the new menu
+ * @param {string} newMenu
+ */
+ function adjustAfterBackButton(newMenu) {
+
+ if (newMenu === 'crafting') {
+ // if the blockMenu is visible, close it
+ var existingMenu = document.getElementById('menuContainer');
+ if (existingMenu && existingMenu.style.display !== 'none') {
+ realityEditor.gui.buttons.logicPocketButtonUp({button: "logicPocket", ignoreIsDown: true});
+ return;
+ // if the blockSettings view is visible, close it
+ } else if (document.getElementById('blockSettingsContainer')) {
+ realityEditor.gui.buttons.settingButtonUp({button: "setting", ignoreIsDown: true});
+ return;
+ }
+ }
+
+ // default option is to close the crafting board
+ realityEditor.gui.buttons.logicButtonUp({button: "logic", ignoreIsDown: true});
+ }
+
+ /**
+ * Highlight a particular button on touch enter
+ * @param {ButtonEvent} event
+ */
+ function buttonActionEnter(event) {
+ buttons[event.button].bg.classList.add('touched');
+ }
+
+ /**
+ * Un-highlight a particular button on touch leave
+ * @param {ButtonEvent} event
+ */
+ function buttonActionLeave(event) {
+ buttons[event.button].bg.classList.remove('touched');
+ }
+
+ /**
+ * Posts the name of the button that was pressed into any visible frames and nodes
+ * @param {string} interfaceName
+ */
+ function sendInterfaces(interfaceName) {
+
+ // update the global app state to know which button was most recently pressed
+ globalStates.interface = interfaceName;
+
+ // send active user interfaceName status in to the AR-UI
+ var msg = { interface: globalStates.interface };
+
+ realityEditor.forEachFrameInAllObjects( function(objectKey, frameKey) {
+
+ // post into each visible frame
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ if (frame.visible) {
+ if (globalDOMCache["iframe" + frameKey] && globalDOMCache["iframe" + frameKey].contentWindow) {
+ globalDOMCache["iframe" + frameKey].contentWindow.postMessage(JSON.stringify(msg), "*");
+ }
+
+ // post into each visible node
+ realityEditor.forEachNodeInFrame(objectKey, frameKey, function(objectKey, frameKey, nodeKey) {
+ var node = realityEditor.getNode(objectKey, frameKey, nodeKey);
+ if (node.visible) {
+ if (globalDOMCache["iframe" + nodeKey] && globalDOMCache["iframe" + nodeKey].contentWindow) {
+ globalDOMCache["iframe" + nodeKey].contentWindow.postMessage(JSON.stringify(msg), "*");
+ }
+ }
+ });
+ }
+ });
+ }
+
+ exports.getVisibility = getVisibility;
+ exports.init = init;
+ exports.switchToMenu = switchToMenu;
+ exports.buttonOn = buttonOn;
+ exports.buttonOff = buttonOff;
+ exports.sendInterfaces = sendInterfaces; // public so other events e.g. search button can send current interface to frames
+
+})(realityEditor.gui.menus);
diff --git a/src/gui/modal.js b/src/gui/modal.js
new file mode 100644
index 000000000..ee0ccae05
--- /dev/null
+++ b/src/gui/modal.js
@@ -0,0 +1,388 @@
+createNameSpace("realityEditor.gui.modal");
+
+(function(exports) {
+
+ /**
+ * Creates and presents a minimal modal with cancel and submit buttons styled like the Reality Server frontend.
+ * @param {string} cancelButtonText - Can comfortably fit 8 "m" characters (or around 11 average characters) @todo auto-resize font to fit
+ * @param {string} submitButtonText - Can comfortably fit 14 "m" characters (or around 20 average characters)
+ * @param {function} onCancelCallback
+ * @param {function} onSubmitCallback
+ */
+ function openRealityModal(cancelButtonText, submitButtonText, onCancelCallback, onSubmitCallback) {
+ // create the instance of the modal
+ // instantiate / modify the DOM elements
+ var domElements = createRealityModalDOM();
+ domElements.cancelButton.innerHTML = cancelButtonText || 'Cancel';
+ domElements.submitButton.innerHTML = submitButtonText || 'Submit';
+
+ // attach callbacks to button pointer events + delete/hide when done
+ domElements.cancelButton.addEventListener('pointerup', function(event) {
+ hideModal(domElements);
+ onCancelCallback(event);
+ });
+ domElements.submitButton.addEventListener('pointerup', function(event) {
+ hideModal(domElements);
+ onSubmitCallback(event);
+ });
+
+ // disable touch actions elsewhere on the screen
+ // todo does this happen automatically from the fade element?
+ domElements.fade.addEventListener('pointerevent', function(event) {
+ event.stopPropagation();
+ });
+
+ // present on the DOM
+ document.body.appendChild(domElements.fade);
+ document.body.appendChild(domElements.container);
+ }
+
+ /**
+ * Properly hides the modal with animations, etc.
+ * @param domElements
+ */
+ function hideModal(domElements) {
+ // immediately remove container
+ removeElements([domElements.container]);
+
+ // fade out darkened background
+ domElements.fade.classList.remove('modalVisibleFadeIn');
+ domElements.fade.classList.add('modalInvisibleFadeOut');
+
+ setTimeout(function() {
+ removeElements([domElements.fade]);
+ }, 250);
+ }
+
+ /**
+ * Helper function to remove a list of DOM elements
+ * @param {Array.} domElementsToRemove
+ */
+ function removeElements(domElementsToRemove) {
+ domElementsToRemove.forEach(function(domElement) {
+ domElement.parentElement.removeChild(domElement);
+ });
+ }
+
+ /**
+ * Creates and presents a modal interface with a description, cancel, and submit button, with a flat/material UI.
+ * @param {string} headerText
+ * @param {string} descriptionText
+ * @param {string} cancelButtonText
+ * @param {string} submitButtonText
+ * @param {function} onCancelCallback
+ * @param {function} onSubmitCallback
+ * @param {boolean} useSmallerVersion
+ */
+ function openClassicModal(headerText, descriptionText, cancelButtonText, submitButtonText, onCancelCallback, onSubmitCallback, useSmallerVersion) {
+ // create the instance of the modal
+ // instantiate / modify the DOM elements
+ var domElements = createClassicModalDOM(useSmallerVersion);
+ domElements.header.innerHTML = headerText;
+ domElements.description.innerHTML = descriptionText;
+ domElements.cancelButton.innerHTML = cancelButtonText || 'Cancel';
+ domElements.submitButton.innerHTML = submitButtonText || 'Submit';
+
+ // attach callbacks to button pointer events + delete/hide when done
+ domElements.cancelButton.addEventListener('pointerup', function(event) {
+ onCancelCallback(event);
+ hideModal(domElements);
+ });
+ domElements.submitButton.addEventListener('pointerup', function(event) {
+ onSubmitCallback(event);
+ hideModal(domElements);
+ });
+
+ // disable touch actions elsewhere on the screen
+ // todo does this happen automatically from the fade element?
+ domElements.fade.addEventListener('pointerevent', function(event) {
+ event.stopPropagation();
+ });
+
+ // present on the DOM
+ document.body.appendChild(domElements.fade);
+ document.body.appendChild(domElements.container);
+ }
+
+ function openInputModal({ headerText, descriptionText, inputPlaceholderText, cancelButtonText, submitButtonText, onCancelCallback, onSubmitCallback }) {
+ // Add a blurry background that can be tapped on to cancel the modal
+ let fade = document.createElement('div'); // darkens/blurs the background
+ fade.id = 'modalFadeClassic';
+ fade.classList.add('modalVisibleFadeIn');
+ document.body.appendChild(fade);
+
+ // create the container with the header, description, a text input, and a submit and cancel button
+ let container = document.createElement('div');
+ container.classList.add('inputModalCard');
+ // container.classList.add('viewCard', 'center', 'popUpModal');
+ let text = document.createElement('div');
+ let header = document.createElement('h3');
+ header.innerText = headerText;
+ text.appendChild(header);
+ let description = document.createElement('p');
+ description.innerText = descriptionText;
+ text.appendChild(description);
+ text.classList.add('inputModalCardText');
+
+ let inputField = document.createElement('input');
+ inputField.classList.add('inputModalCardInput');
+ inputField.setAttribute('type', 'text');
+ if (inputPlaceholderText) {
+ inputField.setAttribute('placeholder', inputPlaceholderText);
+ }
+ inputField.addEventListener('keydown', (downEvent) => {
+ if (downEvent.key === 'Enter') {
+ hideModal();
+ if (onSubmitCallback) {
+ onSubmitCallback(downEvent, inputField.value);
+ }
+ }
+ });
+
+ let submitButton = document.createElement('div');
+ submitButton.innerText = submitButtonText || 'Submit';
+ submitButton.classList.add('inputModalCardButton');
+ let cancelButton = document.createElement('div');
+ cancelButton.innerText = cancelButtonText || 'Cancel';
+ cancelButton.classList.add('inputModalCardButton', 'buttonLight');
+ cancelButton.style.marginBottom = '0';
+
+ container.appendChild(text);
+ container.appendChild(inputField);
+ container.appendChild(submitButton);
+ container.appendChild(cancelButton);
+ document.body.appendChild(container);
+
+ realityEditor.device.keyboardEvents.openKeyboard(); // mark the keyboard as in-use until the modal disappears, so keyboard shortcuts are disabled
+ inputField.focus();
+
+ const hideModal = () => {
+ realityEditor.device.keyboardEvents.closeKeyboard(); // release control of the keyboard
+ container.parentElement.removeChild(container);
+ fade.parentElement.removeChild(fade);
+ };
+
+ // attach callbacks to button pointer events + delete/hide when done
+ [cancelButton, fade].forEach(elt => {
+ elt.addEventListener('pointerup', function(event) {
+ hideModal();
+ if (onCancelCallback) {
+ onCancelCallback(event);
+ }
+ });
+ });
+ // tapping on the submitButton button sends the text input to the callback function
+ submitButton.addEventListener('pointerup', function(event) {
+ hideModal();
+ if (onSubmitCallback) {
+ onSubmitCallback(event, inputField.value);
+ }
+ });
+ }
+
+ /**
+ * Constructs the DOM and returns references to its elements
+ * @return {{fade: HTMLDivElement, container: HTMLDivElement, cancelButton: HTMLDivElement, submitButton: HTMLDivElement}}
+ */
+ function createRealityModalDOM() {
+ var fade = document.createElement('div'); // darkens/blurs the background
+ var container = document.createElement('div'); // panel holding all the modal elements
+ var cancelButton = document.createElement('div');
+ var submitButton = document.createElement('div');
+
+ fade.id = 'modalFade';
+ container.id = 'modalContainer';
+ cancelButton.id = 'modalCancel';
+ submitButton.id = 'modalSubmit';
+
+ cancelButton.classList.add('modalButton', 'buttonWhite');
+ submitButton.classList.add('modalButton', 'buttonRed');
+
+ fade.classList.add('modalVisibleFadeIn');
+
+ container.appendChild(cancelButton);
+ container.appendChild(submitButton);
+
+ return {
+ fade: fade,
+ container: container,
+ cancelButton: cancelButton,
+ submitButton: submitButton
+ }
+ }
+
+ /**
+ * Constructs the DOM and returns references to its elements
+ * @return {{fade: HTMLDivElement, container: HTMLDivElement, header: HTMLDivElement, description: HTMLDivElement, cancelButton: HTMLDivElement, submitButton: HTMLDivElement}}
+ */
+ function createClassicModalDOM(useSmallerCenteredVersion) {
+ var fade = document.createElement('div'); // darkens/blurs the background
+ var container = document.createElement('div'); // panel holding all the modal elements
+ var header = document.createElement('div');
+ var description = document.createElement('div');
+ var cancelButton = document.createElement('div');
+ var submitButton = document.createElement('div');
+
+ fade.id = 'modalFadeClassic';
+ container.id = useSmallerCenteredVersion ? 'modalContainerClassicCentered' : 'modalContainerClassic';
+ header.id = 'modalHeaderClassic';
+ description.id = 'modalDescriptionClassic';
+ cancelButton.id = 'modalCancelClassic';
+ submitButton.id = 'modalSubmitClassic';
+
+ cancelButton.classList.add('modalButtonClassic');
+ submitButton.classList.add('modalButtonClassic');
+
+ container.appendChild(header);
+ container.appendChild(description);
+ container.appendChild(cancelButton);
+ container.appendChild(submitButton);
+
+ return {
+ fade: fade,
+ container: container,
+ header: header,
+ description: description,
+ cancelButton: cancelButton,
+ submitButton: submitButton
+ }
+ }
+
+ //Creates notification container for toast and other messages triggered by in-app events (errors, network
+ // updates, device tracking, etc)
+ function createNotificationContainer() {
+
+ let notificationContainer = document.createElement('div');
+ notificationContainer.id = 'interfaceNotificationContainer';
+ notificationContainer.classList.add('statusBarContainer');
+
+ document.body.appendChild(notificationContainer);
+ }
+
+ function Notification(headerText, descriptionText, onCloseCallback, isPortraitLayout) {
+ this.headerText = headerText;
+ this.descriptionText = descriptionText;
+ this.onCloseCallback = onCloseCallback;
+ this.domElements = createNotificationDOM(true, isPortraitLayout);
+ }
+
+ Notification.prototype.dismiss = function() {
+ hideModal(this.domElements);
+ this.onCloseCallback();
+ };
+
+ function showSimpleNotification(headerText, descriptionText, onCloseCallback, isPortraitLayout) {
+
+ let notification = new Notification(headerText, descriptionText, onCloseCallback, isPortraitLayout);
+
+ // create the instance of the modal
+ // instantiate / modify the DOM elements
+ let domElements = notification.domElements;
+ domElements.header.innerHTML = headerText;
+ domElements.description.innerHTML = descriptionText;
+ // domElements.cancelButton.innerHTML = 'Dismiss'; // cancelButtonText || 'Cancel';
+
+ // attach callbacks to button pointer events + delete/hide when done
+ // domElements.cancelButton.addEventListener('pointerup', function(event) {
+ // hideModal(domElements);
+ // onCloseCallback(event);
+ // });
+
+ // disable touch actions elsewhere on the screen
+ // todo does this happen automatically from the fade element?
+ domElements.fade.addEventListener('pointerevent', function(event) {
+ event.stopPropagation();
+ });
+
+ // present on the DOM
+ document.body.appendChild(domElements.fade);
+ document.body.appendChild(domElements.container);
+
+ return notification;
+ }
+
+ function createNotificationDOM(includeLoader, isPortraitLayout) {
+ var fade = document.createElement('div'); // darkens/blurs the background
+ var container = document.createElement('div'); // panel holding all the modal elements
+ var header = document.createElement('div');
+ var description = document.createElement('div');
+ // var cancelButton = document.createElement('div');
+
+ fade.id = 'modalFadeNotification';
+ container.id = 'modalContainerNotification';
+ header.id = 'modalHeaderNotification';
+ description.id = 'modalDescriptionNotification';
+ // cancelButton.id = 'modalCancelNotification';
+
+ if (isPortraitLayout) {
+ container.classList.add('notificationPortrait');
+ header.classList.add('notificationHeaderPortrait');
+ description.classList.add('notificationDescriptionPortrait');
+ }
+
+ // cancelButton.classList.add('modalButtonNotification');
+
+ fade.classList.add('modalBlurOut');
+
+ container.appendChild(header);
+
+ let loader = null;
+ if (includeLoader) {
+ loader = document.createElement('div');
+ container.classList.add('loaderContainer');
+ loader.classList.add('loader');
+ container.appendChild(loader);
+
+ if (isPortraitLayout) {
+ container.classList.add('loaderContainerPortrait');
+ }
+ }
+
+ container.appendChild(description);
+ // container.appendChild(cancelButton);
+
+ return {
+ fade: fade,
+ container: container,
+ header: header,
+ description: description,
+ // cancelButton: cancelButton,
+ loader: loader
+ }
+ }
+
+ //Whenever this function is used, make sure to append the returned element to the dom/document. This is done so
+ // additional event listeners can be added if needed.
+ function showBannerNotification(message, uiId, textId, timeMs = 3000) {
+
+ let notificationContainer = document.getElementById('interfaceNotificationContainer');
+
+ let notificationUI = document.createElement('div');
+ notificationUI.classList.add('statusBar');
+ notificationUI.id = uiId;
+
+ let notificationTextContainer = document.createElement('div');
+ notificationTextContainer.classList.add('statusBarText');
+ notificationTextContainer.id = textId;
+ notificationUI.appendChild(notificationTextContainer);
+
+ // show and populate with message
+ notificationTextContainer.innerText = message;
+
+ if (timeMs > 0) {
+ setTimeout(() => {
+ notificationContainer.removeChild(notificationUI);
+ }, timeMs);
+ }
+
+ notificationContainer.appendChild(notificationUI);
+ }
+
+ exports.openClassicModal = openClassicModal;
+ exports.openRealityModal = openRealityModal;
+ exports.openInputModal = openInputModal;
+ exports.createNotificationContainer = createNotificationContainer;
+ exports.showSimpleNotification = showSimpleNotification;
+ exports.showBannerNotification = showBannerNotification;
+
+})(realityEditor.gui.modal);
diff --git a/src/gui/moveabilityCorners.js b/src/gui/moveabilityCorners.js
new file mode 100644
index 000000000..f944f2763
--- /dev/null
+++ b/src/gui/moveabilityCorners.js
@@ -0,0 +1,143 @@
+createNameSpace('realityEditor.gui.moveabilityCorners');
+
+(function(exports) {
+
+ function wrapDivWithCorners(div, padding, exclusive, additionalStyling, sizeAdjustment, borderWidth, extraLength) {
+ if (!sizeAdjustment) { sizeAdjustment = 0; }
+ if (exclusive) {
+ var cornersFound = div.querySelector('.corners');
+ if (cornersFound) {
+ console.warn('not adding corners because it already has some');
+ return;
+ }
+ }
+ let divWidth = 100;
+ let divHeight = 100;
+ try {
+ const divStyle = window.getComputedStyle(div);
+ divWidth = parseFloat(divStyle.width);
+ divHeight = parseFloat(divStyle.height);
+ } catch (e) {
+ console.warn('Unable to retrieve div style', e);
+ }
+ let corners = createMoveabilityCorners(div.id+'corners', divWidth + sizeAdjustment, divHeight + sizeAdjustment, padding, borderWidth, extraLength);
+ corners.style.left = (-padding) + 'px';
+ corners.style.top = (-padding) + 'px';
+
+ for (var propertyName in additionalStyling) {
+ corners.style[propertyName] = additionalStyling[propertyName];
+ }
+
+ div.appendChild(corners);
+ return corners;
+ }
+
+ function removeCornersFromDiv(div) {
+ var cornersFound = div.querySelector('.corners');
+ if (cornersFound) {
+ div.removeChild(cornersFound);
+ }
+ }
+
+ function wrapDivInOutline(div, padding, exclusive, additionalStyling, sizeAdjustment, borderWidth) {
+ if (!div) { return; }
+ if (!sizeAdjustment) { sizeAdjustment = 0; }
+ if (!borderWidth) { borderWidth = 2; }
+ if (exclusive) {
+ var outlineFound = div.querySelector('.outline');
+ if (outlineFound) {
+ console.warn('not adding outline because it already has some');
+ return;
+ }
+ }
+ var rect = div.getClientRects()[0];
+ var outline = createDiv(div.id+'outline', 'outline', null, div);
+ outline.style.border = borderWidth + 'px solid cyan';
+ outline.style.left = (-padding) + 'px';
+ outline.style.top = (-padding) + 'px';
+ outline.style.width = (rect.width+padding*2 - (2*borderWidth) + sizeAdjustment) + 'px';
+ outline.style.height = (rect.height+padding*2 - (2*borderWidth) + sizeAdjustment) + 'px';
+
+ for (var propertyName in additionalStyling) {
+ outline.style[propertyName] = additionalStyling[propertyName];
+ }
+
+ return outline;
+ }
+
+ function removeOutlineFromDiv(div) {
+ var outlineFound = div.querySelector('.outline');
+ if (outlineFound) {
+ div.removeChild(outlineFound);
+ }
+ }
+
+ function createMoveabilityCorners(id, width, height, padding, borderWidth, extraLength) {
+ var corners = createDiv(id, 'corners', null, null);
+ var topLeft = createDiv(id+'topleft', 'cornersTop cornersLeft', null, corners);
+ var topRight = createDiv(id+'topleft', 'cornersTop cornersRight', null, corners);
+ var bottomRight = createDiv(id+'topleft', 'cornersBottom cornersRight', null, corners);
+ var bottomLeft = createDiv(id+'topleft', 'cornersBottom cornersLeft', null, corners);
+ if (borderWidth) {
+ topLeft.style.borderTop = borderWidth + 'px solid cyan';
+ topLeft.style.borderLeft = borderWidth + 'px solid cyan';
+
+ topRight.style.borderTop = borderWidth + 'px solid cyan';
+ topRight.style.borderRight = borderWidth + 'px solid cyan';
+
+ bottomRight.style.borderBottom = borderWidth + 'px solid cyan';
+ bottomRight.style.borderRight = borderWidth + 'px solid cyan';
+
+ bottomLeft.style.borderBottom = borderWidth + 'px solid cyan';
+ bottomLeft.style.borderLeft = borderWidth + 'px solid cyan';
+ }
+ if (extraLength) {
+ [topLeft, topRight, bottomRight, bottomLeft].forEach(function(corner) {
+ corner.style.width = (parseInt(corner.style.width) || 0) + extraLength + 'px';
+ corner.style.height = (parseInt(corner.style.height) || 0) + extraLength + 'px';
+ });
+ }
+ corners.style.width = (width + padding * 2) + 'px';
+ corners.style.height = (height + padding * 2) + 'px';
+ return corners;
+ }
+
+ /**
+ * Shortcut for creating a div with certain style and contents, and possibly adding to a parent element
+ * Any parameter can be omitted (pass in null) to ignore those effects
+ * @param {string|null} id
+ * @param {string|Array.|null} classList
+ * @param {string|null} innerHTML
+ * @param {HTMLElement|null} parentToAddTo
+ * @return {HTMLDivElement}
+ */
+ function createDiv(id, classList, innerHTML, parentToAddTo) {
+ var div = document.createElement('div');
+ if (id) {
+ div.id = id;
+ }
+ if (classList) {
+ if (typeof classList === 'string') {
+ div.className = classList;
+ } else if (typeof classList === 'object') {
+ classList.forEach(function(className) {
+ div.classList.add(className);
+ });
+ }
+ }
+ if (innerHTML) {
+ div.innerHTML = innerHTML;
+ }
+ if (parentToAddTo) {
+ parentToAddTo.appendChild(div);
+ }
+ return div;
+ }
+
+ exports.createMoveabilityCorners = createMoveabilityCorners;
+ exports.wrapDivWithCorners = wrapDivWithCorners;
+ exports.removeCornersFromDiv = removeCornersFromDiv;
+ exports.wrapDivInOutline = wrapDivInOutline;
+ exports.removeOutlineFromDiv = removeOutlineFromDiv;
+
+})(realityEditor.gui.moveabilityCorners);
diff --git a/src/gui/navigation.js b/src/gui/navigation.js
new file mode 100644
index 000000000..6bedf61ca
--- /dev/null
+++ b/src/gui/navigation.js
@@ -0,0 +1,489 @@
+createNameSpace("realityEditor.gui.navigation");
+
+/**
+ * @fileOverview realityEditor.app.targetDownloader.js
+ * Compartmentalizes the functions related to pathfinding within a space
+ */
+
+(function(exports) {
+ const trackedObjectIDs = [];
+ const navigationObjects = {};
+ let initialized = false;
+ let pathMeshResources;
+
+ const initialize = () => {
+ initialized = true;
+ realityEditor.gui.threejsScene.onAnimationFrame(() => {
+ trackedObjectIDs.forEach(id => {
+ // refresh path
+ removeNavigationPath(id);
+ addNavigationPath(id);
+ });
+ });
+ }
+
+ setInterval(() => {
+ const whereIs = globalStates.spatial.whereIs;
+ const newObjectIDs = [];
+ for (const ip in whereIs) {
+ for (const objectKey in whereIs[ip]) {
+ newObjectIDs.push(whereIs[ip][objectKey].objectID);
+ }
+ }
+ if (newObjectIDs.length > 0 && !initialized) {
+ initialize();
+ }
+ trackedObjectIDs.filter(id=>!newObjectIDs.includes(id)).forEach(id=>{
+ trackedObjectIDs.splice(trackedObjectIDs.indexOf(id),1);
+ removeNavigationPath(id);
+ });
+ newObjectIDs.filter(id=>!trackedObjectIDs.includes(id)).forEach(id=>{
+ trackedObjectIDs.push(id);
+ });
+ },300);
+
+ // Allows us to reuse materials and geometries
+ const getPathMeshResources = (THREE, lightWidth, lightLength) => {
+ if (!pathMeshResources) {
+ const lightGeometry = new THREE.BoxGeometry(lightWidth,2,lightLength);
+ const lightMaterial = new THREE.MeshBasicMaterial({color:0xFFFFCC, transparent:true});
+ const topMaterial = new THREE.MeshBasicMaterial({color:0x000000, transparent:true});
+ const wallMaterial = new THREE.MeshBasicMaterial({color:0xffff00, transparent:true, opacity:0.8});
+
+ // Fade effect
+ const startFadeInDist = 600; // 0.6m
+ const endFadeInDist = 750; // 0.75m
+ const startFadeOutDist = 2000; // 2m
+ const endFadeOutDist = 3000; // 3m
+ [lightMaterial, topMaterial, wallMaterial].forEach(material => {
+ material.onBeforeCompile = (shader) => {
+ shader.fragmentShader = shader.fragmentShader.replace(
+ 'gl_FragColor = vec4( outgoingLight, diffuseColor.a );',
+ [
+ 'gl_FragColor = vec4( outgoingLight, diffuseColor.a );',
+ 'float z = gl_FragCoord.z / gl_FragCoord.w;',
+ `float s = z < float(${startFadeOutDist}) ? (z - float(${startFadeInDist})) / (float(${endFadeInDist - startFadeInDist})) : (float(${endFadeOutDist})-z) / float(${endFadeOutDist-startFadeOutDist});`,
+ 'gl_FragColor.a *= clamp(s, 0.0, 1.0);',
+ ].join( '\n' )
+ )
+ }
+ });
+ pathMeshResources = {lightGeometry, lightMaterial, topMaterial, wallMaterial};
+ }
+ return pathMeshResources;
+ }
+
+ // Converts a path in 3D space to a three.js mesh
+ const pathToMesh = (path) => {
+ const THREE = realityEditor.gui.threejsScene.THREE;
+ if (path.length < 2) {
+ return new THREE.Group();
+ }
+ const rampAngle = 35;
+ const rampHeight = path[path.length - 1].y - path[0].y;
+ const rampRatio = Math.tan(rampAngle * Math.PI / 180);
+ const rampLength = rampHeight / rampRatio;
+ path[path.length - 1].y = path[0].y; // Simplifies math later
+ const pathWidth = 50; // 50mm
+ const pathHeight = 50; // 50mm
+ const topGeometry = new THREE.BufferGeometry(); // The top represents the flat black top of the line
+ const wallGeometry = new THREE.BufferGeometry(); // The wall represents the yellow sides of the line
+ let topVertices = [];
+ let wallVertices = [];
+ const up = new THREE.Vector3(0,1,0);
+ // Base should be wider to allow visibility while moving along line
+ const bottomScale = 1.4; // How much wider the bottom of the walls is
+ let lightDistanceTraveled = 0; // Used to determine light placement
+ const lightInterval = 128; // mm offset between lights
+ const lightTimingInterval = 2000; // ms frequency of pulse
+ const lightOnDuration = 60; // ms duration of pulse on per light
+ const lightSpeed = 10; // pulse speed multiplier
+ const lightWidth = 10; // mm width of lightSource
+ const lightLength = 64; // mm length of light source
+ const lightGroup = new THREE.Group();
+
+ const resources = getPathMeshResources(THREE, lightWidth, lightLength);
+ const lightGeometry = resources.lightGeometry;
+ const lightMaterial = resources.lightMaterial;
+ const topMaterial = resources.topMaterial;
+ const wallMaterial = resources.wallMaterial;
+
+ for (let i = path.length - 1; i > 0; i--) {
+ const start = path[i];
+ const end = path[i-1];
+ const direction = new THREE.Vector3().subVectors(end, start);
+ const cross = new THREE.Vector3().crossVectors(direction, up).normalize().multiplyScalar(pathWidth / 2);
+ const bottomCross = cross.clone().multiplyScalar(bottomScale);
+
+ const startRampHeight = lightDistanceTraveled >= Math.abs(rampLength) ? 0 : (rampLength - lightDistanceTraveled) * rampRatio;
+ const endRampHeight = lightDistanceTraveled + direction.length() >= Math.abs(rampLength) ? 0 : (rampLength - (lightDistanceTraveled + direction.length())) * rampRatio;
+
+ const startTaperFactor = lightDistanceTraveled >= Math.abs(rampLength) ? 1 : lightDistanceTraveled / rampLength;
+ const endTaperFactor = lightDistanceTraveled + direction.length() >= Math.abs(rampLength) ? 1 : (lightDistanceTraveled + direction.length()) / rampLength;
+
+ // First top triangle
+ topVertices.push(start.x-cross.x*startTaperFactor, start.y+pathHeight*startTaperFactor+startRampHeight, start.z-cross.z*startTaperFactor);
+ topVertices.push(start.x+cross.x*startTaperFactor, start.y+pathHeight*startTaperFactor+startRampHeight, start.z+cross.z*startTaperFactor);
+ topVertices.push(end.x-cross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z-cross.z*endTaperFactor);
+
+ // Second top triangle
+ topVertices.push(start.x+cross.x*startTaperFactor, start.y+pathHeight*startTaperFactor+startRampHeight, start.z+cross.z*startTaperFactor);
+ topVertices.push(end.x+cross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z+cross.z*endTaperFactor);
+ topVertices.push(end.x-cross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z-cross.z*endTaperFactor);
+
+ // First left triangle
+ wallVertices.push(start.x-bottomCross.x*startTaperFactor, start.y+startRampHeight, start.z-bottomCross.z*startTaperFactor);
+ wallVertices.push(start.x-cross.x*startTaperFactor, start.y+pathHeight*startTaperFactor+startRampHeight, start.z-cross.z*startTaperFactor);
+ wallVertices.push(end.x-bottomCross.x*endTaperFactor, end.y+endRampHeight, end.z-bottomCross.z*endTaperFactor);
+
+ // Second left triangle
+ wallVertices.push(start.x-cross.x*startTaperFactor, start.y+pathHeight*startTaperFactor+startRampHeight, start.z-cross.z*startTaperFactor);
+ wallVertices.push(end.x-cross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z-cross.z*endTaperFactor);
+ wallVertices.push(end.x-bottomCross.x*endTaperFactor, end.y+endRampHeight, end.z-bottomCross.z*endTaperFactor);
+
+ // First right triangle
+ wallVertices.push(start.x+cross.x*startTaperFactor, start.y+pathHeight*startTaperFactor+startRampHeight, start.z+cross.z*startTaperFactor);
+ wallVertices.push(start.x+bottomCross.x*startTaperFactor, start.y+startRampHeight, start.z+bottomCross.z*startTaperFactor);
+ wallVertices.push(end.x+cross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z+cross.z*endTaperFactor);
+
+ // Second right triangle
+ wallVertices.push(start.x+bottomCross.x*startTaperFactor, start.y+startRampHeight, start.z+bottomCross.z*startTaperFactor);
+ wallVertices.push(end.x+bottomCross.x*endTaperFactor, end.y+endRampHeight, end.z+bottomCross.z*endTaperFactor);
+ wallVertices.push(end.x+cross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z+cross.z*endTaperFactor);
+
+ // Handle bends
+ if (i > 1) {
+ const nextDirection = new THREE.Vector3().subVectors(path[i-2],end);
+ const nextCross = new THREE.Vector3().crossVectors(nextDirection, up).normalize().multiplyScalar(pathWidth / 2);
+ const nextBottomCross = nextCross.clone().multiplyScalar(bottomScale);
+
+ // First top triangle
+ topVertices.push(end.x-cross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z-cross.z*endTaperFactor);
+ topVertices.push(end.x+cross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z+cross.z*endTaperFactor);
+ topVertices.push(end.x-nextCross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z-nextCross.z*endTaperFactor);
+
+ // Second top triangle
+ topVertices.push(end.x+cross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z+cross.z*endTaperFactor);
+ topVertices.push(end.x+nextCross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z+nextCross.z*endTaperFactor);
+ topVertices.push(end.x-nextCross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z-nextCross.z*endTaperFactor);
+
+ // First left triangle
+ wallVertices.push(end.x-bottomCross.x*endTaperFactor, end.y+endRampHeight, end.z-bottomCross.z*endTaperFactor);
+ wallVertices.push(end.x-cross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z-cross.z*endTaperFactor);
+ wallVertices.push(end.x-nextBottomCross.x*endTaperFactor, end.y+endRampHeight, end.z-nextBottomCross.z*endTaperFactor);
+
+ // Second left triangle
+ wallVertices.push(end.x-cross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z-cross.z*endTaperFactor);
+ wallVertices.push(end.x-nextCross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z-nextCross.z*endTaperFactor);
+ wallVertices.push(end.x-nextBottomCross.x*endTaperFactor, end.y+endRampHeight, end.z-nextBottomCross.z*endTaperFactor);
+
+ // First right triangle
+ wallVertices.push(end.x+cross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z+cross.z*endTaperFactor);
+ wallVertices.push(end.x+bottomCross.x*endTaperFactor, end.y+endRampHeight, end.z+bottomCross.z*endTaperFactor);
+ wallVertices.push(end.x+nextCross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z+nextCross.z*endTaperFactor);
+
+ // Second right triangle
+ wallVertices.push(end.x+bottomCross.x*endTaperFactor, end.y+endRampHeight, end.z+bottomCross.z*endTaperFactor);
+ wallVertices.push(end.x+nextBottomCross.x*endTaperFactor, end.y+endRampHeight, end.z+nextBottomCross.z*endTaperFactor);
+ wallVertices.push(end.x+nextCross.x*endTaperFactor, end.y+pathHeight*endTaperFactor+endRampHeight, end.z+nextCross.z*endTaperFactor);
+ }
+
+ const lightPos = start.clone();
+
+ let segLengthRemaining = direction.length();
+ const directionNorm = direction.clone().normalize();
+ while (segLengthRemaining > lightInterval - (lightDistanceTraveled % lightInterval)) {
+ const intervalDistanceTraveled = lightInterval - (lightDistanceTraveled % lightInterval);
+ lightDistanceTraveled += intervalDistanceTraveled;
+ segLengthRemaining -= intervalDistanceTraveled;
+ lightPos.addScaledVector(directionNorm, intervalDistanceTraveled);
+ const isLightOn = (lightDistanceTraveled / lightSpeed + Date.now()) % lightTimingInterval < lightOnDuration;
+ if (isLightOn) {
+ const frac = segLengthRemaining / direction.length();
+ const rampHeight = startRampHeight * frac + endRampHeight * (1-frac);
+ const taperFactor = startTaperFactor * frac + endTaperFactor * (1-frac);
+ const lightMesh = new THREE.Mesh(lightGeometry, lightMaterial);
+
+ lightMesh.position.copy(lightPos);
+ lightMesh.position.y += pathHeight * taperFactor + rampHeight;
+
+ const lightEnd = end.clone();
+ lightEnd.y += pathHeight * endTaperFactor + endRampHeight;
+ lightMesh.lookAt(lightEnd);
+
+ lightMesh.scale.x *= taperFactor;
+ lightMesh.scale.y *= taperFactor;
+
+ lightGroup.add(lightMesh);
+ }
+ }
+ lightDistanceTraveled += segLengthRemaining;
+ }
+
+ topGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(topVertices), 3));
+ wallGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(wallVertices), 3));
+ const topMesh = new THREE.Mesh(topGeometry, topMaterial);
+ const wallMesh = new THREE.Mesh(wallGeometry, wallMaterial);
+ const group = new THREE.Group();
+ group.add(topMesh);
+ group.add(wallMesh);
+ group.add(lightGroup);
+ group.onRemove = () => {
+ // Since these geometries are not reused, they MUST be disposed to prevent memory leakage
+ topGeometry.dispose();
+ wallGeometry.dispose();
+ }
+ return group;
+ }
+
+ const addNavigationPath = (goalID) => {
+ const THREE = realityEditor.gui.threejsScene.THREE;
+ const navmeshesWithNode = realityEditor.sceneGraph.getObjects()
+ .map(sceneNode => {return {sceneNode, navmesh:JSON.parse(window.localStorage.getItem(`realityEditor.navmesh.${sceneNode.id}`))}})
+ .filter(navmeshWithNode => navmeshWithNode.navmesh != null);
+ if (navmeshesWithNode.length > 0) {
+ const navmeshWithNode = navmeshesWithNode[0];
+ const navmesh = navmeshWithNode.navmesh; //TODO: select navmesh based on which includes/is closest to the goal position
+ const areaTargetNode = navmeshWithNode.sceneNode;
+ const cameraRelativeMatrix = realityEditor.sceneGraph.getCameraNode().getMatrixRelativeTo(areaTargetNode);
+ const goalRelativeMatrix = realityEditor.sceneGraph.getSceneNodeById(goalID).getMatrixRelativeTo(areaTargetNode);
+ const cameraRelativeTranslationMatrix = realityEditor.gui.ar.utilities.extractTranslation(cameraRelativeMatrix);
+ const goalRelativeTranslationMatrix = realityEditor.gui.ar.utilities.extractTranslation(goalRelativeMatrix);
+ const cameraRelativePosition = matrixToPos(cameraRelativeTranslationMatrix);
+ const goalRelativePosition = matrixToPos(goalRelativeTranslationMatrix);
+
+ // area target and navmesh use meters, toolbox uses mm
+ const cameraIndex = posToIndex(navmesh, scalePos(cameraRelativePosition, 1/1000));
+ const goalIndex = posToIndex(navmesh, scalePos(goalRelativePosition, 1/1000));
+ const indexPath = findPath(navmesh, cameraIndex, goalIndex);
+ const pathHeightOffset = 750; // 0.75m
+ const relativePath = indexPath.map(index => indexToPos(navmesh, index)).map(pos => scalePos(pos, 1000)).map(point => new THREE.Vector3(point.x, point.y + pathHeightOffset, point.z));
+ relativePath[0].x = cameraRelativePosition.x;
+ relativePath[0].z = cameraRelativePosition.z;
+ relativePath.push(new THREE.Vector3(goalRelativePosition.x, goalRelativePosition.y, goalRelativePosition.z));
+
+ const pathMesh = pathToMesh(relativePath);
+
+ realityEditor.gui.threejsScene.addToScene(pathMesh, {worldObjectId: areaTargetNode.id, occluded: true});
+ navigationObjects[goalID] = [pathMesh];
+ }
+ else {
+ console.log('no navmeshes available');
+ }
+ }
+
+ const removeNavigationPath = (goalID) => {
+ if (navigationObjects[goalID]) {
+ navigationObjects[goalID].forEach(obj => {
+ realityEditor.gui.threejsScene.removeFromScene(obj);
+ if (obj.onRemove) {
+ obj.onRemove();
+ }
+ });
+ delete navigationObjects[goalID];
+ }
+ }
+
+ const scalePos = (pos, scaleFactor) => {
+ return {
+ x: pos.x * scaleFactor,
+ y: pos.y * scaleFactor,
+ z: pos.z * scaleFactor
+ }
+ }
+
+ const matrixToPos = (matrix) => {
+ return {
+ x: matrix[12],
+ y: matrix[13],
+ z: matrix[14],
+ }
+ }
+
+ const indexToPos = (navmesh, index) => {
+ const map = navmesh.map;
+ const minX = navmesh.minX;
+ const maxX = navmesh.maxX;
+ // const minY = navmesh.minY;
+ const minZ = navmesh.minZ;
+ const maxZ = navmesh.maxZ;
+ const floorOffset = navmesh.floorOffset;
+ const xLength = map.length;
+ const zLength = map[0].length;
+ return {
+ x: ((index.x) / xLength) * (maxX - minX) + minX,
+ y: floorOffset,
+ z: ((index.y) / zLength) * (maxZ - minZ) + minZ
+ };
+ }
+
+ const posToIndex = (navmesh, pos) => {
+ const map = navmesh.map;
+ const minX = navmesh.minX;
+ const maxX = navmesh.maxX;
+ const minZ = navmesh.minZ;
+ const maxZ = navmesh.maxZ;
+ const xLength = map.length;
+ const zLength = map[0].length;
+ return {
+ x: Math.floor((pos.x - minX) / (maxX - minX) * xLength),
+ y: Math.floor((pos.z - minZ) / (maxZ - minZ) * zLength) // Flip z-coordinate to ensure top-down view (rather than bottom-up)
+ };
+ }
+
+ const findNearestValidIndex = (map, index) => {
+ if (index.x < 0) {
+ index.x = 0;
+ }
+ if (index.x >= map.length) {
+ index.x = map.length - 1;
+ }
+ if (index.y < 0) {
+ index.y = 0;
+ }
+ if (index.y >= map[0].length) {
+ index.y = map[0].length - 1;
+ }
+
+ const neighborPosArray = [
+ {x:0,y:-1},
+ {x:1,y:0},
+ {x:0,y:1},
+ {x:-1,y:0},
+ ];
+
+ const visitedArray = [];
+ const queue = [index];
+ while (queue.length != 0) {
+ const currentIndex = queue.pop();
+ if (currentIndex.x < 0 || currentIndex.x >= map.length) {
+ continue;
+ }
+ if (currentIndex.y < 0 || currentIndex.y >= map[0].length) {
+ continue;
+ }
+ if (visitedArray.some(visitedIndex=>visitedIndex.x === currentIndex.x && visitedIndex.y === currentIndex.y)) {
+ continue;
+ }
+ visitedArray.push(currentIndex);
+ if (map[currentIndex.x][currentIndex.y] === 1) {
+ return currentIndex;
+ }
+ queue.splice(0,0,...neighborPosArray.map(offset=>{return{x:currentIndex.x+offset.x,y:currentIndex.y+offset.y}}));
+ }
+ }
+
+ const findPathHeuristic = (x, y, goalX, goalY) => {
+ return Math.sqrt((goalX-x)*(goalX-x) + (goalY-y)*(goalY-y)) * 1.1; // Distance
+ }
+
+ // Backtracks from the final node to the start
+ const reconstructPath = (grid, node) => {
+ const totalPath = [{x:node.x,y:node.y}];
+ let cameFromIndices = node.cameFrom;
+ while (cameFromIndices) {
+ let node = grid[cameFromIndices.x][cameFromIndices.y];
+ totalPath.unshift({x:node.x,y:node.y});
+ cameFromIndices = node.cameFrom;
+ }
+ return totalPath;
+ }
+
+ // Given a start position and end position on the grid, finds a path
+ // Pathfinding adapted from https://en.wikipedia.org/wiki/A*_search_algorithm#Pseudocode
+ const findPath = (navmesh, startIndex, goalIndex) => {
+ const grid = navmesh.map;
+ startIndex = findNearestValidIndex(grid, startIndex); // Adjust for out-of-bounds coordinates
+ goalIndex = findNearestValidIndex(grid, goalIndex); // Adjust for out-of-bounds coordinates
+ const startX = startIndex.x;
+ const startY = startIndex.y;
+ const goalX = goalIndex.x;
+ const goalY = goalIndex.y;
+
+ const pathGrid = grid.map((row,x)=>row.map((value,y)=>{return{value,x,y}}));
+ const openSet = [pathGrid[startX][startY]];
+ pathGrid[startX][startY].gScore = 0; // gScore is the cost of the cheapest path from start
+ pathGrid[startX][startY].fScore = findPathHeuristic(startX, startY, goalX, goalY); // fScore is gScore + heuristic
+
+ while (openSet.length > 0) {
+ const current = openSet.pop();
+ if (current.x === goalX && current.y === goalY) {
+ return simplifyPath(grid, reconstructPath(pathGrid, pathGrid[current.x][current.y]));
+ }
+ const neighborPosArray = [
+ {x:-1,y:-1},
+ {x:0,y:-1},
+ {x:1,y:-1},
+ {x:1,y:0},
+ {x:1,y:1},
+ {x:0,y:1},
+ {x:-1,y:1},
+ {x:-1,y:0},
+ ];
+ neighborPosArray.forEach(neighborPos => {
+ const newPos = {x:current.x+neighborPos.x, y:current.y+neighborPos.y};
+ if (newPos.x < 0 || newPos.x >= pathGrid.length || newPos.y < 0 || newPos.y >= pathGrid[0].length) {
+ return;
+ }
+ if (pathGrid[newPos.x][newPos.y].value === 0) { // Wall
+ return;
+ }
+ const neighbor = pathGrid[newPos.x][newPos.y];
+ const tentativeGScore = current.gScore + Math.sqrt(neighborPos.x*neighborPos.x + neighborPos.y*neighborPos.y);
+ if (neighbor.gScore === undefined || tentativeGScore < neighbor.gScore) {
+ neighbor.cameFrom = {x:current.x, y:current.y};
+ neighbor.gScore = tentativeGScore;
+ neighbor.fScore = tentativeGScore + findPathHeuristic(neighbor.x, neighbor.y, goalX, goalY);
+ if (!openSet.some(elem => elem.x === newPos.x && elem.y === newPos.y)) {
+ const insertIndex = openSet.findIndex(elem => elem.fScore < neighbor.fScore); // Keep smallest on top
+ openSet.splice(insertIndex,0,neighbor);
+ }
+ }
+ });
+ }
+ return null; // Failed to reach goal.
+ }
+
+ // Adapted from line rasterization example from https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm#All_cases
+ const lineIsUnobstructed = (grid, start, end) => {
+ let x = start.x;
+ let y = start.y;
+ const deltaX = Math.abs(end.x-start.x);
+ const signX = start.x < end.x ? 1 : -1;
+ const deltaY = -Math.abs(end.y-start.y);
+ const signY = start.y < end.y ? 1 : -1;
+ let err = deltaX + deltaY;
+
+ while (Math.round(x) != end.x && Math.round(y) != end.y) {
+ if (grid[Math.floor(x)][Math.floor(y)] === 0) {
+ return false;
+ }
+ const e2 = 2*err;
+ if (e2 >= deltaY) {
+ err += deltaY;
+ x += signX;
+ }
+ if (e2 <= deltaX) {
+ err += deltaX;
+ y += signY;
+ }
+ }
+ return true;
+ }
+
+ // Simplifies paths by removing intervening points between points that can
+ // be joined with a straight line. This allows for smoother (non-jagged) paths.
+ const simplifyPath = (grid, path) => {
+ const pathCopy = path.map(point=>{return{x:point.x,y:point.y}});
+ let currentIndex = 0;
+ while(currentIndex < pathCopy.length-2) {
+ while(currentIndex+2 < pathCopy.length && lineIsUnobstructed(grid, pathCopy[currentIndex], pathCopy[currentIndex+2])) {
+ pathCopy.splice(currentIndex+1,1);
+ }
+ currentIndex++;
+ }
+ return pathCopy;
+ }
+
+ exports.findPath = findPath;
+})(realityEditor.gui.navigation);
diff --git a/src/gui/pocket.js b/src/gui/pocket.js
new file mode 100644
index 000000000..c5e27c7d9
--- /dev/null
+++ b/src/gui/pocket.js
@@ -0,0 +1,1685 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.gui.pocket");
+
+/**
+ * @type {CallbackHandler}
+ */
+realityEditor.gui.pocket.callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('gui/pocket');
+
+/**
+ * Adds a callback function that will be invoked when the specified function is called
+ * @param {string} functionName
+ * @param {function} callback
+ */
+realityEditor.gui.pocket.registerCallback = function(functionName, callback) {
+ if (!this.callbackHandler) {
+ this.callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('gui/pocket');
+ }
+ this.callbackHandler.registerCallback(functionName, callback);
+};
+
+realityEditor.gui.pocket.pocketButtonAction = function() {
+ if (globalStates.pocketButtonState === true) {
+ globalStates.pocketButtonState = false;
+
+ if (globalStates.guiState === 'logic') {
+ realityEditor.gui.crafting.blockMenuVisible();
+ }
+ }
+ else {
+ globalStates.pocketButtonState = true;
+
+ if (globalStates.guiState === 'logic') {
+ realityEditor.gui.crafting.blockMenuHide();
+ realityEditor.gui.menus.switchToMenu("crafting", null, ["logicPocket"]);
+ }
+ }
+
+};
+
+realityEditor.gui.pocket.setPocketPosition = function(evt){
+
+ if(pocketItem["pocket"].frames["pocket"].nodes[pocketItemId]){
+
+ var thisItem = pocketItem["pocket"].frames["pocket"].nodes[pocketItemId];
+
+ var pocketDomElement = globalDOMCache['object' + thisItem.uuid];
+ if (!pocketDomElement) return; // wait until DOM element for this pocket item exists before attempting to move it
+
+ var closestObjectKey = realityEditor.gui.ar.getClosestObject()[0];
+
+ if (!closestObjectKey) {
+
+ thisItem.x = evt.clientX - (globalStates.height / 2);
+ thisItem.y = evt.clientY - (globalStates.width / 2);
+
+ } else {
+
+ if(thisItem.screenZ !== 2 && thisItem.screenZ) {
+
+ var centerOffsetX = thisItem.frameSizeX / 2;
+ var centerOffsetY = thisItem.frameSizeY / 2;
+
+ realityEditor.gui.ar.positioning.moveVehicleToScreenCoordinate(thisItem, evt.clientX - centerOffsetX, evt.clientY - centerOffsetY, false);
+
+ }
+ }
+
+ }
+};
+
+realityEditor.gui.pocket.setPocketFrame = function(frame, positionOnLoad, closestObjectKey) {
+ pocketFrame.vehicle = frame;
+ pocketFrame.positionOnLoad = positionOnLoad;
+ pocketFrame.closestObjectKey = closestObjectKey;
+ pocketFrame.waitingToRender = true;
+};
+
+realityEditor.gui.pocket.setPocketNode = function(node, positionOnLoad, closestObjectKey, closestFrameKey) {
+ pocketNode.vehicle = node;
+ pocketNode.positionOnLoad = positionOnLoad;
+ pocketNode.closestObjectKey = closestObjectKey;
+ pocketNode.closestFrameKey = closestFrameKey;
+ pocketNode.waitingToRender = true;
+};
+
+/**
+ * create a new instance of the saved logic node template, add it to the DOM and upload to the server
+ * @param {Logic|undefined} logicNodeMemory
+ * @return {*}
+ */
+realityEditor.gui.pocket.createLogicNode = function(logicNodeMemory) {
+ var addedLogic = new Logic();
+
+ // if this is being created from a logic node memory, copy over most properties from the saved pocket logic node
+ if (logicNodeMemory) {
+ var keysToCopyOver = ['blocks', 'iconImage', 'lastSetting', 'lastSettingBlock', 'links', 'lockPassword', 'lockType', 'name', 'nameInput', 'nameOutput'];
+ keysToCopyOver.forEach( function(key) {
+ addedLogic[key] = logicNodeMemory[key];
+ });
+
+ if (typeof logicNodeMemory.nodeMemoryCustomIconSrc !== 'undefined') {
+ addedLogic.nodeMemoryCustomIconSrc = logicNodeMemory.nodeMemoryCustomIconSrc;
+ }
+
+ }
+
+
+ // give new logic node a new unique identifier so each copy is stored separately
+ var logicKey = realityEditor.device.utilities.uuidTime();
+ addedLogic.uuid = logicKey;
+
+ var closestFrameKey = null;
+ var closestObjectKey = null;
+
+ // try to find the closest local AR frame to attach the logic node to
+ var objectKeys = realityEditor.gui.ar.getClosestFrame(function(frame) {
+ return frame.visualization !== 'screen' && frame.location === 'local';
+ });
+
+ // if no local frames found, expand the search to include all frames
+ if (!objectKeys[1]) {
+ objectKeys = realityEditor.gui.ar.getClosestFrame();
+ }
+
+ if (objectKeys[1] !== null) {
+ closestFrameKey = objectKeys[1];
+ closestObjectKey = objectKeys[0];
+ var closestObject = objects[closestObjectKey];
+ var closestFrame = closestObject.frames[closestFrameKey];
+
+ addedLogic.objectId = closestObjectKey;
+ addedLogic.frameId = closestFrameKey;
+
+ addedLogic.x = 0;
+ addedLogic.y = 0;
+
+ addedLogic.scale = globalStates.defaultScale / 2; // logic nodes are naturally larger so make them smaller
+ //closestObject ? closestObject.averageScale : globalStates.defaultScale;
+ addedLogic.screenZ = 1000;
+ addedLogic.loaded = false;
+ addedLogic.matrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+
+ // make sure that logic nodes only stick to 2.0 server version
+ if(realityEditor.network.testVersion(closestObjectKey) > 165) {
+ closestFrame.nodes[logicKey] = addedLogic;
+
+ // render it
+ var nodeUrl = realityEditor.network.getURL(closestObject.ip, realityEditor.network.getPort(closestObject), "/nodes/logic/index.html");
+ realityEditor.gui.ar.draw.addElement(nodeUrl, closestObjectKey, closestFrameKey, logicKey, 'logic', addedLogic);
+
+ var _thisNode = document.getElementById("iframe" + logicKey);
+ if (_thisNode && _thisNode.getAttribute('loaded')) {
+ realityEditor.network.onElementLoad(closestObjectKey, logicKey);
+ }
+
+ // send it to the server
+ realityEditor.network.postNewLogicNode(closestObject.ip, closestObjectKey, closestFrameKey, logicKey, addedLogic);
+
+ realityEditor.sceneGraph.addNode(closestObjectKey, closestFrameKey, logicKey, addedLogic);
+
+ realityEditor.gui.pocket.setPocketNode(addedLogic, {pageX: globalStates.pointerPosition[0], pageY: globalStates.pointerPosition[1]}, closestObjectKey, closestFrameKey);
+
+ return {
+ logicNode: addedLogic,
+ domElement: globalDOMCache[logicKey],
+ objectKey: closestObjectKey,
+ frameKey: closestFrameKey
+ };
+ }
+ }
+
+ return null;
+};
+
+/**
+ * The Pocket button. Turns into a larger version or a delete button when
+ * the user is creating memories or when the user is dragging saved
+ * memories/programming blocks, respectively.
+ */
+(function(exports) {
+
+ var pocket;
+ var palette;
+ var nodeMemoryBar;
+
+ var inMemoryDeletion = false;
+ // var pocketDestroyTimer = null;
+
+ var isPocketTapped = false;
+
+ var pocketFrameNames = {};
+ var currentClosestObjectKey = null;
+
+ var aggregateFrames = {};
+
+ var ONLY_CLOSEST_OBJECT = false;
+ var SHOW_IP_LABELS = true;
+ var SIMPLE_IP_LABELS = true;
+
+ // stores the JSON.stringified realityElements rendered the last time the pocket was built
+ let previousPocketChecksum = null;
+
+ let selectedElement = null;
+ let pointerDownOnElement = null;
+ let scrollbarPointerDown = false;
+ let scrollbarPointerDownY = 0;
+ let scrollbarHandleInitialOffset = 0;
+
+ let lastPointerY = null;
+
+ let scrollVelocity = 0;
+ let scrollReleaseVelocity = 0;
+ let scrollReleaseTime = 0;
+ let scrollResistance = 1;
+
+ function pocketInit() {
+ pocket = document.querySelector('.pocket');
+ palette = document.querySelector('.palette');
+ // palette.style.marginBottom = '-24px';
+ nodeMemoryBar = document.querySelector('.nodeMemoryBar');
+
+ const pocketScrollContainer = document.getElementById('pocketScrollContainer');
+ pocketScrollContainer.addEventListener('touchmove', function(event) {
+ // Prevent normal scrolling since we have the scroll touch bar
+ event.preventDefault();
+ });
+
+ addMenuButtonActions();
+
+ pocket.addEventListener('pointerup', function(evt) {
+ isPocketTapped = false;
+ lastPointerY = null;
+ scrollReleaseVelocity = scrollVelocity;
+ scrollReleaseTime = Date.now();
+ scrollResistance = 1;
+
+ if (pointerDownOnElement) {
+ pointerDownOnElement.classList.remove('hoverPocketElement');
+ }
+
+ if (pointerDownOnElement && pointerDownOnElement.dataset.name === evt.target.dataset.name) {
+ selectedElement = evt.target;
+ selectElement(evt.target);
+ }
+
+ pointerDownOnElement = null;
+ });
+
+ // On touching an element-template, upload to currently visible object
+ pocket.addEventListener('pointerdown', function(evt) {
+ isPocketTapped = true;
+ lastPointerY = evt.clientY;
+
+ if (!evt.target.classList.contains('element-template')) {
+ return;
+ }
+
+ let dataset = evt.target.dataset;
+
+ if (dataset.src === '_PLACEHOLDER_') {
+ // Don't add frame from placeholder
+ return;
+ }
+
+ // pointermove gesture must have started with a tap on the pocket
+ if (!isPocketTapped) {
+ return;
+ }
+
+ if (selectedElement && selectedElement.dataset.name === dataset.name) {
+
+ createFrame(dataset.name, {
+ startPositionOffset: dataset.startPositionOffset,
+ width: dataset.width,
+ height: dataset.height,
+ pageX: evt.pageX,
+ pageY: evt.pageY
+ });
+
+ deselectElement(evt.target);
+ selectedElement = null;
+ pocketHide();
+ } else {
+ pointerDownOnElement = evt.target;
+ evt.target.classList.add('hoverPocketElement');
+ if (selectedElement) {
+ deselectElement(selectedElement);
+ selectedElement = null;
+ }
+ }
+ });
+
+ pocket.addEventListener('pointermove', function(evt) {
+ if (!isPocketTapped || scrollbarPointerDown) { return; }
+ if (lastPointerY === null) {
+ lastPointerY = evt.clientY; // shouldn't be necessary, but just in case
+ return;
+ }
+
+ // scroll so that mouse's position on the screen matches it's last position
+ let dY = -1 * (evt.clientY - lastPointerY);
+
+ let newVelocity = 1.2 * dY;
+ // if (scrollVelocity === 0) {
+ scrollVelocity = newVelocity;
+ // } else {
+ // let alphaBlend = 0.8;
+ // scrollVelocity = alphaBlend * newVelocity + (1 - alphaBlend) * scrollVelocity;
+ // }
+
+ var scrollContainer = document.getElementById('pocketScrollContainer');
+ scrollContainer.scrollTop = scrollContainer.scrollTop + dY;
+ updateScrollbarToMatchContainerScrollTop(scrollContainer.scrollTop);
+
+ lastPointerY = evt.clientY;
+
+ // cancel pointerDownOn when any scroll happens
+ if (pointerDownOnElement && Math.abs(dY) > 1) {
+ pointerDownOnElement.classList.remove('hoverPocketElement');
+ pointerDownOnElement = null;
+ }
+
+ });
+
+ pocket.addEventListener('pointercancel', function(_evt) {
+ isPocketTapped = false;
+ lastPointerY = null;
+ scrollReleaseVelocity = scrollVelocity;
+ scrollReleaseTime = Date.now();
+ scrollResistance = 1;
+ pointerDownOnElement = null;
+ });
+
+ function updateScroll() {
+ try {
+ if (pocketShown() && scrollVelocity !== 0) {
+
+ if (!isPocketTapped) {
+ var scrollContainer = document.getElementById('pocketScrollContainer');
+
+ scrollVelocity = scrollReleaseVelocity * scrollResistance * Math.cos((Date.now() - scrollReleaseTime) / (1000 + 100 * Math.pow(Math.abs(scrollReleaseVelocity), 0.5)));
+
+ scrollContainer.scrollTop = scrollContainer.scrollTop + scrollVelocity;
+ // accelerationFactor = 0.8; // lose speed faster while held down
+
+ updateScrollbarToMatchContainerScrollTop(scrollContainer.scrollTop);
+
+ // sqrt(percentSlower) has very little effect until speed has dropped significantly already
+ let accelerationFactor = 0.99 * Math.pow(Math.abs(scrollVelocity / scrollReleaseVelocity), 0.2);
+ // the lower scrollVelocity gets, the more acceleration factor should drop
+ scrollResistance *= accelerationFactor;
+ }
+
+ // console.log('updateScroll', scrollReleaseVelocity, scrollVelocity);
+
+ // scrollVelocity *= accelerationFactor;
+ // scrollVelocity = Math.sqrt(scrollVelocity);
+
+ // round to zero if low or swapped signs so it doesn't trail off indefinitely
+ if (Math.abs(scrollVelocity) < 0.01 || (scrollVelocity * scrollReleaseVelocity < 0)) {
+ scrollVelocity = 0;
+ }
+ }
+ } catch (e) {
+ console.warn('error in updateScroll', e);
+ }
+
+ requestAnimationFrame(updateScroll);
+ }
+ updateScroll(); // start the update loop
+
+ // on desktop, hovering over a palette element pre-selects it, so you don't need to double-click
+ pocket.addEventListener('pointermove', function(evt) {
+ if (!evt.target.classList.contains('element-template')) {
+
+ // deselect highlighted item
+ if (selectedElement) {
+ deselectElement(selectedElement);
+ selectedElement = null;
+ hideTargetObjectLabel();
+ }
+
+ return;
+ }
+
+ if (selectedElement) {
+ if (selectedElement.dataset.name !== evt.target.dataset.name) {
+ deselectElement(selectedElement);
+ selectedElement = null;
+ hideTargetObjectLabel();
+ } else {
+ return;
+ }
+ }
+ selectedElement = evt.target;
+ selectElement(evt.target);
+ });
+
+ if (ONLY_CLOSEST_OBJECT) {
+ realityEditor.gui.ar.draw.onClosestObjectChanged(onClosestObjectChanged_OnlyClosest); // TODO: delete / cleanup old attempts
+ } else {
+ // subscribes to the closestObjectChanged event in the draw module, and triggers the pocket to refresh its UI
+ realityEditor.gui.ar.draw.onClosestObjectChanged(onClosestObjectChanged); // TODO: this should actually trigger anytime the set of visibleObjects changes, not just the closest one
+
+ // also triggers pocket refresh whenever a new server with frames was detected
+ realityEditor.network.availableFrames.onServerFramesInfoUpdated(function() {
+ onClosestObjectChanged(currentClosestObjectKey, currentClosestObjectKey);
+ });
+ }
+ }
+
+ const SHOW_NAME_LABEL = false;
+ function selectElement(pocketElement) {
+ pocketElement.classList.add('highlightedPocketElement');
+
+ if (SHOW_NAME_LABEL) {
+ let label = pocketElement.querySelector('.palette-element-label');
+ if (label) {
+ label.innerText = pocketElement.dataset.name;
+ label.style.bottom = '10px';
+ }
+ }
+
+ showTargetObjectLabel();
+ updateTargetObjectLabel(null, pocketElement.dataset.name);
+ }
+
+ function deselectElement(pocketElement) {
+ if (!pocketElement) { return; }
+
+ pocketElement.classList.remove('highlightedPocketElement');
+
+ if (SHOW_NAME_LABEL) {
+ let label = pocketElement.querySelector('.palette-element-label');
+ if (label) {
+ label.innerText = '';
+ label.style.bottom = '';
+ }
+ }
+
+ hideTargetObjectLabel();
+ }
+
+ /**
+ * Looks at all visible worlds and objects, and compiles a set of frame names and srcs into the aggregateFrames variable
+ */
+ function rebuildAggregateFrames() {
+ // see which frames this closest object supports
+ var availablePocketFrames = realityEditor.network.availableFrames.getFramesForAllVisibleObjects(Object.keys(realityEditor.gui.ar.draw.visibleObjects));
+ // this is an array of [{actualIP: string, proxyIP: string, frames: {}}, ...], sorted by priority (distance)
+
+ // we want to generate a set of frame info, with the frame name as each key
+ aggregateFrames = {};
+
+ // for each unique PROXY ip, in order, add all of their frames to an aggregate, tagged with proxy and actual IPs
+ var processedProxyIPs = [];
+ availablePocketFrames.forEach(function(serverFrameInfo) {
+ if (processedProxyIPs.indexOf(serverFrameInfo.proxyIP) > -1) { return; }
+
+ for (var frameName in serverFrameInfo.frames) {
+ if (typeof aggregateFrames[frameName] === 'undefined') {
+ aggregateFrames[frameName] = serverFrameInfo.frames[frameName];
+ aggregateFrames[frameName].actualIP = serverFrameInfo.actualIP;
+ aggregateFrames[frameName].proxyIP = serverFrameInfo.proxyIP;
+ // console.log('took ' + frameName + ' from ' + serverFrameInfo.actualIP + ' (' + serverFrameInfo.proxyIP + ')');
+ }
+ }
+
+ processedProxyIPs.push(serverFrameInfo.proxyIP);
+ });
+
+ }
+
+ /**
+ * When the closest visible object changes, check what the new set of aggregate available frames is, and refresh the pocket UI
+ * @todo: conditions for refreshing the UI can be made to be more exact
+ * @param {string} oldClosestObjectKey
+ * @param {string} newClosestObjectKey
+ */
+ function onClosestObjectChanged(oldClosestObjectKey, newClosestObjectKey) {
+ currentClosestObjectKey = newClosestObjectKey;
+
+ if (!currentClosestObjectKey) {
+ return; // also gets triggered by onServerFramesInfoUpdated, and it's possible that the currentClosestObjectKey might be null
+ }
+
+ // if (selectedElement) {
+ // updateTargetObjectLabel(currentClosestObjectKey, selectedElement.dataset.name);
+ // } else {
+ // hideTargetObjectLabel();
+ // }
+
+ rebuildAggregateFrames();
+
+ // // first check what has changed
+ // var previousPocketFrameNames = (pocketFrameNames[oldClosestObjectKey]) ? Object.keys(pocketFrameNames[oldClosestObjectKey]) : null;
+ // var diff = realityEditor.device.utilities.diffArrays(previousPocketFrameNames, Object.keys(availablePocketFrames));
+ //
+ // // then update the current pocket info
+ // pocketFrameNames[currentClosestObjectKey] = availablePocketFrames;
+ //
+ // // update UI to include the available frames only
+ // if (!diff.isEqual) { // TODO: add back this equality check so we don't unnecessarily rebuild the pocket
+ // TODO: one equality check could be the icon src paths for new aggregateFrames vs the ones currently rendered
+ // remove all old icons
+ Array.from(document.querySelector('.palette').children).forEach(function(child) {
+ child.parentElement.removeChild(child);
+ });
+ // create all new icons
+ createPocketUIPaletteForAggregateFrames();
+
+ // possibly update the scrollbar height
+ createPocketScrollbar();
+
+ finishStylingPocket();
+ // }
+
+ if (selectedElement) { // re-select in case the closest object changed and the pocket was rebuilt
+ let elementName = selectedElement.dataset.name;
+
+ deselectElement(selectedElement);
+ selectedElement = null;
+ hideTargetObjectLabel();
+
+ // try to find the same selected element
+ let elements = document.querySelectorAll('.element-template');
+ elements.forEach(function(elt) {
+ if (elt.dataset.name === elementName) {
+ selectedElement = elt;
+ selectElement(selectedElement);
+ }
+ });
+ }
+ }
+
+ function updateTargetObjectLabel(closestObjectKey, frameType) {
+ if (closestObjectKey && frameType) {
+ console.warn('specify either closestObjectKey or frameType, not both')
+ }
+
+ if (frameType) {
+ closestObjectKey = realityEditor.network.availableFrames.getBestObjectInfoForFrame(frameType);
+ }
+
+ // update the pocket target label
+ let label = document.getElementById('pocketTargetObjectLabel');
+ let object = realityEditor.getObject(closestObjectKey);
+
+ let processedObjectName = object.name.indexOf('_WORLD_') === 0 ?
+ object.name.split('_WORLD_')[1] :
+ object.name;
+
+ let objectType = object.name.indexOf('_WORLD_') === 0 ? 'world object' : 'object';
+
+ let destinationHTMLString = 'the ' + processedObjectName + ' ' + objectType;
+
+ if (closestObjectKey === realityEditor.worldObjects.getLocalWorldId()) {
+ destinationHTMLString = 'your temporary workspace';
+ }
+
+ label.innerHTML = 'Add a ' + frameType + ' tool to ' + destinationHTMLString;
+ }
+
+ function showTargetObjectLabel() {
+ let label = document.getElementById('pocketTargetObjectLabel');
+ label.style.display = '';
+ }
+
+ function hideTargetObjectLabel() {
+ let label = document.getElementById('pocketTargetObjectLabel');
+ label.style.display = 'none';
+ }
+
+ /**
+ * @deprecated - used if we turn on ONLY_CLOSEST_OBJECT mode, which means pocket will only show frames compatible
+ * with the current closest object, instead of frames compatible with anything on the screen
+ * @param oldClosestObjectKey
+ * @param newClosestObjectKey
+ */
+ function onClosestObjectChanged_OnlyClosest(oldClosestObjectKey, newClosestObjectKey) {
+ currentClosestObjectKey = newClosestObjectKey;
+
+ // see which frames this closest object supports
+ // var closestServerIP = realityEditor.getObject(newClosestObjectKey).ip;
+ var availablePocketFrames = realityEditor.network.availableFrames.getFramesForPocket(currentClosestObjectKey);
+
+ // first check what has changed
+ var previousPocketFrameNames = (pocketFrameNames[oldClosestObjectKey]) ? Object.keys(pocketFrameNames[oldClosestObjectKey]) : null;
+ var diff = realityEditor.device.utilities.diffArrays(previousPocketFrameNames, Object.keys(availablePocketFrames));
+
+ // then update the current pocket info
+ pocketFrameNames[currentClosestObjectKey] = availablePocketFrames;
+
+ // update UI to include the available frames only
+ if (!diff.isEqual) {
+ // remove all old icons
+ Array.from(document.querySelector('.palette').children).forEach(function(child) {
+ child.parentElement.removeChild(child);
+ });
+ // create all new icons
+ createPocketUIPaletteForAvailableFrames(currentClosestObjectKey);
+
+ // possibly update the scrollbar height
+ createPocketScrollbar();
+
+ finishStylingPocket();
+ }
+ }
+
+ /**
+ * If frame metadata includes "attachesTo" property, returns that array of locations ("world", "object", etc)
+ * @param {string} frameName
+ * @return {undefined|Array.}
+ */
+ function getAttachesTo(frameName) {
+ // do this if necessary: rebuildAggregateFrames();
+ let frameInfo = aggregateFrames[frameName];
+ if (frameInfo && frameInfo.metadata) {
+ return frameInfo.metadata.attachesTo;
+ }
+ return undefined;
+ }
+
+ /**
+ * Returns a data structure similar to what was previously defined in pocketFrames.js, but dynamically generated
+ * from the set of servers that have been detected and have a visible world or object on the screen
+ * Result contains the IP of the server that this frame would be placed on, the "proxy" IP if this server is relying
+ * on a different server to host its frames, the frame's inferred properties, metadata from server, and a preloaded icon image
+ * @return {Array.<{actualIP: string, proxyIP: string, properties: {name: string, ...}, metadata: {enabled: boolean, ...}, icon: Image}>}
+ */
+ function getRealityElements() {
+ if (ONLY_CLOSEST_OBJECT) {
+ return Object.keys(pocketFrameNames[currentClosestObjectKey]).map(function(frameName) { // turn dictionary into array
+ return pocketFrameNames[currentClosestObjectKey][frameName];
+ });
+
+ } else {
+ rebuildAggregateFrames();
+ return Object.keys(aggregateFrames).map(function(frameName) { // turn dictionary into array
+ return aggregateFrames[frameName];
+ }).filter(function(frameInfo) {
+ var noMetadata = typeof frameInfo.metadata === 'undefined';
+ if (noMetadata) {
+ return true; // older versions without metadata should show up (backwards-compatible)
+ }
+ return frameInfo.metadata.enabled; // newer versions only show up if enabled
+ });
+ }
+ }
+
+ /**
+ * Renders the pocket UI for displaying the set of all currently available frames that can be added.
+ * Loads each icon and src from the correct server that should host that frame.
+ */
+ function createPocketUIPaletteForAggregateFrames() {
+
+ var realityElements = getRealityElements();
+
+ palette = document.querySelector('.palette');
+ if (realityElements.length % 4 !== 0) {
+ var numToAdd = 4 - (realityElements.length % 4);
+ for (let i = 0; i < numToAdd; i++) {
+ realityElements.push({properties: null}); // add blanks to fill in row if needed
+ }
+ }
+
+ var closestObject = realityEditor.getObject(realityEditor.gui.ar.getClosestObject()[0]);
+ if (!closestObject) { return; }
+ var closestObjectIP = closestObject.ip;
+
+ document.getElementById('pocketScrollContainer').style.width = realityEditor.gui.pocket.getWidth() + 'px';
+
+ for (let i = 0; i < realityElements.length; i++) {
+ if (!realityElements[i]) continue;
+
+ var element = realityElements[i].properties;
+
+ var container = document.createElement('div');
+ container.classList.add('element-template');
+ container.id = 'pocket-element';
+ // container.position = 'relative';
+
+ container.style.width = getFrameIconWidth() + 'px';
+ container.style.height = getFrameIconWidth() + 'px'; // height = width
+
+ if (element === null) {
+ // this is just a placeholder to fill out the last row
+ container.dataset.src = '_PLACEHOLDER_';
+ } else {
+ // var thisUrl = 'frames/' + element.name + '.html';
+ var thisUrl = realityEditor.network.getURL(realityElements[i].proxyIP, realityEditor.network.getPort(closestObject), '/frames/' + element.name + '/index.html');
+ // var gifUrl = 'frames/pocketAnimations/' + element.name + '.gif';
+ var gifUrl = realityEditor.network.getURL(realityElements[i].proxyIP, realityEditor.network.getPort(closestObject), '/frames/' + element.name + '/icon.gif');
+ container.dataset.src = thisUrl;
+
+ container.dataset.name = element.name;
+ container.dataset.width = element.width;
+ container.dataset.height = element.height;
+ container.dataset.nodes = JSON.stringify(element.nodes);
+ if (typeof element.startPositionOffset !== 'undefined') {
+ container.dataset.startPositionOffset = JSON.stringify(element.startPositionOffset);
+ }
+ if (typeof element.requiredEnvelope !== 'undefined') {
+ container.dataset.requiredEnvelope = element.requiredEnvelope;
+ }
+
+ var elt = document.createElement('div');
+ elt.classList.add('palette-element');
+ elt.style.backgroundImage = 'url(\'' + gifUrl + '\')';
+ container.appendChild(elt);
+
+ if (SHOW_IP_LABELS) {
+
+ var ipLabel = document.createElement('div');
+ ipLabel.classList.add('palette-element-label');
+
+ if (!SIMPLE_IP_LABELS) {
+ ipLabel.innerText = realityElements[i].actualIP; // 'localhost';
+ if (realityElements[i].actualIP !== realityElements[i].proxyIP) {
+ ipLabel.innerText = realityElements[i].actualIP + ' (' + realityElements[i].proxyIP + ')';
+ }
+ }
+
+ if (realityElements[i].proxyIP !== closestObjectIP) {
+ var worldObjects = realityEditor.worldObjects.getWorldObjectsByIP(realityElements[i].actualIP);
+ if (worldObjects.length > 0) {
+ ipLabel.innerText = worldObjects[0].name;
+ }
+ }
+ container.appendChild(ipLabel);
+ }
+
+ addFrameIconHoverListeners(container, element.name);
+ }
+
+ palette.appendChild(container);
+ }
+
+ // save this so we can avoid re-building the pocket the next time, if nothing changes between now and then
+ previousPocketChecksum = getChecksumForPocketElements(realityElements);
+ }
+
+ function addFrameIconHoverListeners(frameIconContainer, _frameName) {
+ frameIconContainer.addEventListener('pointerenter', function(evt) {
+ // update closest object label
+ // updateTargetObjectLabel(null, frameName);
+
+ if (!isPocketTapped) {
+ evt.target.classList.add('hoverPocketElement');
+ }
+ });
+
+ frameIconContainer.addEventListener('pointerleave', function(evt) {
+ evt.target.classList.remove('hoverPocketElement');
+ });
+ }
+
+ // gets the width of the usable portion of the screen for the pocket
+ function getWidth() {
+ let guiButtonDiv = document.getElementById('guiButtonDiv');
+ let usableScreenWidth = window.innerWidth;
+ if (guiButtonDiv) {
+ let clientRects = guiButtonDiv.getClientRects();
+ if (clientRects && clientRects[0]) {
+ usableScreenWidth = clientRects[0].left - 37;
+ }
+ }
+ return usableScreenWidth;
+ }
+
+ // calculates how wide (and tall) each frame tile should be
+ function getFrameIconWidth() {
+ if (!document.getElementById('pocketScrollBar') || document.getElementById('pocketScrollBar').getClientRects().length === 0) { return; }
+ // 37 is the margin between buttons and edge of screen, buttons and scrollbar, etc
+ let scrollBarWidth = (2 * 37) + document.getElementById('pocketScrollBar').getClientRects()[0].width;
+ let margin = 3;
+
+ let tilesPerRow = 4 + Math.max(0, Math.min(4, Math.round((getWidth() - 900) / 200))); // 5 on iOS device, more on bigger screens
+ let baseTileSize = (getWidth() - scrollBarWidth) / tilesPerRow - (margin * 2); //* 0.18;
+
+ let realScrollBarWidth = document.getElementById('pocketScrollBar').getClientRects()[0].width;
+ let paletteWidth = realityEditor.gui.pocket.getWidth() - realScrollBarWidth;
+ let numPerRow = Math.floor(paletteWidth / baseTileSize);
+
+ let totalWidth = (baseTileSize + (margin * 2)) * numPerRow;
+
+ return baseTileSize * (paletteWidth / totalWidth);
+ }
+
+ function onWindowResized() {
+ if (!pocketShown()) { return; }
+ // update pocket scroll container size if needed
+ let scrollBarWidth = document.getElementById('pocketScrollBar').getClientRects()[0].width;
+ let paletteWidth = realityEditor.gui.pocket.getWidth() - scrollBarWidth;
+ document.getElementById('pocketScrollContainer').style.width = paletteWidth + 'px';
+
+ // update the width of each tile
+ let elements = document.getElementById('pocketScrollContainer').querySelectorAll('.element-template');
+ elements.forEach(function(elt) {
+ elt.style.width = getFrameIconWidth() + 'px';
+ elt.style.height = getFrameIconWidth() + 'px'; // height = width
+ });
+
+ let margin = 3;
+
+ // update the width of each memory container, knowing that there are exactly 4 of them
+ let memoryContainers = document.querySelector('.memoryBar').querySelectorAll('.memoryContainer');
+ memoryContainers.forEach(function(elt) {
+ elt.style.width = (paletteWidth / 4 - (2 * margin)) + 'px';
+ });
+
+ realityEditor.gui.pocket.createPocketScrollbar(); // update number of chapters to match scroll height
+ }
+
+ /**
+ * Converts the full structure of all the frames/icons/etc that the pocket is built of into a literal
+ * that can be compared later to see if it has changed. Currently just using JSON.stringify.
+ * @param {Array.<{actualIP: string, proxyIP: string, properties: {name: string, ...}, metadata: {enabled: boolean, ...}, icon: Image}>} pocketElements
+ * @return {string}
+ */
+ function getChecksumForPocketElements(pocketElements) {
+ return JSON.stringify(pocketElements);
+ }
+
+ /**
+ * @deprecated - used to create pocket frame palette UI if ONLY_CLOSEST_OBJECT is enabled
+ */
+ function createPocketUIPaletteForAvailableFrames(closestObjectKey) {
+
+ // var realityElements = Object.keys(availablePocketFrames).map(function(frameName) {
+ // return availablePocketFrames[frameName];
+ // });
+
+ var realityElements = getRealityElements();
+
+ palette = document.querySelector('.palette');
+ if (realityElements.length % 4 !== 0) {
+ var numToAdd = 4 - (realityElements.length % 4);
+ for (let i = 0; i < numToAdd; i++) {
+ realityElements.push(null);
+ }
+ }
+
+ for (let i = 0; i < realityElements.length; i++) {
+ if (!realityElements[i]) continue;
+
+ var element = realityElements[i].properties;
+ if (typeof element === 'undefined') {
+ console.warn('could not find properties of ', realityElements[i]);
+ continue;
+ }
+
+ var container = document.createElement('div');
+ container.classList.add('element-template');
+ container.id = 'pocket-element';
+ // container.position = 'relative';
+
+ if (element === null) {
+ // this is just a placeholder to fill out the last row
+ container.dataset.src = '_PLACEHOLDER_';
+ } else {
+ // var thisUrl = 'frames/' + element.name + '.html';
+ var thisUrl = realityEditor.network.availableFrames.getFrameSrc(closestObjectKey, element.name);
+ // var gifUrl = 'frames/pocketAnimations/' + element.name + '.gif';
+ var gifUrl = realityEditor.network.availableFrames.getFrameIconSrc(closestObjectKey, element.name);
+ container.dataset.src = thisUrl;
+
+ container.dataset.name = element.name;
+ container.dataset.width = element.width;
+ container.dataset.height = element.height;
+ container.dataset.nodes = JSON.stringify(element.nodes);
+ if (typeof element.startPositionOffset !== 'undefined') {
+ container.dataset.startPositionOffset = JSON.stringify(element.startPositionOffset);
+ }
+ if (typeof element.requiredEnvelope !== 'undefined') {
+ container.dataset.requiredEnvelope = element.requiredEnvelope;
+ }
+
+ var elt = document.createElement('div');
+ elt.classList.add('palette-element');
+ elt.style.backgroundImage = 'url(\'' + gifUrl + '\')';
+ container.appendChild(elt);
+
+ var ipLabel = document.createElement('div');
+ ipLabel.classList.add('palette-element-label');
+ ipLabel.innerText = realityEditor.getObject(closestObjectKey).ip; // 'localhost';
+ container.appendChild(ipLabel);
+ }
+
+ palette.appendChild(container);
+ }
+ }
+
+ /**
+ * Public method to automatically generate a uiTutorial frame, and add it to the world
+ * @param {string} objectKey - object to add the tutorial to (should be the _WORLD_local object)
+ */
+ function addTutorialFrame(objectKey) {
+ try {
+ createFrame('uiTutorial', {
+ startPositionOffset: JSON.stringify({x: 0, y: 0}),
+ width: '568',
+ height: '420',
+ pageX: window.innerWidth / 2,
+ pageY: window.innerHeight / 2,
+ objectKey: objectKey
+ });
+ } catch (e) {
+ // ensure that it fails safely if the corresponding server doesn't have a frame named uiTutorial
+ console.warn(e);
+ }
+ }
+
+ /**
+ * Creates a new frame with the specified options, and uploads it to the server
+ * @param name
+ * @param {object} options
+ * @param {string} options.objectKey
+ * @param {string} options.startPositionOffset
+ * @param {number} options.width
+ * @param {number} options.height
+ * @param {number[]} options.initialMatrix
+ * @param {boolean} options.noUserInteraction
+ * @param {number} options.pageX
+ * @param {number} options.pageY
+ * @param {function} options.onUploadComplete - callback function when network finishes posting frame to server
+ * @returns {Frame}
+ */
+ function createFrame(name, options) {
+ const utils = realityEditor.gui.ar.utilities;
+
+ const closestObjectKey = options.objectKey ? options.objectKey : realityEditor.network.availableFrames.getBestObjectInfoForFrame(name);
+ if (!closestObjectKey) return;
+
+ const closestObject = realityEditor.getObject(closestObjectKey);
+ if (!closestObject) return;
+
+ if (closestObject.integerVersion && closestObject.integerVersion <= 165) return; // before version 165, objects don't have frames
+
+ const frame = new Frame();
+ frame.objectId = closestObjectKey;
+
+ // name the frame "gauge1xyz", "gauge2asd", "gauge3qwe", etc...
+ let numberOfSameFrames = Object.keys(closestObject.frames).map(existingFrameKey => {
+ return closestObject.frames[existingFrameKey].src;
+ }).filter(src => {
+ return src === name;
+ }).length;
+ let frameUniqueName = name + (numberOfSameFrames+1) + realityEditor.device.utilities.uuidTime();
+
+ // set the essential properties
+ frame.name = frameUniqueName;
+ frame.uuid = frame.objectId + frameUniqueName;
+ frame.location = 'global';
+ frame.src = name;
+
+ // add the frame to the object
+ closestObject.frames[frame.uuid] = frame;
+
+ // set position and scale
+ if (options.startPositionOffset) {
+ frame.startPositionOffset = options.startPositionOffset;
+ }
+ frame.ar.scale = globalStates.defaultScale;
+
+ if (typeof options.width !== 'undefined') {
+ frame.frameSizeX = options.width;
+ frame.width = options.width;
+ }
+
+ if (typeof options.height !== 'undefined') {
+ frame.frameSizeY = options.height;
+ frame.height = options.height;
+ }
+
+ // populate properties not contained on server (not in constructor)
+ frame.begin = utils.newIdentityMatrix(); // TODO: try removing this
+ frame.loaded = false;
+ frame.screenZ = 1000;
+ frame.temp = utils.newIdentityMatrix(); // TODO: remove this?
+ frame.fullScreen = false;
+ frame.sendMatrix = false;
+ frame.sendMatrices = {}; // todo: can this be unpopulated like this?
+ // todo: fully remove sendAcceleration, or implement it
+ frame.sendAcceleration = false;
+ frame.integerVersion = 300;
+
+ // set the eventObject so that the frame can interact with screens as soon as you add it
+ realityEditor.device.eventObject.object = closestObjectKey;
+ realityEditor.device.eventObject.frame = frame.uuid;
+ realityEditor.device.eventObject.node = null;
+
+ // tell the iframe that it was just created, not reloaded
+ realityEditor.network.toBeInitialized[frame.uuid] = true;
+
+ if (options.initialMatrix) {
+ frame.ar.matrix = options.initialMatrix;
+ }
+
+ realityEditor.sceneGraph.addFrame(frame.objectId, frame.uuid, frame, frame.ar.matrix);
+ realityEditor.gui.ar.groundPlaneAnchors.sceneNodeAdded(frame.objectId, frame.uuid, frame, frame.ar.matrix);
+ realityEditor.network.postNewFrame(closestObject.ip, closestObjectKey, frame, options.onUploadComplete);
+
+ if (!options.noUserInteraction) {
+ // allows you to drag the frame around as soon as it loads
+ realityEditor.gui.pocket.setPocketFrame(frame, {
+ pageX: options.pageX || 0,
+ pageY: options.pageY || 0
+ }, closestObjectKey);
+ }
+
+ realityEditor.gui.pocket.callbackHandler.triggerCallbacks('frameAdded', {
+ objectKey: closestObjectKey,
+ frameKey: frame.uuid,
+ frameType: frame.src
+ });
+
+ return frame;
+ }
+
+ function addMenuButtonActions() {
+
+ var ButtonNames = realityEditor.gui.buttons.ButtonNames;
+
+ // add callbacks for menu buttons -> hide pocket
+ realityEditor.gui.buttons.registerCallbackForButton(ButtonNames.GUI, hidePocketOnButtonPressed);
+ realityEditor.gui.buttons.registerCallbackForButton(ButtonNames.LOGIC, hidePocketOnButtonPressed);
+ realityEditor.gui.buttons.registerCallbackForButton(ButtonNames.SETTING, hidePocketOnButtonPressed);
+ realityEditor.gui.buttons.registerCallbackForButton(ButtonNames.LOGIC_SETTING, hidePocketOnButtonPressed);
+
+ // add callbacks for pocket button actions
+ realityEditor.gui.buttons.registerCallbackForButton(ButtonNames.POCKET, pocketButtonPressed);
+ realityEditor.gui.buttons.registerCallbackForButton(ButtonNames.LOGIC_POCKET, pocketButtonPressed);
+ realityEditor.gui.buttons.registerCallbackForButton(ButtonNames.BIG_POCKET, bigPocketButtonPressed);
+ realityEditor.gui.buttons.registerCallbackForButton(ButtonNames.HALF_POCKET, halfPocketButtonPressed);
+
+ function hidePocketOnButtonPressed(params) {
+ if (params.newButtonState === 'up') {
+ // hide the pocket
+ pocketHide();
+ }
+ }
+
+ function pocketButtonPressed(params) {
+ if (params.newButtonState === 'up') {
+
+ document.activeElement.blur(); // reset focus in case our scrolling lost focus
+
+ // show UI pocket by switching out of node view when the pocket button is tapped
+ var HACK_AUTO_SWITCH_TO_GUI = true;
+ if (HACK_AUTO_SWITCH_TO_GUI) {
+ if (globalStates.guiState === 'node') {
+ realityEditor.gui.buttons.guiButtonUp({button: "gui", ignoreIsDown: true});
+ }
+ }
+
+ onPocketButtonUp();
+
+ if (globalStates.guiState !== "node" && globalStates.guiState !== "logic") {
+ return;
+ }
+
+ realityEditor.gui.pocket.pocketButtonAction();
+
+ } else if (params.newButtonState === 'enter') {
+
+ realityEditor.gui.pocket.onPocketButtonEnter();
+
+ if (globalStates.guiState !== "node" && globalStates.guiState !== "logic") {
+ return;
+ }
+
+ if (pocketItem["pocket"].frames["pocket"].nodes[pocketItemId]) {
+ // pocketItem["pocket"].objectVisible = false;
+ realityEditor.gui.ar.draw.setObjectVisible(pocketItem["pocket"], false);
+
+ this.gui.ar.draw.hideTransformed("pocket", pocketItemId, pocketItem["pocket"].frames["pocket"].nodes[pocketItemId], "logic"); // TODO: change arguments
+ delete pocketItem["pocket"].frames["pocket"].nodes[pocketItemId];
+ }
+
+ } else if (params.newButtonState === 'leave') {
+
+ // this is where the virtual point creates object
+
+ if (realityEditor.gui.buttons.getButtonState(params.buttonName) === 'down') {
+
+ // create a logic node when dragging out from the button in node mode
+ if (globalStates.guiState === "node") {
+
+ // we're using the same method as when we add a node from a memory, instead of using old pocket method. // TODO: make less hack of a solution
+ let addedElement = null;
+ try {
+ addedElement = realityEditor.gui.pocket.createLogicNode();
+ } catch (e) {
+ console.warn('Unable to create new logic node', e);
+ return;
+ }
+
+ // set the name of the node by counting how many logic nodes the frame already has
+ var closestFrame = realityEditor.getFrame(addedElement.objectKey, addedElement.frameKey);
+ var logicCount = Object.values(closestFrame.nodes).filter(function (node) {
+ return node.type === 'logic'
+ }).length;
+ addedElement.logicNode.name = "LOGIC" + logicCount;
+
+ // upload new name to server when you change it
+ var object = realityEditor.getObject(addedElement.objectKey);
+ realityEditor.network.postNewNodeName(object.ip, addedElement.objectKey, addedElement.frameKey, addedElement.logicNode.uuid, addedElement.logicNode.name);
+
+ realityEditor.gui.menus.switchToMenu("bigTrash", null, null);
+
+ } else if (globalStates.guiState === 'ui') {
+ // create an envelope frame when dragging out from the button in UI mode
+ var realityElements = getRealityElements();
+
+ var envelopeData = realityElements.find(function(elt) { return elt.name === 'all-frame-envelope'; });
+ var touchPosition = realityEditor.gui.ar.positioning.getMostRecentTouchPosition();
+
+ if (envelopeData) {
+ let addedElement = createFrame(envelopeData.name, {
+ startPositionOffset: envelopeData.startPositionOffset,
+ width: envelopeData.width,
+ height: envelopeData.height,
+ pageX: touchPosition.x,
+ pageY: touchPosition.y,
+ });
+
+ if (addedElement) {
+ realityEditor.device.editingState.touchOffset = {
+ x: 0,
+ y: 0
+ };
+
+ try {
+ realityEditor.device.beginTouchEditing(addedElement.objectId, addedElement.uuid, null);
+ } catch (e) {
+ console.warn('error with beginTouchEditing', e);
+ }
+
+ realityEditor.gui.menus.switchToMenu("bigTrash", null, null);
+ }
+ }
+ }
+
+ }
+
+ }
+ }
+
+ function bigPocketButtonPressed(params) {
+ if (params.newButtonState === 'enter') {
+ onBigPocketButtonEnter();
+ }
+ }
+
+ function halfPocketButtonPressed(params) {
+ if (params.newButtonState === 'enter') {
+ onHalfPocketButtonEnter();
+ }
+ }
+
+ }
+
+ function isPocketWanted() {
+ if (pocketShown()) {
+ return true;
+ }
+ if (globalStates.settingsButtonState) {
+ return false;
+ }
+ if (globalStates.editingNode) {
+ return false;
+ }
+ if (inMemoryDeletion) {
+ return false;
+ }
+ return globalStates.guiState === "ui" || globalStates.guiState === "node";
+ }
+
+ function onPocketButtonEnter() {
+ if (!isPocketWanted()) {
+ return;
+ }
+
+ if (pocketButtonIsBig()) {
+ return;
+ }
+
+ if (!globalProgram.objectA) {
+ return;
+ }
+
+ toggleShown();
+ }
+
+ function onPocketButtonUp() {
+ if (!isPocketWanted()) {
+ return;
+ }
+
+ if (pocketButtonIsBig()) {
+ return;
+ }
+
+ toggleShown();
+ }
+
+ function onBigPocketButtonEnter() {
+ if (!isPocketWanted()) {
+ return;
+ }
+
+ if (!pocketButtonIsBig()) {
+ return;
+ }
+
+ if (realityEditor.gui.memory.memoryCanCreate()) {
+ // realityEditor.gui.memory.createMemory();
+ if (globalStates.guiState === "node") {
+ globalStates.drawDotLine = false;
+ }
+ }
+
+ toggleShown();
+ }
+
+ function onHalfPocketButtonEnter() {
+ // if (!isPocketWanted()) {
+ // return;
+ // }
+
+ if (!pocketButtonIsHalf()) {
+ return;
+ }
+
+ // TODO: add any side effects here before showing pocket
+ var editingVehicle = realityEditor.device.getEditingVehicle();
+
+ if (editingVehicle && editingVehicle.type === 'logic') {
+
+ if (editingVehicle) {
+ overlayDiv.classList.add('overlayLogicNode');
+
+ var nameText = document.createElement('div');
+ nameText.style.position = 'absolute';
+ nameText.style.top = '33px';
+ nameText.style.width = '100%';
+ nameText.style.textAlign = 'center';
+ nameText.innerHTML = editingVehicle.name;
+ overlayDiv.innerHTML = '';
+ overlayDiv.appendChild(nameText);
+
+ overlayDiv.storedLogicNode = editingVehicle;
+ }
+ }
+
+ if (pocketShown()) {
+ // // TODO(ben): is there a better place to do this?
+ overlayDiv.innerHTML = '';
+ overlayDiv.classList.remove('overlayLogicNode');
+ }
+
+ toggleShown();
+ }
+
+ function pocketButtonIsBig() {
+ return realityEditor.gui.menus.getVisibility('bigPocket');
+ }
+
+ function pocketButtonIsHalf() {
+ return realityEditor.gui.menus.getVisibility('halfPocket');
+ }
+
+ function toggleShown() {
+ if (pocketShown()) {
+ pocketHide();
+ } else {
+ pocketShow();
+ }
+ }
+
+ // external modules can register a function to apply different CSS classes to each pocket item container
+ var pocketElementHighlightFilters = [];
+
+ function addElementHighlightFilter(callback) {
+ pocketElementHighlightFilters.push(callback);
+ }
+
+ function pocketShow() {
+ pocket.classList.add('pocketShown');
+ realityEditor.gui.menus.buttonOn(['pocket']);
+ if (globalStates.guiState === "node") {
+ palette.style.display = 'none';
+ nodeMemoryBar.style.display = 'block';
+ } else {
+ palette.style.display = 'block';
+ nodeMemoryBar.style.display = 'none';
+ }
+ isPocketTapped = false;
+ realityEditor.gui.memory.nodeMemories.resetEventHandlers();
+
+ var allPocketElements = Array.from(document.querySelector('.palette').children);
+ allPocketElements.forEach(function(pocketElement) {
+ pocketElement.classList.remove('highlightedPocketElement');
+ });
+
+ if (pocketElementHighlightFilters.length > 0) {
+
+ var pocketFrameNames = allPocketElements.map(function(div) { return div.dataset.name });
+
+ pocketElementHighlightFilters.forEach(function(filterFunction) {
+ var framesToHighlight = filterFunction(pocketFrameNames);
+
+ allPocketElements.forEach(function(pocketElement) {
+ if (framesToHighlight.indexOf(pocketElement.dataset.name) > -1) {
+ pocketElement.classList.add('highlightedPocketElement');
+ }
+ });
+
+ });
+ }
+
+ // don't render the pocket again if nothing has changed
+ let currentPocketChecksum = getChecksumForPocketElements(getRealityElements());
+ let shouldRebuildPocketUI = currentPocketChecksum !== previousPocketChecksum;
+
+ if (shouldRebuildPocketUI) {
+ // remove all old icons
+ Array.from(document.querySelector('.palette').children).forEach(function(child) {
+ child.parentElement.removeChild(child);
+ });
+ // create all new icons
+ createPocketUIPaletteForAggregateFrames();
+
+ createPocketScrollbar();
+ }
+
+ onWindowResized();
+
+ finishStylingPocket();
+
+ hideTargetObjectLabel();
+
+ // scroll to top if holding memory
+ if (overlayDiv.classList.contains('overlayMemory')) {
+ var scrollContainer = document.getElementById('pocketScrollContainer');
+ scrollContainer.scrollTop = 0;
+ updateScrollbarToMatchContainerScrollTop(scrollContainer.scrollTop);
+ } else {
+ // update container scrollTop to match scrollbar position
+ setContainerScrollToScrollbarPosition();
+ }
+ }
+
+ function updateScrollbarToMatchContainerScrollTop(scrollTop) {
+ let scrollbar = document.getElementById('pocketScrollBarSegment0');
+ let handle = scrollbar.querySelector('.pocketScrollBarSegmentActive');
+ let paletteHeight = document.querySelector('.palette').getClientRects()[0].height;
+ const pageHeight = window.innerHeight;
+
+ let maxScrollContainerScroll = ((paletteHeight + 130) - pageHeight);
+ let maxHandleScroll = scrollbar.getClientRects()[0].height - handle.getClientRects()[0].height - 10;
+
+ let handleScrollTop = scrollTop * maxHandleScroll / maxScrollContainerScroll;
+ handle.style.top = Math.max(10, Math.min(maxHandleScroll, handleScrollTop)) + 'px';
+ }
+
+ function setContainerScrollToScrollbarPosition() {
+ let scrollbar = document.getElementById('pocketScrollBarSegment0');
+ let handle = scrollbar.querySelector('.pocketScrollBarSegmentActive');
+ let paletteHeight = document.querySelector('.palette').getClientRects()[0].height;
+ const pageHeight = window.innerHeight;
+
+ let maximumScrollAmount = scrollbar.getClientRects()[0].height - handle.getClientRects()[0].height - 10;
+
+ let percentageBetween = (parseFloat(handle.style.top) - 10) / (maximumScrollAmount - 10);
+
+ var scrollContainer = document.getElementById('pocketScrollContainer');
+ // not sure why I have to add 130 to the paletteHeight for this to work, but otherwise it won't fully scroll to the bottom
+ let maxScrollContainerScroll = ((paletteHeight + 130) - pageHeight);
+ scrollContainer.scrollTop = percentageBetween * maxScrollContainerScroll;
+ }
+
+ function pocketHide() {
+ pocket.classList.remove('pocketShown');
+ realityEditor.gui.menus.buttonOff(['pocket']);
+ isPocketTapped = false;
+ selectedElement = null;
+ }
+
+ function pocketShown() {
+ return pocket.classList.contains('pocketShown');
+ }
+
+ /**
+ * Programmatically generates a scroll bar with a number of segments ("chapters") based on the total number of rows
+ * of frames and memories in the pocket, that lets you jump up and down by tapping or scrolling your finger between
+ * the different segments of the scroll bar
+ * @todo: add a vertical margin between each row where we can label the frames with their names
+ */
+ function createPocketScrollbar() {
+ const pageHeight = window.innerHeight;
+
+ let numChapters = 1;
+
+ var scrollbar = document.getElementById('pocketScrollBar');
+
+ if (scrollbar.children.length > 0) {
+ // Already built the pocket scrollbar once
+ // check if we should rebuild it (did number of chapters change)
+ if (numChapters === scrollbar.children.length) {
+ return;
+ }
+
+ while (scrollbar.hasChildNodes()) {
+ scrollbar.removeChild(scrollbar.lastChild);
+ }
+ }
+
+ if (!document.querySelector('.palette') || document.querySelector('.palette').getClientRects().length === 0) {
+ return;
+ }
+
+ let paletteHeight = document.querySelector('.palette').getClientRects()[0].height;
+
+ var allSegmentButtons = [];
+
+ function hideAllSegmentSelections() {
+ allSegmentButtons.forEach(function(div){
+ if (div.firstChild) {
+ div.firstChild.style.visibility = 'hidden';
+ }
+ div.classList.remove('pocketScrollBarSegmentTouched');
+ });
+ }
+
+ function selectSegment(segment) {
+ if (segment.firstChild) {
+ segment.firstChild.style.visibility = 'visible';
+ }
+ segment.classList.add('pocketScrollBarSegmentTouched');
+ }
+
+ function scrollPocketForTouch(e) {
+ // don't scroll if holding a memory
+ if (overlayDiv.classList.contains('overlayMemory')) { return; }
+
+ let scrollbar = document.getElementById('pocketScrollBarSegment0');
+ let handle = scrollbar.querySelector('.pocketScrollBarSegmentActive');
+
+ let amountMoved = e.pageY - scrollbarPointerDownY;
+ let maximumScrollAmount = scrollbar.getClientRects()[0].height - handle.getClientRects()[0].height - 10;
+ handle.style.top = Math.max(10, Math.min(maximumScrollAmount, scrollbarHandleInitialOffset + amountMoved)) + 'px'; //(100 * percentageBetween) + 'px';
+
+ setContainerScrollToScrollbarPosition();
+ }
+
+ function jumpScrollbarToPosition(pageY) {
+ // move center of scrollbar handle to pageY (constrained within bounds)
+
+ // don't scroll if holding a memory
+ if (overlayDiv.classList.contains('overlayMemory')) { return; }
+
+ let scrollbar = document.getElementById('pocketScrollBarSegment0');
+ let handle = scrollbar.querySelector('.pocketScrollBarSegmentActive');
+
+ // for center to be on pageY, top needs to be at pageY - handleHeight/2
+ let calculatedTop = pageY - handle.getClientRects()[0].height/2 - 10;
+ let maximumScrollAmount = scrollbar.getClientRects()[0].height - handle.getClientRects()[0].height - 10;
+ handle.style.top = Math.max(10, Math.min(maximumScrollAmount, calculatedTop)) + 'px'; //(100 * percentageBetween) + 'px';
+
+ setContainerScrollToScrollbarPosition();
+ }
+
+ var pocketPointerDown = false;
+ document.addEventListener('pointerdown', function(_e) {
+ pocketPointerDown = true;
+ });
+ document.addEventListener('pointerup', function(_e) {
+ pocketPointerDown = false;
+ scrollbarPointerDown = false;
+ highlightAvailableMemoryContainers(false); // always un-highlight when release pointer
+ });
+
+ for (var i = 0; i < numChapters; i++) {
+ var segmentButton = document.createElement('div');
+ segmentButton.className = 'pocketScrollBarSegment';
+ segmentButton.id = 'pocketScrollBarSegment' + i;
+ // segmentButton.style.height = (scrollbarHeight / numChapters - marginBetweenSegments) + 'px';
+ // segmentButton.style.top = (scrollbarHeightDifference/2 - marginBetweenSegments/2) + (i * scrollbarHeight / numChapters) + 'px';
+ segmentButton.style.top = '10px';
+
+ var segmentActiveDiv = document.createElement('div');
+ segmentActiveDiv.className = 'pocketScrollBarSegmentActive';
+ if (i > 0) {
+ segmentActiveDiv.style.visibility = 'hidden';
+ }
+ segmentButton.appendChild(segmentActiveDiv);
+
+ segmentActiveDiv.style.height = 'calc(' + (100 * pageHeight / paletteHeight) + '% - 20px)';
+
+ segmentButton.dataset.index = i;
+
+ segmentButton.addEventListener('pointerdown', function(e) {
+ scrollbarPointerDown = true;
+ scrollbarPointerDownY = e.pageY;
+
+ let tappedOnHandle = e.target.classList.contains('pocketScrollBarSegmentActive');
+
+ let scrollbar = document.getElementById('pocketScrollBarSegment0');
+ let handle = scrollbar.querySelector('.pocketScrollBarSegmentActive');
+
+ if (tappedOnHandle) {
+ scrollbarHandleInitialOffset = parseFloat(handle.style.top) || 10;
+ } else {
+ jumpScrollbarToPosition(e.pageY);
+ scrollbarHandleInitialOffset = parseFloat(handle.style.top) || 10;
+ }
+
+ hideAllSegmentSelections();
+ selectSegment(e.currentTarget);
+ scrollPocketForTouch(e);
+
+ // deselect highlighted item
+ if (selectedElement) {
+ deselectElement(selectedElement);
+ selectedElement = null;
+ hideTargetObjectLabel();
+ }
+ });
+ segmentButton.addEventListener('pointerup', function(e) {
+ hideAllSegmentSelections();
+ selectSegment(e.currentTarget);
+ e.currentTarget.classList.remove('pocketScrollBarSegmentTouched');
+ });
+ segmentButton.addEventListener('pointerenter', function(e) {
+ if (!pocketPointerDown) { return; }
+
+ hideAllSegmentSelections();
+ selectSegment(e.currentTarget);
+
+ // deselect highlighted item
+ if (selectedElement) {
+ deselectElement(selectedElement);
+ selectedElement = null;
+ hideTargetObjectLabel();
+ }
+ });
+ segmentButton.addEventListener('pointerleave', function(e) {
+ if (!pocketPointerDown) { return; }
+ e.currentTarget.classList.remove('pocketScrollBarSegmentTouched');
+ });
+ segmentButton.addEventListener('pointercancel', function(e) {
+ e.currentTarget.classList.remove('pocketScrollBarSegmentTouched');
+ });
+ segmentButton.addEventListener('pointermove', function(e) {
+ if (!pocketPointerDown || !scrollbarPointerDown) { return; }
+ scrollPocketForTouch(e);
+ });
+ segmentButton.addEventListener('gotpointercapture', function(evt) {
+ evt.target.releasePointerCapture(evt.pointerId);
+ });
+ scrollbar.appendChild(segmentButton);
+ allSegmentButtons.push(segmentButton);
+ }
+ }
+
+ /**
+ * Adds blue corners to each pocket icon container
+ */
+ function finishStylingPocket() {
+ Array.from(document.querySelectorAll('.palette-element')).forEach(function(paletteElement) {
+ // remove existing ones if needed, to ensure size is correct
+ var cornersFound = paletteElement.querySelector('.corners');
+ if (cornersFound) {
+ paletteElement.removeChild(cornersFound);
+ }
+ // add new corners to each icon container
+ realityEditor.gui.moveabilityCorners.wrapDivWithCorners(paletteElement, 0, true, null, null, 1);
+ });
+
+ // style the memory containers if needed
+ if (overlayDiv.classList.contains('overlayMemory')) {
+ highlightAvailableMemoryContainers(true);
+ } else {
+ highlightAvailableMemoryContainers(false);
+ }
+
+ }
+
+ function highlightAvailableMemoryContainers(shouldHighlight) {
+ let pocketDiv = document.querySelector('.pocket');
+
+ if (shouldHighlight) {
+ Array.from(pocketDiv.querySelectorAll('.memoryContainer')).filter(function(element) {
+ return !(element.classList.contains('nodeMemoryContainer') || element.dataset.objectId);
+ }).forEach(function(element) {
+ element.classList.add('availableContainer');
+ });
+ } else {
+ Array.from(pocketDiv.querySelectorAll('.memoryContainer')).forEach(function(element) {
+ element.classList.remove('availableContainer');
+ });
+ }
+ }
+ exports.highlightAvailableMemoryContainers = highlightAvailableMemoryContainers;
+
+ exports.pocketInit = pocketInit;
+ exports.pocketShown = pocketShown;
+ exports.pocketShow = pocketShow;
+ exports.pocketHide = pocketHide;
+
+ exports.onPocketButtonEnter = onPocketButtonEnter;
+ exports.onPocketButtonUp = onPocketButtonUp;
+ exports.onBigPocketButtonEnter = onBigPocketButtonEnter;
+ exports.onHalfPocketButtonEnter = onHalfPocketButtonEnter;
+
+ exports.addElementHighlightFilter = addElementHighlightFilter;
+
+ exports.getRealityElements = getRealityElements;
+
+ exports.createFrame = createFrame;
+ exports.addTutorialFrame = addTutorialFrame;
+
+ exports.getAttachesTo = getAttachesTo;
+
+ // in case window size is adjusted, these can be called
+ exports.getWidth = getWidth;
+ exports.onWindowResized = onWindowResized;
+ exports.createPocketScrollbar = createPocketScrollbar;
+
+}(realityEditor.gui.pocket));
diff --git a/src/gui/recentlyUsedBar.js b/src/gui/recentlyUsedBar.js
new file mode 100644
index 000000000..c6c51f46d
--- /dev/null
+++ b/src/gui/recentlyUsedBar.js
@@ -0,0 +1,519 @@
+class RecentlyUsedBar {
+ constructor() {
+ this.container = document.createElement('div');
+ this.container.classList.add('ru-container');
+ if (realityEditor.device.environment.isDesktop()) {
+ this.container.classList.add('ru-desktop');
+ } else {
+ this.container.classList.add('ru-mobile');
+ }
+ this.canvas = document.createElement('canvas');
+ this.canvas.className = 'ru-canvas';
+ this.canvas.width = window.innerWidth;
+ this.canvas.height = window.innerHeight;
+ this.ctx = this.canvas.getContext('2d');
+
+ this.iconElts = [];
+ this.capacity = 3;
+ this.lastDraw = Date.now();
+ this.hoverAnimation = new LineToFrameAnimation(this.ctx, null, false);
+ this.animations = [this.hoverAnimation];
+
+
+ this.callbacks = {
+ onIconStartDrag: [],
+ onIconStopDrag: []
+ };
+
+ this.dragState = {
+ pointerDown: false,
+ didStartDrag: false,
+ target: {
+ icon: null,
+ objectId: null,
+ frameId: null
+ },
+ draggedIcon: null
+ };
+
+ this.onVehicleDeleted = this.onVehicleDeleted.bind(this);
+ this.onIconPointerDown = this.onIconPointerDown.bind(this);
+ this.onIconPointerUp = this.onIconPointerUp.bind(this);
+ this.onIconPointerOver = this.onIconPointerOver.bind(this);
+ this.onIconPointerOut = this.onIconPointerOut.bind(this);
+ this.onEnvelopeRegistered = this.onEnvelopeRegistered.bind(this);
+ this.onOpen = this.onOpen.bind(this);
+ this.onClose = this.onClose.bind(this);
+ this.resetDrag = this.resetDrag.bind(this);
+ this.onPointerMove = this.onPointerMove.bind(this);
+ }
+
+ initService() {
+ document.body.appendChild(this.container);
+ document.body.appendChild(this.canvas);
+
+ document.addEventListener('pointercancel', this.resetDrag);
+ document.addEventListener('pointerup', this.resetDrag);
+ document.addEventListener('pointermove', this.onPointerMove);
+
+ realityEditor.device.registerCallback('vehicleDeleted', this.onVehicleDeleted); // deleted using userinterface
+ realityEditor.network.registerCallback('vehicleDeleted', this.onVehicleDeleted); // deleted using server
+
+ realityEditor.device.layout.onWindowResized(this.resizeCanvas.bind(this));
+ this.renderCanvas();
+ }
+
+ resetDrag() {
+ let draggedIcon = this.dragState.draggedIcon;
+ // if we have a draggedIcon, remove it
+ if (draggedIcon && draggedIcon.parentElement) {
+ let boundingRect = draggedIcon.getBoundingClientRect();
+ let x = parseInt(draggedIcon.style.left) + boundingRect.width/2;
+ let y = parseInt(draggedIcon.style.top) + boundingRect.height/2;
+
+ // delete the associated tool if the icon is over the trash zone
+ if (realityEditor.device.isPointerInTrashZone(x, y)) {
+ // delete it
+ let frame = realityEditor.getFrame(this.dragState.target.objectId, this.dragState.target.frameId);
+ if (frame) {
+ realityEditor.device.tryToDeleteSelectedVehicle(frame);
+ }
+ }
+ draggedIcon.parentElement.removeChild(draggedIcon);
+ }
+
+ this.dragState = {
+ pointerDown: false,
+ didStartDrag: false,
+ target: {
+ icon: null,
+ objectId: null,
+ frameId: null
+ },
+ draggedIcon: null
+ }
+
+ this.callbacks.onIconStopDrag.forEach(cb => cb());
+ }
+
+ setDragTarget(objectId, frameId) {
+ this.dragState.target.icon = this.getIcon(frameId);
+ this.dragState.target.objectId = objectId;
+ this.dragState.target.frameId = frameId;
+ }
+
+ onVehicleDeleted(event) {
+ if (!event.objectKey || !event.frameKey || event.nodeKey) {
+ return;
+ }
+
+ this.iconElts = this.iconElts.filter((iconElt) => {
+ if (iconElt.dataset.frameId !== event.frameKey) {
+ return true;
+ }
+ this.container.removeChild(iconElt);
+ return false;
+ });
+ this.updateIconPositions();
+ }
+
+ onIconPointerDown(event) {
+ const iconElt = event.target;
+ this.setDragTarget(iconElt.dataset.objectId, iconElt.dataset.frameId);
+ this.dragState.pointerDown = true;
+ }
+
+ onIconPointerUp(event) {
+ const iconElt = event.target;
+ const frameId = iconElt.dataset.frameId;
+ let isFirstIcon = frameId === this.iconElts[0].dataset.frameId;
+ iconElt.dataset.lastActive = Date.now();
+
+ this.dragState.pointerDown = false;
+
+ let alreadyFocused = false;
+ realityEditor.envelopeManager.getOpenEnvelopes().forEach(function(envelope) {
+ if (envelope.hasFocus) {
+ if (envelope.frame === frameId && isFirstIcon) {
+ alreadyFocused = true;
+ return;
+ }
+
+ if (envelope.isFull2D) {
+ realityEditor.envelopeManager.closeEnvelope(envelope.frame);
+ } else {
+ realityEditor.envelopeManager.blurEnvelope(envelope.frame);
+ }
+ }
+ });
+
+ if (alreadyFocused) {
+ return;
+ }
+
+ realityEditor.envelopeManager.openEnvelope(frameId, false);
+ realityEditor.envelopeManager.focusEnvelope(frameId, false);
+ }
+
+ onIconPointerOver(event) {
+ const iconElt = event.target;
+ this.hoverAnimation.hoveredFrameId = iconElt.dataset.frameId;
+ }
+
+ onIconPointerOut(event) {
+ this.hoverAnimation.hoveredFrameId = null;
+
+ const iconElt = event.target;
+ if (this.dragState.pointerDown &&
+ this.dragState.target.frameId === iconElt.dataset.frameId) {
+ this.activateDrag();
+ }
+ }
+
+ activateDrag() {
+ if (this.dragState.didStartDrag) return;
+ this.dragState.didStartDrag = true;
+
+ //create ghost of button
+ let target = this.dragState.target;
+ let draggedIcon = this.createIconImg(target.objectId, target.frameId);
+ draggedIcon.style.opacity = '.75';
+ draggedIcon.style.pointerEvents = 'none';
+ document.body.appendChild(draggedIcon);
+ this.dragState.draggedIcon = draggedIcon;
+
+ this.callbacks.onIconStartDrag.forEach(cb => cb());
+ }
+
+ onPointerMove(event) {
+ if (!this.dragState.pointerDown) return;
+ if (!this.dragState.didStartDrag) return;
+ if (!this.dragState.draggedIcon) return;
+
+ let boundingRect = this.dragState.draggedIcon.getBoundingClientRect();
+
+ this.dragState.draggedIcon.style.left = `${event.pageX - boundingRect.width/2}px`;
+ this.dragState.draggedIcon.style.top = `${event.pageY - boundingRect.height/2}px`;
+
+ if (realityEditor.device.isPointerInTrashZone(event.pageX, event.pageY)) {
+ overlayDiv.classList.add('overlayNegative');
+ } else {
+ overlayDiv.classList.remove('overlayNegative');
+ }
+ }
+
+ onEnvelopeRegistered(frame) {
+ const publicData = publicDataCache[frame.uuid];
+ if (!publicData || !publicData.storage) {
+ return;
+ }
+ if (typeof publicData.storage.envelopeLastOpen !== 'number') {
+ return;
+ }
+
+ this.updateIcon(frame, publicData.storage.envelopeLastOpen);
+ }
+
+ onOpen(envelope) {
+ const object = objects[envelope.object];
+ if (!object) {
+ return;
+ }
+ const frame = object.frames[envelope.frame];
+ if (!frame) {
+ return;
+ }
+ this.updateIcon(frame, Date.now());
+
+ const icon = this.getIcon(envelope.frame);
+ if (!icon) {
+ return;
+ }
+ icon.classList.add('ru-icon-active');
+ }
+
+ onClose(envelope) {
+ const icon = this.getIcon(envelope.frame);
+ if (!icon) {
+ return;
+ }
+ icon.classList.remove('ru-icon-active');
+ }
+
+ getIcon(frameId) {
+ for (let i = 0; i < this.iconElts.length; i++) {
+ if (this.iconElts[i].dataset.frameId === frameId) {
+ return this.iconElts[i];
+ }
+ }
+ }
+
+ createIconImg(objectId, frameId) {
+ let object = objects[objectId];
+ let frame = object.frames[frameId];
+
+ let icon = document.createElement('img');
+ icon.classList.add('ru-icon');
+ icon.dataset.newlyAdded = true;
+ icon.style.position = 'absolute';
+ // arbitrary amount to make the animation look good
+ icon.style.top = '66px';
+
+ if (object && frame) {
+ icon.dataset.frameId = frame.uuid;
+ icon.dataset.objectId = frame.objectId;
+ let name = frame.src;
+ icon.src = realityEditor.network.getURL(object.ip, realityEditor.network.getPort(object), '/frames/' + name + '/icon.gif');
+ }
+
+ return icon;
+ }
+
+ updateIcon(frame, lastActive) {
+ let icon = this.getIcon(frame.uuid);
+
+ if (!icon) {
+ icon = this.createIconImg(frame.objectId, frame.uuid);
+
+ icon.addEventListener('pointerdown', this.onIconPointerDown);
+ icon.addEventListener('pointerup', this.onIconPointerUp);
+ // hovering over the button only makes sense on a desktop environment โ touchscreens don't have hover
+ if (realityEditor.device.environment.requiresMouseEvents()) {
+ icon.addEventListener('pointerover', this.onIconPointerOver);
+ }
+ icon.addEventListener('pointerout', this.onIconPointerOut);
+ icon.addEventListener('pointercancel', this.onIconPointerUp);
+
+ this.iconElts.push(icon);
+
+ this.container.prepend(icon);
+ }
+
+ icon.dataset.lastActive = lastActive;
+
+ this.updateIconPositions();
+ }
+
+ updateIconPositions() {
+ const animDur = 200;
+
+ this.iconElts.sort((a, b) => {
+ return parseFloat(b.dataset.lastActive) -
+ parseFloat(a.dataset.lastActive);
+ });
+ realityEditor.gui.utilities.animateTranslations(this.iconElts, () => {
+ // Match DOM order with our internal order
+ for (let iconElt of this.iconElts) {
+ if (iconElt.dataset.newlyAdded) {
+ delete iconElt.dataset.newlyAdded;
+ iconElt.style.position = '';
+ iconElt.style.transform = '';
+ }
+ this.container.removeChild(iconElt);
+ this.container.appendChild(iconElt);
+ }
+ }, {
+ duration: animDur,
+ easing: 'ease-out',
+ });
+
+ for (let i = 0; i < this.capacity && i < this.iconElts.length; i++) {
+ let iconInBar = this.iconElts[i];
+ if (iconInBar.style.display !== 'none') {
+ continue;
+ }
+ iconInBar.style.display = '';
+ iconInBar.animate([{
+ opacity: 0,
+ }, {
+ opacity: 1,
+ }], {
+ duration: animDur * 0.5,
+ fill: 'both',
+ });
+ }
+
+ for (let i = this.capacity; i < this.iconElts.length; i++) {
+ let iconOutOfBar = this.iconElts[i];
+ if (iconOutOfBar.style.display === 'none') {
+ continue;
+ }
+ iconOutOfBar.animate([{
+ opacity: 1,
+ }, {
+ opacity: 0,
+ }], {
+ duration: animDur * 0.5,
+ fill: 'both',
+ });
+
+ setTimeout(() => {
+ iconOutOfBar.style.display = 'none';
+ }, animDur * 0.5);
+ }
+ }
+
+ resizeCanvas() {
+ if (this.canvas !== undefined) {
+ this.canvas.width = window.innerWidth;
+ this.canvas.height = window.innerHeight;
+ }
+ }
+
+ renderCanvas() {
+ try {
+ this.ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
+ this.updateAnimationPercent();
+
+ this.renderAnimation();
+ } catch (e) {
+ console.warn(e);
+ }
+ requestAnimationFrame(this.renderCanvas.bind(this));
+ }
+
+ updateAnimationPercent() {
+ let dt = Date.now() - this.lastDraw;
+ this.lastDraw += dt;
+ for (let animation of this.animations) {
+ animation.updateAnimationPercent(dt);
+ }
+ }
+
+ renderAnimation() {
+ for (let animation of this.animations) {
+ if (animation.hoverAnimationPercent <= 0) {
+ animation.lastAnimationPositions = null;
+ } else {
+ animation.renderAnimation();
+ }
+ }
+ }
+
+ /**
+ * Create a new LineToFrameAnimation, adding it to our list of updating
+ * animations
+ * @param {string} frameId
+ * @param {boolean} startFromSearch
+ * @param {boolean} startFromAI
+ * @param {object} aiStartPos
+ * @return {LineToFrameAnimation}
+ */
+ createAnimation(frameId, startFromSearch, startFromAI, aiStartPos) {
+ let animation = new LineToFrameAnimation(this.ctx, frameId, startFromSearch, startFromAI, aiStartPos);
+ this.animations.push(animation);
+ return animation;
+ }
+
+ removeAnimation(animation) {
+ this.animations = this.animations.filter(a => a !== animation);
+ }
+}
+
+class LineToFrameAnimation {
+ constructor(ctx, hoveredFrameId, startFromSearch, startFromAI, aiStartPos) {
+ this.ctx = ctx;
+ this.hoveredFrameId = hoveredFrameId;
+ this.startFromSearch = startFromSearch;
+ this.startFromAI = startFromAI;
+ this.aiStartPos = aiStartPos;
+ this.hoverAnimationPercent = 0;
+ this.hoverAnimationDurationMs = 100; // speed of the slowest part of the line
+ this.lastAnimationPositions = null;
+ }
+
+ updateAnimationPercent(dt) {
+ // the line animates forwards and backwards over time
+ if (this.hoveredFrameId) {
+ this.hoverAnimationPercent = Math.min(1,
+ this.hoverAnimationPercent + (dt / this.hoverAnimationDurationMs));
+ } else {
+ // https://www.nngroup.com/articles/animation-duration/
+ // "animating objects appearing or entering the screen usually need
+ // a subtly longer duration than objects disappearing or exiting the screen"
+ this.hoverAnimationPercent = Math.max(0,
+ this.hoverAnimationPercent - 1.5 * (dt / this.hoverAnimationDurationMs));
+ }
+ }
+
+ renderAnimation() {
+ // draw animated line from hovered icon element to tool
+ // if we stop hovering, draw a receding animation back to the last hovered icon element
+ if (!this.hoveredFrameId && !this.lastAnimationPositions) return;
+
+ let frameScreenPosition = this.hoveredFrameId ?
+ realityEditor.sceneGraph.getScreenPosition(this.hoveredFrameId, [0, 0, 0, 1]) :
+ this.lastAnimationPositions.frame;
+
+ let lineStartX = 0;
+ let lineStartY = 0;
+ if (this.startFromSearch) {
+ lineStartX = window.innerWidth / 2;
+ lineStartY = 115;
+ } else if (this.startFromAI) {
+ lineStartX = this.aiStartPos.x;
+ lineStartY = this.aiStartPos.y;
+ } else {
+ let iconElt = recentlyUsedBar.getIcon(this.hoveredFrameId);
+ if (this.hoveredFrameId && !iconElt) {
+ this.hoveredFrameId = null;
+ return;
+ }
+
+ let iconRect = this.hoveredFrameId ? iconElt.getBoundingClientRect() : null;
+ let iconBottom = this.hoveredFrameId ?
+ { x: iconRect.left + iconRect.width / 2, y: iconRect.bottom } :
+ this.lastAnimationPositions.icon;
+
+ lineStartX = iconBottom.x;
+ lineStartY = iconBottom.y + 5;
+ }
+
+ let lineNextY = lineStartY + 10;
+
+ // the line gets a fast, smooth, fade-in animation by having
+ // multiple layers animate in/out with different speeds
+ let animationLayers = [
+ { speed: 1, opacity: 0.4 },
+ { speed: 2, opacity: 0.2 },
+ { speed: 3, opacity: 0.1 }
+ ];
+
+ animationLayers.forEach(layer => {
+ this.ctx.beginPath();
+ this.ctx.lineWidth = 2;
+ this.ctx.strokeStyle = `rgba(255,255,255,${layer.opacity})`;
+ this.ctx.moveTo(lineStartX, lineStartY);
+ this.ctx.lineTo(lineStartX, lineNextY);
+
+ let adjustedAnimPercent = Math.min(1, this.hoverAnimationPercent * layer.speed);
+
+ // this calculates an animated endpoint for the line based on the hoverAnimationPercent
+ let horizontalDistance = frameScreenPosition.x - lineStartX;
+ let verticalDistance = frameScreenPosition.y - lineNextY;
+ let horizontalPercent = Math.abs(horizontalDistance) / (Math.abs(horizontalDistance) + Math.abs(verticalDistance));
+ let lineEndX = lineStartX + horizontalDistance *
+ Math.min(1, adjustedAnimPercent / horizontalPercent);
+ this.ctx.lineTo(lineEndX, lineNextY);
+ let lineEndY = lineNextY + verticalDistance *
+ Math.max(0, Math.min(1, (adjustedAnimPercent - horizontalPercent) / (1 - horizontalPercent)));
+ this.ctx.lineTo(lineEndX, lineEndY);
+
+ this.ctx.stroke();
+ this.ctx.closePath();
+ });
+
+ // keep track of the line's start and end, so we can do reverse animation
+ // when you stop hovering over the active icon element
+ if (this.hoveredFrameId) {
+ this.lastAnimationPositions = {
+ icon: { x: lineStartX, y: lineStartY - 5 },
+ frame: { x: frameScreenPosition.x, y: frameScreenPosition.y }
+ }
+ }
+ }
+}
+
+let recentlyUsedBar = new RecentlyUsedBar();
+realityEditor.gui.recentlyUsedBar = recentlyUsedBar;
+realityEditor.gui.LineToFrameAnimation = LineToFrameAnimation;
diff --git a/src/gui/scene/AnchoredGroup.js b/src/gui/scene/AnchoredGroup.js
new file mode 100644
index 000000000..6aa442dda
--- /dev/null
+++ b/src/gui/scene/AnchoredGroup.js
@@ -0,0 +1,72 @@
+import * as THREE from "../../../thirdPartyCode/three/three.module.js"
+import {setMatrixFromArray} from "./utils.js"
+
+/**
+ * @typedef {import("./utils.js").MatrixAsArray} MatrixAsArray
+ */
+
+/**
+ * the tracked environment, everything placed in the environment should be attached to this
+ */
+class AnchoredGroup {
+ /**
+ * @type {THREE.Group}
+ */
+ #group;
+
+ /**
+ *
+ * @param {string} name
+ */
+ constructor(name) {
+ this.#group = new THREE.Group();
+ this.#group.matrixAutoUpdate = false; // this is needed to position it directly with matrices
+ this.#group.name = name;
+ }
+
+ getName() {
+ return this.#group.name;
+ }
+
+ /**
+ *
+ * @param {MatrixAsArray} array
+ */
+ setMatrixFromArray(array) {
+ setMatrixFromArray(this.#group.matrix, array)
+ }
+
+ /**
+ *
+ * @param {THREE.Object3D} object
+ */
+ attach(object) {
+ this.#group.attach(object);
+ }
+
+ /**
+ *
+ * @param {THREE.Object3D} object
+ */
+ add(object) {
+ this.#group.add(object);
+ }
+
+ /**
+ *
+ * @param {THREE.Object3D} object
+ */
+ remove(object) {
+ this.#group.remove(object);
+ }
+
+ /**
+ *
+ * @returns {THREE.Group}
+ */
+ getInternalObject() {
+ return this.#group;
+ }
+}
+
+export default AnchoredGroup;
diff --git a/src/gui/scene/Camera.js b/src/gui/scene/Camera.js
new file mode 100644
index 000000000..72a9935d8
--- /dev/null
+++ b/src/gui/scene/Camera.js
@@ -0,0 +1,261 @@
+import * as THREE from "../../../thirdPartyCode/three/three.module.js"
+import {setMatrixFromArray} from "./utils.js"
+
+/**
+ * @typedef {{x: number, y: number}} Coordinate
+ */
+
+class LayerConfig {
+ static LAYER_DEFAULT = 0;
+ static LAYER_LEFT_EYE = 1;
+ static LAYER_RIGHT_EYE = 2;
+ static LAYER_SCAN = 3;
+ static LAYER_BACKGROUND = 4;
+
+ #global = new THREE.Layers();
+ #left = new THREE.Layers();
+ #right = new THREE.Layers();
+
+ constructor(global, left = global, right = global) {
+ this.#global = global;
+ this.#left = left;
+ this.#right = right;
+ }
+
+ configurCamera(camera) {
+ camera.layers.mask = this.#global.mask;
+ if (camera instanceof THREE.ArrayCamera) {
+ camera.cameras[0].layers.mask = this.#left.mask;
+ camera.cameras[1].layers.mask = this.#right.mask;
+ }
+ }
+
+ static createFromCamera(camera) {
+ const global = new THREE.Layers();
+ global.mask = camera.layers.mask;
+ if (camera instanceof THREE.ArrayCamera) {
+ const left = new THREE.Layers();
+ left.mask = camera.cameras[0].layers.mask;
+ const right = new THREE.Layers();
+ right.mask = camera.cameras[1].layers.mask;
+ return new LayerConfig(global, left, right);
+ }
+ return new LayerConfig(global);
+ }
+
+ clone() {
+ const global = new THREE.Layers();
+ global.mask = this.#global.mask;
+ const left = new THREE.Layers();
+ left.mask = this.#left.mask;
+ const right = new THREE.Layers();
+ right.mask = this.#right.mask;
+ return new LayerConfig(global, left, right);
+ }
+
+ setGlobal(layer) {
+ this.#global.set(layer);
+ this.#left.set(layer);
+ this.#right.set(layer);
+ }
+}
+
+class Camera {
+ /**
+ * @type {THREE.PerspectiveCamera}
+ */
+ _camera;
+
+ constructor(camera) {
+ this._camera = camera;
+ }
+
+ /**
+ *
+ * @param {MatrixAsArray} _
+ */
+ setProjectionMatrixFromArray(_) {}
+
+ /**
+ *
+ * @param {MatrixAsArray} _
+ */
+ setCameraMatrixFromArray(_) {}
+
+ /**
+ *
+ * @param {THREE.Object3D} object
+ */
+ attach(object) {
+ this._camera.attach(object);
+ }
+
+ /**
+ *
+ * @param {THREE.Object3D} object
+ */
+ add(object) {
+ this._camera.add(object);
+ }
+
+ /**
+ * source: https://github.com/mrdoob/three.js/issues/78
+ * @override
+ * @param {THREE.Vector3} meshPosition
+ * @returns {Coordinate}
+ */
+ getScreenXY(meshPosition) {
+ let pos = meshPosition.clone();
+ let projScreenMat = new THREE.Matrix4();
+ projScreenMat.multiplyMatrices(this._camera.projectionMatrix, this._camera.matrixWorldInverse);
+ pos.applyMatrix4(projScreenMat);
+
+ // check if the position is behind the camera, if so, manually flip the screen position, b/c the screen position somehow is inverted when behind the camera
+ let meshPosWrtCamera = meshPosition.clone();
+ meshPosWrtCamera.applyMatrix4(this._camera.matrixWorldInverse);
+ if (meshPosWrtCamera.z > 0) {
+ pos.negate();
+ }
+
+ return {
+ x: ( pos.x + 1 ) * window.innerWidth / 2,
+ y: ( -pos.y + 1) * window.innerHeight / 2
+ };
+ }
+
+ /**
+ * source: https://stackoverflow.com/questions/29758233/three-js-check-if-object-is-still-in-view-of-the-camera
+ * @override
+ * @param {THREE.Vector3} pointPosition
+ * @returns {boolean}
+ */
+ isPointOnScreen(pointPosition) {
+ let frustum = new THREE.Frustum();
+ let matrix = new THREE.Matrix4();
+ matrix.multiplyMatrices(this._camera.projectionMatrix, this._camera.matrixWorldInverse);
+ frustum.setFromProjectionMatrix(matrix);
+ if (frustum.containsPoint(pointPosition)) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @override
+ * @param {THREE.Vector3}
+ * @returns {THREE.Vector3}
+ */
+ getWorldDirection(cameraDirection) {
+ return this._camera.getWorldDirection(cameraDirection);
+ }
+
+ /**
+ * @override
+ * @param {THREE.Vector3} cameraPosition
+ * @returns {THREE.Vector3}
+ */
+ getWorldPosition(cameraPosition) {
+ return this._camera.getWorldPosition(cameraPosition);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ getNear() {
+ return this._camera.near;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ getFar() {
+ return this._camera.far;
+ }
+
+ /**
+ *
+ * @returns {LayerConfig}
+ */
+ getLayerConfig() {
+ return LayerConfig.createFromCamera(this._camera);
+ }
+
+ /**
+ *
+ * @param {LayerConfig} layerConfig
+ */
+ setLayerConfig(layerConfig) {
+ layerConfig.configurCamera(this._camera);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ getInternalObject() {
+ return this._camera;
+ }
+}
+
+/**
+ * Default camera class
+ */
+class DefaultCamera extends Camera {
+
+ /**
+ *
+ * @param {string} name
+ * @param {number} aspectRatio
+ */
+ constructor(name, aspectRatio) {
+ // setup an initial configuration fro the camera, both camera matrix and projection matrix will be calculated externaly and applied to this camera
+ const camera = new THREE.PerspectiveCamera(70, aspectRatio, 1, 1000);
+ // do not recalculate matrices, we will set them our selves
+ camera.matrixAutoUpdate = false;
+ camera.name = name;
+ camera.layers.enable(LayerConfig.LAYER_SCAN);
+ camera.layers.enable(LayerConfig.LAYER_BACKGROUND);
+ super(camera);
+ }
+
+ /**
+ * @override
+ * @param {MatrixAsArray} matrix
+ */
+ setProjectionMatrixFromArray(matrix) {
+ setMatrixFromArray(this._camera.projectionMatrix, matrix);
+ this._camera.projectionMatrixInverse.copy(this._camera.projectionMatrix).invert();
+ }
+
+ /**
+ * @override
+ * @param {MatrixAsArray} matrix
+ */
+ setCameraMatrixFromArray(matrix) {
+ setMatrixFromArray(this._camera.matrix, matrix);
+ this._camera.updateMatrixWorld(true);
+ }
+}
+
+class WebXRCamera extends Camera {
+
+ /**
+ *
+ * @param {string} name
+ * @param {import('./Renderer.js').Renderer} renderer
+ */
+ constructor(name, renderer) {
+ /** @type {THREE.ArrayCamera} */
+ const camera = renderer.getInternalRenderer().xr.getCamera();
+ camera.layers.enable(LayerConfig.LAYER_SCAN);
+ camera.layers.enable(LayerConfig.LAYER_BACKGROUND);
+ for (const cameraEntry of camera.cameras) {
+ cameraEntry.layers.enable(LayerConfig.LAYER_SCAN);
+ cameraEntry.layers.enable(LayerConfig.LAYER_BACKGROUND);
+ }
+ camera.name = name;
+ super(camera);
+ }
+}
+
+export {Camera, DefaultCamera, WebXRCamera, LayerConfig};
diff --git a/src/gui/scene/GroundPlane.js b/src/gui/scene/GroundPlane.js
new file mode 100644
index 000000000..d1fef01cf
--- /dev/null
+++ b/src/gui/scene/GroundPlane.js
@@ -0,0 +1,71 @@
+import * as THREE from "../../../thirdPartyCode/three/three.module.js"
+
+/**
+ * @typedef {number} Millimeters
+ */
+
+/**
+ * Ground plane occlusion object, evrything placed on the ground should be attached to this
+ */
+class GroundPlane {
+ /** @type {THREE.Mesh} */
+ #plane;
+
+ /**
+ *
+ * @param {Millimeters} size
+ */
+ constructor(size) {
+ const geometry = new THREE.PlaneGeometry(size, size);
+ geometry.rotateX(Math.PI / 2); // directly set the geometry's rotation to get the desired visual rotation & raycast direction. Otherwise setting mesh's rotation & run updateWorldMatrix(true, false) looks correct, but has wrong raycast direction
+ const material = new THREE.MeshBasicMaterial({color: 0x88ffff, side: THREE.DoubleSide, wireframe: true});
+ this.#plane = new THREE.Mesh(geometry, material);
+ // plane.rotateX(Math.PI/2);
+ this.#plane.visible = false;
+ // plane.position.set(0, -10, 0); // todo Steve: figure out a way to raycast on mesh first & if no results, raycast on ground plane next. Figure out a way to do it in one go (possibly using depth tests & stuff), instead of using 2 raycasts, to improve performance
+ this.#plane.name = 'groundPlaneCollider';
+ }
+
+ tryUpdatingGroundPlanePosition(areaTargetMesh, areaTargetNavmesh) {
+ this.#plane.remove(this.#plane);
+ areaTargetMesh.add(this.#plane);
+ let areaTargetMeshScale = Math.max(areaTargetMesh.matrixWorld.elements[0], areaTargetMesh.matrixWorld.elements[5], areaTargetMesh.matrixWorld.elements[10]);
+ let floorOffset = (areaTargetNavmesh.floorOffset / realityEditor.gui.threejsScene.getInternals().getGlobalScale().getSceneScale()) / areaTargetMeshScale;
+ this.#plane.position.set(0, floorOffset, 0);
+ this.#plane.updateMatrix();
+ this.#plane.updateWorldMatrix(true);
+ console.log(this.#plane.matrixWorld);
+
+ // update the groundPlane sceneNode to match the position of the new groundplane collider
+ let groundPlaneRelativeOrigin = areaTargetMesh.localToWorld(this.#plane.position.clone());
+ let groundPlaneRelativeMatrix = new THREE.Matrix4().setPosition(groundPlaneRelativeOrigin); //.copyPosition(groundPlaneRelativeOrigin);
+ realityEditor.sceneGraph.setGroundPlanePosition(groundPlaneRelativeMatrix.elements);
+ }
+
+ /**
+ *
+ * @param {boolean} updateParents
+ * @param {boolean} updateChildren
+ */
+ updateWorldMatrix(updateParents, updateChildren) {
+ this.#plane.updateWorldMatrix(updateParents, updateChildren);
+ }
+
+ /**
+ * this function is used to connect the groundplane to the three.js scene
+ * @returns {THREE.Mesh}
+ */
+ getInternalObject() {
+ return this.#plane;
+ }
+
+ /**
+ *
+ * @param {THREE.Object3D} object
+ */
+ add(object) {
+ this.#plane.add(object);
+ }
+}
+
+export default GroundPlane;
diff --git a/src/gui/scene/Renderer.js b/src/gui/scene/Renderer.js
new file mode 100644
index 000000000..ae67cbc21
--- /dev/null
+++ b/src/gui/scene/Renderer.js
@@ -0,0 +1,430 @@
+import * as THREE from '../../../thirdPartyCode/three/three.module.js';
+import DateTimer from '../../../objectdefaultFiles/scene/DateTimer.js';
+import { RoomEnvironment } from '../../../thirdPartyCode/three/RoomEnvironment.module.js';
+import { acceleratedRaycast } from '../../../thirdPartyCode/three-mesh-bvh.module.js';
+import { WebXRVRButton } from './WebXRVRButton.js';
+import { Camera } from './Camera.js';
+import AnchoredGroup from './AnchoredGroup.js';
+import {ToolManager} from './ToolManager.js';
+import {ResourceCache} from './SmartResourceCache.js';
+
+/**
+ * @typedef {DateTimer} Timer
+ * @typedef {ResourceCache} GeometryCache
+ * @typedef {import("./SmartResourceCache.js").ResourceEntry} GeometryEntry
+ * @typedef {import("./SmartResource.js").SmartResource} GeometryRef
+ * @typedef {ResourceCache} MaterialCache
+ * @typedef {import("./SmartResourceCache.js").ResourceEntry} MaterialEntry
+ * @typedef {import("./SmartResource.js").SmartResource} MaterialRef
+ * @typedef {ResourceCache} TextureCache
+ * @typedef {import("./SmartResourceCache.js").ResourceEntry} TextureEntry
+ * @typedef {import("./SmartResource.js").SmartResource} TextureRef
+ * @typedef {number} pixels
+ * @typedef {number} DeviceUnitsPerMeter
+ * @typedef {number} MetersPerSceneUnit
+ * @typedef {number} DeviceUnitsPerSceneUnit
+ * @typedef {number} SceneUnitsPerDeviceUnit
+ * @typedef {number} SceneUnits
+ * @typedef {number} seconds
+ * @typedef {{date: seconds, timer: seconds}} TimerDateOffset
+ * @typedef {(globalScale: GlobalScale) => void} GlobalScaleListener
+ * @typedef {{a: number, b: number, c: number, normal: THREE.Vector3, materialIndex: number}} Face
+ * @typedef {{distance: number, distanceToRay?: number|undefined, point: THREE.Vector3, index?: number | undefined, face?: Face | null | undefined, faceIndex?: number | undefined, object: THREE.Object3D, uv?: THREE.Vector2 | undefined, uv1?: THREE.Vector2 | undefined, normal?: THREE.Vector3, instanceId?: number | undefined, pointOnLine?: THREE.Vector3, batchId?: number, scenePoint: THREE.Vector3, sceneDistance: SceneUnits}} Intersection
+ */
+
+class GlobalScale {
+ /** @type {DeviceUnitsPerMeter} */
+ #deviceScale;
+
+ /** @type {MetersPerSceneUnit} */
+ #sceneScale;
+
+ /** @type {THREE.Group} */
+ #node;
+
+ /** @type {GlobalScaleListener[]} */
+ #listeners;
+
+ /**
+ *
+ * @param {DeviceUnitsPerMeter} deviceScale
+ * @param {MetersPerSceneUnit} sceneScale
+ */
+ constructor(deviceScale, sceneScale) {
+ this.#deviceScale = deviceScale;
+ this.#sceneScale = sceneScale;
+ this.#listeners = [];
+
+ this.#node = new THREE.Group();
+ this.#node.name = "worldScaleNode";
+ this.#node.scale.setScalar(this.getGlobalScale());
+ }
+
+ getDeviceScale() {
+ return this.#deviceScale;
+ }
+
+ /**
+ *
+ * @param {DeviceUnitsPerMeter} scale
+ */
+ setDeviceScale(scale) {
+ this.#deviceScale = scale;
+ this.#node.scale.setScalar(this.getGlobalScale());
+ this.#notifyListeners();
+ }
+
+ getSceneScale() {
+ return this.#sceneScale;
+ }
+
+ /**
+ *
+ * @returns {DeviceUnitsPerSceneUnit}
+ */
+ getGlobalScale() {
+ return this.#deviceScale * this.#sceneScale;
+ }
+
+ /**
+ *
+ * @returns {SceneUnitsPerDeviceUnit}
+ */
+ getInvGlobalScale() {
+ return 1.0 / this.getGlobalScale();
+ }
+
+ getNode() {
+ return this.#node;
+ }
+
+ /**
+ *
+ * @param {GlobalScaleListener} func
+ */
+ addListener(func) {
+ this.#listeners.push(func);
+ }
+
+ #notifyListeners() {
+ for (const listener of this.#listeners) {
+ listener(this);
+ }
+ }
+}
+
+/**
+ * Manages the rendering of the main scene
+ */
+class Renderer {
+ /** @type {THREE.WebGLRenderer} */
+ #renderer
+
+ /** @type {Camera} */
+ #camera
+
+ /** @type {THREE.Scene} */
+ #scene
+
+ /** @type {THREE.Raycaster} */
+ #raycaster
+
+ /** @type {GlobalScale} */
+ #globalScale
+
+ /** @type {ToolManager} */
+ #tools;
+
+ /** @type {Timer} */
+ #timer
+
+ /** @type {GeometryCache} */
+ #geometryCache;
+
+ /** @type {MaterialCache} */
+ #materialCache;
+
+ /** @type {TextureCache} */
+ #textureCache;
+
+ /**
+ *
+ * @param {HTMLCanvasElement} domElement
+ */
+ constructor(domElement) {
+ this.#timer = new DateTimer();
+ this.#geometryCache = new ResourceCache("geometryCache");
+ this.#materialCache = new ResourceCache("materialCache");
+ this.#textureCache = new ResourceCache("textureCache");
+ this.#renderer = new THREE.WebGLRenderer({canvas: domElement, alpha: true, antialias: true});
+ this.#renderer.setPixelRatio(window.devicePixelRatio);
+ this.#renderer.setSize(window.innerWidth, window.innerHeight);
+ this.#renderer.outputEncoding = THREE.sRGBEncoding;
+ if (this.#renderer.xr && !realityEditor.device.environment.isARMode()) {
+ this.#renderer.xr.enabled = true;
+ if (realityEditor.gui.getMenuBar) {
+ const menuBar = realityEditor.gui.getMenuBar();
+ menuBar.addItemToMenu(realityEditor.gui.MENU.Develop, WebXRVRButton.createButton(this.#renderer));
+ }
+ }
+
+ this.#scene = new THREE.Scene();
+
+ // in the webbrowser we work with milimeters so 1000 browserunits are 1 meter and 0.001 meter is one scene unit (effectively canceling each other out)
+ // we use this for headsets, in order to change the deviceScale
+ this.#globalScale = new GlobalScale(1000, 0.001);
+ this.#scene.add(this.#globalScale.getNode());
+
+ realityEditor.device.layout.onWindowResized(({width, height}) => {
+ this.#renderer.setSize(width, height);
+ });
+
+ this.#setupLighting();
+
+ let pmremGenerator = new THREE.PMREMGenerator(this.#renderer);
+ pmremGenerator.compileEquirectangularShader();
+
+ let neutralEnvironment = pmremGenerator.fromScene(new RoomEnvironment()).texture;
+ this.#scene.environment = neutralEnvironment;
+
+ // Add the BVH optimized raycast function from three-mesh-bvh.module.js
+ // Assumes the BVH is available on the `boundsTree` variable
+ THREE.Mesh.prototype.raycast = acceleratedRaycast;
+ this.#raycaster = new THREE.Raycaster();
+
+ this.#tools = new ToolManager(this);
+ }
+
+ /**
+ *
+ * @returns {GeometryCache}
+ */
+ getGeometryCache() {
+ return this.#geometryCache;
+ }
+
+ /**
+ *
+ * @returns {MaterialCache}
+ */
+ getMaterialCache() {
+ return this.#materialCache;
+ }
+
+ /**
+ *
+ * @returns {TextureCache}
+ */
+ getTextureCache() {
+ return this.#textureCache;
+ }
+
+ /**
+ * use this helper function to update the camera matrix using the camera matrix from the sceneGraph
+ */
+ #setupLighting() {
+ // This doesn't seem to work with the area target model material, but adding it for everything else
+ let ambLight = new THREE.AmbientLight(0xffffff, 0.3);
+ this.#globalScale.getNode().add(ambLight);
+
+ // attempts to light the scene evenly with directional lights from each side, but mostly from the top
+ let dirLightTopDown = new THREE.DirectionalLight(0xffffff, 1.5);
+ dirLightTopDown.position.set(0, 1, 0); // top-down
+ dirLightTopDown.lookAt(0, 0, 0);
+ this.#globalScale.getNode().add(dirLightTopDown);
+ }
+
+ /**
+ *
+ * @param {Camera|AnchoredGroup|THREE.Object3D} obj
+ */
+ add(obj) {
+ if (obj instanceof Camera) {
+ if (this.#camera) {
+ this.#scene.remove(this.#camera.getInternalObject());
+ }
+ this.#scene.add(obj.getInternalObject());
+ } else if (obj instanceof AnchoredGroup) {
+ this.#globalScale.getNode().add(obj.getInternalObject());
+ } else if (obj instanceof THREE.Object3D) {
+ this.#globalScale.getNode().add(obj)
+ }
+ }
+
+ /**
+ * @param {string} type
+ * @param {string} toolId
+ */
+ addTool(toolId, type) {
+ this.#tools.add(toolId, type);
+ }
+
+ /**
+ *
+ * @param {string} toolId
+ */
+ removeTool(toolId) {
+ this.#tools.remove(toolId);
+ }
+
+ /**
+ *
+ * @param {AnchoredGroup} anchoredGroup
+ */
+ setAnchoredGroupForTools(anchoredGroup) {
+ this.#tools.setAnchoredGroup(anchoredGroup);
+ }
+
+ /**
+ *
+ * @returns {boolean}
+ */
+ isInWebXRMode() {
+ return this.webXRAvailable() && this.#renderer.xr.isPresenting;
+ }
+
+ webXRAvailable() {
+ return this.#renderer.xr && this.#renderer.xr.enabled === true;
+ }
+
+ /**
+ *
+ * @param {Camera} camera
+ */
+ setCamera(camera) {
+ this.#camera = camera;
+ }
+
+ render() {
+ this.#timer.update();
+ this.#tools.update();
+ this.#renderer.render(this.#scene, this.#camera.getInternalObject());
+ }
+
+ /**
+ *
+ * @param {THREE.WebGLRenderTarget} renderTexture
+ * @param {THREE.Scene} customScene
+ */
+ renderToTexture(renderTexture) {
+ this.#renderer.setRenderTarget(renderTexture);
+ this.#renderer.render(this.#scene, this.#camera.getInternalObject());
+ this.#renderer.setRenderTarget(null);
+ }
+
+ /**
+ *
+ * @param {() => void} func
+ */
+ setAnimationLoop(func) {
+ this.#renderer.setAnimationLoop(func);
+ }
+
+ /**
+ *
+ * @param {string} name
+ * @returns {THREE.Object3D|undefined}
+ */
+ getObjectByName(name) {
+ return this.#globalScale.getNode().getObjectByName(name);
+ }
+
+ /**
+ * return all objects with the name
+ * @param {string} name
+ * @returns {THREE.Object3D[]}
+ */
+ getObjectsByName(name) {
+ if (name === undefined) return;
+ /** @type {THREE.Object3D[]} */
+ const objects = [];
+ this.#globalScale.getNode().traverse((object) => {
+ if (object.name === name) objects.push(object);
+ })
+ return objects;
+ }
+
+ /**
+ * this module exports this utility so that other modules can perform hit tests
+ * objectsToCheck defaults to scene.children (all objects in the scene) if unspecified
+ * NOTE: returns the coordinates in threejs scene world coordinates:
+ * may need to call objectToCheck.worldToLocal(results[0].point) to get the result in the right system
+ * @param {pixels} clientX - screen coordinate left to right
+ * @param {pixels} clientY - screen coordinate top to bottom
+ * @param {THREE.Object3D[]} objectsToCheck
+ * @returns {Intersection[]}
+ */
+ getRaycastIntersects(clientX, clientY, objectsToCheck) {
+ let mouse = new THREE.Vector2();
+ mouse.x = ( clientX / window.innerWidth ) * 2 - 1;
+ mouse.y = - ( clientY / window.innerHeight ) * 2 + 1;
+
+ //2. set the picking ray from the camera position and mouse coordinates
+ this.#raycaster.setFromCamera( mouse, this.#camera.getInternalObject() );
+
+ this.#raycaster.firstHitOnly = true; // faster (using three-mesh-bvh)
+
+ //3. compute intersections
+ // add object layer to raycast layer mask
+ objectsToCheck.forEach(obj => {
+ this.#raycaster.layers.mask = this.#raycaster.layers.mask | obj.layers.mask;
+ });
+ let results = this.#raycaster.intersectObjects( objectsToCheck || this.#globalScale.getNode().children, true );
+ results.forEach(intersection => {
+ intersection.rayDirection = this.#raycaster.ray.direction;
+ intersection.scenePoint = intersection.point.clone();
+ intersection.scenePoint.multiplyScalar(this.#globalScale.getInvGlobalScale());
+ intersection.sceneDistance = intersection.distance / this.#globalScale.getGlobalScale();
+ });
+ return results;
+ }
+
+ /**
+ *
+ * @returns {THREE.Renderer}
+ */
+ getInternalRenderer() {
+ return this.#renderer;
+ }
+
+ /**
+ *
+ * @returns {Camera}
+ */
+ getCamera() {
+ return this.#camera;
+ }
+
+ /**
+ * @returns {GlobalScale}
+ */
+ getGlobalScale() {
+ return this.#globalScale;
+ }
+
+ /**
+ *
+ * @returns {THREE.Scene}
+ */
+ getInternalScene() {
+ return this.#scene;
+ }
+
+ /**
+ *
+ * @returns {HTMLCanvasElement}
+ */
+ getInternalCanvas() {
+ return this.#renderer.domElement;
+ }
+
+ /**
+ *
+ * @returns {Timer}
+ */
+ getTimer() {
+ return this.#timer;
+ }
+}
+
+export { Renderer, GlobalScale };
diff --git a/src/gui/scene/SmartResource.js b/src/gui/scene/SmartResource.js
new file mode 100644
index 000000000..52b379e20
--- /dev/null
+++ b/src/gui/scene/SmartResource.js
@@ -0,0 +1,142 @@
+/**
+ * @typedef {{dispose: () => void}} Resource
+ * @typedef {(resource) => void} safeFunc
+ */
+
+/**
+ * @template {Resource} T
+ */
+class SmartResource {
+ /** @type {SmartResourceAdmin} */
+ #admin;
+
+ /** @type {T} */
+ #resource;
+
+ /**
+ *
+ * @param {SmartResourceAdmin} smartPointerAdmin
+ */
+ constructor(smartPointerAdmin) {
+ this.#admin = smartPointerAdmin;
+ this.#resource = this.#admin.getRef();
+ }
+
+ /**
+ *
+ * @param {T} resource
+ * @returns {SmartResource}
+ */
+ static create(resource) {
+ return new SmartResource(new SmartResourceAdmin(resource));
+ }
+
+ /**
+ *
+ * @returns {SmartResource}
+ */
+ copy() {
+ return new SmartResource(this.#admin);
+ }
+
+ /**
+ *
+ * @returns {T}
+ */
+ getResource() {
+ return this.#resource;
+ }
+
+ /**
+ *
+ */
+ release() {
+ this.#admin.releaseRef();
+ this.#resource = null;
+ this.#admin = null;
+ }
+}
+
+/**
+ * @template {Resource} T
+ */
+class SmartResourceAdmin {
+ /** @type {number} */
+ #refCount;
+
+ /** @type {T} */
+ #resource;
+
+ /**
+ * @param {T} resource
+ */
+ constructor(resource) {
+ this.#resource = resource;
+ this.#refCount = 0;
+ }
+
+ /**
+ *
+ * @returns {T}
+ */
+ getWeakRef() {
+ return this.#resource;
+ }
+
+ /**
+ *
+ * @returns {T}
+ */
+ getRef() {
+ this.#refCount++;
+ /*if (this.#resource.id === "https://192\\.168\\.0\\.42:8080/frames/gltfExample/flagab\\.glb@0.Plane001@2." || this.#resource.id === "https://192\\.168\\.0\\.42:8080/frames/gltfExample/flagab\\.glb@0.Plane001@2.Material\\.003") {
+ console.log(`resource ${this.#refCount} ${this.#resource.id}`);
+ console.trace();
+ }*/
+ return this.#resource;
+ }
+
+ /**
+ *
+ * @returns {boolean}
+ */
+ releaseRef() {
+ this.#refCount--;
+ /*if (this.#resource.id === "https://192\\.168\\.0\\.42:8080/frames/gltfExample/flagab\\.glb@0.Plane001@2." || this.#resource.id === "https://192\\.168\\.0\\.42:8080/frames/gltfExample/flagab\\.glb@0.Plane001@2.Material\\.003") {
+ console.log(`resource ${this.#refCount} ${this.#resource.id}`);
+ console.trace();
+ }*/
+ if (this.#refCount == 0) {
+ this.#resource.dispose();
+ return true;
+ }
+ return false;
+ }
+}
+
+/**
+ * @template {Resource} T
+ * @param {SmartResource|null} resource
+ * @returns {null}
+ */
+function safeRelease(resource) {
+ if (resource) {
+ resource.release();
+ }
+ return null;
+}
+
+/**
+ * @template {Resource} T
+ * @param {SmartResource|null} resource
+ * @param {safeFunc} func
+ */
+function safeUsing(resource, func) {
+ try {
+ func(resource);
+ } finally {
+ safeRelease(resource);
+ }
+}
+
+export {SmartResource, SmartResourceAdmin, safeRelease, safeUsing};
diff --git a/src/gui/scene/SmartResourceCache.js b/src/gui/scene/SmartResourceCache.js
new file mode 100644
index 000000000..407915079
--- /dev/null
+++ b/src/gui/scene/SmartResourceCache.js
@@ -0,0 +1,179 @@
+import {SmartResource, SmartResourceAdmin, safeUsing} from "./SmartResource.js";
+
+/**
+ * @typedef {import("./SmartResource.js").Resource} Resource
+ * @typedef {number} resourceVersion
+ * @typedef {string} resourceId
+ */
+
+/**
+ * @template {Resource} T
+ */
+class ResourceEntry {
+ /** @type {resourceVersion} */
+ #version;
+
+ /** @type {T} */
+ #resource;
+
+ /** @type {ResourceCache} */
+ #cache;
+
+ /** @type {resourceId} */
+ #id;
+
+ /**
+ *
+ * @param {ResourceCache} cache
+ * @param {resourceId} id
+ * @param {T} resource
+ * @param {resourceVersion} version
+ */
+ constructor(cache, id, resource, version) {
+ this.#cache = cache;
+ this.#id = id;
+ this.#resource = resource;
+ this.#version = version;
+ }
+
+ /**
+ *
+ * @returns {T}
+ */
+ getResource() {
+ return this.#resource;
+ }
+
+ /**
+ *
+ * @returns {resourceVersion}
+ */
+ getVersion() {
+ return this.#version;
+ }
+
+ /**
+ *
+ * @returns {resourceId}
+ */
+ get id() {
+ return this.#id;
+ }
+
+ /**
+ *
+ */
+ dispose() {
+ this.#cache.remove(this.#id, this.#version);
+ if (this.#resource.dispose) {
+ this.#resource.dispose();
+ }
+ this.#id = null;
+ this.#version = -1;
+ this.#resource = null;
+ this.#cache = null;
+ }
+}
+
+/**
+ * @template {Resource} T
+ */
+class ResourceCache {
+ /** @type {{[key: resourceId]: SmartResourceAdmin>[]}} */
+ #cache;
+
+ /** @type {string} */
+ #name;
+
+ /**
+ * @param {string} name
+ */
+ constructor(name) {
+ this.#cache = {};
+ this.#name = name;
+ }
+
+ /**
+ *
+ * @param {SmartResourceAdmin>[]} entry
+ * @returns {resourceVersion}
+ */
+ #getCurrentVersion(entry) {
+ let version = -1;
+ safeUsing(new SmartResource(entry[entry.length - 1]), (currentResource) => {
+ version = currentResource.getResource().getVersion() + 1;
+ });
+ return version;
+ }
+
+ /**
+ *
+ * @param {resourceId} id
+ * @param {T} resource
+ * @returns {SmartResource>}
+ */
+ insert(id, resource) {
+ if (this.#cache.hasOwnProperty(id)) {
+ const entry = this.#cache[id];
+ const version = this.#getCurrentVersion(entry) + 1;
+ const smartAdmin = new SmartResourceAdmin(new ResourceEntry(this, id, resource, version));
+ entry.push(smartAdmin);
+ return new SmartResource(smartAdmin);
+ } else {
+ const smartAdmin = new SmartResourceAdmin(new ResourceEntry(this, id, resource, 0));
+ this.#cache[id] = [smartAdmin];
+ console.log(`${this.#name} ${Object.keys(this.#cache).length} ${id}`);
+ return new SmartResource(smartAdmin);
+ }
+ }
+
+ /**
+ *
+ * @param {resourceId} id
+ * @param {resourceVersion} version
+ * @returns {SmartResource>|undefined}
+ */
+ get(id, version = null) {
+ if (this.#cache.hasOwnProperty(id)) {
+ const entry = this.#cache[id];
+ let smartAdmin = entry[entry.length - 1];
+ if (version) {
+ for (const adminEntry of entry) {
+ if (adminEntry.getResource().getVersion() === version) {
+ smartAdmin = adminEntry;
+ break;
+ }
+ }
+ }
+ return new SmartResource(smartAdmin);
+ } else {
+ return undefined;
+ }
+ }
+
+ /**
+ *
+ * @param {resourceId} id
+ * @param {resourceVersion} version
+ */
+ remove(id, version) {
+ if (this.#cache.hasOwnProperty(id)) {
+ const entry = this.#cache[id];
+ for (let index = 0; index < entry.length; ++index) {
+ if (entry[index].getWeakRef().getVersion() === version) {
+ entry.splice(index, 1);
+ if (entry.length == 0) {
+ delete this.#cache[id];
+ }
+ console.log(`${this.#name} ${Object.keys(this.#cache).length} ${id}`);
+ return;
+ }
+ }
+ console.error(`${this.#name} No such resource: ${id} with version: ${version}`);
+ } else {
+ console.error(`${this.#name} No such resource: ${id}`);
+ }
+ }
+}
+
+export {ResourceCache, ResourceEntry};
diff --git a/src/gui/scene/ThreejsEntity.js b/src/gui/scene/ThreejsEntity.js
new file mode 100644
index 000000000..87f11e951
--- /dev/null
+++ b/src/gui/scene/ThreejsEntity.js
@@ -0,0 +1,164 @@
+import BaseEntity from "/objectDefaultFiles/scene/BaseEntity.js";
+import { safeRelease } from "./SmartResource.js";
+
+/**
+ * @typedef {{update: (object3D = THREE.Object3D) => void}} ThreejsComponent
+ * @typedef {import("./SmartResource.js").ResourceReference} ResourceReference
+ */
+
+class ThreejsEntity extends BaseEntity {
+ /** @type {THREE.Object3D} */
+ #object;
+
+ /** @type {ResourceReference|null} */
+ #geometryRef;
+
+ /** @type {ResourceReference|null} */
+ #materialRef;
+
+ constructor(object, geometryRef = null, materialRef = null) {
+ super();
+ this.#object = object;
+ this.#geometryRef = geometryRef;
+ this.#materialRef = materialRef;
+ }
+
+ /**
+ *
+ * @returns {THREE.Object3D}
+ */
+ getInternalObject() {
+ return this.#object;
+ }
+
+ /**
+ *
+ * @returns {Vector3Value}
+ */
+ get position() {
+ return this.#object.position;
+ }
+
+ /**
+ *
+ * @param {Vector3Value} position
+ */
+ set position(position) {
+ this.#object.position.set(position.x, position.y, position.z);
+ }
+
+ /**
+ *
+ * @returns {QuaternionValue}
+ */
+ get rotation() {
+ return this.#object.quaternion;
+ }
+
+ /**
+ *
+ * @param {QuaternionValue} rotation
+ */
+ set rotation(rotation) {
+ this.#object.quaternion.set(rotation.x, rotation.y, rotation.z, rotation.w);
+ }
+
+ /**
+ *
+ * @returns {Vector3Value}
+ */
+ get scale() {
+ return this.#object.scale;
+ }
+
+ /**
+ *
+ * @param {Vector3Value} scale
+ */
+ set scale(scale) {
+ this.#object.scale.set(scale.x, scale.y, scale.z);
+ }
+
+ /**
+ *
+ * @param {boolean} isVisible
+ */
+ set isVisible(isVisible) {
+ this.#object.visible = isVisible;
+ }
+
+ /**
+ *
+ * @returns {boolean}
+ */
+ get isVisible() {
+ return this.#object.visible;
+ }
+
+ /**
+ *
+ * @param {string} key
+ * @param {ThreejsEntity} child
+ */
+ setChild(key, child) {
+ super.setChild(key, child);
+ const internalChild = child;
+ if (internalChild.getInternalObject().parent !== this.#object) {
+ this.#object.add(child.getInternalObject());
+ }
+ }
+
+ /**
+ *
+ * @param {string} key
+ */
+ removeChild(key) {
+ this.#object.remove(this.getChild(key).getInternalObject());
+ super.removeChild(key);
+ }
+
+ /**
+ * @returns {ResourceReference|null}
+ */
+ get geometryRef() {
+ return this.#geometryRef;
+ }
+
+ /**
+ * @param {ResourceReference|null} geometryRef
+ */
+ set geometryRef(geometryRef) {
+ this.#geometryRef = safeRelease(this.geometryRef);
+ this.#geometryRef = geometryRef ? geometryRef.copy() : null;
+ }
+
+ /**
+ * @returns {ReosurceReference|null}
+ */
+ get materialRef() {
+ return this.#materialRef;
+ }
+
+ /**
+ * @param {ResourceReference} materialRef
+ */
+ set materialRef(materialRef) {
+ this.#materialRef = safeRelease(this.materialRef);
+ this.#materialRef = materialRef ? materialRef.copy() : null;
+ }
+
+ internalRelease() {
+ this.#geometryRef = safeRelease(this.geometryRef);
+ this.#materialRef = safeRelease(this.materialRef);
+ }
+
+ /**
+ *
+ */
+ dispose() {
+ this.#object.removeFromParent();
+ this.release();
+ }
+}
+
+export default ThreejsEntity;
diff --git a/src/gui/scene/ThreejsEntityNode.js b/src/gui/scene/ThreejsEntityNode.js
new file mode 100644
index 000000000..d550850fb
--- /dev/null
+++ b/src/gui/scene/ThreejsEntityNode.js
@@ -0,0 +1,50 @@
+import * as THREE from '../../../thirdPartyCode/three/three.module.js';
+import BaseEntityNode from '../../../objectDefaultFiles/scene/BaseEntityNode.js';
+import ThreejsEntity from "./ThreejsEntity.js";
+import GLTFLoaderComponentNode from "/objectDefaultFiles/scene/GLTFLoaderComponentNode.js";
+import ThreejsGLTFLoaderComponentNode from './ThreejsGLTFLoaderComponentNode.js';
+import MaterialComponentNode from "/objectDefaultFiles/scene/MaterialComponentNode.js";
+import ThreejsMaterialComponentNode from './ThreejsMaterialComponentNode.js';
+
+class ThreejsEntityNode extends BaseEntityNode {
+ /**
+ * @param {ThreejsEntity} entity
+ * @param {string} type
+ */
+ constructor(entity, type) {
+ super(entity, type);
+ }
+
+ /**
+ * @param {string} _key
+ * @param {string} name
+ * @returns {ThreejsEntity}
+ */
+ createEntity(_key, name) {
+ const obj = new THREE.Object3D();
+ obj.name = name;
+ return new ThreejsEntityNode(new ThreejsEntity(obj));
+ }
+
+ /**
+ * @param {number} _index
+ * @param {ValueDict} state
+ * @returns {ComponentInterface}
+ */
+ createComponent(_index, state) {
+ if (state.hasOwnProperty("type")) {
+ if (state.type === GLTFLoaderComponentNode.TYPE) {
+ return new ThreejsGLTFLoaderComponentNode();
+ } else if (state.type === MaterialComponentNode.TYPE) {
+ return new ThreejsMaterialComponentNode();
+ }
+ }
+ return null;
+ }
+
+ dispose() {
+ this.entity.dispose();
+ }
+}
+
+export default ThreejsEntityNode;
diff --git a/src/gui/scene/ThreejsGLTFLoaderComponentNode.js b/src/gui/scene/ThreejsGLTFLoaderComponentNode.js
new file mode 100644
index 000000000..ea85f14ab
--- /dev/null
+++ b/src/gui/scene/ThreejsGLTFLoaderComponentNode.js
@@ -0,0 +1,267 @@
+import {GLTFLoader} from '../../../thirdPartyCode/three/GLTFLoader.module.js';
+import ObjectNode from '../../../objectDefaultFiles/scene/ObjectNode.js';
+import VersionedNode from "/objectDefaultFiles/scene/VersionedNode.js";
+import EntityNode from "/objectDefaultFiles/scene/BaseEntityNode.js";
+import ThreejsEntity from "./ThreejsEntity.js";
+import {ResourceCache} from './SmartResourceCache.js';
+import {getRoot} from "./utils.js";
+import MaterialComponentNode from "/objectDefaultFiles/scene/MaterialComponentNode.js";
+import ThreejsMaterialComponentNode from "./ThreejsMaterialComponentNode.js";
+import ThreejsEntityNode from './ThreejsEntityNode.js';
+import { safeUsing } from './SmartResource.js';
+
+/**
+ * @typedef {import("/objectDefaultFiles/scene/ThreejsGLTFLoaderComponentNode.js").default} ThreejsGLTFLoaderComponentNode
+ * @typedef {import("./SmartResourceCache.js").ResourceReference} ResourceReference
+ * @typedef {({ref: ResourceReference, node: entity}) => void} onLoadFunc
+ * @typedef {(error: Error) => void} onErrorFunc
+ */
+
+class CreateChildEntityWalker {
+ /** @type {ResourceCache} */
+ #geometryCache;
+
+ /** @type {ResourceCache} */
+ #materialCache;
+
+ /**
+ *
+ * @param {ResourceCache} geometryCache
+ * @param {ResourceCache} materialCache
+ */
+ constructor(geometryCache, materialCache) {
+ this.#geometryCache = geometryCache;
+ this.#materialCache = materialCache;
+ }
+
+ /**
+ *
+ * @param {THREE.Material|THREE.Geometry} resource
+ * @param {string} uniqueIdPrefix
+ * @param {ResourceCache} cache
+ */
+ #createSmartResource(resource, uniqueIdPrefix, cache) {
+ if (resource.userData.hasOwnProperty("toolboxId")) {
+ return cache.get(resource.userData.toolboxId);
+ } else {
+ const resourceId = uniqueIdPrefix + "." + resource.name.replace(/[\\.@]/g, "\\$&");
+ resource.userData.toolboxId = resourceId;
+ return cache.insert(resourceId, resource);
+ }
+ }
+
+ /**
+ *
+ * @param {EntityNode} entityNode
+ * @param {string} uniqueIdPrefix
+ */
+ run(entityNode, uniqueIdPrefix) {
+ this.#internalRun(entityNode, uniqueIdPrefix);
+ }
+
+ /**
+ *
+ * @param {EntityNode} entityNode
+ * @param {string} uniqueIdPrefix
+ */
+ #internalRun(entityNode, uniqueIdPrefix) {
+ const object3D = entityNode.entity.getInternalObject();
+ if (object3D.hasOwnProperty("material")) {
+ const materialRef = this.#createSmartResource(object3D.material, uniqueIdPrefix, this.#materialCache);
+ entityNode.entity.materialRef = materialRef;
+ materialRef.release();
+ entityNode.setComponent("1000", new ThreejsMaterialComponentNode(), false);
+ }
+ if (object3D.hasOwnProperty("geometry")) {
+ const geometryRef = this.#createSmartResource(object3D.geometry, uniqueIdPrefix, this.#geometryCache);
+ entityNode.entity.geometryRef = geometryRef;
+ geometryRef.release();
+ }
+ const children = object3D.children;
+ for (let i = 0; i < children.length; ++i) {
+ const childNode = new ThreejsEntityNode(new ThreejsEntity(children[i]));
+ this.#internalRun(childNode, uniqueIdPrefix + `.${children[i].name ? children[i].name.replace(/[\\.@]/g, '\\$&') : ""}@${i}`);
+ entityNode.setChild(`${i}`, childNode, false);
+ }
+ }
+}
+
+class CachedGLTFLoader {
+ /** @type {GLTFLoader} */
+ #gltfLoader;
+
+ /** @type {SmartResourceCache} */
+ #cache;
+
+ /** @type {{[key: number]: {onLoad: onLoadFunc, onError: onErrorFunc}}} */
+ #loading;
+
+ /** @type {CreateChildEntityWalker} */
+ #childEntityWalker;
+
+ /**
+ * @param {ResourceCache} geometryCache
+ * @param {ResourceCache} materialCache
+ */
+ constructor(geometryCache, materialCache) {
+ this.#gltfLoader = new GLTFLoader();
+ this.#cache = new ResourceCache("glTFCache");
+ this.#loading = {};
+ this.#childEntityWalker = new CreateChildEntityWalker(geometryCache, materialCache);
+ }
+
+ /**
+ *
+ * @param {*} modelData
+ * @param {string} absUrl
+ * @param {number} version
+ * @returns {EntityNode}
+ */
+ #createEntityNode(modelData, absUrl, version) {
+ const modelNode = new ThreejsEntityNode(new ThreejsEntity(modelData.scene));
+ this.#childEntityWalker.run(modelNode, absUrl.replace(/[\\.@]/g, '\\$&') + "@" + version);
+ return modelNode;
+ }
+
+ #internalCloneEntityNode(srcNode, object3D) {
+ const dstNode = new ThreejsEntityNode(new ThreejsEntity(object3D));
+ dstNode.entity.materialRef = srcNode.entity.materialRef;
+ if (srcNode.entity.materialRef) {
+ dstNode.setComponent("1000", new ThreejsMaterialComponentNode(), false);
+ }
+ dstNode.entity.geometryRef = srcNode.entity.geometryRef;
+ for (let i = 0; i < object3D.children.length; ++i) {
+ dstNode.setChild(`${i}`, this.#internalCloneEntityNode(srcNode.getChild(i), object3D.children[i]));
+ }
+ return dstNode;
+ }
+
+ #cloneEntityNode(modelNode) {
+ return this.#internalCloneEntityNode(modelNode, modelNode.entity.getInternalObject().clone());
+ }
+
+ /**
+ *
+ * @param {string} url
+ * @param {onLoadFunc} onLoad
+ * @param {onErrorFunc} onError
+ */
+ #reload(absUrl, onLoad, onError, version) {
+ if (!this.#loading.hasOwnProperty(absUrl)) {
+ this.#loading[absUrl] = [{onLoad, onError}];
+ this.#gltfLoader.load(absUrl, (modelData) => {
+ const entityNode = this.#createEntityNode(modelData, absUrl, version);
+ const reference = this.#cache.insert(absUrl, entityNode);
+ for (const entry of this.#loading[absUrl]) {
+ entry.onLoad({ref: reference.copy(), node: this.#cloneEntityNode(entityNode)});
+ }
+ reference.release();
+ delete this.#loading[absUrl];
+ }, null, (error) => {
+ for (const entry of this.#loading[absUrl]) {
+ entry.onError(error);
+ }
+ delete this.#loading[absUrl];
+ });
+ } else {
+ this.#loading[absUrl].push({onLoad, onError});
+ }
+ }
+
+ load(url, onLoad, onError, version) {
+ const absUrl = new URL(url).href;
+ let cacheRef = this.#cache.get(absUrl);
+ if (cacheRef) {
+ if (cacheRef.getResource().getVersion() < version) {
+ cacheRef.release();
+ this.#reload(absUrl, onLoad, onError, version);
+ } else {
+ onLoad({ref: cacheRef, node: this.#cloneEntityNode(cacheRef.getResource().getResource())});
+ }
+ } else {
+ this.#reload(absUrl, onLoad, onError, version);
+ }
+ }
+}
+
+
+
+class ThreejsGLTFLoaderComponentNode extends ObjectNode {
+ /** @type {CachedGLTFLoader|null} */
+ static #gltfLoader = null;
+
+ /** @type {EntityNode|null} */
+ #node;
+
+ /** @type {VersionedNode} */
+ #urlNode;
+
+ /** @type {boolean} */
+ #forceLoad;
+
+ /** @type {SmartResource|null} */
+ #resourceRef
+
+ constructor() {
+ super();
+ this.#node = null;
+ this.#urlNode = new VersionedNode("");
+ this.#urlNode.onChanged = () => {this.#forceLoad = true};
+ this.#forceLoad = false;
+ this.#resourceRef = null;
+ this._set("url", this.#urlNode);
+ }
+
+ /**
+ *
+ * @param {EntityNode} node
+ */
+ setEntityNode(node) {
+ this.#node = node;
+ this.#forceLoad = true;
+ }
+
+ /**
+ *
+ * @param {ThreejsGLTFLoaderComponentNode} _thisNode
+ * @returns
+ */
+ getProperties(_thisNode) {
+ return {
+ "url": this.#urlNode
+ };
+ }
+
+ async update() {
+ if (this.#forceLoad) {
+ this.#forceLoad = false;
+ let version = 0;
+ if (this.#resourceRef) {
+ version = this.#resourceRef.getVersion() + 1;
+ this.#resourceRef.release();
+ }
+ if (!ThreejsGLTFLoaderComponentNode.#gltfLoader) {
+ const worldNode = getRoot(this.#node);
+ ThreejsGLTFLoaderComponentNode.#gltfLoader = new CachedGLTFLoader(worldNode.geometryCache, worldNode.materialCache);
+ }
+ const result = await new Promise((resolve, reject) => {
+ ThreejsGLTFLoaderComponentNode.#gltfLoader.load(new URL(this.#urlNode.value).href, (resourceRef) => resolve(resourceRef), reject, version);
+ });
+ this.#resourceRef = result.ref;
+ this.#node.setChild("Scene", result.node);
+ }
+ }
+
+ release() {
+ if (this.#resourceRef) {
+ this.#resourceRef.release();
+ }
+ this.#resourceRef = null;
+ }
+
+ get component() {
+ return this;
+ }
+}
+
+export default ThreejsGLTFLoaderComponentNode;
diff --git a/src/gui/scene/ThreejsMaterialComponentNode.js b/src/gui/scene/ThreejsMaterialComponentNode.js
new file mode 100644
index 000000000..6f1a5b53f
--- /dev/null
+++ b/src/gui/scene/ThreejsMaterialComponentNode.js
@@ -0,0 +1,247 @@
+import * as THREE from '../../../thirdPartyCode/three/three.module.js';
+import ObjectNode from "../../../objectDefaultFiles/scene/ObjectNode.js";
+import ValueNode from "../../../objectDefaultFiles/scene/ValueNode.js";
+import ColorNode from "../../../objectDefaultFiles/scene/ColorNode.js";
+import EulerAnglesNode from "../../../objectDefaultFiles/scene/EulerAnglesNode.js";
+import Vector2Node from "../../../objectDefaultFiles/scene/Vector2Node.js";
+import {getRoot} from "./utils.js";
+import DictionaryNode from "../../../objectDefaultFiles/scene/DictionaryNode.js";
+import {safeRelease, safeUsing} from "./SmartResource.js";
+import ThreejsTextureNode from './ThreejsTextureNode.js';
+import MaterialComponentNode from '../../../objectDefaultFiles/scene/MaterialComponentNode.js';
+
+/**
+ * @typedef {import("./Renderer.js").TextureCache} TextureCache
+ * @typedef {import("./Renderer.js").MaterialCache} MaterialCache
+ * @typedef {import("./SmartResourceCache.js").resourceId} resourceId
+ */
+
+class ThreejsMaterialComponentNode extends ObjectNode {
+ /** @type {ValueNode} */
+ #materialIdNode;
+
+ /** @type {DictionaryNode} */
+ #propertiesNode;
+
+ /** @type {EntityNode} */
+ #node;
+
+ /** @type {boolean} */
+ #entityNeedsUpdate;
+
+ /** @type {MaterialCache|null} */
+ #cache;
+
+ /** @type {boolean} */
+ #nodeChanged;
+
+ /** @type {string[]} */
+ #changedProperties;
+
+ /** @type {TextureCache|null} */
+ #textureCache;
+
+ /**
+ *
+ */
+ constructor() {
+ super(MaterialComponentNode.TYPE);
+ this.#materialIdNode = new ValueNode("");
+ this.#materialIdNode.onChanged = () => {this.#entityNeedsUpdate = true;};
+ this.#propertiesNode = new DictionaryNode();
+ this.#entityNeedsUpdate = false;
+ this.#cache = null;
+ this.#node = null;
+ this.#nodeChanged = false;
+ this.#changedProperties = [];
+ this.#textureCache = null;
+ this._set("material", this.#materialIdNode);
+ this._set("properties", this.#propertiesNode);
+ }
+
+ /**
+ * @param {EntityNode} node
+ */
+ setEntityNode(node) {
+ this.#node = node;
+ this.#nodeChanged = true;
+ }
+
+ #addColor(object3D, propertyName) {
+ const color = new ColorNode(object3D.material[propertyName]);
+ color.onChanged = (_node) => {this.#changedProperties.push(propertyName);};
+ this.#propertiesNode.set(propertyName, color);
+ }
+
+ #addEulerAngles(object3D, propertyName) {
+ const eulerAngles = new EulerAnglesNode(object3D.material[propertyName]);
+ eulerAngles.onChanged = (_node) => {this.#changedProperties.push(propertyName);};
+ this.#propertiesNode.set(propertyName, eulerAngles);
+ }
+
+ #addTexture(object3D, propertyName) {
+ let textureRef = null;
+ /** @type {resourceId} */
+ const textureData = {id: null, mapping: THREE.UVMapping, wrapS: THREE.ClampToEdgeWrapping, wrapT: THREE.ClampToEdgeWrapping, magFilter: THREE.LinearFilter, minFilter: THREE.LinearFilter, anisotropy: 1};
+ if (object3D.material[propertyName]) {
+ const texture = object3D.material[propertyName];
+ if (texture.userData.hasOwnProperty("toolboxId")) {
+ textureData.id = texture.userData.toolboxId;
+ textureRef = this.#textureCache.get(textureData.id);
+ } else {
+ textureData.id = `${this.#materialIdNode.value}.${propertyName.replace(/[\\.@]/g, "\\$&")}.${texture.name.replace(/[\\.@]/g, "\\$&")}`;
+ texture.userData.toolboxId = textureData.id;
+ textureRef = this.#textureCache.insert(textureData.id, texture);
+ }
+ textureData.mapping = texture.mapping;
+ textureData.wrapS = texture.wrapS;
+ textureData.wrapT = texture.wrapT;
+ textureData.magFilter = texture.magFilter;
+ textureData.minFilter = texture.minFilter;
+ textureData.anisotropy = texture.anisotropy;
+ }
+ const textureNode = new ThreejsTextureNode(textureData, this.#textureCache);
+ textureNode.onChanged = () => {this.#changedProperties.push(propertyName);};
+ this.#propertiesNode.set(propertyName, textureNode);
+ safeRelease(textureRef);
+ }
+
+ #addValue(object3D, propertyName) {
+ const value = new ValueNode(object3D.material[propertyName]);
+ value.onChanged = () => {this.#changedProperties.push(propertyName);};
+ this.#propertiesNode.set(propertyName, value);
+ }
+
+ #addVector2(object3D, propertyName) {
+ const vector2 = new Vector2Node(object3D.material[propertyName]);
+ vector2.onChanged = () => {this.#changedProperties.push(propertyName);};
+ this.#propertiesNode.set(propertyName, vector2);
+ }
+
+ update() {
+ if (!this.#cache) {
+ const worldNode = getRoot(this.#node);
+ this.#cache = worldNode.materialCache;
+ }
+ if (!this.#textureCache) {
+ const worldNode = getRoot(this.#node);
+ this.#textureCache = worldNode.textureCache;
+ }
+ if (this.#entityNeedsUpdate || this.#nodeChanged) {
+ this.#entityNeedsUpdate = false;
+ const entityNode = this.#node;
+ const entity = entityNode.entity;
+ const object3D = entity.getInternalObject();
+ if (this.#nodeChanged) {
+ this.#nodeChanged = false;
+ this.#materialIdNode.value = entity.materialRef.getResource().id;
+ }
+ safeUsing(this.#cache.get(this.#materialIdNode.value), (ref) => {
+ entity.materialRef = ref;
+ });
+ if (entity.materialRef) {
+ object3D.material = entity.materialRef.getResource().getResource();
+ if (object3D.material instanceof THREE.MeshStandardMaterial) {
+ // Material
+ this.#addValue(object3D, "alphaTest");
+ this.#addValue(object3D, "alphaToCoverage");
+ this.#addValue(object3D, "blendDst");
+ this.#addValue(object3D, "blendDstAlpha");
+ this.#addValue(object3D, "blendEquation");
+ this.#addValue(object3D, "blendEquationAlpha");
+ this.#addValue(object3D, "blending");
+ this.#addValue(object3D, "blendSrc");
+ this.#addValue(object3D, "blendSrcAlpha");
+ this.#addValue(object3D, "clipIntersection");
+ this.#addValue(object3D, "clipShadows");
+ this.#addValue(object3D, "colorWrite");
+ this.#addValue(object3D, "depthFunc");
+ this.#addValue(object3D, "depthTest");
+ this.#addValue(object3D, "depthWrite");
+ this.#addValue(object3D, "stencilWrite");
+ this.#addValue(object3D, "stencilWriteMask");
+ this.#addValue(object3D, "stencilFunc");
+ this.#addValue(object3D, "stencilRef");
+ this.#addValue(object3D, "stencilFuncMask");
+ this.#addValue(object3D, "stencilFail");
+ this.#addValue(object3D, "stencilZFail");
+ this.#addValue(object3D, "stencilZPass");
+ this.#addValue(object3D, "opacity");
+ this.#addValue(object3D, "polygonOffset");
+ this.#addValue(object3D, "polygonOffsetFactor");
+ this.#addValue(object3D, "polygonOffsetUnits");
+ this.#addValue(object3D, "precision");
+ this.#addValue(object3D, "premultipliedAlpha");
+ this.#addValue(object3D, "dithering");
+ this.#addValue(object3D, "shadowSide");
+ this.#addValue(object3D, "side");
+ this.#addValue(object3D, "toneMapped");
+ this.#addValue(object3D, "transparent");
+ this.#addValue(object3D, "vertexColors");
+ this.#addValue(object3D, "visible");
+
+ // MeshStandardMaterial
+
+ this.#addTexture(object3D, "aoMap");
+ this.#addValue(object3D, "aoMapIntensity");
+ this.#addTexture(object3D, "bumpMap");
+ this.#addValue(object3D, "bumpScale");
+ this.#addColor(object3D, "color");
+ this.#addTexture(object3D, "displacementMap");
+ this.#addValue(object3D, "displacementScale");
+ this.#addValue(object3D, "displacementBias");
+ this.#addColor(object3D, "emissive");
+ this.#addTexture(object3D, "emissiveMap");
+ this.#addValue(object3D, "emissiveIntensity");
+ this.#addTexture(object3D, "envMap");
+ this.#addEulerAngles(object3D, "envMapRotation");
+ this.#addValue(object3D, "envMapIntensity");
+ this.#addValue(object3D, "flatShading");
+ this.#addValue(object3D, "fog");
+ this.#addTexture(object3D, "lightMap");
+ this.#addValue(object3D, "lightMapIntensity");
+ this.#addTexture(object3D, "map");
+ this.#addValue(object3D, "metalness");
+ this.#addTexture(object3D, "metalnessMap");
+ this.#addTexture(object3D, "normalMap");
+ this.#addValue(object3D, "normalMapType");
+ this.#addVector2(object3D, "normalScale");
+ this.#addValue(object3D, "roughness");
+ this.#addTexture(object3D, "roughnessMap");
+ this.#addValue(object3D, "wireframe");
+ this.#addValue(object3D, "wireframeLinecap");
+ this.#addValue(object3D, "wireframeLinejoin");
+ this.#addValue(object3D, "wireframeLinewidth");
+ }
+ }
+ }
+ for (const change of this.#changedProperties) {
+ const material = this.#node.getEntity().getInternalObject().material;
+ if (material[change] instanceof THREE.Color) {
+ material[change].copy(this.#propertiesNode.get(change).value);
+ } else if (material[change] instanceof THREE.Euler) {
+ material[change].copy(this.#propertiesNode.get(change).value);
+ } else if (material[change] instanceof THREE.Texture) {
+ material[change] = this.#propertiesNode.get(change).swapAndGetTexture();
+ } else if (typeof material[change] === "number") {
+ material[change] = this.#propertiesNode.get(change).value;
+ }
+ }
+ this.#changedProperties = [];
+ }
+
+ release() {
+ const properties = this.#propertiesNode.values();
+ for (let property of properties) {
+ if (property && property.release) {
+ property = safeRelease(property);
+ }
+ }
+ }
+
+ get component() {
+ return this;
+ }
+}
+
+export default ThreejsMaterialComponentNode;
diff --git a/src/gui/scene/ThreejsTextureNode.js b/src/gui/scene/ThreejsTextureNode.js
new file mode 100644
index 000000000..7f26efc05
--- /dev/null
+++ b/src/gui/scene/ThreejsTextureNode.js
@@ -0,0 +1,90 @@
+import * as THREE from '../../../thirdPartyCode/three/three.module.js';
+import TextureNode from "../../../../objectDefaultFiles/scene/TextureNode.js";
+import {safeRelease} from "./SmartResource.js";
+
+/**
+ * @typedef {import("../../../thirdPartyCode/three/three.module.js").Texture} Texture
+ * @typedef {import("./Renderer.js").TextureCache} TextureCache
+ * @typedef {import("./Renderer.js").TextureRef} TextureRef
+ * @typedef {import("../../../objectDefaultFiles/scene/TextureNode.js").TextureValue} TextureValue
+ */
+
+class ThreejsTextureNode extends TextureNode {
+ /** @type {TextureCache} */
+ #cache;
+
+ /** @type {TextureRef|null} */
+ #oldRef;
+
+ /** @type {TextureRef|null} */
+ #newRef;
+
+ /**
+ *
+ * @param {TextureValue} value
+ * @param {TextureCache} cache
+ */
+ constructor(value = {id: null, mapping: THREE.UVMapping, wrapS: THREE.ClampToEdgeWrapping, wrapT: THREE.ClampToEdgeWrapping, magFilter: THREE.LinearFilter, minFilter: THREE.LinearFilter, anisotropy: 1}, cache) {
+ super(value);
+ this.#cache = cache;
+ this.#oldRef = null;
+ if (value.id) {
+ const ref = this.#cache.get(value.id);
+ if (ref) {
+ this.#newRef = ref;
+ } else {
+ this.#newRef = null;
+ }
+ } else {
+ this.#newRef = null;
+ }
+ }
+
+ /**
+ * @override
+ * @param {ObjectNodeDelta} delta
+ */
+ setChanges(delta) {
+ if (delta.hasOwnProperty("protperties") && delta.properties.hasOwnProperty("id")) {
+ if (!this.#oldRef) {
+ this.#oldRef = this.#newRef;
+ this.#newRef = null;
+ }
+ this.#newRef = safeRelease(this.#newRef);
+ if (delta.properties.id) {
+ const ref = this.#cache.get(delta.properties.id);
+ if (ref) {
+ this.#newRef = ref;
+ } else {
+ this.#newRef = null;
+ }
+ }
+ }
+ super.setChanges(delta);
+ }
+
+
+ /**
+ *
+ * @returns {Texture|null}
+ */
+ swapAndGetTexture() {
+ this.#oldRef = safeRelease(this.#oldRef);
+ if (this.#newRef) {
+ return this.#newRef.getResource().getResource();
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ *
+ */
+ release() {
+ this.#oldRef = safeRelease(this.#oldRef);
+ this.#newRef = safeRelease(this.#newRef);
+ this.cache = null;
+ }
+}
+
+export default ThreejsTextureNode;
diff --git a/src/gui/scene/ThreejsToolNode.js b/src/gui/scene/ThreejsToolNode.js
new file mode 100644
index 000000000..43c390c5d
--- /dev/null
+++ b/src/gui/scene/ThreejsToolNode.js
@@ -0,0 +1,55 @@
+import * as THREE from '../../../thirdPartyCode/three/three.module.js';
+import ThreejsEntityNode from "./ThreejsEntityNode.js"
+import ThreejsEntity from "./ThreejsEntity.js";
+import GLTFLoaderComponentNode from "/objectDefaultFiles/scene/GLTFLoaderComponentNode.js";
+import ThreejsGLTFLoaderComponentNode from './ThreejsGLTFLoaderComponentNode.js';
+import MaterialComponentNode from "/objectDefaultFiles/scene/MaterialComponentNode.js";
+import ThreejsMaterialComponentNode from './ThreejsMaterialComponentNode.js';
+import ToolNode from '../../../objectDefaultFiles/scene/ToolNode.js';
+
+/**
+ * @typedef {import("./ToolManager.js").ToolProxy} ToolProxy
+ */
+
+class ThreejsToolNode extends ThreejsEntityNode {
+ /** @type {ToolProxy} */
+ #toolProxy;
+
+ /**
+ *
+ * @param {ToolProxy} toolProxy
+ */
+ constructor(toolProxy, type = ToolNode.TYPE) {
+ super(toolProxy.entity, type);
+ this.#toolProxy = toolProxy;
+ }
+
+ /**
+ * @param {string} _key
+ * @param {string} name
+ * @returns {ThreejsEntity}
+ */
+ createEntity(_key, name) {
+ const obj = new THREE.Object3D();
+ obj.name = name;
+ return new ThreejsEntityNode(new ThreejsEntity(obj));
+ }
+
+ /**
+ * @param {number} _index
+ * @param {ValueDict} state
+ * @returns {ComponentInterface}
+ */
+ createComponent(_index, state) {
+ if (state.hasOwnProperty("type")) {
+ if (state.type === GLTFLoaderComponentNode.TYPE) {
+ return new ThreejsGLTFLoaderComponentNode();
+ } else if (state.type === MaterialComponentNode.TYPE) {
+ return new ThreejsMaterialComponentNode();
+ }
+ }
+ return null;
+ }
+}
+
+export default ThreejsToolNode;
diff --git a/src/gui/scene/ToolManager.js b/src/gui/scene/ToolManager.js
new file mode 100644
index 000000000..4c7bb6677
--- /dev/null
+++ b/src/gui/scene/ToolManager.js
@@ -0,0 +1,323 @@
+import * as THREE from '../../../thirdPartyCode/three/three.module.js';
+import {ToolRenderSocket} from "../../../objectDefaultFiles/scene/ToolRenderStream.js";
+import {IFrameMessageInterface} from "../../../objectDefaultFiles/scene/MessageInterface.js";
+import Engine3DWorldNode from "./engine3D/Engine3DWorldNode.js";
+import ThreejsToolNode from "./ThreejsToolNode.js";
+import ToolNode from "../../../objectDefaultFiles/scene/ToolNode.js";
+import TransformComponentNode from "../../../objectDefaultFiles/scene/TransformComponentNode.js";
+import ThreejsEntity from "./ThreejsEntity.js";
+import {setMatrixFromArray} from "./utils.js";
+
+
+/**
+ * @typedef {import('./AnchoredGroup.js').default} AnchoredGroup
+ * @typedef {import('../../../objectDefaultFiles/scene/AnchoredGroupNode.js').default} AnchoredGroupNode
+ * @typedef {import('../../../objectDefaultFiles/scene/WorldNode.js').WorldNodeState} WorldNodeState
+ * @typedef {import('../../../objectDefaultFiles/scene/WorldNode.js').WorldNodeDelta} WorldNodeDelta
+ */
+
+class ToolProxyHandler {
+ /** @type {ToolProxy} */
+ #toolProxy;
+
+ /** @type {ToolRenderInterface} */
+ #socket
+
+ /** @type {boolean} Received initial get command*/
+ #isInitialized
+
+ /**
+ *
+ * @param {ToolProxy} toolProxy
+ * @param {HTMLIFrameElement} iframe
+ */
+ constructor(toolProxy, iframe) {
+ this.#toolProxy = toolProxy;
+ const messageInterface = new IFrameMessageInterface(iframe, "*");
+ this.#socket = new ToolRenderSocket(messageInterface, toolProxy.getToolId());
+ this.#socket.setListener(this);
+ this.#isInitialized = false;
+ }
+
+ sendSet(state) {
+ this.#socket.sendSet(state);
+ this.#isInitialized = true;
+ }
+
+ sendUpdate(delta) {
+ this.#socket.sendUpdate(delta);
+ }
+
+ onReceivedGet() {
+ this.sendSet(this.#toolProxy.getWorldState());
+ }
+
+ onReceivedUpdate(delta) {
+ console.log(`${this.#toolProxy.getToolId()} -> composition layer: `, delta);
+ this.#toolProxy.setWorldChanges(delta);
+ }
+
+ isInitialized() {
+ return this.#isInitialized;
+ }
+
+ onDelete() {
+ this.#socket.onDelete();
+ }
+}
+
+class ToolProxy {
+ /** @type {ToolManager} */
+ #manager;
+
+ /** @type {string} */
+ #toolId;
+
+ /** @type {HTMLIFrameElement} */
+ #worker;
+
+ /** @type {ToolProxyHandler} */
+ #handler;
+
+ /** @type {ThreejsEntity} */
+ #rootEntity;
+
+ /** @type {THREE.Matrix4|null} */
+ #lastToolMatrix;
+
+ /**
+ *
+ * @param {ToolManager} manager
+ * @param {string} toolId
+ * @param {HTMLIFrameElement} worker
+ * @param {THREE.Group} rootGroup
+ */
+ constructor(manager, toolId, worker, rootGroup) {
+ this.#manager = manager;
+ this.#toolId = toolId;
+ this.#worker = worker;
+ this.#rootEntity = new ThreejsEntity(rootGroup);
+ this.#handler = new ToolProxyHandler(this, this.#worker);
+ this.#lastToolMatrix = new THREE.Matrix4();
+ }
+
+ /**
+ *
+ * @returns {string}
+ */
+ getToolId() {
+ return this.#toolId;
+ }
+
+ /**
+ *
+ * @returns {ThreejsEntity}
+ */
+ get entity() {
+ return this.#rootEntity;
+ }
+
+ /**
+ *
+ * @returns {WorldObjectState}
+ */
+ getWorldState() {
+ return this.#manager.getStateForTool(this.#toolId);
+ }
+
+ setWorldChanges(delta) {
+ this.#manager.setChanges(delta);
+ }
+
+ #checkPath(obj, propertyName) {
+ if (propertyName.length === 0) return Object.keys(obj).length > 0;
+ if (obj.hasOwnProperty(propertyName[0])) {
+ const curPropName = propertyName[0];
+ propertyName.shift();
+ return this.#checkPath(obj[curPropName], propertyName);
+ }
+ return false;
+ }
+
+ #createDeltaForTool(delta, propertyName, toolChanges) {
+ const ret = {};
+ if (propertyName.length === 1) {
+ ret[propertyName[0]] = toolChanges;
+ } else {
+ for (const key of Object.keys(delta)) {
+ if (key === propertyName[0]) continue;
+ ret[key] = structuredClone(delta[key]);
+ }
+ const curPropName = propertyName[0];
+ propertyName.shift();
+ ret[curPropName] = this.#createDeltaForTool(delta[curPropName], propertyName, toolChanges);
+ }
+ return ret;
+ }
+
+ sendUpdate(delta) {
+ if (this.#handler.isInitialized() && this.#checkPath(delta, ["properties", "tools", "properties", this.#toolId])) {
+ const toolChanges = delta.properties.tools.properties[this.#toolId];
+ const toolDelta = this.#createDeltaForTool(delta, ["properties", "tools", "properties", this.#toolId], toolChanges);
+ this.#handler.sendUpdate(toolDelta);
+ }
+ }
+
+ /**
+ *
+ */
+ #updateMatrix() {
+ const toolMat = new THREE.Matrix4();
+ const toolNode = realityEditor.sceneGraph.getSceneNodeById(this.#toolId);
+ setMatrixFromArray(toolMat, toolNode.worldMatrix);
+
+ if (!toolMat.equals(this.#lastToolMatrix)) {
+ this.#lastToolMatrix.copy(toolMat);
+
+ const axisCorrectionMat = new THREE.Matrix4().set(1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1);
+ const localToolMatrix = toolMat.multiply(axisCorrectionMat);
+
+ const decomposedMatrix = {
+ position: new THREE.Vector3(),
+ rotation: new THREE.Quaternion(),
+ scale: new THREE.Vector3()
+ }
+ localToolMatrix.decompose(decomposedMatrix.position, decomposedMatrix.rotation, decomposedMatrix.scale);
+ const transformComponent = this.#rootEntity.getComponentByType(TransformComponentNode.TYPE);
+ transformComponent.position = decomposedMatrix.position;
+ transformComponent.rotation = decomposedMatrix.rotation;
+ transformComponent.scale = decomposedMatrix.scale;
+ }
+ }
+
+ /**
+ *
+ */
+ updateComponents() {
+ this.#updateMatrix();
+ this.#rootEntity.updateComponents();
+ }
+
+ /**
+ *
+ */
+ onDelete() {
+ this.#handler.onDelete();
+ this.#rootEntity.dispose();
+ }
+}
+
+class ToolManager {
+ /** @type {import('./Renderer.js').Renderer} */
+ #renderer;
+
+ /** @type {WorldNode} */
+ #worldNode;
+
+ /** @type {AnchoredGroupNode} */
+ #anchoredGroupNode;
+
+ /** @type {ToolsRootNode} */
+ #toolsRootNode;
+
+ /** @type {{[key: string]: ToolProxy} */
+ #toolProxies;
+
+ /**
+ *
+ * @param {import('./Renderer.js').Renderer} renderer
+ */
+ constructor(renderer) {
+ this.#renderer = renderer;
+ realityEditor.device.registerCallback('vehicleDeleted', (params) => this.onVehicleDeleted(params));
+ realityEditor.network.registerCallback('vehicleDeleted', (params) => this.onVehicleDeleted(params));
+ this.#worldNode = new Engine3DWorldNode(this.#renderer);
+ this.#anchoredGroupNode = this.#worldNode.get("threejsContainer");
+ this.#toolsRootNode = this.#worldNode.get("tools");
+ this.#toolProxies = {};
+ }
+
+ /**
+ *
+ * @param {string} toolId
+ */
+ remove(toolId) {
+ if (this.#toolProxies.hasOwnProperty(toolId)) {
+ this.#toolProxies[toolId].onDelete();
+ delete this.#toolProxies[toolId];
+ }
+ }
+
+ /**
+ * @param {string} type
+ * @param {string} toolId
+ */
+ add(toolId, type) {
+ if (this.#toolProxies.hasOwnProperty(toolId)) {
+ console.warn(`toolId already exist`);
+ return;
+ }
+ const worker = globalDOMCache['iframe' + toolId];
+ const toolRoot = this.#toolsRootNode.toolsRoot.create(toolId);
+ const toolProxy = new ToolProxy(this, toolId, worker, toolRoot);
+ this.#toolsRootNode.set(toolId, new ThreejsToolNode(toolProxy, `${ToolNode.TYPE}.${type}`));
+ this.#toolProxies[toolId] = toolProxy;
+ }
+
+ /**
+ *
+ * @param {*} params
+ */
+ onVehicleDeleted(params) {
+ if (params.objectKey && params.frameKey && !params.nodeKey) { // only react to frames, not nodes
+ this.remove(params.frameKey);
+ }
+ }
+
+ /**
+ *
+ * @param {AnchoredGroup} anchoredGroup
+ */
+ setAnchoredGroup(anchoredGroup) {
+ this.#anchoredGroupNode.anchoredGroup = anchoredGroup;
+ }
+
+ /**
+ *
+ * @param {string} toolId
+ * @returns {WorldNodeState}
+ */
+ getStateForTool(toolId) {
+ return this.#worldNode.getStateForTool(toolId);
+ }
+
+ /**
+ *
+ * @param {WorldNodeDelta} delta
+ */
+ setChanges(delta) {
+ this.#worldNode.setChanges(delta);
+ }
+
+ update() {
+ this.updateComponents();
+ this.sendUpdate();
+ }
+
+ updateComponents() {
+ for(let toolProxy of Object.values(this.#toolProxies)) {
+ toolProxy.updateComponents();
+ }
+ }
+
+ sendUpdate() {
+ let delta = this.#worldNode.getChanges();
+ if (Object.keys(delta).length > 0) {
+ for(let toolProxy of Object.values(this.#toolProxies)) {
+ toolProxy.sendUpdate(delta);
+ }
+ }
+ }
+}
+
+export {ToolManager, ToolProxy}
diff --git a/src/gui/scene/ToolsRoot.js b/src/gui/scene/ToolsRoot.js
new file mode 100644
index 000000000..e96c694e9
--- /dev/null
+++ b/src/gui/scene/ToolsRoot.js
@@ -0,0 +1,37 @@
+import * as THREE from "../../../thirdPartyCode/three/three.module.js"
+
+class ToolsRoot {
+ /** @type {THREE.Group} */
+ #root;
+
+ constructor() {
+ this.#root = new THREE.Group();
+ this.#root.name = "tools";
+ }
+
+ /**
+ *
+ * @param {string} toolId
+ * @returns {THREE.Group}
+ */
+ create(toolId) {
+ const tool = new THREE.Group();
+ tool.name = toolId;
+ this.#root.add(tool);
+ return tool;
+ }
+
+ onDelete() {
+ this.#root.removeFromParent();
+ }
+
+ /**
+ *
+ * @returns {THREE.Group}
+ */
+ getInternalObject() {
+ return this.#root;
+ }
+}
+
+export default ToolsRoot;
diff --git a/src/gui/scene/WebXRVRButton.js b/src/gui/scene/WebXRVRButton.js
new file mode 100644
index 000000000..58a87b351
--- /dev/null
+++ b/src/gui/scene/WebXRVRButton.js
@@ -0,0 +1,145 @@
+class WebXRVRButton {
+
+ static createButton( renderer ) {
+
+ const button = new realityEditor.gui.MenuItem("", {toggle: false}, null);
+
+ function showEnterVR( /*device*/ ) {
+
+ let currentSession = null;
+
+ async function onSessionStarted( session ) {
+
+ session.addEventListener( 'end', onSessionEnded );
+
+ await renderer.xr.setSession( session );
+ button.setText('Exit VR');
+
+ currentSession = session;
+
+ }
+
+ function onSessionEnded( /*event*/ ) {
+
+ currentSession.removeEventListener( 'end', onSessionEnded );
+
+ button.setText('Enter VR');
+
+ currentSession = null;
+
+ }
+
+ //
+
+ button.setText('Enter VR');
+
+ button.addCallback(function () {
+
+ if ( currentSession === null ) {
+
+ // WebXR's requestReferenceSpace only works if the corresponding feature
+ // was requested at session creation time. For simplicity, just ask for
+ // the interesting ones as optional features, but be aware that the
+ // requestReferenceSpace call will fail if it turns out to be unavailable.
+ // ('local' is always available for immersive sessions and doesn't need to
+ // be requested separately.)
+
+ const sessionInit = { optionalFeatures: [ 'local-floor', 'bounded-floor', 'hand-tracking', 'layers' ] };
+ navigator.xr.requestSession( 'immersive-vr', sessionInit ).then( onSessionStarted );
+
+ } else {
+
+ currentSession.end();
+
+ }
+
+ });
+
+ }
+
+ function disableButton() {
+
+ button.disable();
+
+ }
+
+ function showWebXRNotFound() {
+
+ disableButton();
+
+ button.setText('VR not supported');
+
+ }
+
+ function showVRNotAllowed( exception ) {
+
+ disableButton();
+
+ console.warn( 'Exception when trying to call xr.isSessionSupported', exception );
+
+ button.setText('VR not allowed');
+
+ }
+
+ if ( 'xr' in navigator ) {
+
+ navigator.xr.isSessionSupported( 'immersive-vr' ).then( function ( supported ) {
+
+ supported ? showEnterVR() : showWebXRNotFound();
+
+ if ( supported && WebXRVRButton.xrSessionIsGranted ) {
+
+ button.triggerItem();
+
+ }
+
+ } ).catch( showVRNotAllowed );
+
+ return button;
+
+ } else {
+
+ disableButton();
+
+ if ( window.isSecureContext === false ) {
+
+ button.setText('WebXR needs https'); // TODO Improve message
+
+
+ } else {
+
+ button.setText('WebXR not available');
+
+ }
+
+ return button;
+
+ }
+
+ }
+
+ static xrSessionIsGranted = false;
+
+ static registerSessionGrantedListener() {
+
+ if ( 'xr' in navigator ) {
+
+ // WebXRViewer (based on Firefox) has a bug where addEventListener
+ // throws a silent exception and aborts execution entirely.
+ if ( /WebXRViewer\//i.test( navigator.userAgent ) ) return;
+
+ navigator.xr.addEventListener( 'sessiongranted', () => {
+
+ WebXRVRButton.xrSessionIsGranted = true;
+
+ } );
+
+ }
+
+ }
+
+}
+
+WebXRVRButton.registerSessionGrantedListener();
+
+export { WebXRVRButton };
diff --git a/src/gui/scene/engine3D/Engine3DAnchoredGroupNode.js b/src/gui/scene/engine3D/Engine3DAnchoredGroupNode.js
new file mode 100644
index 000000000..7fa31e046
--- /dev/null
+++ b/src/gui/scene/engine3D/Engine3DAnchoredGroupNode.js
@@ -0,0 +1,33 @@
+import ObjectNode from "../../../../objectDefaultFiles/scene/ObjectNode.js"
+import AnchoredGroupNode from "../../../../objectDefaultFiles/scene/AnchoredGroupNode.js";
+
+/**
+ * @typedef {import("../AnchoredGroup.js").default} AnchoredGroup
+ * @typedef {import("/objectDefaultFiles/scene/AnchoredGroupNode.js").default} AnchoredGroupNode
+ */
+
+class Engine3DAnchoredGroupNode extends ObjectNode {
+ /** @type {AnchoredGroup|null} */
+ #anchoredGroup;
+
+ constructor() {
+ super(AnchoredGroupNode.TYPE);
+ this.#anchoredGroup = null;
+ }
+
+ /**
+ * @returns {AnchoredGroup}
+ */
+ get anchoredGroup() {
+ return this.#anchoredGroup;
+ }
+
+ /**
+ * @param {Anchoredgroup} anchoredGroup
+ */
+ set anchoredGroup(anchoredGroup) {
+ this.#anchoredGroup = anchoredGroup;
+ }
+}
+
+export default Engine3DAnchoredGroupNode;
diff --git a/src/gui/scene/engine3D/Engine3DToolsRootNode.js b/src/gui/scene/engine3D/Engine3DToolsRootNode.js
new file mode 100644
index 000000000..46847cb64
--- /dev/null
+++ b/src/gui/scene/engine3D/Engine3DToolsRootNode.js
@@ -0,0 +1,61 @@
+import DictionaryNode from "../../../../objectDefaultFiles/scene/DictionaryNode.js";
+import ToolsRootNode from "../../../../objectDefaultFiles/scene/ToolsRootNode.js";
+
+/**
+ * @typedef {import("../ToolsRoot.js").ToolsRoot} ToolsRoot
+ */
+
+class Engine3DToolsRootStore extends DictionaryNode {
+ /** @type {ToolsRoot} */
+ #toolsRoot;
+
+ /**
+ *
+ * @param {ToolsRoot} toolsRoot
+ */
+ constructor(toolsRoot) {
+ super(ToolsRootNode.TYPE);
+ this.#toolsRoot = toolsRoot;
+ }
+
+ getStateForTool(toolId) {
+ const ret = super.getState();
+ ret.properties = {};
+ ret.properties[toolId] = this.get(toolId).getState();
+ return ret;
+ }
+
+ /**
+ * @override
+ * @param {string} _key
+ * @param {BaseNodeState} _state
+ * @returns {BaseNode}
+ */
+ create(_key, _state) {
+ return undefined;
+ }
+
+ /**
+ * @override
+ * @param {string} _key
+ * @param {BaseNode} _oldNode
+ * @param {BaseNodeState} _state
+ */
+ cast(_key, _oldNode, _state) {
+ throw Error("ToolsRoot only accepts tools, can't cast");
+ }
+
+ canDelete(_key, oldNode) {
+ oldNode.dispose();
+ return true;
+ }
+
+ /**
+ * @returns {ToolsRoot}
+ */
+ get toolsRoot() {
+ return this.#toolsRoot;
+ }
+}
+
+export default Engine3DToolsRootStore;
diff --git a/src/gui/scene/engine3D/Engine3DWorldNode.js b/src/gui/scene/engine3D/Engine3DWorldNode.js
new file mode 100644
index 000000000..664a3d4f5
--- /dev/null
+++ b/src/gui/scene/engine3D/Engine3DWorldNode.js
@@ -0,0 +1,91 @@
+import ObjectNode from "../../../../objectDefaultFiles/scene/ObjectNode.js"
+import Engine3DAnchoredGroupNode from "./Engine3DAnchoredGroupNode.js"
+import Engine3DToolsRootNode from "./Engine3DToolsRootNode.js"
+import ToolsRoot from "../ToolsRoot.js";
+import WorldNode from "../../../../objectDefaultFiles/scene/WorldNode.js";
+
+/**
+ * @typedef {import("./../../../objectDefaultFiles/scene/ObjectNode.js").ObjectInterface} ObjectInterface
+ * @typedef {import("../Renderer.js").Renderer} Renderer
+ * @typedef {import("../Renderer.js").Timer} Timer
+ * @typedef {import("../Renderer.js").GeometryCache} GeometryCache
+ * @typedef {import("../Renderer.js").MaterialCache} MaterialCache
+ * @typedef {import("../Renderer.js").TextureCache} TextureCache
+ */
+
+class Engine3DWorldNode extends ObjectNode {
+ /** @type {Renderer} */
+ #renderer;
+
+ /** @type {ToolsRoot} */
+ #toolsRoot;
+
+ /**
+ *
+ * @param {Renderer} renderer
+ */
+ constructor(renderer) {
+ super(WorldNode.TYPE);
+ this.#renderer = renderer;
+ this.#toolsRoot = new ToolsRoot();
+ this.#renderer.getGlobalScale().getNode().add(this.#toolsRoot.getInternalObject());
+ this._set("threejsContainer", new Engine3DAnchoredGroupNode());
+ this._set("tools", new Engine3DToolsRootNode(this.#toolsRoot));
+ }
+
+ /**
+ *
+ * @returns {Timer}
+ */
+ get timer() {
+ return this.#renderer.getTimer();
+ }
+
+ /**
+ *
+ * @returns {Renderer}
+ */
+ get renderer() {
+ return this.#renderer;
+ }
+
+ /**
+ *
+ * @returns {GeometryCache}
+ */
+ get geometryCache() {
+ return this.#renderer.getGeometryCache();
+ }
+
+ /**
+ *
+ * @returns {MaterialCache}
+ */
+ get materialCache() {
+ return this.#renderer.getMaterialCache();
+ }
+
+ /**
+ *
+ * @returns {TextureCache}
+ */
+ get textureCache() {
+ return this.#renderer.getTextureCache();
+ }
+
+ /**
+ *
+ * @param {string} toolId
+ * @returns {WorldNodeState}
+ */
+ getStateForTool(toolId) {
+ const ret = super.getState();
+ ret.properties = {};
+ ret.properties["threejsContainer"] = this.get("threejsContainer").getState();
+ ret.properties["tools"] = this.get("tools").getStateForTool(toolId);
+ ret.toolsRoot = ["tools"];
+ return ret;
+ }
+}
+
+export default Engine3DWorldNode;
diff --git a/src/gui/scene/utils.js b/src/gui/scene/utils.js
new file mode 100644
index 000000000..014b5406c
--- /dev/null
+++ b/src/gui/scene/utils.js
@@ -0,0 +1,46 @@
+import * as THREE from '../../../thirdPartyCode/three/three.module.js';
+
+/**
+ * @typedef {import("/objectDefaultFiles/scene/BaseNode.js").default} BaseNode
+ * @typedef {number[]} MatrixAsArray - a 4x4 matrix representated as an column-mayor array of 16 numbers
+ */
+
+/**
+ * small helper function for setting three.js matrices from the custom format we use
+ * @param {THREE.Matrix4} matrix
+ * @param {MatrixAsArray} array
+ */
+function setMatrixFromArray(matrix, array) {
+ matrix.set( array[0], array[4], array[8], array[12],
+ array[1], array[5], array[9], array[13],
+ array[2], array[6], array[10], array[14],
+ array[3], array[7], array[11], array[15]
+ );
+}
+
+function decomposeMatrix(matrix) {
+ const threeMatrix = new THREE.Matrix4();
+ setMatrixFromArray(threeMatrix, matrix);
+ const ret = {
+ position: new THREE.Vector3(),
+ rotation: new THREE.Quaternion(),
+ scale: new THREE.Vector3()
+ }
+ threeMatrix.decompose(ret.position, ret.rotation, ret.scale);
+ return ret;
+}
+
+/**
+ *
+ * @param {BaseNode} node
+ * @returns {BaseNode}
+ */
+function getRoot(node) {
+ if (node.parent) {
+ return getRoot(node.parent);
+ } else {
+ return node;
+ }
+}
+
+export { setMatrixFromArray, decomposeMatrix, getRoot }
diff --git a/src/gui/screenExtension.js b/src/gui/screenExtension.js
new file mode 100644
index 000000000..c72419cee
--- /dev/null
+++ b/src/gui/screenExtension.js
@@ -0,0 +1,642 @@
+createNameSpace("realityEditor.gui.screenExtension");
+
+// all screenObjects ever detected in the system
+// maps frameKey -> (object, frame, node)
+realityEditor.gui.screenExtension.registeredScreenObjects = {};
+
+// the screenObjects currently visible (that should be notified of touch events)
+// maps objectKey -> bool
+realityEditor.gui.screenExtension.visibleScreenObjects = {};
+
+realityEditor.gui.screenExtension.screenObject = {
+ touchState : null,
+ closestObject : null,
+ x : 0,
+ y : 0,
+ scale : 1,
+ object : null,
+ frame : null,
+ node : null,
+ isScreenVisible: false,
+ touchOffsetX: 0,
+ touchOffsetY: 0,
+ touches: null,
+ lastEditor: globalStates.tempUuid
+};
+
+// distance to screen when first tap down
+realityEditor.gui.screenExtension.initialDistance = null;
+
+/**
+ * @type {CallbackHandler}
+ */
+realityEditor.gui.screenExtension.callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('gui/screenExtension');
+
+/**
+ * Adds a callback function that will be invoked when the specified function is called
+ * @param {string} functionName
+ * @param {function} callback
+ */
+realityEditor.gui.screenExtension.registerCallback = function(functionName, callback) {
+ if (!this.callbackHandler) {
+ this.callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('gui/screenExtension');
+ }
+ this.callbackHandler.registerCallback(functionName, callback);
+};
+
+// If this is false, it will automatically keep registeredScreenObjects and visibleScreenObjects up-to-date
+// by searching for all objects marked as 'screen'.
+// If this is true, relies on a tool on that object to use the activateScreenObject API to register the object.
+// This is set to false because the tool with activateScreenObject is needed anyways for screen message passing
+// but I can imagine a future where that tool isn't needed and we can remove this flag
+realityEditor.gui.screenExtension.disableAutoRegistration = true;
+
+realityEditor.gui.screenExtension.initService = function() {
+ if (this.disableAutoRegistration) {
+ console.warn('SCREEN EXTENSION initService is currently internally-disabled');
+ return;
+ }
+
+ // register screen objects when they are loaded
+ realityEditor.network.addObjectDiscoveredCallback(function(object, objectKey) {
+ // check if this object is visualization===screen and if so, add to registeredScreenObjects
+ if (object.visualization !== 'screen') { return; }
+
+ realityEditor.gui.screenExtension.registeredScreenObjects[objectKey] = {
+ object: objectKey,
+ frame: null,
+ node: null
+ };
+ });
+
+ // register screen objects when their visualization property is updated
+ // TODO: this would require a network listener to detect changes
+
+ // use registeredScreenObjects and visibleObjects to get visibleScreenObjects
+ realityEditor.gui.ar.draw.addUpdateListener(function(visibleObjects) {
+ // return if no registered screenObjects
+ let screenExtension = realityEditor.gui.screenExtension;
+ let registeredObjectKeys = Object.keys(screenExtension.registeredScreenObjects);
+ if (registeredObjectKeys.length === 0) { return; }
+
+ // remove visibleScreenObjects that aren't in visibleObjects anymore
+ let visibleRegisteredObjectKeys = Object.keys(screenExtension.visibleScreenObjects);
+ visibleRegisteredObjectKeys.forEach(function(objectKey) {
+ if (!visibleObjects.hasOwnProperty(objectKey)) {
+ delete screenExtension.visibleScreenObjects[objectKey];
+ }
+ });
+
+ // add objects that are registered and visible but not in visibleScreenObjects yet
+ registeredObjectKeys.forEach(function(objectKey) {
+ if (visibleObjects.hasOwnProperty(objectKey) && !screenExtension.visibleScreenObjects.hasOwnProperty(objectKey)) {
+ screenExtension.visibleScreenObjects[objectKey] = {
+ object: objectKey,
+ frame: null,
+ node: null,
+ x: 0,
+ y: 0,
+ touches: null
+ };
+ }
+ });
+ });
+};
+
+realityEditor.gui.screenExtension.shouldSendTouchesToScreen = function(eventObject) {
+
+ // don't send touches
+ if (globalStates.guiState !== 'ui') {
+ return false;
+ }
+
+ // don't send multi-touch if already editing a frame in AR
+ if (this.getValidTouches(eventObject).length > 1 && realityEditor.device.editingState.frame) {
+ return false;
+ }
+
+ // don't send touch to screen if the pocket is open
+ if (realityEditor.gui.pocket.pocketShown()) {
+ return false;
+ }
+
+ return true;
+};
+
+realityEditor.gui.screenExtension.touchStart = function (eventObject){
+
+ if (!this.shouldSendTouchesToScreen(eventObject)) return;
+
+ // additionally, don't send touch start to screen if tapping a menu button
+ var frontTouchedElement = document.elementFromPoint(eventObject.x, eventObject.y);
+ var didTouchMenuButton = frontTouchedElement && frontTouchedElement.id && frontTouchedElement.id.indexOf('ButtonDiv') > -1;
+ if (didTouchMenuButton) return;
+
+ // this.updateScreenObject(eventObject);
+ this.onScreenTouchDown(eventObject);
+
+ var didTouchARFrame = (!!this.screenObject.object && !!this.screenObject.frame);
+
+ if(this.areAnyScreensVisible() && !didTouchARFrame) {
+ realityEditor.gui.screenExtension.sendScreenObject();
+ }
+};
+
+realityEditor.gui.screenExtension.touchMove = function (eventObject){
+
+ if (!this.shouldSendTouchesToScreen(eventObject)) return;
+
+ // this will retroactively set the screen object to a new frame when it gets added by dragging in from the pocket
+ if (eventObject.object && eventObject.frame && !this.screenObject.object && !this.screenObject.frame){
+ this.onScreenTouchDown(eventObject);
+ }
+
+ this.onScreenTouchMove(eventObject);
+
+ // make sure we aren't manipulating a screenObject frame with AR visualization mode
+ var thisVisualization = "";
+ if (this.screenObject.object && this.screenObject.frame) {
+ var activeFrame = realityEditor.getFrame(this.screenObject.object, this.screenObject.frame);
+ if (activeFrame) {
+ thisVisualization = activeFrame.visualization;
+ }
+ }
+
+ if (this.areAnyScreensVisible() && thisVisualization !== "ar") {
+ realityEditor.gui.screenExtension.sendScreenObject();
+ }
+};
+
+realityEditor.gui.screenExtension.touchEnd = function (eventObject){
+
+ if (!this.shouldSendTouchesToScreen(eventObject)) return;
+
+ this.onScreenTouchUp(eventObject);
+
+ if (this.areAnyScreensVisible()) {
+ realityEditor.gui.screenExtension.sendScreenObject();
+ }
+
+ this.screenObject.x = 0;
+ this.screenObject.y = 0;
+ this.screenObject.scale = 1;
+ // this.screenObject.object = null;
+ // this.screenObject.frame = null;
+ // this.screenObject.node = null;
+ this.screenObject.closestObject = null;
+ this.screenObject.touchState = null;
+
+ globalStates.initialDistance = null;
+
+ //console.log("end", this.screenObject);
+};
+
+/**
+ * Filters a list of TouchEvents to only include those with populated coordinate fields (sometimes empty objects get stuck there)
+ * @param {ScreenEventObject} eventObject
+ * @return {Array.<{screenX: number, screenY: number, type: string}>}
+ */
+realityEditor.gui.screenExtension.getValidTouches = function(eventObject) {
+ return eventObject.touches.filter(function(touch) {
+ return touch && (typeof touch.screenX === "number" && typeof touch.screenY === "number");
+ });
+};
+
+realityEditor.gui.screenExtension.onScreenTouchDown = function(eventObject) {
+ // figure out if I'm touching on AR frame, screen frame, or nothing
+ // console.log('onScreenTouchDown', eventObject, this.screenObject);
+
+ this.screenObject.closestObject = realityEditor.gui.ar.getClosestObject()[0];
+ this.screenObject.touchState = eventObject.type;
+
+ if (this.getValidTouches(eventObject).length < 2) { // don't reset in between scaling gestures
+ this.screenObject.object = eventObject.object;
+ this.screenObject.frame = eventObject.frame;
+ this.screenObject.node = eventObject.node;
+ }
+
+ var didTouchARFrame = (!!eventObject.object && !!eventObject.frame);
+
+ this.screenObject.isScreenVisible = !didTouchARFrame;
+
+ if (this.screenObject.closestObject && !didTouchARFrame) {
+
+ // for every visible screen, calculate this touch's exact x,y coordinate within that screen plane
+ for (var frameKey in this.visibleScreenObjects) {
+ if (!this.visibleScreenObjects.hasOwnProperty(frameKey)) continue;
+ var visibleScreenObject = this.visibleScreenObjects[frameKey];
+ var point = realityEditor.gui.ar.utilities.screenCoordinatesToTargetXY(visibleScreenObject.object, eventObject.x, eventObject.y);
+ visibleScreenObject.x = point.x;
+ visibleScreenObject.y = point.y;
+ }
+
+ }
+
+ // console.log(this.screenObject);
+};
+
+/**
+ *
+ * @param {ScreenEventObject} eventObject
+ */
+realityEditor.gui.screenExtension.onScreenTouchMove = function(eventObject) {
+ // do nothing other than send xy to screen // maybe iff I'm touching on screen frame, move AR frame to mirror its position
+ // console.log('onScreenTouchMove', eventObject, this.screenObject);
+
+ this.screenObject.closestObject = realityEditor.gui.ar.getClosestObject()[0];
+ this.screenObject.touchState = eventObject.type;
+
+ if (!this.screenObject.closestObject) {
+ return;
+ }
+
+ // for every visible screen, calculate this touch's exact x,y coordinate within that screen plane
+ for (var frameKey in this.visibleScreenObjects) {
+ if (!this.visibleScreenObjects.hasOwnProperty(frameKey)) continue;
+ var visibleScreenObject = this.visibleScreenObjects[frameKey];
+ var point = realityEditor.gui.ar.utilities.screenCoordinatesToTargetXY(visibleScreenObject.object, eventObject.x, eventObject.y);
+ visibleScreenObject.x = point.x;
+ visibleScreenObject.y = point.y;
+
+ // console.log('touched (x,y) = (' + visibleScreenObject.x + ', ' + visibleScreenObject.y + ')')
+
+ // var targetWidth = targetSize.width;
+ // var screenX = point.x + targetWidth/2;
+ //
+ // console.log('x -> ' + screenX);
+
+ // TODO: also do this separately for each visible screen object
+ if (this.getValidTouches(eventObject).length > 1) {
+ visibleScreenObject.touches = [];
+ visibleScreenObject.touches[0] = {
+ x: point.x,
+ y: point.y,
+ type: eventObject.type
+ };
+
+ var secondPoint = realityEditor.gui.ar.utilities.screenCoordinatesToTargetXY(visibleScreenObject.object, eventObject.touches[1].screenX, eventObject.touches[1].screenY);
+ visibleScreenObject.touches[1] = {
+ x: secondPoint.x,
+ y: secondPoint.y,
+ type: eventObject.touches[1].type
+ };
+ } else {
+ visibleScreenObject.touches = null;
+ }
+
+ // also needs to update AR frame positions so that AR nodes match their screen frames' positions
+ if (this.screenObject.object && this.screenObject.frame && this.screenObject.object === visibleScreenObject.object) {
+ var matchingARFrame = realityEditor.getFrame(this.screenObject.object, this.screenObject.frame);
+ if (matchingARFrame && matchingARFrame.visualization === 'screen') {
+
+ // console.log('moved matching ar frame from (' + matchingARFrame.ar.x + ', ' + matchingARFrame.ar.y + ') ...');
+
+ // keep the invisible AR frames synchronized with the position of their screen frames (so that nodes are in same place and pulls out in the right place)
+ matchingARFrame.ar.x = point.x;
+ matchingARFrame.ar.y = -1 * point.y; // y-axis is inverted between screen and AR
+
+ // console.log('...to (' + matchingARFrame.ar.x + ', ' + matchingARFrame.ar.y + ')');
+ }
+ }
+ }
+
+ // console.log(this.screenObject);
+};
+
+realityEditor.gui.screenExtension.onScreenTouchUp = function(eventObject) {
+ // reset screen object to null and update screen state to match
+ // console.log('onScreenTouchUp', eventObject, this.screenObject);
+
+ this.screenObject.closestObject = realityEditor.gui.ar.getClosestObject()[0];
+ this.screenObject.touchState = eventObject.type;
+
+ if (this.getValidTouches(eventObject).length < 2) { // don't reset in between scaling gestures
+ this.screenObject.object = null;
+ this.screenObject.frame = null;
+ this.screenObject.node = null;
+ }
+
+ // console.log(this.screenObject);
+};
+
+realityEditor.gui.screenExtension.update = function (){
+
+ if (globalStates.guiState !== 'ui') return;
+ if (!this.areAnyScreensVisible()) return;
+
+ // console.log("end", this.screenObject);
+ if(this.screenObject.touchState) {
+ realityEditor.gui.screenExtension.calculatePushPop();
+ }
+
+};
+
+realityEditor.gui.screenExtension.receiveObject = function (object){
+
+ // console.log('receiveObject', object);
+
+ this.screenObject.object = object.object;
+ this.screenObject.frame = object.frame;
+ this.screenObject.node = object.node;
+ this.screenObject.touchOffsetX = object.touchOffsetX;
+ this.screenObject.touchOffsetY = object.touchOffsetY;
+
+ if (this.screenObject.object && this.screenObject.frame) {
+ overlayDiv.classList.add('overlayScreenFrame');
+ overlayDiv.style.backgroundImage = 'none';
+ overlayDiv.classList.remove('overlayMemory');
+ } else {
+ overlayDiv.classList.remove('overlayScreenFrame');
+ }
+
+};
+
+realityEditor.gui.screenExtension.onScreenPushIn = function(screenFrame) {
+ // set screen object visible, wait to hear that the screen received it, then hide AR frame
+
+ var isScreenVisible = true;
+
+ if (isScreenVisible !== this.screenObject.isScreenVisible) {
+
+ console.log('onScreenPushIn');
+
+ this.screenObject.isScreenVisible = true;
+ this.screenObject.scale = realityEditor.gui.ar.positioning.getPositionData(screenFrame).scale;
+ // realityEditor.gui.ar.draw.changeVisualization(screenFrame, newVisualization); // TODO: combine this with updateArFrameVisibility
+ realityEditor.app.tap();
+ realityEditor.gui.screenExtension.updateArFrameVisibility();
+ }
+
+};
+
+realityEditor.gui.screenExtension.onScreenPullOut = function() {
+ // set screen object hidden, wait to hear that the screen received it, then move AR frame to position and show AR frame
+
+ var isScreenVisible = false;
+
+ if (isScreenVisible !== this.screenObject.isScreenVisible) {
+
+ console.log('onScreenPullOut');
+
+ this.screenObject.isScreenVisible = false;
+ // realityEditor.gui.ar.draw.changeVisualization(screenFrame, newVisualization); // TODO: combine this with updateArFrameVisibility
+ realityEditor.app.tap();
+ realityEditor.gui.screenExtension.updateArFrameVisibility();
+ }
+
+};
+
+realityEditor.gui.screenExtension.calculatePushPop = function() {
+ if (globalStates.freezeButtonState) return; // don't allow pushing and pulling if the background is frozen
+
+ var screenFrame = realityEditor.getFrame(this.screenObject.object, this.screenObject.frame);
+ if (!screenFrame) return;
+
+ // don't do it if we're moving the tool directly dropped from the pocket
+ if (pocketFrame.vehicle && pocketFrame.vehicle.uuid === this.screenObject.frame) {
+ return;
+ }
+
+ // don't do it if we're transitioning the tool from another object
+ if (globalStates.inTransitionFrame === this.screenObject.frame) {
+ return;
+ }
+
+ var isScreenObjectVisible = !!realityEditor.gui.ar.draw.visibleObjects[this.screenObject.object]; // can only push in frames to visible objects
+ if (screenFrame && isScreenObjectVisible && !pocketDropAnimation) { // can only push in frames not being animated forwards when dropping from pocket
+
+ if (screenFrame.location === 'global') { // only able to push global frames into the screen
+
+ let toolNode = realityEditor.sceneGraph.getSceneNodeById(this.screenObject.frame);
+ let objectNode = realityEditor.sceneGraph.getSceneNodeById(this.screenObject.object);
+
+ // get position of tool relative to object, because this is used to push things into the screen
+ let relativeMatrix = (toolNode && objectNode) ? toolNode.getMatrixRelativeTo(objectNode) : realityEditor.gui.ar.utilities.newIdentityMatrix();
+ let zDistance = relativeMatrix[14];
+
+ // calculate distance from camera to object, because this is used to pull things out of screen
+ var distanceToObject = realityEditor.sceneGraph.getDistanceToCamera(this.screenObject.object);
+
+ if (!globalStates.initialDistance) {
+ globalStates.initialDistance = distanceToObject;
+ }
+
+ var distanceThreshold = globalStates.framePullThreshold;
+
+ // only push in frame if it is within the width and height of the image target (screen size)
+ var touchPosition = realityEditor.gui.ar.positioning.getMostRecentTouchPosition();
+ var projectedPoint = realityEditor.gui.ar.utilities.screenCoordinatesToTargetXY(this.screenObject.object, touchPosition.x, touchPosition.y);
+ let targetSize = realityEditor.gui.utilities.getTargetSize(this.screenObject.object);
+ var isWithinWidth = Math.abs(projectedPoint.x) < (targetSize.width * 1000)/2;
+ var isWithinHeight = Math.abs(projectedPoint.y) < (targetSize.height * 1000)/2;
+
+ var didPushIn = false;
+
+ // pushing in happens when z distance between tool and screen is negative
+ if (isWithinWidth && isWithinHeight && zDistance < 0) {
+ didPushIn = true;
+ }
+
+ // pulling out happens when current distance to screen exceeds initial distance by a certain threshold
+ var didPullOut = (distanceToObject > (globalStates.initialDistance + distanceThreshold) ||
+ !isWithinWidth || !isWithinHeight);
+
+ if (didPullOut) {
+ this.onScreenPullOut(screenFrame);
+ } else if (didPushIn) {
+ this.onScreenPushIn(screenFrame);
+ }
+ }
+ }
+};
+
+realityEditor.gui.screenExtension.sendScreenObject = function (){
+
+ for (var frameKey in this.visibleScreenObjects) {
+ if (!this.visibleScreenObjects.hasOwnProperty(frameKey)) continue;
+ var visibleScreenObject = this.visibleScreenObjects[frameKey];
+ var screenObjectClone = JSON.parse(JSON.stringify(this.screenObject));
+ screenObjectClone.x = visibleScreenObject.x;
+ screenObjectClone.y = visibleScreenObject.y;
+ screenObjectClone.targetScreen = {
+ object: visibleScreenObject.object,
+ frame: visibleScreenObject.frame
+ };
+ screenObjectClone.touches = visibleScreenObject.touches;
+
+ var iframe = globalDOMCache["iframe" + frameKey];
+ if (iframe) {
+ iframe.contentWindow.postMessage(JSON.stringify({
+ screenObject: screenObjectClone
+ }), '*');
+ }
+ }
+
+};
+
+/**
+ * Map touchOffset x and y from target units to 0-1 range representing the percent x and y within the touched frame
+ * e.g. (0,0) means tapped upper left corner, (0.5, 0.5) is center, (1,1) is lower right corner
+ * @param thisFrame
+ * @return {{x: number, y: number}}
+ */
+realityEditor.gui.screenExtension.getTouchOffsetAsPercent = function(thisFrame) {
+
+ var frameWidth = parseInt(thisFrame.width);
+ var frameHeight = parseInt(thisFrame.height);
+
+ var frameDimensions = {
+ width: frameWidth * thisFrame.ar.scale,
+ height: frameHeight * thisFrame.ar.scale
+ };
+
+ // touchOffset is only (0,0) on the upper left corner of an iframe if that frame has scale=1
+ // otherwise, the upper left corner "scales into" a higher x,y touchOffset coordinate
+ // this calculation corrects for that so that frames of any scale have (0,0) at upper left
+ var touchOffsetCorrectedForScale = {
+ x: realityEditor.device.editingState.touchOffset.x - frameWidth * (1.0 - thisFrame.ar.scale) / 2,
+ y: realityEditor.device.editingState.touchOffset.y - frameHeight * (1.0 - thisFrame.ar.scale) / 2
+ };
+
+ var xPercent = (touchOffsetCorrectedForScale.x / frameDimensions.width);
+ var yPercent = (touchOffsetCorrectedForScale.y / frameDimensions.height);
+
+ return {
+ x: xPercent,
+ y: yPercent
+ };
+};
+
+realityEditor.gui.screenExtension.updateArFrameVisibility = function (){
+ var thisFrame = realityEditor.getFrame(this.screenObject.object, this.screenObject.frame);
+ if(thisFrame) {
+
+ globalStates.initialDistance = null;
+
+ var oldVisualizationPositionData = null;
+
+ if (this.screenObject.isScreenVisible) {
+ console.log('hide frame -> screen');
+ thisFrame.visualization = "screen";
+
+ if (realityEditor.device.editingState.touchOffset) {
+
+ var touchOffsetPercent = this.getTouchOffsetAsPercent(thisFrame);
+ this.screenObject.touchOffsetX = touchOffsetPercent.x;
+ this.screenObject.touchOffsetY = touchOffsetPercent.y;
+
+ }
+
+ realityEditor.gui.ar.draw.hideTransformed(thisFrame.uuid, thisFrame, globalDOMCache, cout);
+
+ thisFrame.ar.x = 0;
+ thisFrame.ar.y = 0;
+ thisFrame.begin = [];
+ thisFrame.ar.matrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+
+ oldVisualizationPositionData = thisFrame.ar;
+
+ realityEditor.device.resetEditingState();
+
+ // update position on server
+ // var urlEndpoint = (realityEditor.network.useHTTPS ? 'https' : 'http') + '://' + objects[this.screenObject.object].ip + ':' + httpPort + '/object/' + this.screenObject.object + "/frame/" + this.screenObject.frame + "/node/" + null + "/size/";
+ // var content = thisFrame.ar;
+ // content.lastEditor = globalStates.tempUuid;
+ // realityEditor.network.postData(urlEndpoint, content);
+
+ } else {
+ console.log('show frame -> AR');
+
+ thisFrame.visualization = "ar";
+
+ // set to false so it definitely gets re-added and re-rendered
+ thisFrame.visible = false;
+
+ var activeKey = thisFrame.uuid;
+
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(activeKey);
+ let startingMatrix = realityEditor.gui.ar.utilities.newIdentityMatrix();
+ startingMatrix[14] = 50; // start out 5 cm in front of screen
+ sceneNode.setLocalMatrix(startingMatrix);
+
+ // resize iframe to override incorrect size it starts with so that it matches the screen frame
+ var iframe = globalDOMCache['iframe' + activeKey];
+ var overlay = globalDOMCache[activeKey];
+ var svg = globalDOMCache['svg' + activeKey];
+
+ iframe.style.width = thisFrame.frameSizeX + 'px';
+ iframe.style.height = thisFrame.frameSizeY + 'px';
+ iframe.style.left = ((globalStates.height - parseFloat(thisFrame.frameSizeX)) / 2) + "px";
+ iframe.style.top = ((globalStates.width - parseFloat(thisFrame.frameSizeY)) / 2) + "px";
+
+ overlay.style.width = iframe.style.width;
+ overlay.style.height = iframe.style.height;
+ overlay.style.left = iframe.style.left;
+ overlay.style.top = iframe.style.top;
+
+ svg.style.width = iframe.style.width;
+ svg.style.height = iframe.style.height;
+ realityEditor.gui.ar.moveabilityOverlay.createSvg(svg);
+
+ // TODO: this still isn't correct all of the time
+ // set the correct position for the frame that was just pulled to AR
+
+ // 1. move it so it is centered on the pointer, ignoring touchOffset
+ var touchPosition = realityEditor.gui.ar.positioning.getMostRecentTouchPosition();
+ // realityEditor.gui.ar.positioning.moveVehicleToScreenCoordinateBasedOnTarget(thisFrame, touchPosition.x, touchPosition.y, false);
+
+ let xPos = touchPosition.x - window.innerWidth/2; // (0, 0) is the middle of the screen
+ let yPos = touchPosition.y - window.innerHeight/2;
+
+ realityEditor.gui.ar.positioning.moveVehicleToScreenCoordinate(thisFrame, xPos, yPos, false);
+
+ // set touch offset to center of tool's container (which is sized to fit the screen)
+ realityEditor.device.editingState.touchOffset = {
+ x: window.innerWidth/2,
+ y: window.innerHeight/2
+ };
+
+ // realityEditor.gui.ar.positioning.moveVehicleToScreenCoordinate(thisFrame, touchPosition.x, touchPosition.y, true);
+
+ // // 2. convert touch offset from percent scale to actual scale of the frame
+ // var convertedTouchOffsetX = (this.screenObject.touchOffsetX) * parseFloat(thisFrame.width);
+ // var convertedTouchOffsetY = (this.screenObject.touchOffsetY) * parseFloat(thisFrame.height);
+ //
+ // // 3. manually apply the touchOffset to the results so that it gets rendered in the correct place on the first pass
+ // thisFrame.ar.x -= (convertedTouchOffsetX - parseFloat(thisFrame.width)/2 ) * thisFrame.ar.scale;
+ // thisFrame.ar.y -= (convertedTouchOffsetY - parseFloat(thisFrame.height)/2 ) * thisFrame.ar.scale;
+
+ // TODO: this causes a bug now with the offset... figure out why it used to be necessary but doesn't help anymore
+ // 4. set the actual touchOffset so that it stays in the correct offset as you drag around
+ // realityEditor.device.editingState.touchOffset = {
+ // x: convertedTouchOffsetX,
+ // y: convertedTouchOffsetY
+ // };
+
+ realityEditor.gui.ar.draw.showARFrame(activeKey);
+
+ realityEditor.device.beginTouchEditing(thisFrame.objectId, activeKey);
+
+ }
+ console.log('updateArFrameVisibility', thisFrame.visualization);
+ // realityEditor.gui.ar.draw.changeVisualization(thisFrame, thisFrame.visualization);
+
+ realityEditor.gui.screenExtension.sendScreenObject();
+
+ realityEditor.network.updateFrameVisualization(objects[thisFrame.objectId].ip, thisFrame.objectId, thisFrame.uuid, thisFrame.visualization, oldVisualizationPositionData);
+
+ this.callbackHandler.triggerCallbacks('updateArFrameVisibility', {objectKey: this.screenObject.object, frameKey: this.screenObject.frame, newVisualization: thisFrame.visualization});
+
+ }
+};
+
+realityEditor.gui.screenExtension.areAnyScreensVisible = function() {
+
+ return Object.keys(this.visibleScreenObjects).length > 0;
+
+};
diff --git a/src/gui/search.js b/src/gui/search.js
new file mode 100644
index 000000000..3a061a816
--- /dev/null
+++ b/src/gui/search.js
@@ -0,0 +1,119 @@
+createNameSpace('realityEditor.gui.search');
+
+let searchElement;
+let searchInput;
+let searchVisible = false;
+
+function getFrameText(frame) {
+ if (frame.src === 'communication') {
+ const storage = Object.values(frame.nodes)[0];
+ const messages = storage.publicData.messages;
+ if (!messages) {
+ return 'communication';
+ }
+ let text = 'communication\n';
+ for (const message of messages) {
+ text += `${message.author}: ${message.messageText}\n`;
+ }
+ return text;
+ } else if (frame.src === 'spatialPatch') {
+ const storage = Object.values(frame.nodes)[0];
+ const serialization = storage.publicData.serialization;
+ if (!serialization) {
+ return 'photo';
+ }
+ return serialization.description || 'photo';
+ } else if (frame.src === 'linkedFile') {
+ const storage = Object.values(frame.nodes)[0];
+ const summary = storage.publicData.summary;
+ return summary || 'linked file';
+ }
+ return frame.src;
+}
+
+export {getFrameText};
+
+function createSearch() {
+ searchElement = document.createElement('div');
+ searchElement.classList.add('search-container');
+ searchElement.classList.add('search-container-hidden');
+
+ searchInput = document.createElement('input');
+ searchInput.type = 'text';
+ searchInput.classList.add('search-input');
+ searchInput.addEventListener('keyup', e => e.stopPropagation());
+ searchInput.addEventListener('keydown', e => {
+ e.stopPropagation();
+ });
+ searchInput.addEventListener('input', () => {
+ updateSearchHighlights();
+ });
+ searchInput.addEventListener('keypress', e => e.stopPropagation());
+
+ searchElement.appendChild(searchInput);
+ document.body.appendChild(searchElement);
+}
+
+let animations = {};
+
+function setFrameHighlight(frame, isHighlighted) {
+ const frameId = frame.uuid;
+ let animation = animations[frameId];
+ if (!isHighlighted) {
+ if (!animation) {
+ return;
+ }
+ animation.hoveredFrameId = null;
+ if (animation.hoverAnimationPercent <= 0) {
+ realityEditor.gui.recentlyUsedBar.removeAnimation(animation);
+ delete animations[frameId];
+ }
+ return;
+ }
+
+ if (!animation) {
+ animation = realityEditor.gui.recentlyUsedBar.createAnimation(frameId, true);
+ animations[frameId] = animation;
+ } else {
+ animation.hoveredFrameId = frameId;
+ }
+}
+
+
+function updateSearchHighlights() {
+ let frames = realityEditor.worldObjects.getBestWorldObject().frames;
+ for (const frameId in frames) {
+ const frame = frames[frameId];
+ let matches = false;
+ let searchText = searchInput.value.toLowerCase();
+ if (searchText.length > 0) {
+ let envText = getFrameText(frame);
+ matches = envText.toLowerCase().includes(searchText);
+ }
+ setFrameHighlight(frame, matches);
+ }
+}
+
+function toggleShowSearch() {
+ if (!searchElement) {
+ createSearch();
+ }
+ searchVisible = !searchVisible;
+ if (searchVisible) {
+ searchElement.classList.remove('search-container-hidden');
+ searchInput.focus();
+ } else {
+ searchElement.classList.add('search-container-hidden');
+ }
+}
+
+export const initService = function initService() {
+ realityEditor.device.keyboardEvents.registerCallback('keyUpHandler', function (params) {
+ if (params.event.key !== '`') {
+ return;
+ }
+ toggleShowSearch();
+ });
+};
+
+realityEditor.gui.search.initService = initService;
diff --git a/src/gui/settings.js b/src/gui/settings.js
new file mode 100644
index 000000000..f2846dd84
--- /dev/null
+++ b/src/gui/settings.js
@@ -0,0 +1,403 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+
+createNameSpace("realityEditor.gui.settings");
+
+/**
+ * List of all SettingsToggles created with addToggle or addToggleWithText.
+ * We can iterate over this to generate the settings menu.
+ * @type {Array.}
+ */
+realityEditor.gui.settings.addedToggles = [];
+
+/**
+ * Gets a key for each SettingsToggle with its propertyName and a boolean value of whether that setting is on or off.
+ * For SettingsToggles of type TOGGLE_WITH_TEXT, also contains a key named propertyName+'Text' for the text setting.
+ * @type {Object.}
+ */
+realityEditor.gui.settings.toggleStates = {};
+
+/**
+ * Enum defining different types of settings UIs
+ * @type {Readonly<{TOGGLE: string, TOGGLE_WITH_TEXT: string, TOGGLE_WITH_FROZEN_TEXT: string}>}
+ */
+realityEditor.gui.settings.InterfaceType = Object.freeze({
+ TOGGLE: 'TOGGLE',
+ TOGGLE_WITH_TEXT: 'TOGGLE_WITH_TEXT',
+ TOGGLE_WITH_FROZEN_TEXT: 'TOGGLE_WITH_FROZEN_TEXT',
+ URL: 'URL',
+ SLIDER: 'SLIDER',
+});
+
+/**
+ * Enum defining the different sub-menus
+ * @type {Readonly<{MAIN: string, DEVELOP: string}>}
+ */
+realityEditor.gui.settings.MenuPages = Object.freeze({
+ MAIN: 'MAIN',
+ DEVELOP: 'DEVELOP'
+});
+
+/**
+ * @typedef {Object} ToggleOptions
+ * @property {boolean|undefined} ignoreOnload
+ * @property {boolean|undefined} dontPersist
+ */
+
+/**
+ * @TODO: rename "toggle" to something more general
+ * @constructor
+ * An object that defines a particular setting in the settings menu that is dynamically added.
+ *
+ * @param {string} title - the text label for the setting in the menu
+ * @param {string} description - a short description string that is rendered next to the title
+ * @param {string} settingType - from the InterfaceType enum - if TOGGLE, has a switch that changes a boolean
+ * if TOGGLE_WITH_TEXT, also has a text box and a string variable
+ * @param {string} propertyName - creates a variable with this name (in realityEditor.gui.settings.toggleStates) to store the boolean
+ * if TOGGLE_WITH_TEXT, also creates one named propertyName+'Text' to store the string
+ * @param {string} iconSrc - the path to an icon to render. path should be relative to src/gui/settings/index.html
+ * @param {boolean} defaultValue - whether it should start toggled on or off the first time (saves persistently after that)
+ * @param {string|undefined} placeholderText - if TOGGLE_WITH_TEXT, placeholder text for the UI text box
+ * @param {function} onToggleCallback - gets triggered when the switch is toggled
+ * @param {function|undefined} onTextCallback - if TOGGLE_WITH_TEXT, gets triggered every time the text box changes
+ * @param {ToggleOptions|undefined} options
+ */
+function SettingsToggle(title, description, settingType, propertyName, iconSrc, defaultValue, placeholderText, onToggleCallback, onTextCallback, options) {
+ this.title = title;
+ this.description = description;
+ this.propertyName = propertyName;
+ let persistentStorageId = 'SETTINGS:' + propertyName;
+ this.iconSrc = iconSrc;
+ this.settingType = settingType;
+ this.placeholderText = placeholderText;
+ this.menuName = realityEditor.gui.settings.MenuPages.MAIN; // defaults to main menu. use moveToDevelopMenu to change.
+
+ const ignoreOnload = options ? options.ignoreOnload : false; // don't trigger the callback once automatically when it loads, only when UI adjusted
+ this.dontPersist = options ? options.dontPersist : false;
+
+ // try loading the value from persistent storage to see what its default value should be
+ let savedValue = this.dontPersist ? defaultValue : window.localStorage.getItem(persistentStorageId);
+ try {
+ savedValue = JSON.parse(savedValue);
+ } catch (e) {
+ savedValue = defaultValue; // if there isn't a saved value, set it to the specified default value
+ }
+ realityEditor.gui.settings.toggleStates[propertyName] = savedValue;
+
+ // update the property value, save it persistently, and then trigger the added callback when the switch is toggled
+ this.onToggleCallback = function(newValue) {
+ realityEditor.gui.settings.toggleStates[propertyName] = newValue;
+ if (!this.dontPersist) {
+ window.localStorage.setItem(persistentStorageId, newValue);
+ }
+ if (onToggleCallback) {
+ if (settingType === realityEditor.gui.settings.InterfaceType.TOGGLE_WITH_FROZEN_TEXT || settingType === realityEditor.gui.settings.InterfaceType.TOGGLE_WITH_TEXT) {
+ onToggleCallback(newValue, realityEditor.gui.settings.toggleStates[propertyName + 'Text']); // trigger additional side effects
+ } else {
+ onToggleCallback(newValue); // trigger additional side effects
+ }
+ }
+ };
+
+ this.onTextCallback = function() {};
+ if (settingType === realityEditor.gui.settings.InterfaceType.TOGGLE_WITH_TEXT ||
+ settingType === realityEditor.gui.settings.InterfaceType.TOGGLE_WITH_FROZEN_TEXT ||
+ settingType === realityEditor.gui.settings.InterfaceType.URL) {
+ // set up the property containing the value in the setting text box
+ let savedValue = window.localStorage.getItem(persistentStorageId + '_TEXT');
+ if (savedValue !== null) {
+ realityEditor.gui.settings.toggleStates[propertyName + 'Text'] = savedValue;
+ } else {
+ realityEditor.gui.settings.toggleStates[propertyName + 'Text'] = '';
+ }
+
+ // anytime a new character is typed into the text box, this will trigger
+ this.onTextCallback = function(newValue) {
+ realityEditor.gui.settings.toggleStates[propertyName + 'Text'] = newValue;
+ if (!this.dontPersist) {
+ window.localStorage.setItem(persistentStorageId + '_TEXT', newValue);
+ }
+ if (onTextCallback) {
+ onTextCallback(newValue);
+ }
+ };
+ if (!ignoreOnload) {
+ this.onTextCallback(realityEditor.gui.settings.toggleStates[propertyName + 'Text']); // trigger once for side effects
+ }
+ }
+
+ // trigger the callback one time automatically on init, so that any side effects for the saved value get triggered
+ if (!ignoreOnload) {
+ this.onToggleCallback(realityEditor.gui.settings.toggleStates[propertyName]);
+ }
+}
+
+/**
+ * Puts the setting in the DEVELOP sub-menu instead of the MAIN sub-menu
+ */
+SettingsToggle.prototype.moveToDevelopMenu = function() {
+ this.menuName = realityEditor.gui.settings.MenuPages.DEVELOP;
+ return this;
+};
+
+/**
+ * Programatically override the existing toggle value
+ * @param {boolean} newValue
+ */
+SettingsToggle.prototype.setValue = function(newValue) {
+ realityEditor.gui.settings.toggleStates[this.propertyName] = newValue;
+ let persistentStorageId = 'SETTINGS:' + this.propertyName;
+ if (!this.dontPersist) {
+ window.localStorage.setItem(persistentStorageId, newValue);
+ }
+ return this;
+};
+
+/**
+ * Creates a new entry that will added to the settings menu, including the associated property and persistent storage.
+ * This type of entry has a toggle switch UI.
+ * @param {string} title
+ * @param {string} description
+ * @param {string} propertyName
+ * @param {string} iconSrc
+ * @param {boolean} defaultValue
+ * @param {function} onToggleCallback - gets triggered when the switch is toggled
+ * @param {ToggleOptions|undefined} options
+ * @return {SettingsToggle}
+ */
+realityEditor.gui.settings.addToggle = function(title, description, propertyName, iconSrc, defaultValue, onToggleCallback, options) {
+ let newToggle = new SettingsToggle(title, description, realityEditor.gui.settings.InterfaceType.TOGGLE, propertyName, iconSrc, defaultValue, undefined, onToggleCallback, undefined, options);
+ realityEditor.gui.settings.addedToggles.push(newToggle);
+ return newToggle;
+};
+
+/**
+ * Creates a new entry that will added to the settings menu, including the associated property and persistent storage.
+ * This type of entry has a toggle switch UI, and a text box UI.
+ * @param {string} title
+ * @param {string} description
+ * @param {string} propertyName
+ * @param {string} iconSrc
+ * @param {boolean} defaultValue
+ * @param {string} placeholderText
+ * @param {function} onToggleCallback - gets triggered when the switch is toggled
+ * @param onTextCallback - gets triggered every time the text box changes
+ * @param {ToggleOptions|undefined} options
+ * @return {SettingsToggle}
+ */
+realityEditor.gui.settings.addToggleWithText = function(title, description, propertyName, iconSrc, defaultValue, placeholderText, onToggleCallback, onTextCallback, options) {
+ let newToggle = new SettingsToggle(title, description, realityEditor.gui.settings.InterfaceType.TOGGLE_WITH_TEXT, propertyName, iconSrc, defaultValue, placeholderText, onToggleCallback, onTextCallback, options);
+ realityEditor.gui.settings.addedToggles.push(newToggle);
+ return newToggle;
+};
+
+/**
+ * Creates a new entry that will added to the settings menu, including the associated property and persistent storage.
+ * This type of entry has a toggle switch UI, and a text box UI.
+ * The toggle can only turn on if there is text. While active, the text cannot be edited
+ * @param {string} title
+ * @param {string} description
+ * @param {string} propertyName
+ * @param {string} iconSrc
+ * @param {boolean} defaultValue
+ * @param {string} placeholderText
+ * @param {function} onToggleCallback - gets triggered when the switch is toggled. includes the textbox value
+ * @param {boolean} ignoreOnload - ignore the first callback that gets triggered automatically when the toggle is added
+ * @param {ToggleOptions|undefined} options
+ * @return {SettingsToggle}
+ */
+realityEditor.gui.settings.addToggleWithFrozenText = function(title, description, propertyName, iconSrc, defaultValue, placeholderText, onToggleCallback, options) {
+ let newToggle = new SettingsToggle(title, description, realityEditor.gui.settings.InterfaceType.TOGGLE_WITH_FROZEN_TEXT, propertyName, iconSrc, defaultValue, placeholderText, onToggleCallback, undefined, options);
+ realityEditor.gui.settings.addedToggles.push(newToggle);
+ return newToggle;
+};
+
+/**
+ * Creates a new entry that will added to the settings menu, including the associated property and persistent storage.
+ * This type of entry is a frozen view of a URL
+ * @param {string} title
+ * @param {string} description
+ * @param {string} propertyName
+ * @param {string} iconSrc
+ * @param {boolean} defaultValue
+ * @param {string} placeholderText
+ * @return {SettingsToggle}
+ */
+realityEditor.gui.settings.addURLView = function(title, description, propertyName, iconSrc, defaultValue, placeholderText) {
+ let newToggle = new SettingsToggle(title, description, realityEditor.gui.settings.InterfaceType.URL, propertyName, iconSrc, defaultValue, placeholderText);
+ realityEditor.gui.settings.addedToggles.push(newToggle);
+ return newToggle;
+};
+
+/**
+ * Creates a new entry that will added to the settings menu, including the associated property and persistent storage.
+ * This type of entry has a toggle switch UI, and a text box UI.
+ * The toggle can only turn on if there is text. While active, the text cannot be edited
+ * @param {string} title
+ * @param {string} description
+ * @param {string} propertyName
+ * @param {string} iconSrc
+ * @param {number} defaultValue - (float between 0 and 1)
+ * @param {function} onToggleCallback - gets triggered when the slider is moved
+ * @param {boolean} ignoreOnload - ignore the first callback that gets triggered automatically when the slider is added
+ * @return {SettingsToggle}
+ */
+realityEditor.gui.settings.addSlider = function(title, description, propertyName, iconSrc, defaultValue, onToggleCallback, ignoreOnload) {
+ let newToggle = new SettingsToggle(title, description, realityEditor.gui.settings.InterfaceType.SLIDER, propertyName, iconSrc, defaultValue, undefined, onToggleCallback, undefined, ignoreOnload);
+ realityEditor.gui.settings.addedToggles.push(newToggle);
+ return newToggle;
+};
+
+/**
+ * Creates a JSON body that can be sent into the settings iframe with all the current setting values.
+ * In addition to a few hard-coded settings, injects all the settings that were created using the addToggle API.
+ * @return {Object.}
+ */
+realityEditor.gui.settings.generateGetSettingsJsonMessage = function() {
+ let defaultMessage = {
+ settingsButton : globalStates.settingsButtonState
+ };
+
+ // dynamically sends in the current property values for each of the switches that were added using the addToggle API
+ this.addedToggles.forEach(function(toggle) {
+ defaultMessage[toggle.propertyName] = this.toggleStates[toggle.propertyName];
+ }.bind(this));
+
+ return defaultMessage;
+};
+
+/**
+ * Creates a JSON body that can be sent into the settings iframe with the settings that should be rendered on the specified
+ * settings page, which were generated using the addToggle API. Each item consists of the name, description text,
+ * icon image, and the current value of that setting. Entries added with addToggleWithText contain more data.
+ * @param {string} menuName - from enum MenuPages - MAIN or DEVELOP
+ * @return {Object.}
+ */
+realityEditor.gui.settings.generateDynamicSettingsJsonMessage = function(menuName) {
+ let defaultMessage = {};
+
+ // dynamically sends in the current property values for each of the switches that were added using the addToggle API
+ this.addedToggles.filter(function(toggle) {
+ return toggle.menuName === menuName;
+ }).forEach(function(toggle) {
+ defaultMessage[toggle.propertyName] = {
+ value: this.toggleStates[toggle.propertyName],
+ title: toggle.title,
+ description: toggle.description,
+ iconSrc: toggle.iconSrc,
+ settingType: toggle.settingType
+ };
+ if (toggle.settingType === realityEditor.gui.settings.InterfaceType.TOGGLE_WITH_TEXT ||
+ toggle.settingType === realityEditor.gui.settings.InterfaceType.TOGGLE_WITH_FROZEN_TEXT ||
+ toggle.settingType === realityEditor.gui.settings.InterfaceType.URL) {
+ defaultMessage[toggle.propertyName].associatedText = {
+ propertyName: toggle.propertyName + 'Text',
+ value: this.toggleStates[toggle.propertyName + 'Text'],
+ placeholderText: toggle.placeholderText
+ }
+ }
+ }.bind(this));
+
+ return defaultMessage;
+};
+
+realityEditor.gui.settings.hideSettings = function() {
+
+ globalStates.settingsButtonState = false;
+
+ document.getElementById("settingsIframe").contentWindow.postMessage(JSON.stringify({
+ getSettings: this.generateGetSettingsJsonMessage()
+ }), "*");
+
+ document.getElementById("settingsIframe").style.visibility = "hidden";
+ document.getElementById("settingsIframe").style.display = "none";
+
+ if (document.getElementById("settingsEdgeDiv")) {
+ document.getElementById("settingsEdgeDiv").style.display = "none";
+ }
+
+ if (realityEditor.gui.settings.toggleStates.clearSkyState) {
+ document.getElementById("UIButtons").classList.add('clearSky');
+ } else {
+ document.getElementById("UIButtons").classList.remove('clearSky');
+ }
+
+ this.cout("hide Settings");
+};
+
+realityEditor.gui.settings.showSettings = function() {
+
+ if (!realityEditor.gui.settings.toggleStates.realityState) {
+ realityEditor.gui.menus.switchToMenu("setting", ["setting"], null);
+ } else {
+ realityEditor.gui.menus.switchToMenu("settingReality", ["setting"], null);
+ }
+
+ globalStates.settingsButtonState = true;
+ document.getElementById("settingsIframe").style.visibility = "visible";
+ document.getElementById("settingsIframe").style.display = "inline";
+
+ if (document.getElementById("settingsEdgeDiv")) {
+ document.getElementById("settingsEdgeDiv").style.display = "inline";
+ }
+
+ document.getElementById("settingsIframe").contentWindow.postMessage(JSON.stringify({
+ getSettings: realityEditor.gui.settings.generateGetSettingsJsonMessage(),
+ getMainDynamicSettings: realityEditor.gui.settings.generateDynamicSettingsJsonMessage(realityEditor.gui.settings.MenuPages.MAIN)
+ }), "*");
+
+ overlayDiv.style.display = "none";
+
+ if(document.getElementById("UIButtons").classList.contains('clearSky')) {
+ document.getElementById("UIButtons").classList.remove('clearSky');
+ }
+
+ this.cout("show Settings");
+};
diff --git a/src/gui/settings/about.html b/src/gui/settings/about.html
new file mode 100644
index 000000000..23199c150
--- /dev/null
+++ b/src/gui/settings/about.html
@@ -0,0 +1,1841 @@
+
+
+
+
+
+
+
+
+
+
+ Reality Editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Vuforia Spatial Toolbox 1.2.7
+
+
+ The Vuforia Spatial Toolbox and Vuforia Spatial Edge Server make up a shared research platform for exploring spatial computing as a community. This research platform is not an out of the box production-ready enterprise solution. Please read the MPL 2.0 license before you use the Vuforia Spatial Edge Server.
+ If something does not seem to work, be forgiving and contact us at forum.spatialtoolbox.vuforia.com. In case there is something that should work or even if you have a new idea, tell us about it.
+ If you find a bug, tell us about it for sure. We would love to hear about your feedback too.
+
+
+
+
+
+
+
3rd Party License
+
+
reality editor / open hybrid 2011 - 2017
+
+
Core Team
+
Valentin Heun, Benjamin F Reynolds, James Hobin
+
+
Significant Contributor
+
Kevin Wong, Michelle Suh, Carsten Strunk, Shunichi Kasahara, Mohammed Ibrahim
+
+
Scientific Support
+
Prof. Pattie Maes, Prof. Hiroshi Ishii, Prof. Mitchel Resnick, Prof. Joi Ito
+
+
Copyright (c) 2011-2017 Valentin Heun / OpenHybrid Community / RealityEditor Community - MPL Version 2.0
+
+
Mozilla Public License, version 2.0
+
+
1. Definitions
+
+
1.1. "Contributor"
+
+
means each individual or legal entity that creates, contributes to the
+ creation of, or owns Covered Software.
+
+
1.2. "Contributor Version"
+
+
means the combination of the Contributions of others (if any) used by a
+ Contributor and that particular Contributor's Contribution.
+
+
1.3. "Contribution"
+
+
means Covered Software of a particular Contributor.
+
+
1.4. "Covered Software"
+
+
means Source Code Form to which the initial Contributor has attached the
+ notice in Exhibit A, the Executable Form of such Source Code Form, and
+ Modifications of such Source Code Form, in each case including portions
+ thereof.
+
+
1.5. "Incompatible With Secondary Licenses"
+
means
+
+
a. that the initial Contributor has attached the notice described in
+ Exhibit B to the Covered Software; or
+
+
b. that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the terms of
+ a Secondary License.
+
+
1.6. "Executable Form"
+
+
means any form of the work other than Source Code Form.
+
+
1.7. "Larger Work"
+
+
means a work that combines Covered Software with other material, in a
+ separate file or files, that is not Covered Software.
+
+
1.8. "License"
+
+
means this document.
+
+
1.9. "Licensable"
+
+
means having the right to grant, to the maximum extent possible, whether
+ at the time of the initial grant or subsequently, any and all of the
+ rights conveyed by this License.
+
+
1.10. "Modifications"
+
+
means any of the following:
+
+
a. any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered Software; or
+
+
b. any new file in Source Code Form that contains any Covered Software.
+
+
1.11. "Patent Claims" of a Contributor
+
+
means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the License,
+ by the making, using, selling, offering for sale, having made, import,
+ or transfer of either its Contributions or its Contributor Version.
+
+
1.12. "Secondary License"
+
+
means either the GNU General Public License, Version 2.0, the GNU Lesser
+ General Public License, Version 2.1, the GNU Affero General Public
+ License, Version 3.0, or any later versions of those licenses.
+
+
1.13. "Source Code Form"
+
+
means the form of the work preferred for making modifications.
+
+
1.14. "You" (or "Your")
+
+
means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that controls, is
+ controlled by, or is under common control with You. For purposes of this
+ definition, "control" means (a) the power, direct or indirect, to cause
+ the direction or management of such entity, whether by contract or
+ otherwise, or (b) ownership of more than fifty percent (50%) of the
+ outstanding shares or beneficial ownership of such entity.
+
+
+
2. License Grants and Conditions
+
+
2.1. Grants
+
+
Each Contributor hereby grants You a world-wide, royalty-free,
+ non-exclusive license:
+
+
a. under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+
b. under Patent Claims of such Contributor to make, use, sell, offer for
+ sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+
2.2. Effective Date
+
+
The licenses granted in Section 2.1 with respect to any Contribution
+ become effective for each Contribution on the date the Contributor first
+ distributes such Contribution.
+
+
2.3. Limitations on Grant Scope
+
+
The licenses granted in this Section 2 are the only rights granted under
+ this License. No additional rights or licenses will be implied from the
+ distribution or licensing of Covered Software under this License.
+ Notwithstanding Section 2.1(b) above, no patent license is granted by a
+ Contributor:
+
+
a. for any code that a Contributor has removed from Covered Software; or
+
+
b. for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+
c. under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+
This License does not grant any rights in the trademarks, service marks,
+ or logos of any Contributor (except as may be necessary to comply with
+ the notice requirements in Section 3.4).
+
+
2.4. Subsequent Licenses
+
+
No Contributor makes additional grants as a result of Your choice to
+ distribute the Covered Software under a subsequent version of this
+ License (see Section 10.2) or under the terms of a Secondary License (if
+ permitted under the terms of Section 3.3).
+
+
2.5. Representation
+
+
Each Contributor represents that the Contributor believes its
+ Contributions are its original creation(s) or it has sufficient rights to
+ grant the rights to its Contributions conveyed by this License.
+
+
2.6. Fair Use
+
+
This License is not intended to limit any rights You have under
+ applicable copyright doctrines of fair use, fair dealing, or other
+ equivalents.
+
+
2.7. Conditions
+
+
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in
+ Section 2.1.
+
+
+
3. Responsibilities
+
+
3.1. Distribution of Source Form
+
+
All distribution of Covered Software in Source Code Form, including any
+ Modifications that You create or to which You contribute, must be under
+ the terms of this License. You must inform recipients that the Source
+ Code Form of the Covered Software is governed by the terms of this
+ License, and how they can obtain a copy of this License. You may not
+ attempt to alter or restrict the recipients' rights in the Source Code
+ Form.
+
+
3.2. Distribution of Executable Form
+
+
If You distribute Covered Software in Executable Form then:
+
+
a. such Covered Software must also be made available in Source Code Form,
+ as described in Section 3.1, and You must inform recipients of the
+ Executable Form how they can obtain a copy of such Source Code Form by
+ reasonable means in a timely manner, at a charge no more than the cost
+ of distribution to the recipient; and
+
+
b. You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter the
+ recipients' rights in the Source Code Form under this License.
+
+
3.3. Distribution of a Larger Work
+
+
You may create and distribute a Larger Work under terms of Your choice,
+ provided that You also comply with the requirements of this License for
+ the Covered Software. If the Larger Work is a combination of Covered
+ Software with a work governed by one or more Secondary Licenses, and the
+ Covered Software is not Incompatible With Secondary Licenses, this
+ License permits You to additionally distribute such Covered Software
+ under the terms of such Secondary License(s), so that the recipient of
+ the Larger Work may, at their option, further distribute the Covered
+ Software under the terms of either this License or such Secondary
+ License(s).
+
+
3.4. Notices
+
+
You may not remove or alter the substance of any license notices
+ (including copyright notices, patent notices, disclaimers of warranty, or
+ limitations of liability) contained within the Source Code Form of the
+ Covered Software, except that You may alter any license notices to the
+ extent required to remedy known factual inaccuracies.
+
+
3.5. Application of Additional Terms
+
+
You may choose to offer, and to charge a fee for, warranty, support,
+ indemnity or liability obligations to one or more recipients of Covered
+ Software. However, You may do so only on Your own behalf, and not on
+ behalf of any Contributor. You must make it absolutely clear that any
+ such warranty, support, indemnity, or liability obligation is offered by
+ You alone, and You hereby agree to indemnify every Contributor for any
+ liability incurred by such Contributor as a result of warranty, support,
+ indemnity or liability terms You offer. You may include additional
+ disclaimers of warranty and limitations of liability specific to any
+ jurisdiction.
+
+
4. Inability to Comply Due to Statute or Regulation
+
+
If it is impossible for You to comply with any of the terms of this License
+ with respect to some or all of the Covered Software due to statute,
+ judicial order, or regulation then You must: (a) comply with the terms of
+ this License to the maximum extent possible; and (b) describe the
+ limitations and the code they affect. Such description must be placed in a
+ text file included with all distributions of the Covered Software under
+ this License. Except to the extent prohibited by statute or regulation,
+ such description must be sufficiently detailed for a recipient of ordinary
+ skill to be able to understand it.
+
+
5. Termination
+
+
5.1. The rights granted under this License will terminate automatically if You
+ fail to comply with any of its terms. However, if You become compliant,
+ then the rights granted under this License from a particular Contributor
+ are reinstated (a) provisionally, unless and until such Contributor
+ explicitly and finally terminates Your grants, and (b) on an ongoing
+ basis, if such Contributor fails to notify You of the non-compliance by
+ some reasonable means prior to 60 days after You have come back into
+ compliance. Moreover, Your grants from a particular Contributor are
+ reinstated on an ongoing basis if such Contributor notifies You of the
+ non-compliance by some reasonable means, this is the first time You have
+ received notice of non-compliance with this License from such
+ Contributor, and You become compliant prior to 30 days after Your receipt
+ of the notice.
+
+
5.2. If You initiate litigation against any entity by asserting a patent
+ infringement claim (excluding declaratory judgment actions,
+ counter-claims, and cross-claims) alleging that a Contributor Version
+ directly or indirectly infringes any patent, then the rights granted to
+ You by any and all Contributors for the Covered Software under Section
+ 2.1 of this License shall terminate.
+
+
5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user
+ license agreements (excluding distributors and resellers) which have been
+ validly granted by You or Your distributors under this License prior to
+ termination shall survive termination.
+
+
6. Disclaimer of Warranty
+
+
Covered Software is provided under this License on an "as is" basis,
+ without warranty of any kind, either expressed, implied, or statutory,
+ including, without limitation, warranties that the Covered Software is free
+ of defects, merchantable, fit for a particular purpose or non-infringing.
+ The entire risk as to the quality and performance of the Covered Software
+ is with You. Should any Covered Software prove defective in any respect,
+ You (not any Contributor) assume the cost of any necessary servicing,
+ repair, or correction. This disclaimer of warranty constitutes an essential
+ part of this License. No use of any Covered Software is authorized under
+ this License except under this disclaimer.
+
+
7. Limitation of Liability
+
+
Under no circumstances and under no legal theory, whether tort (including
+ negligence), contract, or otherwise, shall any Contributor, or anyone who
+ distributes Covered Software as permitted above, be liable to You for any
+ direct, indirect, special, incidental, or consequential damages of any
+ character including, without limitation, damages for lost profits, loss of
+ goodwill, work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses, even if such party shall have been
+ informed of the possibility of such damages. This limitation of liability
+ shall not apply to liability for death or personal injury resulting from
+ such party's negligence to the extent applicable law prohibits such
+ limitation. Some jurisdictions do not allow the exclusion or limitation of
+ incidental or consequential damages, so this exclusion and limitation may
+ not apply to You.
+
+
8. Litigation
+
+
Any litigation relating to this License may be brought only in the courts
+ of a jurisdiction where the defendant maintains its principal place of
+ business and such litigation shall be governed by laws of that
+ jurisdiction, without reference to its conflict-of-law provisions. Nothing
+ in this Section shall prevent a party's ability to bring cross-claims or
+ counter-claims.
+
+
9. Miscellaneous
+
+
This License represents the complete agreement concerning the subject
+ matter hereof. If any provision of this License is held to be
+ unenforceable, such provision shall be reformed only to the extent
+ necessary to make it enforceable. Any law or regulation which provides that
+ the language of a contract shall be construed against the drafter shall not
+ be used to construe this License against a Contributor.
+
+
+
10. Versions of the License
+
+
10.1. New Versions
+
+
Mozilla Foundation is the license steward. Except as provided in Section
+ 10.3, no one other than the license steward has the right to modify or
+ publish new versions of this License. Each version will be given a
+ distinguishing version number.
+
+
10.2. Effect of New Versions
+
+
You may distribute the Covered Software under the terms of the version
+ of the License under which You originally received the Covered Software,
+ or under the terms of any subsequent version published by the license
+ steward.
+
+
10.3. Modified Versions
+
+
If you create software not governed by this License, and you want to
+ create a new license for such software, you may create and use a
+ modified version of this License if you rename the license and remove
+ any references to the name of the license steward (except to note that
+ such modified license differs from this License).
+
+
10.4. Distributing Source Code Form that is Incompatible With Secondary
+ Licenses If You choose to distribute Source Code Form that is
+ Incompatible With Secondary Licenses under the terms of this version of
+ the License, the notice described in Exhibit B of this License must be
+ attached.
+
+
Exhibit A - Source Code Form License Notice
+
+
This Source Code Form is subject to the
+ terms of the Mozilla Public License, v.
+ 2.0. If a copy of the MPL was not
+ distributed with this file, You can
+ obtain one at
+ http://mozilla.org/MPL/2.0/.
+
+
If it is not possible or desirable to put the notice in a particular file,
+ then You may include the notice in a location (such as a LICENSE file in a
+ relevant directory) where a recipient would be likely to look for such a
+ notice.
+
+
You may add additional accurate notices of copyright ownership.
+
+
Exhibit B - "Incompatible With Secondary Licenses" Notice
+
+
This Source Code Form is "Incompatible
+ With Secondary Licenses", as defined by
+ the Mozilla Public License, v. 2.0.
+
+
+
ratchet
+
The MIT License (MIT)
+
+
Copyright (c) 2015 connors and other contributors
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
+ this software and associated documentation files (the "Software"), to deal in
+ the Software without restriction, including without limitation the rights to
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+ the Software, and to permit persons to whom the Software is furnished to do so,
+ subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
open frameworks
+
Copyright (c) 2004 - openFrameworks Community
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
+ this software and associated documentation files (the "Software"), to deal in
+ the Software without restriction, including without limitation the rights to
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+ the Software, and to permit persons to whom the Software is furnished to do so,
+ subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
PEP
+
+
Copyright jQuery Foundation and other contributors, https://jquery.org/
+
+
This software consists of voluntary contributions made by many
+ individuals. For exact contribution history, see the revision history
+ available at https://github.com/jquery/PEP
+
+
The following license applies to all parts of this software except as
+ documented below:
+
+
====
+
+
Permission is hereby granted, free of charge, to any person obtaining
+ a copy of this software and associated documentation files (the
+ "Software"), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to
+ permit persons to whom the Software is furnished to do so, subject to
+ the following conditions:
+
+
The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
====
+
+
Copyright and related rights for sample code are waived via CC0. Sample
+ code is defined as all source code contained within the samples directory.
+
+
CC0: http://creativecommons.org/publicdomain/zero/1.0/
+
+
====
+
+
All files located in the node_modules directory are externally maintained
+ libraries used by this software which have their own licenses; we recommend
+ you read them, as their terms may differ from the terms above.
+
+
+
D3
+
+
Copyright 2010-2016 Mike Bostock
+ All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without modification,
+ are permitted provided that the following conditions are met:
+
+
* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+
* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+
* Neither the name of the author nor the names of contributors may be used to
+ endorse or promote products derived from this software without specific prior
+ written permission.
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+
idb filesystem
+
+
Copyright 2012 - Eric Bidelman (ebidel@gmail.com)
+
+
Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+
http://www.apache.org/licenses/LICENSE-2.0
+
+
Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+
+
Polymer
+
+
Copyright (c) 2014 The Polymer Authors. All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are
+ met:
+
+
* Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following disclaimer
+ in the documentation and/or other materials provided with the
+ distribution.
+ * Neither the name of Google Inc. nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+
WebComponents
+
+
# License
+
+
Everything in this repo is BSD style license unless otherwise specified.
+
+
Copyright (c) 2015 The Polymer Authors. All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without modification,
+ are permitted provided that the following conditions are met:
+
+
* Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following disclaimer
+ in the documentation and/or other materials provided with the
+ distribution.
+ * Neither the name of Google Inc. nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+ IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+ WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ POSSIBILITY OF SUCH DAMAGE.
+
+
+
Bower Components
+
+
Copyright (c) 2016 Twitter and other contributors
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
+ this software and associated documentation files (the "Software"), to deal in
+ the Software without restriction, including without limitation the rights to
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ of the Software, and to permit persons to whom the Software is furnished to do
+ so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+
+
+
Node.js
+
Node.js is licensed for use as follows:
+
+ """
+
Copyright Node.js contributors. All rights reserved.
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to
+ deal in the Software without restriction, including without limitation the
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ sell copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ IN THE SOFTWARE.
+ """
+
+
This license applies to parts of Node.js originating from the
+ https://github.com/joyent/node repository:
+
+ """
+
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to
+ deal in the Software without restriction, including without limitation the
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ sell copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ IN THE SOFTWARE.
+ """
+
+
The Node.js license applies to all parts of Node.js that are not externally
+ maintained libraries.
+
+
The externally maintained libraries used by Node.js are:
+
+
- c-ares, located at deps/cares, is licensed as follows:
+ """
+
Copyright 1998 by the Massachusetts Institute of Technology.
+ Copyright (C) 2007-2013 by Daniel Stenberg
+
+
Permission to use, copy, modify, and distribute this
+ software and its documentation for any purpose and without
+ fee is hereby granted, provided that the above copyright
+ notice appear in all copies and that both that copyright
+ notice and this permission notice appear in supporting
+ documentation, and that the name of M.I.T. not be used in
+ advertising or publicity pertaining to distribution of the
+ software without specific, written prior permission.
+ M.I.T. makes no representations about the suitability of
+ this software for any purpose. It is provided "as is"
+ without express or implied warranty.
+ """
+
+
- HTTP Parser, located at deps/http_parser, is licensed as follows:
+ """
+
http_parser.c is based on src/http/ngx_http_parse.c from NGINX copyright
+ Igor Sysoev.
+
+
Additional changes are licensed under the same terms as NGINX and
+ copyright Joyent, Inc. and other Node contributors. All rights reserved.
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to
+ deal in the Software without restriction, including without limitation the
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ sell copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ IN THE SOFTWARE.
+ """
+
+
- ICU, located at deps/icu-small, is licensed as follows:
+ """
+
COPYRIGHT AND PERMISSION NOTICE (ICU 58 and later)
+
+
Copyright ยฉ 1991-2017 Unicode, Inc. All rights reserved.
+ Distributed under the Terms of Use in http://www.unicode.org/copyright.html
+
+
Permission is hereby granted, free of charge, to any person obtaining
+ a copy of the Unicode data files and any associated documentation
+ (the "Data Files") or Unicode software and any associated documentation
+ (the "Software") to deal in the Data Files or Software
+ without restriction, including without limitation the rights to use,
+ copy, modify, merge, publish, distribute, and/or sell copies of
+ the Data Files or Software, and to permit persons to whom the Data Files
+ or Software are furnished to do so, provided that either
+ (a) this copyright and permission notice appear with all copies
+ of the Data Files or Software, or
+ (b) this copyright and permission notice appear in associated
+ Documentation.
+
+
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT OF THIRD PARTY RIGHTS.
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS
+ NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL
+ DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
+ DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
+ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+ PERFORMANCE OF THE DATA FILES OR SOFTWARE.
+
+
Except as contained in this notice, the name of a copyright holder
+ shall not be used in advertising or otherwise to promote the sale,
+ use or other dealings in these Data Files or Software without prior
+ written authorization of the copyright holder.
+
+
---------------------
+
+
Third-Party Software Licenses
+
+
This section contains third-party software notices and/or additional
+ terms for licensed third-party software components included within ICU
+ libraries.
+
+
1. ICU License - ICU 1.8.1 to ICU 57.1
+
+
COPYRIGHT AND PERMISSION NOTICE
+
+
Copyright (c) 1995-2016 International Business Machines Corporation and others
+ All rights reserved.
+
+
Permission is hereby granted, free of charge, to any person obtaining
+ a copy of this software and associated documentation files (the
+ "Software"), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, and/or sell copies of the Software, and to permit persons
+ to whom the Software is furnished to do so, provided that the above
+ copyright notice(s) and this permission notice appear in all copies of
+ the Software and that both the above copyright notice(s) and this
+ permission notice appear in supporting documentation.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+ OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+ HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY
+ SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER
+ RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF
+ CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+ CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+
Except as contained in this notice, the name of a copyright holder
+ shall not be used in advertising or otherwise to promote the sale, use
+ or other dealings in this Software without prior written authorization
+ of the copyright holder.
+
+
All trademarks and registered trademarks mentioned herein are the
+ property of their respective owners.
+
+
2. Chinese/Japanese Word Break Dictionary Data (cjdict.txt)
+
+
The Google Chrome software developed by Google is licensed under
+ the BSD license. Other software included in this distribution is
+ provided under other licenses, as set forth below.
+
+
The BSD License
+ http://opensource.org/licenses/bsd-license.php
+ Copyright (C) 2006-2008, Google Inc.
+
+
All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+
+
Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+ Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided with
+ the distribution.
+ Neither the name of Google Inc. nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+ BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+
The word list in cjdict.txt are generated by combining three word lists
+ listed below with further processing for compound word breaking. The
+ frequency is generated with an iterative training against Google web
+ corpora.
+
+
* Libtabe (Chinese)
+ - https://sourceforge.net/project/?group_id=1519
+ - Its license terms and conditions are shown below.
+
+
* IPADIC (Japanese)
+ - http://chasen.aist-nara.ac.jp/chasen/distribution.html
+ - Its license terms and conditions are shown below.
+
+
---------COPYING.libtabe ---- BEGIN--------------------
+
+
+
Copyrighy (c) 1999 TaBE Project.
+ Copyright (c) 1999 Pai-Hsiang Hsiao.
+
All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions
+ are met:
+
+
. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
. Neither the name of the TaBE Project nor the names of its
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+
Copyright (c) 1999 Computer Systems and Communication Lab,
+ Institute of Information Science, Academia
+ Sinica. All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions
+ are met:
+
+
. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
. Neither the name of the Computer Systems and Communication Lab
+ nor the names of its contributors may be used to endorse or
+ promote products derived from this software without specific
+ prior written permission.
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+
Copyright 1996 Chih-Hao Tsai @ Beckman Institute,
+ University of Illinois
+ c-tsai4@uiuc.edu http://casper.beckman.uiuc.edu/~c-tsai4
+
+
---------------COPYING.libtabe-----END--------------------------------
+
+
+
---------------COPYING.ipadic-----BEGIN-------------------------------
+
+
Copyright 2000, 2001, 2002, 2003 Nara Institute of Science
+ and Technology. All Rights Reserved.
+
+
Use, reproduction, and distribution of this software is permitted.
+ Any copy of this software, whether in its original form or modified,
+ must include both the above copyright notice and the following
+ paragraphs.
+
+
Nara Institute of Science and Technology (NAIST),
+ the copyright holders, disclaims all warranties with regard to this
+ software, including all implied warranties of merchantability and
+ fitness, in no event shall NAIST be liable for
+ any special, indirect or consequential damages or any damages
+ whatsoever resulting from loss of use, data or profits, whether in an
+ action of contract, negligence or other tortuous action, arising out
+ of or in connection with the use or performance of this software.
+
+
A large portion of the dictionary entries
+ originate from ICOT Free Software. The following conditions for ICOT
+ Free Software applies to the current dictionary as well.
+
+
Each User may also freely distribute the Program, whether in its
+ original form or modified, to any third party or parties, PROVIDED
+ that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear
+ on, or be attached to, the Program, which is distributed substantially
+ in the same form as set out herein and that such intended
+ distribution, if actually made, will neither violate or otherwise
+ contravene any of the laws and regulations of the countries having
+ jurisdiction over the User or the intended distribution itself.
+
+
NO WARRANTY
+
+
The program was produced on an experimental basis in the course of the
+ research and development conducted during the project and is provided
+ to users as so produced on an experimental basis. Accordingly, the
+ program is provided without any warranty whatsoever, whether express,
+ implied, statutory or otherwise. The term "warranty" used herein
+ includes, but is not limited to, any warranty of the quality,
+ performance, merchantability and fitness for a particular purpose of
+ the program and the nonexistence of any infringement or violation of
+ any right of any third party.
+
+
Each user of the program will agree and understand, and be deemed to
+ have agreed and understood, that there is no warranty whatsoever for
+ the program and, accordingly, the entire risk arising from or
+ otherwise connected with the program is assumed by the user.
+
+
Therefore, neither ICOT, the copyright holder, or any other
+ organization that participated in or was otherwise related to the
+ development of the program and their respective officials, directors,
+ officers and other employees shall be held liable for any and all
+ damages, including, without limitation, general, special, incidental
+ and consequential damages, arising out of or otherwise in connection
+ with the use or inability to use the program or any product, material
+ or result produced or otherwise obtained by using the program,
+ regardless of whether they have been advised of, or otherwise had
+ knowledge of, the possibility of such damages at any time during the
+ project or thereafter. Each user will be deemed to have agreed to the
+ foregoing by his or her commencement of use of the program. The term
+ "use" as used herein includes, but is not limited to, the use,
+ modification, copying and distribution of the program and the
+ production of secondary products from the program.
+
+
In the case where the program, whether in its original form or
+ modified, was distributed or delivered to or received by a user from
+ any person, organization or entity other than ICOT, unless it makes or
+ grants independently of ICOT any specific warranty to the user in
+ writing, such person, organization or entity, will also be exempted
+ from and not be held liable to the user for any such damages as noted
+ above as far as the program is concerned.
+
+
---------------COPYING.ipadic-----END----------------------------------
+
+
3. Lao Word Break Dictionary Data (laodict.txt)
+
+
Copyright (c) 2013 International Business Machines Corporation
+ and others. All Rights Reserved.
+
+
Project: http://code.google.com/p/lao-dictionary/
+ Dictionary: http://lao-dictionary.googlecode.com/git/Lao-Dictionary.txt
+ License: http://lao-dictionary.googlecode.com/git/Lao-Dictionary-LICENSE.txt
+ (copied below)
+
+
This file is derived from the above dictionary, with slight
+ modifications.
+
----------------------------------------------------------------------
+
Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell.
+ All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without
+ modification,
+ are permitted provided that the following conditions are met:
+
+
+
Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer. Redistributions in
+ binary form must reproduce the above copyright notice, this list of
+ conditions and the following disclaimer in the documentation and/or
+ other materials provided with the distribution.
+
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ OF THE POSSIBILITY OF SUCH DAMAGE.
+ --------------------------------------------------------------------------
+
+
4. Burmese Word Break Dictionary Data (burmesedict.txt)
+
+
Copyright (c) 2014 International Business Machines Corporation
+ and others. All Rights Reserved.
+
+
This list is part of a project hosted at:
+ github.com/kanyawtech/myanmar-karen-word-lists
+
+
--------------------------------------------------------------------------
+
Copyright (c) 2013, LeRoy Benjamin Sharon
+ All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions
+ are met: Redistributions of source code must retain the above
+ copyright notice, this list of conditions and the following
+ disclaimer. Redistributions in binary form must reproduce the
+ above copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+
+
Neither the name Myanmar Karen Word Lists, nor the names of its
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+ MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS
+ BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+ TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+ TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
+ THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ SUCH DAMAGE.
+
--------------------------------------------------------------------------
+
+
5. Time Zone Database
+
+
ICU uses the public domain data and code derived from Time Zone
+ Database for its time zone support. The ownership of the TZ database
+ is explained in BCP 175: Procedure for Maintaining the Time Zone
+ Database section 7.
+
+
7. Database Ownership
+
+
The TZ database itself is not an IETF Contribution or an IETF
+ document. Rather it is a pre-existing and regularly updated work
+ that is in the public domain, and is intended to remain in the
+ public domain. Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do
+ not apply to the TZ Database or contributions that individuals make
+ to it. Should any claims be made and substantiated against the TZ
+ Database, the organization that is providing the IANA
+ Considerations defined in this RFC, under the memorandum of
+ understanding with the IETF, currently ICANN, may act in accordance
+ with all competent court orders. No ownership claims will be made
+ by ICANN or the IETF Trust on the database or the code. Any person
+ making a contribution to the database or code waives all rights to
+ future claims in that contribution or in the TZ Database.
+ """
+
+
- libuv, located at deps/uv, is licensed as follows:
+ """
+
libuv is licensed for use as follows:
+
+
====
+
Copyright (c) 2015-present libuv project contributors.
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to
+ deal in the Software without restriction, including without limitation the
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ sell copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ IN THE SOFTWARE.
+ ====
+
+
This license applies to parts of libuv originating from the
+ https://github.com/joyent/libuv repository:
+
+
====
+
+
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to
+ deal in the Software without restriction, including without limitation the
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ sell copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ IN THE SOFTWARE.
+
+
====
+
+
This license applies to all parts of libuv that are not externally
+ maintained libraries.
+
+
The externally maintained libraries used by libuv are:
+
+
- tree.h (from FreeBSD), copyright Niels Provos. Two clause BSD license.
+
+
- inet_pton and inet_ntop implementations, contained in src/inet.c, are
+ copyright the Internet Systems Consortium, Inc., and licensed under the ISC
+ license.
+
+
- stdint-msvc2008.h (from msinttypes), copyright Alexander Chemeris. Three
+ clause BSD license.
+
+
- pthread-fixes.h, pthread-fixes.c, copyright Google Inc. and Sony Mobile
+ Communications AB. Three clause BSD license.
+
+
- android-ifaddrs.h, android-ifaddrs.c, copyright Berkeley Software Design
+ Inc, Kenneth MacKay and Emergya (Cloud4all, FP7/2007-2013, grant agreement
+ nยฐ 289016). Three clause BSD license.
+ """
+
+
- OpenSSL, located at deps/openssl, is licensed as follows:
+ """
+
Copyright (c) 1998-2017 The OpenSSL Project. All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions
+ are met:
+
+
1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+
2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+
3. All advertising materials mentioning features or use of this
+ software must display the following acknowledgment:
+ "This product includes software developed by the OpenSSL Project
+ for use in the OpenSSL Toolkit. (http://www.openssl.org/)"
+
+
4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to
+ endorse or promote products derived from this software without
+ prior written permission. For written permission, please contact
+ openssl-core@openssl.org.
+
+
5. Products derived from this software may not be called "OpenSSL"
+ nor may "OpenSSL" appear in their names without prior written
+ permission of the OpenSSL Project.
+
+
6. Redistributions of any form whatsoever must retain the following
+ acknowledgment:
+ "This product includes software developed by the OpenSSL Project
+ for use in the OpenSSL Toolkit (http://www.openssl.org/)"
+
+
THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY
+ EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR
+ ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ OF THE POSSIBILITY OF SUCH DAMAGE.
+ ====================================================================
+
+
This product includes cryptographic software written by Eric Young
+ (eay@cryptsoft.com). This product includes software written by Tim
+ Hudson (tjh@cryptsoft.com).
+ """
+
+
- Punycode.js, located at lib/punycode.js, is licensed as follows:
+ """
+
Copyright Mathias Bynens
+
+
Permission is hereby granted, free of charge, to any person obtaining
+ a copy of this software and associated documentation files (the
+ "Software"), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to
+ permit persons to whom the Software is furnished to do so, subject to
+ the following conditions:
+
+
The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ """
+
+
- V8, located at deps/v8, is licensed as follows:
+ """
+
This license applies to all parts of V8 that are not externally
+ maintained libraries. The externally maintained libraries used by V8
+ are:
+
+
- PCRE test suite, located in
+ test/mjsunit/third_party/regexp-pcre/regexp-pcre.js. This is based on the
+ test suite from PCRE-7.3, which is copyrighted by the University
+ of Cambridge and Google, Inc. The copyright notice and license
+ are embedded in regexp-pcre.js.
+
+
- Layout tests, located in test/mjsunit/third_party/object-keys. These are
+ based on layout tests from webkit.org which are copyrighted by
+ Apple Computer, Inc. and released under a 3-clause BSD license.
+
+
- Strongtalk assembler, the basis of the files assembler-arm-inl.h,
+ assembler-arm.cc, assembler-arm.h, assembler-ia32-inl.h,
+ assembler-ia32.cc, assembler-ia32.h, assembler-x64-inl.h,
+ assembler-x64.cc, assembler-x64.h, assembler-mips-inl.h,
+ assembler-mips.cc, assembler-mips.h, assembler.cc and assembler.h.
+ This code is copyrighted by Sun Microsystems Inc. and released
+ under a 3-clause BSD license.
+
+
- Valgrind client API header, located at third_party/valgrind/valgrind.h
+ This is release under the BSD license.
+
+
These libraries have their own licenses; we recommend you read them,
+ as their terms may differ from the terms below.
+
+
Further license information can be found in LICENSE files located in
+ sub-directories.
+
+
Copyright 2014, the V8 project authors. All rights reserved.
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are
+ met:
+
+
* Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+ * Neither the name of Google Inc. nor the names of its
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ """
+
+
- zlib, located at deps/zlib, is licensed as follows:
+ """
+
zlib.h -- interface of the 'zlib' general purpose compression library
+ version 1.2.11, January 15th, 2017
+
+
Copyright (C) 1995-2017 Jean-loup Gailly and Mark Adler
+
+
This software is provided 'as-is', without any express or implied
+ warranty. In no event will the authors be held liable for any damages
+ arising from the use of this software.
+
+
Permission is granted to anyone to use this software for any purpose,
+ including commercial applications, and to alter it and redistribute it
+ freely, subject to the following restrictions:
+
+
1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would be
+ appreciated but is not required.
+ 2. Altered source versions must be plainly marked as such, and must not be
+ misrepresented as being the original software.
+ 3. This notice may not be removed or altered from any source distribution.
+
+
Jean-loup Gailly Mark Adler
+ jloup@gzip.org madler@alumni.caltech.edu
+ """
+
+
- npm, located at deps/npm, is licensed as follows:
+ """
+
The npm application
+ Copyright (c) npm, Inc. and Contributors
+ Licensed on the terms of The Artistic License 2.0
+
+
Node package dependencies of the npm application
+ Copyright (c) their respective copyright owners
+ Licensed on their respective license terms
+
+
The npm public registry at https://registry.npmjs.org
+ and the npm website at https://www.npmjs.com
+ Operated by npm, Inc.
+ Use governed by terms published on https://www.npmjs.com
+
+
"Node.js"
+ Trademark Joyent, Inc., https://joyent.com
+ Neither npm nor npm, Inc. are affiliated with Joyent, Inc.
+
+
The Node.js application
+ Project of Node Foundation, https://nodejs.org
+
+
The npm Logo
+ Copyright (c) Mathias Pettersson and Brian Hammond
+
+
"Gubblebum Blocky" typeface
+ Copyright (c) Tjarda Koster, https://jelloween.deviantart.com
+ Used with permission
+
+
--------
+
+
The Artistic License 2.0
+
+
Copyright (c) 2000-2006, The Perl Foundation.
+
+
Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
Preamble
+
+
This license establishes the terms under which a given free software
+ Package may be copied, modified, distributed, and/or redistributed.
+ The intent is that the Copyright Holder maintains some artistic
+ control over the development of that Package while still keeping the
+ Package available as open source and free software.
+
+
You are always permitted to make arrangements wholly outside of this
+ license directly with the Copyright Holder of a given Package. If the
+ terms of this license do not permit the full use that you propose to
+ make of the Package, you should contact the Copyright Holder and seek
+ a different licensing arrangement.
+
+
Definitions
+
+
"Copyright Holder" means the individual(s) or organization(s)
+ named in the copyright notice for the entire Package.
+
+
"Contributor" means any party that has contributed code or other
+ material to the Package, in accordance with the Copyright Holder's
+ procedures.
+
+
"You" and "your" means any person who would like to copy,
+ distribute, or modify the Package.
+
+
"Package" means the collection of files distributed by the
+ Copyright Holder, and derivatives of that collection and/or of
+ those files. A given Package may consist of either the Standard
+ Version, or a Modified Version.
+
+
"Distribute" means providing a copy of the Package or making it
+ accessible to anyone else, or in the case of a company or
+ organization, to others outside of your company or organization.
+
+
"Distributor Fee" means any fee that you charge for Distributing
+ this Package or providing support for this Package to another
+ party. It does not mean licensing fees.
+
+
"Standard Version" refers to the Package if it has not been
+ modified, or has been modified only in ways explicitly requested
+ by the Copyright Holder.
+
+
"Modified Version" means the Package, if it has been changed, and
+ such changes were not explicitly requested by the Copyright
+ Holder.
+
+
"Original License" means this Artistic License as Distributed with
+ the Standard Version of the Package, in its current version or as
+ it may be modified by The Perl Foundation in the future.
+
+
"Source" form means the source code, documentation source, and
+ configuration files for the Package.
+
+
"Compiled" form means the compiled bytecode, object code, binary,
+ or any other form resulting from mechanical transformation or
+ translation of the Source form.
+
+
Permission for Use and Modification Without Distribution
+
+
(1) You are permitted to use the Standard Version and create and use
+ Modified Versions for any purpose without restriction, provided that
+ you do not Distribute the Modified Version.
+
+
Permissions for Redistribution of the Standard Version
+
+
(2) You may Distribute verbatim copies of the Source form of the
+ Standard Version of this Package in any medium without restriction,
+ either gratis or for a Distributor Fee, provided that you duplicate
+ all of the original copyright notices and associated disclaimers. At
+ your discretion, such verbatim copies may or may not include a
+ Compiled form of the Package.
+
+
(3) You may apply any bug fixes, portability changes, and other
+ modifications made available from the Copyright Holder. The resulting
+ Package will still be considered the Standard Version, and as such
+ will be subject to the Original License.
+
+
Distribution of Modified Versions of the Package as Source
+
+
(4) You may Distribute your Modified Version as Source (either gratis
+ or for a Distributor Fee, and with or without a Compiled form of the
+ Modified Version) provided that you clearly document how it differs
+ from the Standard Version, including, but not limited to, documenting
+ any non-standard features, executables, or modules, and provided that
+ you do at least ONE of the following:
+
+
(a) make the Modified Version available to the Copyright Holder
+ of the Standard Version, under the Original License, so that the
+ Copyright Holder may include your modifications in the Standard
+ Version.
+
+
(b) ensure that installation of your Modified Version does not
+ prevent the user installing or running the Standard Version. In
+ addition, the Modified Version must bear a name that is different
+ from the name of the Standard Version.
+
+
(c) allow anyone who receives a copy of the Modified Version to
+ make the Source form of the Modified Version available to others
+ under
+
+
(i) the Original License or
+
+
(ii) a license that permits the licensee to freely copy,
+ modify and redistribute the Modified Version using the same
+ licensing terms that apply to the copy that the licensee
+ received, and requires that the Source form of the Modified
+ Version, and of any works derived from it, be made freely
+ available in that license fees are prohibited but Distributor
+ Fees are allowed.
+
+
Distribution of Compiled Forms of the Standard Version
+ or Modified Versions without the Source
+
+
(5) You may Distribute Compiled forms of the Standard Version without
+ the Source, provided that you include complete instructions on how to
+ get the Source of the Standard Version. Such instructions must be
+ valid at the time of your distribution. If these instructions, at any
+ time while you are carrying out such distribution, become invalid, you
+ must provide new instructions on demand or cease further distribution.
+ If you provide valid instructions or cease distribution within thirty
+ days after you become aware that the instructions are invalid, then
+ you do not forfeit any of your rights under this license.
+
+
(6) You may Distribute a Modified Version in Compiled form without
+ the Source, provided that you comply with Section 4 with respect to
+ the Source of the Modified Version.
+
+
Aggregating or Linking the Package
+
+
(7) You may aggregate the Package (either the Standard Version or
+ Modified Version) with other packages and Distribute the resulting
+ aggregation provided that you do not charge a licensing fee for the
+ Package. Distributor Fees are permitted, and licensing fees for other
+ components in the aggregation are permitted. The terms of this license
+ apply to the use and Distribution of the Standard or Modified Versions
+ as included in the aggregation.
+
+
(8) You are permitted to link Modified and Standard Versions with
+ other works, to embed the Package in a larger work of your own, or to
+ build stand-alone binary or bytecode versions of applications that
+ include the Package, and Distribute the result without restriction,
+ provided the result does not expose a direct interface to the Package.
+
+
Items That are Not Considered Part of a Modified Version
+
+
(9) Works (including, but not limited to, modules and scripts) that
+ merely extend or make use of the Package, do not, by themselves, cause
+ the Package to be a Modified Version. In addition, such works are not
+ considered parts of the Package itself, and are not subject to the
+ terms of this license.
+
+
General Provisions
+
+
(10) Any use, modification, and distribution of the Standard or
+ Modified Versions is governed by this Artistic License. By using,
+ modifying or distributing the Package, you accept this license. Do not
+ use, modify, or distribute the Package, if you do not accept this
+ license.
+
+
(11) If your Modified Version has been derived from a Modified
+ Version made by someone other than you, you are nevertheless required
+ to ensure that your Modified Version complies with the requirements of
+ this license.
+
+
(12) This license does not grant you the right to use any trademark,
+ service mark, tradename, or logo of the Copyright Holder.
+
+
(13) This license includes the non-exclusive, worldwide,
+ free-of-charge patent license to make, have made, use, offer to sell,
+ sell, import and otherwise transfer the Package with respect to any
+ patent claims licensable by the Copyright Holder that are necessarily
+ infringed by the Package. If you institute patent litigation
+ (including a cross-claim or counterclaim) against any party alleging
+ that the Package constitutes direct or contributory patent
+ infringement, then this Artistic License to you shall terminate on the
+ date that such litigation is filed.
+
+
(14) Disclaimer of Warranty:
+ THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS "AS
+ IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR
+ NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY YOUR LOCAL
+ LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR CONTRIBUTOR WILL
+ BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+ DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE, EVEN IF
+ ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
--------
+ """
+
+
- GYP, located at tools/gyp, is licensed as follows:
+ """
+
Copyright (c) 2009 Google Inc. All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are
+ met:
+
+
* Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
* Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following disclaimer
+ in the documentation and/or other materials provided with the
+ distribution.
+
* Neither the name of Google Inc. nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ """
+
+
- marked, located at tools/doc/node_modules/marked, is licensed as follows:
+ """
+
Copyright (c) 2011-2014, Christopher Jeffrey (https://github.com/chjj/)
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
+ """
+
+
- cpplint.py, located at tools/cpplint.py, is licensed as follows:
+ """
+
Copyright (c) 2009 Google Inc. All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are
+ met:
+
+
* Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
* Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following disclaimer
+ in the documentation and/or other materials provided with the
+ distribution.
+
* Neither the name of Google Inc. nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ """
+
+
- ESLint, located at tools/eslint, is licensed as follows:
+ """
+
Copyright JS Foundation and other contributors, https://js.foundation
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
+ """
+
+
- gtest, located at deps/gtest, is licensed as follows:
+ """
+
Copyright 2008, Google Inc.
+ All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are
+ met:
+
+
* Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
* Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following disclaimer
+ in the documentation and/or other materials provided with the
+ distribution.
+
* Neither the name of Google Inc. nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ """
+
+
- nghttp2, located at deps/nghttp2, is licensed as follows:
+ """
+
The MIT License
+
+
Copyright (c) 2012, 2014, 2015, 2016 Tatsuhiro Tsujikawa
+
Copyright (c) 2012, 2014, 2015, 2016 nghttp2 contributors
+
+
Permission is hereby granted, free of charge, to any person obtaining
+ a copy of this software and associated documentation files (the
+ "Software"), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to
+ permit persons to whom the Software is furnished to do so, subject to
+ the following conditions:
+
+
The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ """
+
+
GCDWebServer
+
+
Copyright (c) 2012-2014, Pierre-Olivier Latour
+ All rights reserved.
+
+
Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+
* Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
* Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
* The name of Pierre-Olivier Latour may not be used to endorse
+ or promote products derived from this software without specific
+ prior written permission.
+
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL PIERRE-OLIVIER LATOUR BE LIABLE FOR ANY
+ DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+
P5.js
+
+
GNU LESSER GENERAL PUBLIC LICENSE
+ Version 2.1, February 1999
+
+
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
License URL: https://github.com/processing/p5.js/blob/master/license.txt
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/gui/settings/develop.html b/src/gui/settings/develop.html
new file mode 100644
index 000000000..2891be052
--- /dev/null
+++ b/src/gui/settings/develop.html
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+ Reality Editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/gui/settings/foundObjects.html b/src/gui/settings/foundObjects.html
new file mode 100644
index 000000000..a96789021
--- /dev/null
+++ b/src/gui/settings/foundObjects.html
@@ -0,0 +1,549 @@
+
+
+
+
+
+
+
+
+
+
+ Spatial Toolbox - Found Objects
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Please reload the app after you have finished uploading targets.
+
+
+
Please reload the app after you have finished uploading targets.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/gui/settings/foundServers.html b/src/gui/settings/foundServers.html
new file mode 100644
index 000000000..d9eb736e2
--- /dev/null
+++ b/src/gui/settings/foundServers.html
@@ -0,0 +1,358 @@
+
+
+
+
+
+
+
+
+
+
+ Spatial Toolbox - Found Servers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No servers with objects have been found on your network.
+
+ Make sure your Vuforia Spatial Edge Server is running and has at
+ least one object set up.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/gui/settings/index.html b/src/gui/settings/index.html
new file mode 100644
index 000000000..49530a9d0
--- /dev/null
+++ b/src/gui/settings/index.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+ Reality Editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/gui/settings/index.js b/src/gui/settings/index.js
new file mode 100644
index 000000000..ded857b5e
--- /dev/null
+++ b/src/gui/settings/index.js
@@ -0,0 +1,361 @@
+createNameSpace("realityEditor.gui.settings");
+
+// TODO: import this from common source as settings.js gets it instead of redefining
+const InterfaceType = Object.freeze({
+ TOGGLE: 'TOGGLE',
+ TOGGLE_WITH_TEXT: 'TOGGLE_WITH_TEXT',
+ TOGGLE_WITH_FROZEN_TEXT: 'TOGGLE_WITH_FROZEN_TEXT',
+ URL: 'URL',
+ SLIDER: 'SLIDER',
+});
+
+let sliderDown = null;
+let mouseListenersAdded = false;
+
+realityEditor.gui.settings.setSettings = function (id, state) {
+ if (!document.getElementById(id)) return;
+
+ // updates the toggle switch to display the current value
+ if (id) {
+ let isSlider = document.getElementById(id).classList.contains('slider');
+ if (isSlider) {
+ setSliderValue(id, state);
+ return;
+ }
+
+ // if not slider, always has a toggle
+ if (state) {
+ document.getElementById(id).classList.add('active');
+ } else {
+ document.getElementById(id).classList.remove('active');
+ }
+
+ // update associated text field if needed (for TOGGLE_WITH_FROZEN_TEXT)
+ let textfield = document.getElementById(id).parentElement.querySelector('.settingTextField');
+ if (textfield && textfield.classList.contains('frozen')) {
+ textfield.disabled = document.getElementById(id).classList.contains('active');
+ }
+ }
+};
+
+function setSliderValue(id, value) {
+ let slider = document.getElementById(id);
+ let sliderHandle = slider.querySelector('.slider-handle');
+ let sliderFill = slider.querySelector('.slider-fill');
+
+ if (slider.getClientRects()[0]) { // avoids error if slider isn't on screen
+ sliderHandle.style.left = value * parseFloat(slider.getClientRects()[0].width) + 'px';
+ sliderFill.style.width = value * parseFloat(slider.getClientRects()[0].width) + 'px';
+ }
+}
+
+realityEditor.gui.settings.loadSettingsPost = function () {
+ let settingsRequest = {
+ getSettings: true, // ask for the current values of all settings variables
+ getEnvironmentVariables: true // ask for the current environment variables
+ };
+
+ // this is a temporary fix to check if this script is being executed on the main settings vs the developer settings
+ if (document.querySelector('.content').id === 'mainSettings') {
+ settingsRequest.getMainDynamicSettings = true; // ask for which settings should be displayed on the main settings page
+
+ } else if (document.querySelector('.content').id === 'developSettings') {
+ settingsRequest.getDevelopDynamicSettings = true; // ask for which settings should be displayed on the main settings page
+ }
+
+ // Get all the Setting states.
+ parent.postMessage(JSON.stringify({
+ settings: settingsRequest
+ }), "*");
+
+ window.addEventListener("message", function (e) {
+
+ var msg = {};
+ try {
+ msg = JSON.parse(e.data);
+ } catch (e) {
+ // console.warn(e);
+ }
+
+ if (typeof msg.getSettings !== 'undefined') {
+ onGetSettings(msg);
+ }
+
+ if (typeof msg.getMainDynamicSettings !== 'undefined') {
+ if (document.querySelector('.content').id === 'mainSettings') {
+ onGetMainDynamicSettings(msg.getMainDynamicSettings);
+ }
+ }
+
+ if (typeof msg.getDevelopDynamicSettings !== 'undefined') {
+ if (document.querySelector('.content').id === 'developSettings') {
+ onGetMainDynamicSettings(msg.getDevelopDynamicSettings); // TODO: see if I can re-use this function or need to create another
+ }
+ }
+
+ if (typeof msg.getEnvironmentVariables !== 'undefined') {
+ onGetEnvironmentVaribles(msg.getEnvironmentVariables);
+ }
+
+ }.bind(realityEditor.gui.settings));
+
+ var onGetSettings = function (msg) {
+ for (let key in msg.getSettings) {
+ this.states[key] = msg.getSettings[key];
+ this.setSettings(key, this.states[key]);
+ }
+
+ if (typeof realityEditor.gui.settings.logo !== "undefined" && this.states.settingsButton && !this.states.animationFrameRequested) {
+ this.states.animationFrameRequested = true;
+ if (realityEditor.gui.settings.logo && typeof (realityEditor.gui.settings.logo.step) === 'function') {
+ window.requestAnimationFrame(realityEditor.gui.settings.logo.step);
+ }
+ }
+
+ if (!this.states.settingsButton) {
+ this.states.animationFrameRequested = false;
+ }
+
+ if (typeof this.callObjects !== "undefined" && this.states.settingsButton && !this.states.setInt) {
+ this.states.setInt = true;
+ this.objectInterval = setInterval(this.callObjects, 1000);
+ }
+
+ if (!this.states.settingsButton) {
+ this.states.setInt = false;
+ if (typeof this.objectInterval !== "undefined") {
+ clearInterval(this.objectInterval);
+ }
+ }
+ }.bind(realityEditor.gui.settings);
+
+ var onGetMainDynamicSettings = function (dynamicSettings) {
+ var container = document.querySelector('.content').querySelector('.table-view');
+ if (!container) {
+ console.warn('cant find container to create settings');
+ return;
+ }
+
+ for (let key in dynamicSettings) {
+
+ var settingInfo = dynamicSettings[key];
+
+ // add HTML element for this toggle if it doesn't exist already
+ var existingElement = container.querySelector('#' + key);
+
+ if (existingElement) {
+ // console.log('found element for ' + key);
+ if (settingInfo.settingType === InterfaceType.URL) {
+ // TODO update
+ const urlView = container.querySelector('#' + key + 'Text');
+ if (urlView) {
+ urlView.dataset.href = settingInfo.associatedText.value;
+ }
+ }
+
+ if (settingInfo.settingType === InterfaceType.TOGGLE_WITH_TEXT) {
+ // TODO update if not modified by user
+ }
+
+ if (settingInfo.settingType === InterfaceType.TOGGLE_WITH_FROZEN_TEXT) {
+ const textField = container.querySelector('#' + key + 'Text');
+ if (textField) {
+ textField.value = settingInfo.associatedText.value;
+ }
+ }
+ } else {
+ // console.log('need to create element for ' + key);
+
+ let newElement = document.createElement('li');
+ newElement.classList.add('table-view-cell');
+ newElement.style.position = 'relative';
+
+ let icon = document.createElement('img');
+ icon.classList.add('media-object', 'pull-left', 'settingsIcon');
+ icon.src = settingInfo.iconSrc; //'../../../svg/object.svg';
+ newElement.appendChild(icon);
+
+ let name = document.createElement('span');
+ name.innerText = settingInfo.title;
+ newElement.appendChild(name);
+
+ let description = document.createElement('small');
+ description.innerText = settingInfo.description;
+ description.className = 'description';
+ newElement.appendChild(description);
+
+ if (settingInfo.settingType === InterfaceType.TOGGLE_WITH_TEXT ||
+ settingInfo.settingType === InterfaceType.TOGGLE_WITH_FROZEN_TEXT) {
+
+ let textField = document.createElement('input');
+ textField.id = key + 'Text';
+ textField.classList.add('pull-left', 'settingTextField');
+ if (settingInfo.settingType === InterfaceType.TOGGLE_WITH_FROZEN_TEXT) {
+ textField.classList.add('frozen');
+ }
+ textField.type = 'text';
+ if (settingInfo.associatedText) {
+ textField.value = settingInfo.associatedText.value;
+ textField.placeholder = settingInfo.associatedText.placeholderText || '';
+ }
+
+ textField.addEventListener('input', function () {
+ uploadSettingText(this.id);
+ });
+
+ newElement.appendChild(textField);
+ }
+
+ if (settingInfo.settingType === InterfaceType.URL) {
+ let urlView = document.createElement('button');
+ let iconShare = document.createElement('span');
+ iconShare.classList.add('icon', 'icon-share');
+ let urlText = document.createElement('span');
+ urlText.innerHTML = ' Share';
+ urlView.appendChild(iconShare);
+ urlView.appendChild(urlText);
+
+ urlView.id = key;
+ urlText.id = key + 'Text';
+ urlView.classList.add('btn', 'btn-primary', 'pull-left', 'settingURLView');
+ if (settingInfo.associatedText) {
+ urlView.dataset.href = settingInfo.associatedText.value;
+ }
+
+ urlView.addEventListener('click', function() {
+ navigator.share({
+ title: 'Pop-up Metaverse Access',
+ text: 'Pop-up Metaverse Access',
+ url: urlView.dataset.href,
+ });
+ });
+
+ newElement.appendChild(urlView);
+ }
+
+ if (settingInfo.settingType === InterfaceType.TOGGLE ||
+ settingInfo.settingType === InterfaceType.TOGGLE_WITH_TEXT ||
+ settingInfo.settingType === InterfaceType.TOGGLE_WITH_FROZEN_TEXT) {
+
+ let toggle = document.createElement('div');
+ toggle.classList.add('toggle');
+ toggle.id = key;
+ newElement.appendChild(toggle);
+
+ let toggleHandle = document.createElement('div');
+ toggleHandle.classList.add('toggle-handle');
+ toggle.appendChild(toggleHandle);
+
+ } else if (settingInfo.settingType === InterfaceType.SLIDER) {
+
+ let slider = document.createElement('div');
+ slider.classList.add('slider');
+ slider.id = key;
+ newElement.appendChild(slider);
+
+ // fills the part of the slider to the left of the handle with blue color
+ let sliderFill = document.createElement('div');
+ sliderFill.classList.add('slider-fill');
+ slider.appendChild(sliderFill);
+
+ let sliderHandle = document.createElement('div');
+ sliderHandle.classList.add('slider-handle');
+ slider.appendChild(sliderHandle);
+
+ sliderHandle.addEventListener('pointerdown', function (_event) {
+ sliderDown = slider;
+ });
+ }
+
+ container.appendChild(newElement);
+ }
+ }
+
+ for (let key in dynamicSettings) {
+ let settingInfo = dynamicSettings[key];
+ this.setSettings(key, settingInfo.value);
+ }
+
+ }.bind(realityEditor.gui.settings);
+
+ var onGetEnvironmentVaribles = function (environmentVariables) {
+ // allows iOS-styled UI toggles to be clicked using mouse
+ if (environmentVariables.requiresMouseEvents && !mouseListenersAdded) {
+ document.addEventListener('click', function (e) {
+ if (e.target && e.target.classList.contains('toggle-handle')) {
+ let wasActive = e.target.parentElement.classList.contains('active');
+ if (wasActive) {
+ e.target.parentElement.classList.remove('active');
+ } else {
+ e.target.parentElement.classList.add('active');
+ }
+ onToggle(e.target.parentElement, !wasActive);
+ }
+ });
+ mouseListenersAdded = true;
+ }
+ };
+
+ document.addEventListener('toggle', function (e) {
+ onToggle(e.target, e.detail.isActive);
+ });
+
+ function onToggle(target, newIsActive) {
+ uploadSettingsForToggle(target.id, newIsActive);
+
+ let textfield = target.parentElement.querySelector('.settingTextField');
+ // check if it has an attached text field, and if so, update if it needs frozen/unfrozen
+ if (textfield && textfield.classList.contains('frozen')) {
+ textfield.disabled = target.classList.contains('active');
+ }
+ }
+
+ document.addEventListener('pointermove', function(event) {
+ if (sliderDown) {
+ let sliderHandle = sliderDown.querySelector('.slider-handle');
+ let sliderFill = sliderDown.querySelector('.slider-fill');
+ let parentLeft = sliderDown.getClientRects()[0].left;
+ let dx = Math.max(0, Math.min(sliderDown.getClientRects()[0].width, event.pageX - parentLeft));
+ sliderHandle.style.left = dx - sliderHandle.getClientRects()[0].width/2 + 'px';
+ sliderFill.style.width = dx - sliderHandle.getClientRects()[0].width/2 + 'px';
+ }
+ });
+
+ document.addEventListener('pointerup', function(_event) {
+ if (sliderDown) {
+ let sliderHandle = sliderDown.querySelector('.slider-handle');
+ let value = parseFloat(sliderHandle.style.left) / parseFloat(sliderDown.getClientRects()[0].width);
+ uploadSettingsForToggle(sliderDown.id, value);
+ }
+ sliderDown = null;
+ });
+
+ function uploadSettingsForToggle(elementId, isActive) {
+ var msg = {};
+ msg.settings = {};
+ msg.settings.setSettings = {};
+ msg.settings.setSettings[elementId] = isActive;
+
+ let element = document.getElementById(elementId);
+ // check if it has an attached text field, and if so, send that text too
+ if (element.parentElement.querySelector('.settingTextField')) {
+ msg.settings.setSettings[elementId + 'Text'] = element.parentElement.querySelector('.settingTextField').value;
+ }
+ parent.postMessage(JSON.stringify(msg), "*");
+ }
+
+ function uploadSettingText(textElementId) {
+ var msg = {};
+ msg.settings = {};
+ msg.settings.setSettings = {};
+ msg.settings.setSettings[textElementId] = document.getElementById(textElementId).value;
+ parent.postMessage(JSON.stringify(msg), "*");
+ }
+
+};
+
+window.onload = function() {
+ setTimeout(function() {
+ realityEditor.gui.settings.loadSettingsPost();
+ }, 100); // delay it or it happens too early to load settings
+};
diff --git a/src/gui/settings/logo.js b/src/gui/settings/logo.js
new file mode 100644
index 000000000..58357d5bb
--- /dev/null
+++ b/src/gui/settings/logo.js
@@ -0,0 +1,179 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆ โฆโฌ โฌโโ โฌโโโฌโโฌโ โโโโโ โฌโโโโโโโโฌโโโโ
+ * โ โโฃโโฌโโโดโโโฌโโ โโ โ โโโดโ โโโค โ โ โโโ
+ * โฉ โฉ โด โโโโดโโโดโโดโ โโโโโโโโโโโโโโ โด โโโ
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+createNameSpace("realityEditor.gui.settings.logo");
+
+//window.onload = function () {
+
+realityEditor.gui.settings.logo.version = "Version 1.8";
+realityEditor.gui.settings.logo.timeCorrection = {delta: 0, now: 0, then: 0};
+realityEditor.gui.settings.logo.logoSize = {W: (1785 / 2) * 1.5, H: 1035 * 1.5, offsetX: 100, offsetY: 60};
+
+realityEditor.gui.settings.logo.c = document.getElementById("reLogo");
+realityEditor.gui.settings.logo.ctx = document.getElementById("reLogo").getContext("2d");
+
+realityEditor.gui.settings.logo.line = [{
+ p1: [936, 144],
+ p2: [1658, 131],
+ l1: 118 / 4.5,
+ l2: 114 / 4.5,
+ ballAnimationCount: 0
+},
+ {p1: [936, 144], p2: [111, 780], l1: 118 / 4.5, l2: 101 / 4.5, ballAnimationCount: 0},
+ {p1: [936, 144], p2: [1440, 735], l1: 118 / 4.5, l2: 78 / 4.5, ballAnimationCount: 0},
+ {p1: [1658, 131], p2: [993, 920], l1: 114 / 4.5, l2: 99 / 4.5, ballAnimationCount: 0},
+ {p1: [993, 920], p2: [626, 722], l1: 99 / 4.5, l2: 32 / 4.5, ballAnimationCount: 0},
+ {p1: [993, 920], p2: [1398, 920], l1: 99 / 4.5, l2: 32 / 4.5, ballAnimationCount: 0}];
+
+realityEditor.gui.settings.logo.thePoint = [{p1: [936, 144], l1: 118},
+ {p1: [1658, 131], l1: 114},
+ {p1: [111, 780], l1: 101},
+
+ {p1: [1440, 735], l1: 78},
+ {p1: [993, 920], l1: 99},
+ {p1: [626, 722], l1: 32},
+ {p1: [1398, 920], l1: 32}];
+
+realityEditor.gui.settings.logo.text = [{p1: [160, 533], l1: 210},
+ {p1: [178, 622], l1: 68}, {p1: [178, 160], l1: 68}];
+
+realityEditor.gui.settings.logo.shadowX = 40;
+realityEditor.gui.settings.logo.shadow = 40;
+
+// window.onresize=logoResize;
+
+//window.onload = logoResize;
+
+realityEditor.gui.settings.logo.logoResize = function () {
+
+ for (var i1 = 0; i1 < this.line.length; i1++) {
+ this.line[i1] = {
+ p1: [this.logoSize.offsetX + realityEditor.gui.ar.utilities.map(this.line[i1].p1[0], 0, 1785, 0, this.logoSize.W),
+ this.logoSize.offsetY + realityEditor.gui.ar.utilities.map(this.line[i1].p1[1], 0, 1035, 0, 1035 / 1785 * this.logoSize.W)]
+
+ ,
+ p2: [this.logoSize.offsetX + realityEditor.gui.ar.utilities.map(this.line[i1].p2[0], 0, 1785, 0, this.logoSize.W),
+ this.logoSize.offsetY + realityEditor.gui.ar.utilities.map(this.line[i1].p2[1], 0, 1035, 0, 1035 / 1785 * this.logoSize.W)]
+
+ ,
+ l1: realityEditor.gui.ar.utilities.map(this.line[i1].l1, 0, 1785, 0, this.logoSize.W),
+ l2: realityEditor.gui.ar.utilities.map(this.line[i1].l2, 0, 1785, 0, this.logoSize.W),
+ ballAnimationCount: 0
+ };
+ }
+
+ for (i1 = 0; i1 < this.thePoint.length; i1++) {
+ this.thePoint[i1] = {
+ p1: [this.logoSize.offsetX + realityEditor.gui.ar.utilities.map(this.thePoint[i1].p1[0], 0, 1785, 0, this.logoSize.W),
+ this.logoSize.offsetY + realityEditor.gui.ar.utilities.map(this.thePoint[i1].p1[1], 0, 1035, 0, 1035 / 1785 * this.logoSize.W)]
+
+ , l1: realityEditor.gui.ar.utilities.map(this.thePoint[i1].l1, 0, 1785, 0, this.logoSize.W)
+ };
+ }
+
+ for (i1 = 0; i1 < this.text.length; i1++) {
+ this.text[i1] = {
+ p1: [this.logoSize.offsetX + realityEditor.gui.ar.utilities.map(this.text[i1].p1[0], 0, 1785, 0, this.logoSize.W),
+ this.logoSize.offsetY + realityEditor.gui.ar.utilities.map(this.text[i1].p1[1], 0, 1035, 0, 1035 / 1785 * this.logoSize.W)]
+
+ , l1: realityEditor.gui.ar.utilities.map(this.text[i1].l1, 0, 1785, 0, this.logoSize.W)
+ };
+ }
+
+ this.shadow = realityEditor.gui.ar.utilities.map(this.shadowX, 0, 1785, 0, this.logoSize.W);
+}
+
+realityEditor.gui.settings.logo.logoResize();
+//ctx.scale(0.5,0.5);
+
+realityEditor.gui.settings.logo.logo = function () {
+ realityEditor.gui.ar.utilities.timeSynchronizer(this.timeCorrection);
+ this.ctx.clearRect(0, 0, this.c.width, this.c.height);
+
+ for (var i1 = 0; i1 < this.line.length; i1++) {
+ realityEditor.gui.ar.lines.drawLine(this.ctx, this.line[i1].p1, this.line[i1].p2, this.line[i1].l1, this.line[i1].l2, this.line[i1], this.timeCorrection, 0, 2, 0.04);
+ }
+
+ for (var e1 = 0; e1 < this.thePoint.length; e1++) {
+ this.drawCircle(this.ctx, this.thePoint[e1].p1, this.thePoint[e1].l1);
+ }
+
+ this.ctx.shadowColor = "#969696";
+ this.ctx.shadowOffsetX = 0;
+ this.ctx.shadowOffsetY = 0;
+ // ctx.shadowBlur = shadow;
+ this.ctx.fillStyle = '#000000';
+ this.ctx.font = "normal normal 900 " + this.text[0].l1 + "px Futura";
+ this.ctx.fillText("Reality Editor", this.text[0].p1[0], this.text[0].p1[1]);
+ this.ctx.fillStyle = '#000000';
+ this.ctx.font = "normal normal 900 " + this.text[1].l1 + "px Futura";
+ this.ctx.fillText(this.version, this.text[1].p1[0], this.text[1].p1[1]);
+ this.ctx.shadowBlur = 0;
+ this.ctx.fillStyle = '#000000';
+ this.ctx.font = "normal normal 900 " + this.text[1].l1 + "px Futura";
+ this.ctx.fillText("", this.text[2].p1[0], this.text[2].p1[1]);
+ this.ctx.shadowBlur = 0;
+};
+
+realityEditor.gui.settings.logo.drawCircle = function (ctx, point, bSize) {
+ ctx.beginPath();
+ ctx.arc(point[0], point[1], bSize, 0, 2 * Math.PI, false);
+ ctx.fillStyle = '#222222';
+ ctx.fill();
+ ctx.lineWidth = bSize / 8.4286;
+ ctx.strokeStyle = '#00ffff';
+ ctx.stroke();
+};
+
+realityEditor.gui.settings.logo.step = function () {
+
+ if (realityEditor.gui.settings.states.settingsButton) {
+
+ this.logo();
+ window.requestAnimationFrame(realityEditor.gui.settings.logo.step);
+ } else return;
+}.bind(realityEditor.gui.settings.logo);
+// window.requestAnimationFrame(step);
+//}
diff --git a/src/gui/settings/setupSettingsMenu.js b/src/gui/settings/setupSettingsMenu.js
new file mode 100644
index 000000000..89c724abb
--- /dev/null
+++ b/src/gui/settings/setupSettingsMenu.js
@@ -0,0 +1,237 @@
+createNameSpace('realityEditor.gui.settings.setupSettingsMenu');
+
+(function(exports) {
+
+ function initService() {
+ // populate the default settings menus with toggle switches and text boxes, with associated callbacks
+ realityEditor.gui.settings.addToggleWithText('Zone', 'limit object discovery to zone', 'zoneState', '../../../svg/zone.svg', false, 'enter zone name',
+ function(_newValue) {
+ // console.log('zone mode was set to ' + newValue);
+ },
+ function(_newValue) {
+ // console.log('zone text was set to ' + newValue);
+ }
+ );
+
+ realityEditor.gui.settings.addToggle('Power-Save Mode', 'turns off some effects for faster performance', 'powerSaveMode', '../../../svg/powerSave.svg', false, function(newValue) {
+ // only draw frame ghosts while in programming mode if we're not in power-save mode
+ globalStates.renderFrameGhostsInNodeViewEnabled = !newValue;
+ });
+
+ realityEditor.gui.settings.addToggle('Grouping', 'double-tap background to draw group around frames', 'groupingEnabled', '../../../svg/grouping.svg', false, function(newValue) {
+ realityEditor.gui.ar.grouping.toggleGroupingMode(newValue);
+ });
+
+ realityEditor.gui.settings.addToggle('Realtime Collaboration', 'constantly synchronizes with other users', 'realtimeEnabled', '../../../svg/realtime.svg', true, function(newValue) {
+ if (newValue) {
+ realityEditor.network.realtime.initService();
+ } else {
+ realityEditor.network.realtime.pauseRealtime();
+ }
+ // TODO: turning this off currently doesn't actually end the realtime mode unless you restart the app
+ });
+
+ realityEditor.gui.settings.addToggle('Show Tutorial', 'add tutorial frame on app start', 'tutorialState', '../../../svg/tutorial.svg', false, function(_newValue) {
+ // console.log('tutorial mode was set to ' + newValue);
+ });
+
+ let introToggle = realityEditor.gui.settings.addToggle('Show Intro Page', 'shows tips on app start', 'introTipsState', '../../../svg/tutorial.svg', false, function(newValue) {
+ if (newValue) {
+ window.localStorage.removeItem('neverAgainShowIntroTips');
+ } else {
+ window.localStorage.setItem('neverAgainShowIntroTips', 'true');
+ }
+ });
+
+ // add settings toggles for the Develop sub-menu
+
+ realityEditor.gui.settings.addToggle('AR-UI Repositioning', 'instantly drag frames instead of interacting', 'editingMode', '../../../svg/move.svg', false, function(newValue) {
+ realityEditor.device.setEditingMode(newValue);
+ }).moveToDevelopMenu();
+
+ realityEditor.gui.settings.addToggle('Clear Sky Mode', 'hides all buttons', 'clearSkyState', '../../../svg/clear.svg', false, function(_newValue) {
+ // console.log('clear sky mode set to ' + newValue);
+ }).moveToDevelopMenu();
+
+ realityEditor.gui.settings.addToggleWithFrozenText('Interface URL', 'currently: ' + window.location.href, 'externalState', '../../../svg/download.svg', false, (realityEditor.network.useHTTPS ? 'https' : 'http') + '://...', function(newValue, textValue) {
+
+ if (newValue && textValue.length > 0) {
+ // we still need to save this to native device storage to be backwards-compatible with how the interface is loaded
+ realityEditor.app.saveExternalText(textValue);
+
+ let isCurrentUrl = window.location.href.includes(textValue);
+ if (!isCurrentUrl) {
+ setTimeout(function() { // load from external server when toggled on with a new url
+ realityEditor.app.appFunctionCall("loadNewUI", {reloadURL: textValue});
+ }.bind(this), 1000);
+ }
+ } else {
+ realityEditor.app.saveExternalText('');
+ setTimeout(function() { // reload from local server when toggled off
+ realityEditor.app.appFunctionCall("loadNewUI", {reloadURL: ''});
+ }.bind(this), 1000);
+ }
+
+ }, { ignoreOnload: true }).moveToDevelopMenu().setValue(!window.location.href.includes('127.0.0.1') && !window.location.href.includes('localhost')); // default value is based on the current source
+
+ realityEditor.gui.settings.addToggleWithFrozenText('Discovery Server', 'load objects from static server', 'discoveryState', '../../../svg/discovery.svg', false, (realityEditor.network.useHTTPS ? 'https' : 'http') + '://...', function(newValue, textValue) {
+ if (newValue) {
+ setTimeout(function() {
+ realityEditor.network.discoverObjectsFromServer(textValue);
+ }, 1000); // wait to make sure all the necessary modules for object discovery/creation are ready
+ }
+
+ }).moveToDevelopMenu();
+
+ realityEditor.gui.settings.addToggle('Demo Aspect Ratio', 'set screen ratio to 16:9', 'demoAspectRatio', '../../../svg/cameraZoom.svg', false, function() {
+ const currentRatio = globalStates.height / globalStates.width;
+ if (Math.abs(currentRatio - (16/9)) < 0.001) {
+ realityEditor.app.setAspectRatio(0); // Resets to default
+ } else {
+ realityEditor.app.setAspectRatio(16/9);
+ }
+ }, { ignoreOnload: true }).moveToDevelopMenu();
+
+ // Add a debug toggle to the develop menu that forces the targetDownloader to re-download each time instead of using the cache
+ realityEditor.gui.settings.addToggle('Reset Target Cache', 'clear cache of downloaded target data', 'resetTargetCache', '../../../svg/object.svg', false, function(newValue) {
+ if (newValue) {
+ realityEditor.app.targetDownloader.resetTargetDownloadCache();
+ }
+ }).moveToDevelopMenu();
+
+ // Add a debug toggle to the develop menu that forces the targetDownloader to re-download each time instead of using the cache
+ realityEditor.gui.settings.addToggle('Disable Unloading', 'don\'t unload offscreen tools', 'disableUnloading', '../../../svg/object.svg', false, function(newValue) {
+ globalStates.disableUnloading = newValue;
+ // if (newValue) {
+ // // realityEditor.app.targetDownloader.resetTargetDownloadCache();
+ // }
+ }).moveToDevelopMenu();
+
+ let enablePoseTrackingTimeout = null;
+ // Add a toggle to enable virtualization features
+ realityEditor.gui.settings.addToggle('Virtualization', 'enable virtualization and pose detection', 'enableVirtualization', '../../../svg/object.svg', false, function(newValue) {
+ if (newValue) {
+ function enablePoseTracking() {
+ let bestWorldObject = realityEditor.worldObjects.getBestWorldObject();
+ if (!bestWorldObject || bestWorldObject.objectId === realityEditor.worldObjects.getLocalWorldId()) {
+ enablePoseTrackingTimeout = setTimeout(enablePoseTracking, 100);
+ return;
+ }
+ realityEditor.app.appFunctionCall("enablePoseTracking", {
+ ip: bestWorldObject.ip,
+ port: bestWorldObject.port.toString(),
+ });
+
+ let recordButton = document.getElementById('recordPointCloudsButton');
+ if (!recordButton) {
+ recordButton = document.createElement('img');
+ recordButton.src = '../../../svg/recordButton3D-start.svg';
+ recordButton.id = 'recordPointCloudsButton';
+ document.body.appendChild(recordButton);
+
+ recordButton.addEventListener('pointerup', _e => {
+ if (recordButton.classList.contains('pointCloudButtonActive')) {
+ recordButton.classList.remove('pointCloudButtonActive');
+ recordButton.src = '../../../svg/recordButton3D-start.svg';
+ realityEditor.device.videoRecording.stop3DVideoRecording();
+ } else {
+ recordButton.classList.add('pointCloudButtonActive');
+ recordButton.src = '../../../svg/recordButton3D-stop.svg';
+ realityEditor.device.videoRecording.start3DVideoRecording();
+ }
+ });
+ }
+ recordButton.classList.remove('hiddenButtons');
+ }
+ enablePoseTracking();
+ } else {
+ if (enablePoseTrackingTimeout) {
+ clearTimeout(enablePoseTrackingTimeout);
+ enablePoseTrackingTimeout = null;
+ }
+ realityEditor.app.appFunctionCall("disablePoseTracking", {});
+ let recordButton = document.getElementById('recordPointCloudsButton');
+ if (recordButton) {
+ recordButton.classList.add('hiddenButtons');
+ }
+ }
+ }, {ignoreOnload: true, dontPersist: true}).moveToDevelopMenu();
+
+ let toggleCloudUrl = realityEditor.gui.settings.addURLView('Cloud URL', 'link to access your metaverse', 'cloudUrl', '../../../svg/zone.svg', false, 'unavailable',
+ function(_newValue) {
+ // console.log('user wants cloudConnection to be', newValue);
+ },
+ function(_newValue) {
+ // console.log('cloud url text was set to', newValue);
+ }
+ );
+ let toggleNewNetworkId = realityEditor.gui.settings.addToggleWithFrozenText('New Network ID', 'generate new network id for cloud connection', 'generateNewNetworkId', '../../../svg/object.svg', false, 'unknown', function(_newValue) {
+ // console.log('user wants newNetworkId to be', newValue);
+ });
+ let toggleNewSecret = realityEditor.gui.settings.addToggleWithFrozenText('New Secret', 'generate new secret for cloud connection', 'generateNewSecret', '../../../svg/object.svg', false, 'unknown', function(_newValue) {
+ // console.log('user wants newSecret to be', newValue);
+ });
+
+ let cachedSettings = {};
+ const localSettingsHost = `localhost:${realityEditor.device.environment.getLocalServerPort()}`;
+
+ function processNewSettings(settings) {
+ let anyChanged = false;
+ if (cachedSettings.isConnected !== settings.isConnected) {
+ toggleCloudUrl.onToggleCallback(settings.isConnected);
+ anyChanged = true;
+ }
+ if ((cachedSettings.serverUrl !== settings.serverUrl) ||
+ (cachedSettings.networkUUID !== settings.networkUUID) ||
+ (cachedSettings.networkSecret !== settings.networkSecret)) {
+ anyChanged = true;
+ toggleCloudUrl.onTextCallback(`https://${settings.serverUrl}/stable` +
+ `/n/${settings.networkUUID}` +
+ `/s/${settings.networkSecret}`);
+ toggleNewNetworkId.onTextCallback(settings.networkUUID);
+ toggleNewSecret.onTextCallback(settings.networkSecret);
+ }
+ cachedSettings = settings;
+ if (anyChanged) {
+ document.getElementById("settingsIframe").contentWindow.postMessage(JSON.stringify({
+ getSettings: realityEditor.gui.settings.generateGetSettingsJsonMessage(),
+ getMainDynamicSettings: realityEditor.gui.settings.generateDynamicSettingsJsonMessage(realityEditor.gui.settings.MenuPages.MAIN)
+ }), "*");
+ }
+ }
+
+ // If we're viewing this on localhost we can connect to and read settings
+ // from the local server
+ if (window.location.host.split(':')[0] === localSettingsHost.split(':')[0]) {
+ fetch(`${realityEditor.network.useHTTPS ? 'https' : 'http'}://${localSettingsHost}/hardwareInterface/edgeAgent/settings`).then(res => res.json()).then(settings => {
+ processNewSettings(settings);
+ });
+ }
+ // Update settings when changed
+ realityEditor.network.realtime.subscribeToInterfaceSettings('edgeAgent', settings => {
+ processNewSettings(settings);
+ });
+
+ // see if we should open the modal - defaults hidden but can be turned on from menu
+ let shouldShowIntroModal = window.localStorage.getItem('neverAgainShowIntroTips') !== 'true';
+
+ if (shouldShowIntroModal) {
+ let modalBody = "The Vuforia Spatial Toolbox is an open source research platform for exploring Augmented Reality and Spatial Computing. " +
+ "";
+
+ realityEditor.gui.modal.openClassicModal('Welcome to the Vuforia Spatial Toolbox!', modalBody, 'Close', 'Close and Don\'t Show Again', function() {
+ // console.log('Closed');
+ }, function() {
+ // console.log('Closed and Don\'t Show Again!');
+ introToggle.setValue(false);
+ });
+ }
+ }
+ exports.initService = initService;
+
+})(realityEditor.gui.settings.setupSettingsMenu);
diff --git a/src/gui/settings/states.js b/src/gui/settings/states.js
new file mode 100644
index 000000000..0dc4dc7cf
--- /dev/null
+++ b/src/gui/settings/states.js
@@ -0,0 +1,10 @@
+createNameSpace("realityEditor.gui.settings");
+
+realityEditor.gui.settings.states = {
+ logoAnimation:false,
+ settingsButton:false,
+ animationFrameRequested: false,
+ setInt : false
+};
+
+realityEditor.gui.settings.objectInterval = null;
diff --git a/src/gui/shaders.js b/src/gui/shaders.js
new file mode 100644
index 000000000..8b47f9976
--- /dev/null
+++ b/src/gui/shaders.js
@@ -0,0 +1,178 @@
+import {ShaderChunk} from "../../thirdPartyCode/three/three.module.js";
+
+createNameSpace("realityEditor.gui.shaders");
+
+(function(exports) {
+ const commonShader = `
+ vec3 HSLToRGB(float h, float s, float l) {
+ s /= 100.0;
+ l /= 100.0;
+
+ float k0 = mod((0.0 + h / 30.0), 12.0);
+ float k4 = mod((8.0 + h / 30.0), 12.0);
+ float k8 = mod((4.0 + h / 30.0), 12.0);
+
+ float a = s * min(l, 1.0 - l);
+
+ float f0 = l - a * max(-1.0, min(k0 - 3.0, min(9.0 - k0, 1.0)));
+ float f4 = l - a * max(-1.0, min(k4 - 3.0, min(9.0 - k4, 1.0)));
+ float f8 = l - a * max(-1.0, min(k8 - 3.0, min(9.0 - k8, 1.0)));
+
+ return vec3(255.0 * f0, 255.0 * f8, 255.0 * f4) / 255.0;
+ }
+
+ float Remap01 (float x, float low, float high) {
+ return clamp((x - low) / (high - low), 0., 1.);
+ }
+
+ float Remap (float x, float lowIn, float highIn, float lowOut, float highOut) {
+ return lowOut + (highOut - lowOut) * Remap01(x, lowIn, highIn);
+ }
+ `;
+ function heightMapVertexShader() {
+ // return `
+ // // #include
+ // // todo Steve: varying vec3 vWorldPosition; somehow vWorldPosition has almost near 0. x, y, and z values. Further investigate
+ // varying vec3 vPosition;
+ // void main() {
+ // vPosition = position.xyz;
+ // // vPosition = vWorldPosition.xyz;
+ // gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ // }
+ // `;
+ return ShaderChunk.meshphysical_vert
+ .replace('#include ', `#include
+ varying vec3 vPosition;
+ varying vec3 vNormal2;
+ `)
+ .replace('#include ', `#include
+ vPosition = position.xyz; // makes position accessible in the fragment shader
+ vNormal2 = normal.xyz;
+ `);
+ }
+
+ function gradientMapVertexShader() {
+ return ShaderChunk.meshphysical_vert
+ .replace('#include ', `#include
+ attribute vec4 tangent;
+ varying vec4 vTangent;
+ varying vec3 vPosition;
+ varying vec3 vNormal2;
+ `)
+ .replace('#include ', `#include
+ vTangent = tangent.xyzw;
+ vPosition = position.xyz;
+ vNormal2 = normal.xyz;
+ `)
+ }
+
+ function gradientMapFragmentShader() {
+ return ShaderChunk.meshphysical_frag
+ .replace('#include ', `#include
+ #define heightMap_blur 0.01
+
+ varying vec4 vTangent;
+ varying vec3 vPosition;
+ varying vec3 vNormal2;
+
+ uniform float gradientMap_minAngle;
+ uniform float gradientMap_maxAngle;
+ uniform bool gradientMap_outOfRangeAreaOriginalColor;
+
+ ${commonShader}
+ `)
+ .replace('#include ', `#include
+ if (true) {
+ vec3 col = vec3(0.);
+
+ // gradient map colored heat map
+ // // method 1:
+ // float a = Remap01(vTangent.y, -1., 1.);
+ // // method 2:
+ // float a = Remap01(abs(vTangent.y), 0., 1.);
+ // // method 3:
+ // float a = Remap01(abs(dot(vTangent, normalize(vec3(vTangent.x, 0., vTangent.z)))), 0., 1.);
+ // // method 4:
+ float steepness = abs(dot(normalize(vNormal2), vec3(0., 1., 0.))); // Range [0., 1.]. 0. ~ very steep; 1. ~ very flat
+ float angle = degrees(acos(steepness));
+ float a = Remap(angle, gradientMap_minAngle, gradientMap_maxAngle, .1, .8);
+ col = HSLToRGB(a * 360. + 70., 100., 50.) * 0.5;
+
+ float mapAlpha = gradientMap_outOfRangeAreaOriginalColor ? (angle < gradientMap_minAngle || angle > gradientMap_maxAngle ? 0. : 1.) : 1.;
+ // col *= mapAlpha;
+ if (gradientMap_outOfRangeAreaOriginalColor == true) {
+ col = mapAlpha == 0. ? vec3(1., 0., 0.) : vec3(0., 1., 0.);
+ col *= 0.5;
+ }
+
+ // height map grid lines, 0.5 m per line
+ float thickness = 0.01;
+ thickness *= pow(1. - steepness, 1.); // attenuate thickness regarding angle of flatness. The flatter the surface, the thinner the line should be to avoid having a very thick line that doesn't look like height map lines
+ float b = mod(vPosition.y, 0.5);
+ float d = b - thickness;
+ // d = smoothstep(heightMap_blur, -heightMap_blur, d);
+ d = smoothstep(fwidth(b), -fwidth(b), d);
+ col += vec3(1.) * d;
+
+ // gl_FragColor.rgb *= 0.5;
+ if (gradientMap_outOfRangeAreaOriginalColor == true || mapAlpha == 1.) {
+ // if (mapAlpha == 1.) {
+ gl_FragColor.rgb *= 0.5;
+ }
+ gl_FragColor += vec4(col, 1.);
+
+ // gl_FragColor = vec4(0., steepness, 0., 1.);
+ }
+ `);
+ }
+
+ function heightMapFragmentShader() {
+ return ShaderChunk.meshphysical_frag
+ .replace('#include ', `#include
+ #define heightMap_blur 0.01
+
+ varying vec3 vPosition; // --- vPosition.y approximately [-1.05, 1.5] for office new, [-2.4, 1.6] for harpak ulma machine
+ varying vec3 vNormal2;
+
+ uniform float heightMap_maxY;
+ uniform float heightMap_minY;
+
+ ${commonShader}
+ `)
+ .replace('#include ', `
+ #include
+
+ if (true) {
+ vec3 col = vec3(0.);
+
+ float steepness = abs(dot(normalize(vNormal2), vec3(0., 1., 0.))); // Range [0., 1.]. 0. ~ very steep; 1. ~ very flat
+
+ // height map colored heat map
+ float a = Remap01(vPosition.y, heightMap_minY, heightMap_maxY);
+ a = Remap(a, 0., 1., .1, .8); // remap the range to [0.1, 0.8] to get the color range between red and dark purple, and avoid looping the color
+ col = HSLToRGB(a * 360. - 290., 100., 50.) * 0.5; // attenuate height map color with a fraction
+
+ // height map grid lines, 0.5 m per line
+ float thickness = 0.01;
+
+ thickness *= pow(1. - steepness, 1.); // attenuate thickness regarding angle of flatness. The flatter the surface, the thinner the line should be to avoid having a very thick line that doesn't look like height map lines
+
+ // float b = mod(vPosition.y + (heightMap_maxY + heightMap_minY) / 2., 0.5); // this one almost (still not quite perfectly accurate) got the grid lines to appear at y === 0., but one issue emerged: a large portion of the ground gets colored white
+ float b = mod(vPosition.y, 0.5);
+ float d = b - thickness;
+ // d = smoothstep(heightMap_blur, -heightMap_blur, d);
+ d = smoothstep(fwidth(b), -fwidth(b), d);
+ col += vec3(1.) * d;
+
+ gl_FragColor.rgb *= 0.5; // attenuate original mesh texture color with a fraction
+ gl_FragColor += vec4(col, 1.);
+ }
+ `);
+ }
+
+ exports.heightMapVertexShader = heightMapVertexShader;
+ exports.heightMapFragmentShader = heightMapFragmentShader;
+
+ exports.gradientMapVertexShader = gradientMapVertexShader;
+ exports.gradientMapFragmentShader = gradientMapFragmentShader;
+})(realityEditor.gui.shaders);
diff --git a/src/gui/spatial/index.js b/src/gui/spatial/index.js
new file mode 100644
index 000000000..19720d5b7
--- /dev/null
+++ b/src/gui/spatial/index.js
@@ -0,0 +1,559 @@
+/*
+* Created by Valentin on 04/23/20.
+*
+* Copyright (c) 2020 PTC Inc
+*
+* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/.
+*/
+
+createNameSpace("realityEditor.gui.spatial");
+realityEditor.gui.spatial.worldOrigin = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+];
+realityEditor.gui.spatial.objects = realityEditor.objects;
+realityEditor.gui.spatial.spatial = globalStates.spatial;
+realityEditor.gui.spatial.screenLocation = {x:-1,y:-1};
+realityEditor.gui.spatial.utilities = realityEditor.gui.ar.utilities;
+realityEditor.gui.spatial.whereIsList = {};
+realityEditor.gui.spatial.howFarIsList = {};
+realityEditor.gui.spatial.whereWasList = {};
+realityEditor.gui.spatial.velocityOfList = {};
+realityEditor.gui.spatial.nodeList = {};
+realityEditor.gui.spatial.historianOn = false;
+realityEditor.gui.spatial.spatialOn = false;
+realityEditor.gui.spatial.myp5 = null;
+realityEditor.gui.spatial.draw = {};
+realityEditor.gui.spatial.clearSpatialList = function (){
+ realityEditor.gui.spatial.whereIsList = {};
+ realityEditor.gui.spatial.howFarIsList = {};
+ realityEditor.gui.spatial.whereWasList = {};
+ realityEditor.gui.spatial.velocityOfList = {};
+ realityEditor.gui.spatial.nodeList = {};
+};
+realityEditor.gui.spatial.lineAnimationList = {};
+
+realityEditor.gui.spatial.checkState = function() {
+ realityEditor.gui.spatial.historianOn = false;
+ realityEditor.gui.spatial.spatialOn = false;
+
+ for (let ip in globalStates.spatial.whereIs) {
+ if (Object.keys(globalStates.spatial.whereIs[ip]).length > 0) {
+ realityEditor.gui.spatial.spatialOn = true;
+ break;
+ }
+ }
+
+ if (!realityEditor.gui.spatial.spatialOn) {
+ for (let ip in globalStates.spatial.howFarIs) {
+ if (Object.keys(globalStates.spatial.howFarIs[ip]).length > 0) {
+ realityEditor.gui.spatial.spatialOn = true;
+ break;
+ }
+ }
+ }
+
+ for (let ip in globalStates.spatial.whereWas) {
+ if (Object.keys(globalStates.spatial.whereWas[ip]).length > 0) {
+ realityEditor.gui.spatial.spatialOn = true;
+ realityEditor.gui.spatial.historianOn = true;
+ break;
+ }
+ }
+
+ if (!realityEditor.gui.spatial.spatialOn ||
+ !realityEditor.gui.spatial.historianOn) {
+ for (let ip in globalStates.spatial.velocityOf) {
+ if (Object.keys(globalStates.spatial.velocityOf[ip]).length > 0) {
+ realityEditor.gui.spatial.spatialOn = true;
+ realityEditor.gui.spatial.historianOn = true;
+ break;
+ }
+ }
+ }
+
+ if (realityEditor.gui.spatial.myp5 === null && realityEditor.gui.spatial.spatialOn) {
+ realityEditor.gui.spatial.myp5 = new p5(realityEditor.gui.spatial.sketch.bind(realityEditor.gui.spatial), 'p5WebGL');
+ }
+};
+
+// update the whereIsList, howFarIsList, whereWasList, and velocityOfList with modelView matrices of each selected thing
+// also append the model matrices of each selected thing to the historian timeRecorder
+realityEditor.gui.spatial.collectSpatialLists = function() {
+ if (!realityEditor.gui.spatial.spatialOn) return;
+
+ this.worldOrigin = realityEditor.sceneGraph.getViewMatrix();
+
+ this.collectSpatialList(globalStates.spatial.whereIs, this.whereIsList);
+ this.collectSpatialList(globalStates.spatial.howFarIs, this.howFarIsList);
+ this.collectSpatialList(globalStates.spatial.whereWas, this.whereWasList);
+ this.collectSpatialList(globalStates.spatial.velocityOf, this.velocityOfList);
+
+ let cameraNode = realityEditor.sceneGraph.getSceneNodeById('CAMERA');
+
+ // if the historian is on, store the matrix of each visible object at each timestep
+ if (realityEditor.gui.spatial.historianOn) {
+ Object.keys(realityEditor.gui.ar.draw.visibleObjects).forEach(function (objectKey) {
+ this.timeRecorder.initSequence(objectKey, objectKey, '', '');
+
+ let objMatrix = []; // remove viewMatrix from modelView matrix to get correct modelMatrix
+ let objMVMatrix = realityEditor.sceneGraph.getModelViewMatrix(objectKey);
+ this.utilities.multiplyMatrix(objMVMatrix, cameraNode.localMatrix, objMatrix);
+
+ this.timeRecorder.addMatrix(objMatrix, objectKey);
+
+ let thisObject = realityEditor.getObject(objectKey);
+ if (thisObject) {
+ Object.keys(thisObject.frames).forEach(function(frameKey) {
+ this.timeRecorder.initSequence(frameKey, objectKey, frameKey, '');
+
+ let frameMatrix = []; // remove viewMatrix from modelView matrix to get correct modelMatrix
+ let frameMVMatrix = realityEditor.sceneGraph.getModelViewMatrix(frameKey);
+ this.utilities.multiplyMatrix(frameMVMatrix, cameraNode.localMatrix, frameMatrix);
+
+ this.timeRecorder.addMatrix(frameMatrix, frameKey);
+
+ }.bind(this));
+ }
+ }.bind(this));
+ }
+};
+
+// helper function to populate the correct list (e.g. whereIsList) with the ID and modelView matrix pairs of each
+// object or tool selected for each server IP that has some active spatial questions
+realityEditor.gui.spatial.collectSpatialList = function(selectionList, resultsList) {
+ for (let ip in selectionList) {
+ for (let key in selectionList[ip]) {
+ // try to get the ModelView matrix of this entity
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(key);
+ if (sceneNode) {
+ resultsList[key] = {
+ 'key': key,
+ 'matrix': realityEditor.sceneGraph.getModelViewMatrix(key)
+ };
+ }
+ }
+ }
+};
+
+realityEditor.gui.spatial.myFont = null;
+realityEditor.gui.spatial.canvasThis = null;
+realityEditor.gui.spatial.saveOldMatrix = null;
+
+let _canvasTexture = null;
+
+realityEditor.gui.spatial.sketch = function(p) {
+ p.preload = function() {
+ this.myFont = p.loadFont('thirdPartyCode/fonts/roboto.ttf');
+ }.bind(this);
+
+ p.setup = function() {
+ p.setAttributes('antialias', true);
+ this.canvasThis = p.createCanvas(globalStates.height,globalStates.width, p.WEBGL);
+ this.canvasThis.id('p5jsCanvas');
+ let gl = document.getElementById('p5jsCanvas').getContext('webgl');
+ gl.disable(gl.DEPTH_TEST);
+ _canvasTexture = p.createGraphics(globalStates.height, globalStates.width,null, globalCanvas.canvas);
+
+ // p.frameRate(5);
+ }.bind(this);
+
+ p.draw = function() {
+ p.clear();
+
+ // copy normal ball connection context
+
+ p.push();
+ this.canvasThis.uPMatrix.set(globalStates.realProjectionMatrix);
+ // console.log(p.frameRate());
+
+
+ this.canvasThis.uMVMatrix.set([
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1]);
+
+ /*
+ p.push();
+
+ p.translate(globalStates.height/2,-globalStates.width/2,-544);
+ // if(globalStates.deviceOrientationRight) {
+ p.rotateY(Math.PI);
+ //}
+ // canvasTexture.background(100);
+ p.image(canvasTexture, 0, 0);
+ p.pop();
+ */
+ /* for(let key in this.nodeList) {
+ this.draw.nodesP5(this.nodeList[key],p);
+ }*/
+
+ for(let key in this.whereIsList) {
+ this.draw.whereIsP5(this.whereIsList[key],p);
+ }
+
+ for(let key in this.howFarIsList) {
+ this.draw.howFarIsP5(this.howFarIsList[key],p);
+ }
+
+ for(let key in this.velocityOfList) {
+ this.draw.velocityOfP5(this.velocityOfList[key],p);
+ }
+
+ this.canvasThis.uMVMatrix.apply(this.worldOrigin);
+
+ // p.translate( this.worldOrigin[12], this.worldOrigin[13], this.worldOrigin[14]);
+ /*
+ p.fill('rgba(0,255,255, 1)');
+ p.stroke('rgba(0,255,255, 1)');
+ p.circle(0,0,20);
+ p.sphere(5);*/
+
+
+ for(let key in this.whereWasList) {
+ this.draw.whereWasP5(this.whereWasList[key],p);
+ }
+
+ p.pop();
+
+ if(!realityEditor.gui.spatial.spatialOn){
+ p.remove();
+ console.log("removed p5js")
+ realityEditor.gui.spatial.myp5 = null;
+ }
+
+ }.bind(this);
+};
+
+// creating the p5 canvas somehow sets display:none on the globalCanvas
+// for now, fix it by repeatedly setting it back to un-hidden a few times
+for (let time = 100; time < 5000; time *= 2) {
+ setTimeout(function() {
+ if (globalCanvas && globalCanvas.canvas) {
+ globalCanvas.canvas.style.display = ''; // unhide the canvas getting auto-hidden by p5
+ }
+ }, time);
+}
+
+realityEditor.gui.spatial.draw.nodesP5 = function (object,p) {
+ p.push();
+ realityEditor.gui.spatial.canvasThis.uMVMatrix.apply(object.matrix);
+//p.translate(m[12], m[13], m[14]*0.97);
+ // p.background(100);
+ p.erase();
+ p.fill('rgba(255,255,255, 1)');
+ p.stroke('rgba(0,255,255, 1)');
+ p.circle(0,0,250);
+ p.noErase();
+ p.blendMode(p.ADD);
+ // p.sphere(50);
+
+ p.translate(0, -400, 0);
+
+ p.textFont(realityEditor.gui.spatial.myFont);
+ p.textSize(250);
+ p.textAlign(p.CENTER, p.CENTER);
+ p.text(object.object.data.value, 0, 0);
+ p.pop();
+};
+
+realityEditor.gui.spatial.draw.whereWasP5 = function (workObject,p){
+ p.noStroke();
+ if(!(workObject.key in realityEditor.gui.spatial.timeRecorder.sequences)) return;
+ let sequence = realityEditor.gui.spatial.timeRecorder.sequences[workObject.key].sequence;
+
+ if(sequence.length>2){
+ for (let i = 1; i < sequence.length; i++) {
+ // p.vertex(sequence[i].m[0], sequence[i].m[1],sequence[i].m[2]);
+ // realityEditor.gui.spatial.draw.drawLineP5(p, null, sequence[i-1].m,sequence[i].m,2, 2,[0,255,255, 1],[0,255,255, 1], "solid", 2, null);
+
+ let lineWidth = 2 * realityEditor.device.environment.getLineWidthMultiplier();
+
+ realityEditor.gui.spatial.draw.drawLineP5(p, workObject, sequence[i-1].m,sequence[i].m ,lineWidth, lineWidth,[0,255,255, 1],[0,255,255, 1], "solid", -0.1, null);
+
+ }
+ /* p.endShape();
+ p.pop();*/
+ }
+};
+
+realityEditor.gui.spatial.draw.lastLocation = {};
+
+realityEditor.gui.spatial.draw.velocityOfP5 = function (workObject,p) {
+ if (!(workObject.key in realityEditor.gui.spatial.timeRecorder.sequences)) return;
+
+ let thisSequence = realityEditor.gui.spatial.timeRecorder.sequences[workObject.key];
+
+ let m1 = workObject.matrix;
+ p.fill("rgba(0,255,255, 1)");
+ p.noStroke();
+ p.push();
+ p.translate(workObject.matrix[12], workObject.matrix[13],workObject.matrix[14]);
+ p.sphere(5);
+ p.pop();
+ // erase background
+ p.push();
+ p.translate(
+ m1[12],
+ m1[13]-25,
+ m1[14]);
+ if(!globalStates.deviceOrientationRight) {
+ p.rotateX(Math.PI);
+ } else {
+ p.rotateY(Math.PI);
+ }
+
+ p.textFont(realityEditor.gui.spatial.myFont);
+ p.textSize(15);
+ p.textAlign(p.CENTER, p.CENTER);
+
+ p.text(parseInt(thisSequence.speed*10)/10 + ' m/s', 0, 0);
+ p.pop();
+
+
+ let m4 = realityEditor.gui.spatial.timeRecorder.copyArray(workObject.matrix);
+
+ m4[12] -= thisSequence.speedVector[0];
+ m4[13] -= thisSequence.speedVector[1];
+ m4[14] -= thisSequence.speedVector[2];
+
+ realityEditor.gui.spatial.draw.drawLineP5(p, workObject, workObject.matrix, m4, 4, 2, [255, 255, 0, 1], [255, 255, 0, 1], "solid", 0, null);
+};
+
+realityEditor.gui.spatial.draw.whereIsP5 = function (workObject,p) {
+ let matrix = workObject.matrix;
+
+ p.push();
+
+ p.stroke('rgba(0,255,255, 0.8)');
+ p.noStroke();
+ p.fill('rgba(0,255,255, 0.8)');
+
+ p.beginShape();
+
+ p.vertex(-5,10, -20);
+ p.vertex(-5,matrix[13], matrix[14]);
+ p.vertex(0,matrix[13]-5, matrix[14]);
+ p.vertex(+5,matrix[13], matrix[14]);
+ p.vertex(+5,10, -20);
+ p.endShape();
+
+ p.stroke('rgba(0,255,255, 0.8)');
+ p.strokeWeight(0.0);
+ p.fill('rgba(0,255,255, 0.8)');
+ p.beginShape();
+ p.vertex(-5, matrix[13], matrix[14]);
+ p.vertex(0, matrix[13]-5, matrix[14]);
+ p.vertex(5, matrix[13], matrix[14]);
+ p.vertex(0, matrix[13]+5, matrix[14]);
+ p.endShape();
+
+
+ p.fill('rgba(0,255,255, 0.8)');
+ p.beginShape();
+ p.vertex(0, matrix[13]+5, matrix[14]);
+ p.vertex(matrix[12], matrix[13]+2, matrix[14]);
+ p.vertex(matrix[12], matrix[13]-2, matrix[14]);
+ p.vertex(0, matrix[13]-5, matrix[14]);
+ p.endShape();
+
+ p.translate(matrix[12], matrix[13], matrix[14]);
+ p.fill('rgba(0,255,255, 1)');
+ p.sphere(5);
+ p.pop();
+};
+
+let _angle = 0;
+realityEditor.gui.spatial.draw.howFarIsP5 = function (obj,p) {
+ let m1 = obj.matrix;
+ let _worldAngle = Math.atan2(m1[13], m1[12]);
+ let color;
+ color = [0,255,255, 0.8];
+
+ p.noStroke();
+
+ p.fill('rgba(0,255,255, 1)');
+ p.push();
+ p.translate(obj.matrix[12], obj.matrix[13],obj.matrix[14]);
+ p.sphere(5);
+ p.pop();
+ p.fill(color);
+ for (let key in realityEditor.gui.spatial.howFarIsList) {
+ if (key !== obj.key) {
+ let m2 = realityEditor.gui.spatial.howFarIsList[key].matrix;
+
+ if (m1[12]>m2[12]) {
+
+ realityEditor.gui.spatial.draw.drawLineP5(p, obj, m1,m2,2, 2,[0,255,255, 1],[0,255,255, 1], "solid", 15, "line");
+
+ // erase background
+ p.push();
+ p.translate(
+ (m1[12]+m2[12])/2,
+ (m1[13]+m2[13])/2,
+ (m1[14]+m2[14])/2 );
+ p.translate(0,0,19.9);
+ p.fill("rgba("+color+")");
+ p.erase();
+ p.rect(-20, -10, 40, 20, 5);
+ p.noErase();
+ p.blendMode(p.ADD);
+
+ // distance Number
+ p.translate(0,0,0.1);
+ if(!globalStates.deviceOrientationRight) {
+ p.rotateX(Math.PI);
+ } else {
+ p.rotateY(Math.PI);
+ }
+ p.fill("rgba("+color+")");
+ let distance = Math.sqrt(Math.pow(m1[12]-m2[12], 2) + Math.pow(m1[13]-m2[13], 2) + Math.pow(m1[14]-m2[14], 2));
+ p.textFont(realityEditor.gui.spatial.myFont);
+ p.textSize(15);
+ p.textAlign(p.CENTER, p.CENTER);
+ p.text(parseInt(distance)/10, 0, 0);
+
+ p.pop();
+ }
+ }
+ }
+};
+
+realityEditor.gui.spatial.draw.mL = {
+ x : 12,
+ y : 13,
+ z : 14,
+ x2 : 12,
+ y2 : 13,
+ z2 : 14
+};
+
+realityEditor.gui.spatial.draw.drawLineP5 = function (p, obj, m1,m2,startWidth, endWidth, startColor, endColor, lineType, endSpacer, endpointType) {
+ let that = realityEditor.gui.spatial.draw.mL;
+
+ that.x = 12;
+ that.y = 13;
+ that.z = 14;
+
+ that.x2 = 12;
+ that.y2 = 13;
+ that.z2 = 14;
+
+
+ if(m1.length < 5){
+ that.x = 0;
+ that.y = 1;
+ that.z = 2;
+ }
+
+ if(m2.length < 5){
+ that.x2 = 0;
+ that.y2 = 1;
+ that.z2 = 2;
+ }
+ // init math
+ that.distance = Math.sqrt(Math.pow(m1[that.x]-m2[that.x2], 2) + Math.pow(m1[that.y]-m2[that.y2], 2) + Math.pow(m1[that.z]-m2[that.z2], 2));
+ that.angle = Math.atan2(m1[that.y]-m2[that.y2], m1[that.x]-m2[that.x2]);
+ that.angleZ = Math.asin((m1[that.z] - m2[that.z2])/that.distance);
+ that.h = that.angle + (Math.PI/2);
+ that.hZ = that.angleZ + (Math.PI/2);
+ that.h2 = ((Math.PI/2) - that.angle);
+ that.h2Z = ((Math.PI/2) - that.angle);
+ that.rX = startWidth * Math.cos(that.h);
+ that.rY = startWidth * Math.sin(that.h);
+ that.rZ = startWidth * Math.sin(that.hZ);
+ that.endX = endWidth * Math.cos(that.h);
+ that.endY = endWidth * Math.sin(that.h);
+ that.endZ = endWidth * Math.sin(that.hZ);
+ that.sY = endSpacer * Math.cos(that.h2);
+ that.sX = endSpacer * Math.sin(that.h2);
+ that.sZ = endSpacer * Math.tan(that.h2Z);
+
+ // endpoint
+ if(endpointType === "line") {
+ that.wDist = 7;
+ p.push();
+ p.fill("rgba("+startColor+")");
+ p.translate(m1[that.x], m1[that.y], m1[that.z]);
+ p.rotateZ(that.h);
+ p.beginShape();
+ endSpacer = endSpacer - 2;
+ p.vertex(-that.wDist, endSpacer + startWidth, 0);
+ p.vertex(-that.wDist, endSpacer - startWidth, 0);
+ p.vertex(+that.wDist, endSpacer - startWidth, 0);
+ p.vertex(+that.wDist, endSpacer + startWidth, 0);
+ p.endShape();
+ p.pop();
+
+ p.push();
+ p.fill("rgba("+startColor+")");
+ p.translate(m2[that.x2], m2[that.y2], m2[that.z2]);
+ p.rotateZ(that.h);
+ p.rotateZ(Math.PI);
+ p.beginShape();
+ endSpacer = endSpacer + 2;
+ p.vertex(-that.wDist, endSpacer + startWidth, 0);
+ p.vertex(-that.wDist, endSpacer - startWidth, 0);
+ p.vertex(+that.wDist, endSpacer - startWidth, 0);
+ p.vertex(+that.wDist, endSpacer + startWidth, 0);
+ p.endShape();
+ p.pop();
+ }
+ // solid line
+ if (lineType === "solid") {
+ p.push();
+ p.fill("rgba("+startColor+")");
+ p.beginShape();
+ p.vertex(m1[that.x]+that.rX-that.sX, m1[that.y]+that.rY-that.sY, m1[that.z]);
+ p.vertex(m1[that.x]-that.rX-that.sX, m1[that.y]-that.rY-that.sY, m1[that.z]);
+ p.vertex(m2[that.x2]-that.endX+that.sX, m2[that.y2]-that.endY+that.sY, m2[that.z2]);
+ p.vertex(m2[that.x2]+that.endX+that.sX, m2[that.y2]+that.endY+that.sY, m2[that.z2]);
+ p.endShape();
+ p.pop();
+
+ } else if(lineType === "balls"){
+ realityEditor.gui.spatial.drawLine(p, obj, m1, m2, startWidth, endWidth, startColor, endColor, null, 1, 1);
+ }
+};
+
+realityEditor.gui.spatial.dL = {
+ step:realityEditor.gui.spatial.lineAnimationList, spacer:null,lineVectorLength:null, angle:null, angleZ:null, vX:null, vY:null, vZ:null, stepLength:null,counter:null
+};
+
+realityEditor.gui.spatial.drawLine = function(p, obj, m1, m2, startWeight, endWeight, startColor, endColor, speed, _startAplha, _endAlpha) {
+ let that = realityEditor.gui.spatial;
+ startWeight = 20;
+ that.spacer = 5;
+ if (!speed) speed = 0.5;
+
+ that.lineVectorLength = Math.sqrt(Math.pow(m1[12]-m2[12], 2) + Math.pow(m1[13]-m2[13], 2) + Math.pow(m1[14]-m2[14], 2));
+ that.angle = Math.atan2((m1[13] - m2[13]), (m1[12] - m2[12]));
+ that.angleZ = Math.asin((m1[14] - m2[14])/that.lineVectorLength);
+ that.vX = Math.cos(that.angle) * (startWeight + that.spacer)*-1;
+ that.vY = Math.sin(that.angle) * (startWeight + that.spacer)*-1;
+ that.vZ = Math.tan(that.angleZ) * (startWeight + that.spacer)*-1;
+ that.stepLength = Math.sqrt(Math.pow(that.vX, 2) + Math.pow(that.vY, 2) + Math.pow(that.vZ, 2));
+ that.counter = that.lineVectorLength / that.stepLength-1;
+
+ if (!realityEditor.gui.spatial.lineAnimationList[obj.key]) realityEditor.gui.spatial.lineAnimationList[obj.key] = 0;
+ if (realityEditor.gui.spatial.lineAnimationList[obj.key] >= startWeight + that.spacer) realityEditor.gui.spatial.lineAnimationList[obj.key] = 0;
+
+ p.push();
+ p.fill("rgba("+startColor+")");
+ p.translate(m1[12], m1[13], m1[14]);
+ p.translate( -Math.cos(that.angle) * realityEditor.gui.spatial.lineAnimationList[obj.key], -Math.sin(that.angle) * realityEditor.gui.spatial.lineAnimationList[obj.key], -Math.tan(that.angleZ) * realityEditor.gui.spatial.lineAnimationList[obj.key]);
+ p.circle(0, 0, startWeight);
+
+ for (let i = 0; i < that.counter; i++) {
+ p.translate(that.vX, that.vY, that.vZ);
+ p.circle(0, 0, startWeight);
+ // p.sphere(startWeight/2);
+ }
+ p.pop();
+ realityEditor.gui.spatial.lineAnimationList[obj.key] += (timeCorrection.delta)+speed;
+};
diff --git a/src/gui/spatial/timeRecorder.js b/src/gui/spatial/timeRecorder.js
new file mode 100644
index 000000000..f09976c01
--- /dev/null
+++ b/src/gui/spatial/timeRecorder.js
@@ -0,0 +1,125 @@
+/*
+* Created by Valentin on 04/23/20.
+*
+* Copyright (c) 2020 PTC Inc
+*
+* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/.
+*/
+
+createNameSpace("realityEditor.gui.spatial.timeRecorder");
+
+realityEditor.gui.spatial.timeRecorder.sequences = {};
+realityEditor.gui.spatial.timeRecorder.recordingTime = 1800000; // multiplied by 10 on 4-18-21
+realityEditor.gui.spatial.timeRecorder.recordingSteps = 1000; // multiplied by 10 on 4-18-21
+realityEditor.gui.spatial.timeRecorder.sensitivity = 30.0;
+realityEditor.gui.spatial.timeRecorder.TimeSequence = function (_objectID, _toolID, _nodeID) {
+ this.objectID = '';
+ this.toolID = '';
+ this.nodeID = '';
+ this.sequence = [];
+
+ this.lastLocation = null;
+ this.lastSavedLocation = null;
+ this.distanceVector = 0;
+ this.speedVector = [0,0,0];
+ this.timeVector = 0;
+ this.speed = 0;
+ this.lastLocationDelay = [0,0,0];
+};
+
+realityEditor.gui.spatial.timeRecorder.TimeSequenceItem = function (time, location) {
+ this.t = time;
+ this.m = location;
+};
+
+realityEditor.gui.spatial.timeRecorder.initSequence = function (id, objectID, toolID, nodeID){
+ if(!(id in this.sequences)){
+ this.sequences[id] = new this.TimeSequence(objectID, toolID, nodeID);
+ }
+ this.currentTime = Date.now();
+ this.cleanUpSequence(id)
+};
+
+realityEditor.gui.spatial.timeRecorder.cleanUpSequence = function (id) {
+ if(!this.sequences[id].sequence.length) return;
+ if (this.sequences[id].sequence[0].t < (this.currentTime - this.recordingTime) || this.sequences[id].sequence.length> this.recordingSteps) {
+ this.sequences[id].sequence.shift();
+ }
+};
+
+realityEditor.gui.spatial.timeRecorder.addMatrix = function (m, id) {
+ if(!this.sequences[id].lastLocation) {
+ this.sequences[id].lastLocation = new this.TimeSequenceItem(this.currentTime, this.location(m));
+ }
+ if(!this.sequences[id].lastSavedLocation) {
+ this.sequences[id].lastSavedLocation = new this.TimeSequenceItem(this.currentTime, this.location(m));
+ }
+
+
+ this.sequences[id].distanceVector = Math.sqrt(Math.pow(m[12]-this.sequences[id].lastLocation.m[0], 2) + Math.pow(m[13]-this.sequences[id].lastLocation.m[1], 2) + Math.pow(m[14]-this.sequences[id].lastLocation.m[2], 2));
+ this.sequences[id].timeVector = this.currentTime - this.sequences[id].lastLocation.t;
+
+ this.sequences[id].lastLocation.t = this.currentTime;
+ this.sequences[id].lastLocation.m = this.location(m);
+
+ this.sequences[id].speedVector = [
+ this.sequences[id].lastLocation.m[0]- this.sequences[id].lastLocationDelay[0],
+ this.sequences[id].lastLocationDelay[1] - this.sequences[id].lastLocation.m[1],
+ this.sequences[id].lastLocationDelay[2] - this.sequences[id].lastLocation.m[2]
+ ];
+
+ setTimeout(function(){
+ realityEditor.gui.spatial.timeRecorder.sequences[id].lastLocationDelay = realityEditor.gui.spatial.timeRecorder.location(m);
+ }, 100);
+
+ this.sequences[id].speed = (this.sequences[id].distanceVector/100)/(this.sequences[id].timeVector/1000);
+
+
+ if(Math.abs(m[12]-this.sequences[id].lastSavedLocation.m[0]) > this.sensitivity ||
+ Math.abs(m[13]-this.sequences[id].lastSavedLocation.m[1]) > this.sensitivity ||
+ Math.abs(m[14]-this.sequences[id].lastSavedLocation.m[2]) > this.sensitivity){
+
+ this.sequences[id].sequence.push(new this.TimeSequenceItem(this.currentTime, this.location(m)));
+ this.sequences[id].lastSavedLocation.t = this.currentTime;
+ this.sequences[id].lastSavedLocation.m = this.location(m);
+ }
+};
+
+realityEditor.gui.spatial.timeRecorder.location = function (m) {
+ return [m[12], m[13], m[14]];
+};
+
+realityEditor.gui.spatial.timeRecorder.getSpeed = function (id) {
+ return this.sequences[id].speed;
+};
+
+realityEditor.gui.spatial.timeRecorder.copyArray = function (array) {
+ let returnItem = [];
+ for (let i = 0; i < array.length; i++) {
+ returnItem.push(array[i]);
+ }
+ return returnItem;
+};
+
+realityEditor.gui.spatial.timeRecorder.lastLocation = function (sequence) {
+ this.storage = sequence.length - 1;
+ if (this.storage >= 0)
+ return sequence[this.storage];
+ else return this.identity;
+};
+realityEditor.gui.spatial.timeRecorder.sequenceUp = function (number, sequence) {
+ this.storage = sequence.length - (1 + number);
+ if (this.storage >= 0)
+ return sequence[this.storage];
+ else return this.identity;
+};
+realityEditor.gui.spatial.timeRecorder.storage = null;
+realityEditor.gui.spatial.timeRecorder.currentTime = Date.now();
+realityEditor.gui.spatial.timeRecorder.identity = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+];
diff --git a/src/gui/spatialArrow.js b/src/gui/spatialArrow.js
new file mode 100644
index 000000000..77c4db730
--- /dev/null
+++ b/src/gui/spatialArrow.js
@@ -0,0 +1,253 @@
+createNameSpace("realityEditor.gui.spatialArrow");
+
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+
+(function (exports) {
+
+ let canvasContainer;
+ let canvas;
+ let ctx;
+ let screenW, screenH;
+ let screenRatio;
+ let menuBarHeight;
+
+ function initService() {
+ addCanvas();
+ resizeCanvas();
+ initCanvas();
+ update();
+
+ window.addEventListener('resize', () => {
+ // translate the canvas back to its original place and clear it
+ translate(-translateX, -translateY);
+ clear();
+ resizeCanvas();
+ initCanvas();
+ update();
+ });
+ }
+
+ function addCanvas() {
+ canvasContainer = document.createElement('div');
+ canvasContainer.className = 'arrow-canvas-container';
+ canvasContainer.style.position = 'absolute';
+ canvasContainer.style.top = '0';
+ canvasContainer.style.left = '0';
+ canvasContainer.style.pointerEvents = 'none';
+ document.body.appendChild(canvasContainer);
+
+ canvas = document.createElement('canvas');
+ canvas.className = 'arrow-canvas';
+ canvas.style.position = 'absolute';
+ menuBarHeight = realityEditor.device.environment.variables.screenTopOffset;
+ canvas.style.top = `${menuBarHeight}px`;
+ canvas.style.left = '0';
+ canvas.style.zIndex = '3001';
+ canvasContainer.appendChild(canvas);
+
+ ctx = canvas.getContext("2d");
+ }
+
+ function resizeCanvas() {
+ if (canvas !== undefined) {
+ screenW = window.innerWidth;
+ screenH = window.innerHeight - menuBarHeight;
+ screenRatio = screenH / screenW;
+ canvas.width = screenW;
+ canvas.height = screenH;
+ }
+ }
+
+ const clamp = (x, low, high) => {
+ return Math.min(Math.max(x, low), high);
+ }
+
+ const remap01 = (x, low, high) => {
+ return clamp((x - low) / (high - low), 0, 1);
+ }
+
+ const remap = (x, lowIn, highIn, lowOut, highOut) => {
+ return lowOut + (highOut - lowOut) * remap01(x, lowIn, highIn);
+ }
+
+ let translateX = 0, translateY = 0;
+
+ function translate(x, y) {
+ translateX += x;
+ translateY += y;
+ ctx.translate(x, y);
+ }
+
+ function rotate(a) {
+ ctx.rotate(a);
+ }
+
+ function scale(x, y) {
+ ctx.scale(x, y);
+ }
+
+ function clear() {
+ ctx.clearRect(-translateX, -translateY, screenW, screenH);
+ }
+
+ // draw an arrow at (x, y) center
+ function drawArrow(x, y, rotation, scaleFactor, innerColor='rgb(0, 255, 255)', outerColor='rgb(255, 255, 255)') {
+ translate(x, y);
+ scale(scaleFactor, scaleFactor);
+ rotate(rotation);
+
+ // draw path
+ let region = new Path2D();
+ region.moveTo(0, -3);
+ region.lineTo(-2, 2);
+ region.lineTo(2, 2);
+ region.closePath();
+ // fill path
+ ctx.fillStyle = innerColor;
+ ctx.fill(region, 'evenodd');
+ // stroke path
+ ctx.strokeStyle = outerColor;
+ ctx.lineWidth = .4;
+ ctx.stroke(region);
+
+ rotate(-rotation);
+ scale(1 / scaleFactor, 1 / scaleFactor);
+ translate(-x, -y);
+ }
+
+ function initCanvas() {
+ // make (0, 0) the center of canvas
+ translate(screenW / 2, screenH / 2);
+ clear();
+ }
+
+ let indicators = [];
+ function searchForIndicators() {
+ // todo: auto detect the indicator names, instead of hard-coded 'cylinderIndicator'
+ indicators = realityEditor.gui.threejsScene.getObjectsByName('cylinderIndicator');
+ }
+
+ function drawArrowBasedOnWorldPosition(worldPos, color, colorLighter) {
+ let finalPosX = 0, finalPosY = 0;
+ let screenX, screenY;
+ let screenBorderFactor = 0.97;
+ let desX = 0, desY = 0;
+ let angle = 0;
+ let k;
+
+ if (worldPos === null) return; // if rendering my avatar laser beam, then doesn't need to draw the arrow, since the laser beam is always on screen
+ // if the object is off screen, then reverse its original screen position, then add the indicator
+ if (!realityEditor.gui.threejsScene.isPointOnScreen(worldPos)) {
+ let screenXY = realityEditor.gui.threejsScene.getScreenXY(worldPos);
+
+ screenX = screenXY.x;
+ screenY = screenXY.y;
+
+ desX = remap(screenX, 0, screenW, -screenW/2, screenW/2);
+ desY = remap(screenY, 0, screenH, -screenH/2, screenH/2);
+
+ angle = Math.atan2(desY, desX);
+ angle += Math.PI / 2;
+
+ k = (screenY - screenH / 2) / (screenX - screenW / 2);
+
+ if (k < 0) {
+ if (Math.abs(k) < screenRatio) {
+ if (screenX < screenW / 2) {
+ // left side bottom half
+ finalPosX = - screenW / 2;
+ finalPosY = finalPosX * k;
+ } else {
+ // right side top half
+ finalPosX = screenW / 2;
+ finalPosY = finalPosX * k;
+ }
+ } else {
+ if (screenX < screenW / 2) {
+ // bottom side left half
+ finalPosY = screenH / 2;
+ finalPosX = finalPosY / k;
+ } else {
+ // top side right half
+ finalPosY = - screenH / 2;
+ finalPosX = finalPosY / k;
+ }
+ }
+ } else {
+ if (Math.abs(k) < screenRatio) {
+ if (screenX < screenW / 2) {
+ // left side top half
+ finalPosX = - screenW / 2;
+ finalPosY = finalPosX * k;
+ } else {
+ // right side bottom half
+ finalPosX = screenW / 2;
+ finalPosY = finalPosX * k;
+ }
+ } else {
+ if (screenX < screenW / 2) {
+ // top side left half
+ finalPosY = - screenH / 2;
+ finalPosX = finalPosY / k;
+ } else {
+ // bottom side right half
+ finalPosY = screenH / 2;
+ finalPosX = finalPosY / k;
+ }
+ }
+ }
+
+ finalPosX *= screenBorderFactor;
+ finalPosY *= screenBorderFactor;
+
+ drawArrow(finalPosX, finalPosY, angle, 5, color, colorLighter);
+ }
+ }
+
+ function drawArrowsAtIndicatorScreenPositions() {
+
+ let worldPos = new THREE.Vector3();
+
+ indicators.forEach((indicator) => {
+ indicator.getWorldPosition(worldPos);
+
+ drawArrowBasedOnWorldPosition(worldPos, indicator.avatarColor, indicator.avatarColorLighter);
+ })
+
+ // displaying off screen arrows for laser beams
+ for (let idx in laserBeamIndicators) {
+ let laserBeam = laserBeamIndicators[idx];
+ drawArrowBasedOnWorldPosition(laserBeam.worldPos, laserBeam.color, laserBeam.colorLighter);
+ }
+ }
+
+ let laserBeamIndicators = {};
+ function addLaserBeamIndicator(id, worldPos, color, colorLighter) {
+ laserBeamIndicators[id] = {
+ worldPos,
+ color,
+ colorLighter
+ };
+ }
+
+ function deleteLaserBeamIndicator(id) {
+ delete laserBeamIndicators[id];
+ }
+
+ function drawIndicatorArrows() {
+ searchForIndicators();
+ drawArrowsAtIndicatorScreenPositions();
+ }
+
+ function update() {
+ clear();
+ drawIndicatorArrows();
+ window.requestAnimationFrame(update);
+ }
+
+ exports.initService = initService;
+ exports.drawArrowBasedOnWorldPosition = drawArrowBasedOnWorldPosition;
+ exports.addLaserBeamIndicator = addLaserBeamIndicator;
+ exports.deleteLaserBeamIndicator = deleteLaserBeamIndicator;
+
+})(realityEditor.gui.spatialArrow);
diff --git a/src/gui/spatialIndicator.js b/src/gui/spatialIndicator.js
new file mode 100644
index 000000000..b5e32d05c
--- /dev/null
+++ b/src/gui/spatialIndicator.js
@@ -0,0 +1,386 @@
+createNameSpace("realityEditor.gui.spatialIndicator");
+
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+import { mergeBufferGeometries } from '../../thirdPartyCode/three/BufferGeometryUtils.module.js';
+
+(function (exports) {
+ let camera;
+
+ const DISABLE_SPATIAL_INDICATORS = true;
+
+ const vertexShader = `
+ varying vec2 vUv;
+
+ void main() {
+ vUv = uv;
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
+ gl_Position = projectionMatrix * mvPosition;
+ }
+ `;
+
+ const cylinderFragmentShader = `
+ #define S(a, b, x) smoothstep(a, b, x)
+ #define PI 3.14159
+ // #define blur 0.002
+ #define blur 0.1
+ #define brightness 0.001
+
+ #define black vec3(0.)
+ #define white vec3(1.)
+ #define red vec3(1., 0., 0.)
+ #define green vec3(0., 1., 0.)
+ #define blue vec3(0., 0., 1.)
+ #define cyan vec3(0., 1., 1.)
+
+ struct Lines {
+ float width;
+ float height;
+ float x;
+ float y;
+ float speed;
+ };
+
+ uniform int amount;
+ // cannot initialize the lines[] array with variable size
+ // so update the amount in the js file and shader always the same
+ uniform Lines lines[5];
+
+ // set up color uniforms
+ struct AvatarColor {
+ vec3 color;
+ vec3 colorLighter;
+ };
+ uniform AvatarColor avatarColor[1];
+
+ varying vec2 vUv;
+
+ // draw a vertical line segment at p with width w and height h
+ float line(vec2 uv, vec2 p, float w, float h) {
+ uv -= p;
+ // un-comment below line to find out what I did wrong
+ // float horizontal = S(.01, 0., abs(length(uv.x - w / 2.)));
+ float horizontal = S(blur, 0., abs(uv.x)- w / 2.);
+ float vertical = S(blur, 0., abs(uv.y) - h / 2.);
+ return horizontal * vertical;
+ }
+
+ float GlowingLine(vec2 uv, vec2 p, float w, float h) {
+ uv -= p;
+ float horizontal = S(blur, 0., abs(uv.x)- w / 2.);
+ float vertical = S(blur, 0., abs(uv.y) - h / 2.);
+ float d = horizontal * vertical;
+ float fx = brightness / abs(d - 1.);
+ fx = pow(fx, .5);
+ return fx;
+ }
+
+ void main(void) {
+ vec2 uv = vUv;
+
+ vec3 col = vec3(0.);
+ float alpha = 0.;
+
+ // draw the ascending lines
+ for (int i = 0; i < amount; i++) {
+ float x = lines[i].x, y = lines[i].y, width = lines[i].width, height = lines[i].height;
+ float d = GlowingLine(uv, vec2(x, y), width, height);
+ col += avatarColor[0].color * d;
+ alpha += d;
+ }
+
+ col *= .1;
+ alpha *= .1;
+ if (alpha < .35) alpha = 0.;
+
+ // draw the fluctuating upper alpha fade out boundary
+ // float boundary = S(0.0, 0.8, 1. - uv.y);
+ // float boundary = S(0.8, 0.0, 1. - uv.y);
+ // col = mix(col, red, boundary);
+
+ // float boundary = S(0.2, 0.0, 1. - uv.y);
+ // alpha = mix(alpha, 0., boundary);
+
+ gl_FragColor = vec4(col, alpha);
+ }
+ `;
+
+ // the amount variable always needs to be identical to the lines[] array in fragment shader
+ const amount = 5;
+ let lines = [];
+
+ let color = 'rgb(0, 255, 255)', colorLighter = 'rgb(255, 255, 255)';
+ let finalColor = [{
+ color: new THREE.Color(color),
+ colorLighter: new THREE.Color(colorLighter)
+ }];
+ let uniforms = {
+ 'avatarColor': {value: finalColor},
+ 'amount': {value: amount},
+ 'lines': {value: lines},
+ };
+
+ const cylinderMaterial = new THREE.ShaderMaterial({
+ vertexShader: vertexShader,
+ fragmentShader: cylinderFragmentShader,
+ uniforms: uniforms,
+ transparent: true,
+ side: THREE.DoubleSide,
+ });
+
+ const clamp = (x, low, high) => {
+ return Math.min(Math.max(x, low), high);
+ }
+
+ const remap01 = (x, low, high) => {
+ return clamp((x - low) / (high - low), 0, 1);
+ }
+
+ const remap = (x, lowIn, highIn, lowOut, highOut) => {
+ return lowOut + (highOut - lowOut) * remap01(x, lowIn, highIn);
+ }
+
+ if (!DISABLE_SPATIAL_INDICATORS) {
+ window.addEventListener('pointerdown', (e) => {
+ if (realityEditor.device.isMouseEventCameraControl(e)) return;
+ if (!realityEditor.device.utilities.isEventHittingBackground(e)) return;
+ handleMouseClick(e);
+ });
+ }
+
+ let worldIntersectPoint = {};
+ function getRaycastCoordinates(screenX, screenY) {
+ // todo: get objectsToCheck outside of this function & stop resetting & pushing every time this function runs,
+ // todo: in order to make the calculation more efficient
+ let objectsToCheck = [];
+ if (cachedOcclusionObject) {
+ objectsToCheck.push(cachedOcclusionObject);
+ }
+ objectsToCheck = objectsToCheck.concat(indicatorList);
+ // if (realityEditor.gui.threejsScene.getGroundPlaneCollider()) {
+ // objectsToCheck.push(realityEditor.gui.threejsScene.getGroundPlaneCollider());
+ // }
+ if (cachedWorldObject && objectsToCheck.length > 0) {
+ // by default, three.js raycast returns coordinates in the top-level scene coordinate system
+ let raycastIntersects = realityEditor.gui.threejsScene.getRaycastIntersects(screenX, screenY, objectsToCheck);
+ if (raycastIntersects.length > 0) {
+ // if hit a cylinder indicator, then make the cylinders expand and last longer
+ if (raycastIntersects[0].object.parent.name === 'cylinderIndicator') {
+ raycastIntersects[0].object.parent.iclick++;
+ return;
+ }
+ let groundPlaneMatrix = realityEditor.sceneGraph.getGroundPlaneNode().worldMatrix;
+ let inverseGroundPlaneMatrix = new realityEditor.gui.threejsScene.THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(inverseGroundPlaneMatrix, groundPlaneMatrix);
+ inverseGroundPlaneMatrix.invert();
+ raycastIntersects[0].scenePoint.applyMatrix4(inverseGroundPlaneMatrix);
+ let trInvGroundPlaneMat = inverseGroundPlaneMatrix.clone().transpose();
+ worldIntersectPoint = {
+ point: raycastIntersects[0].point,
+ normalVector: raycastIntersects[0].face.normal.clone().applyMatrix4(trInvGroundPlaneMat).normalize(),
+ }
+ }
+ }
+ return worldIntersectPoint; // these are relative to the world object
+ }
+
+ async function getMyAvatarColor() {
+ let myAvatarColor = await realityEditor.avatar.getMyAvatarColor();
+ color = `${myAvatarColor.color}`;
+ colorLighter = `${myAvatarColor.colorLighter}`;
+ finalColor[0] = {
+ color: new THREE.Color(color),
+ colorLighter: new THREE.Color(colorLighter)
+ };
+ }
+
+ let spatialIndicatorActivated = false;
+ function handleMouseClick(e) {
+ // if (!avatarActive) return;
+ spatialIndicatorActivated = true;
+ worldIntersectPoint = getRaycastCoordinates(e.clientX, e.clientY);
+ if (worldIntersectPoint !== undefined) addSpatialIndicator();
+ }
+
+ const indicatorAxis = new THREE.Vector3(0, 1, 0);
+ const indicatorHeight = 400;
+ const indicatorName = 'cylinderIndicator';
+ const iDuration = 5;
+ const iAnimDuration = 1;
+ const iScaleFactor = 1.1;
+ let indicatorList = [];
+
+ let innerWidth = 20;
+ let innerBottomHeight = 70;
+ let innerTopHeight = 250;
+ let innerHeightOffset = 50;
+
+ function addSpatialIndicator() {
+ console.info('should add a cylinder to the scene');
+ // add an indicator group
+ const indicatorGroup = new THREE.Group();
+ indicatorGroup.position.set(worldIntersectPoint.point.x, worldIntersectPoint.point.y, worldIntersectPoint.point.z);
+ // make worldIntersectPoint normalVector always point towards the same side of the camera
+ let normalVector = worldIntersectPoint.normalVector.clone();
+ let camPos = new THREE.Vector3();
+ camera.getWorldPosition(camPos);
+ let dotProduct = normalVector.dot(camPos.sub(worldIntersectPoint.point));
+ if (dotProduct < 0) {
+ normalVector.negate();
+ }
+ indicatorGroup.quaternion.setFromUnitVectors(indicatorAxis, normalVector);
+ realityEditor.gui.threejsScene.addToScene(indicatorGroup);
+ // store name & avatar colors in the indicator groups, so that spatialArrows can grab them as properties and render to correct colors
+ indicatorGroup.name = indicatorName;
+ indicatorGroup.avatarColor = color;
+ indicatorGroup.avatarColorLighter = colorLighter;
+ const material1 = new THREE.MeshStandardMaterial( {
+ color: finalColor[0].color,
+ transparent: true,
+ opacity: 1,
+ flatShading: true,
+ });
+
+ // add inner cones
+ const bottomConeGeometry = new THREE.ConeGeometry(innerWidth, innerBottomHeight, 4, 1, true);
+ bottomConeGeometry.translate(0, innerBottomHeight / 2, 0);
+ bottomConeGeometry.rotateX(Math.PI);
+ const topConeGeometry = new THREE.ConeGeometry(innerWidth, innerTopHeight, 4, 1, true);
+ topConeGeometry.translate(0, innerTopHeight / 2, 0);
+ const innerConeGeometry = mergeBufferGeometries([bottomConeGeometry, topConeGeometry]);
+ const innerCone = new THREE.Mesh(innerConeGeometry, material1);
+ innerCone.position.y = innerBottomHeight + innerHeightOffset;
+ indicatorGroup.add(innerCone);
+
+ // add outer cylinder
+ const geometry2 = new THREE.CylinderGeometry( 50, 50, indicatorHeight, 32, 1, true );
+ const cylinder2 = new THREE.Mesh(geometry2, cylinderMaterial);
+ cylinder2.position.set(0, indicatorHeight / 2, 0);
+ indicatorGroup.add(cylinder2);
+ // add a clock and duration value to the indicatorGroup, to keep track of the time to scale the cylinder indicators
+ let clock = new THREE.Clock();
+ indicatorGroup.iclock = clock;
+ indicatorGroup.iclick = 0;
+ indicatorList.push(indicatorGroup);
+ }
+
+ let occlusionDownloadInterval = null;
+ let cachedOcclusionObject = null;
+ let cachedWorldObject = null;
+
+ function onLoadOcclusionObject(callback) {
+ occlusionDownloadInterval = setInterval(() => {
+ if (!cachedWorldObject) {
+ cachedWorldObject = realityEditor.worldObjects.getBestWorldObject();
+ }
+ if (!cachedWorldObject) {
+ return;
+ }
+ if (cachedWorldObject.objectId === realityEditor.worldObjects.getLocalWorldId()) {
+ cachedWorldObject = null; // don't accept the local world object
+ }
+ if (cachedWorldObject && !cachedOcclusionObject) {
+ cachedOcclusionObject = realityEditor.gui.threejsScene.getObjectForWorldRaycasts(cachedWorldObject.objectId);
+ if (cachedOcclusionObject) {
+ // trigger the callback and clear the interval
+ callback(cachedWorldObject, cachedOcclusionObject);
+ clearInterval(occlusionDownloadInterval);
+ occlusionDownloadInterval = null;
+ }
+ }
+ }, 1000);
+ }
+
+ async function initService() {
+ onLoadOcclusionObject((worldObject, occlusionObject) => {
+ cachedWorldObject = worldObject;
+ cachedOcclusionObject = occlusionObject;
+ });
+
+ // initialize 20 line data
+ for (let i = 0; i < amount; i++) {
+ let width = remap(Math.random(), 0, 1, .002, .006);
+ let height = remap(Math.random(), 0, 1, .2, .4);
+ let x = remap(Math.random(), 0, 1, width / 2, 1 - width / 2);
+ let y = 0;
+ let speed = remap(Math.random(), 0, 1, 2, 6);
+
+ lines.push({
+ width: width,
+ height: height,
+ x: x,
+ y: y,
+ speed: speed
+ });
+ }
+
+ finalColor[0] = {
+ color: new THREE.Color(color),
+ colorLighter: new THREE.Color(colorLighter)
+ };
+
+ camera = realityEditor.gui.threejsScene.getInternals().getCamera();
+
+ update();
+
+ await getMyAvatarColor();
+ uniforms['avatarColor'].value = finalColor;
+ }
+
+ function updateUniforms() {
+ if (!spatialIndicatorActivated) return;
+
+ // change the Lines data here
+ lines.forEach(line => {
+ if (line.y >= 1) {
+ line.width = remap(Math.random(), 0, 1, .004, .012);
+ line.height = remap(Math.random(), 0, 1, .2, .4);
+ line.x = remap(Math.random(), 0, 1, line.width / 2, 1 - line.width / 2);
+ line.y = 0;
+ line.speed = remap(Math.random(), 0, 1, 2, 6);
+ } else {
+ line.y += .01 * line.speed;
+ }
+ })
+
+ uniforms['lines'].value = lines;
+ }
+
+ function changeIndicatorTransforms() {
+ for (let i = 0; i < indicatorList.length; i++) {
+ let item = indicatorList[i];
+ let iClock = item.iclock;
+ let iClick = item.iclick;
+ let iTime = iClock.getElapsedTime();
+ // translate up/down and rotate the inner cones
+ let innerCone = item.children[0];
+ innerCone.position.y = remap(Math.sin(iTime * 4), -1, 1, innerBottomHeight + innerHeightOffset + 60, innerBottomHeight + innerHeightOffset - 60);
+ innerCone.rotation.y = iTime;
+ // change the entire indicator group scale
+ let y1 = Math.pow(iScaleFactor, iClick) * (-1 / iAnimDuration * (iTime - 0.3 * iClick) + (iDuration + iAnimDuration) / iAnimDuration);
+ let y2 = Math.min(Math.pow(iScaleFactor, iClick), Math.max(0, y1));
+ if (y2 <= 0) {
+ // if indicator scale <= 0, remove it from the scene & indicatorList
+ realityEditor.gui.threejsScene.removeFromScene(item);
+ indicatorList.splice(i, 1);
+ i--;
+ } else {
+ // if indicator scale > 0, then make indicator scale the same as y2
+ item.scale.set(y2, y2, y2);
+ }
+ }
+ }
+
+ function update() {
+ updateUniforms();
+ changeIndicatorTransforms();
+ window.requestAnimationFrame(update);
+ }
+
+ function getIndicatorName() {
+ return indicatorName;
+ }
+
+ exports.initService = initService;
+ exports.getIndicatorName = getIndicatorName;
+
+})(realityEditor.gui.spatialIndicator);
diff --git a/src/gui/threejsScene.js b/src/gui/threejsScene.js
new file mode 100644
index 000000000..dd4fac3b4
--- /dev/null
+++ b/src/gui/threejsScene.js
@@ -0,0 +1,1254 @@
+createNameSpace("realityEditor.gui.threejsScene");
+
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+import { CSS2DRenderer } from '../../thirdPartyCode/three/CSS2DRenderer.js';
+import { FBXLoader } from '../../thirdPartyCode/three/FBXLoader.js';
+import { GLTFLoader } from '../../thirdPartyCode/three/GLTFLoader.module.js';
+import { mergeBufferGeometries } from '../../thirdPartyCode/three/BufferGeometryUtils.module.js';
+import { MeshBVH } from '../../thirdPartyCode/three-mesh-bvh.module.js';
+import { TransformControls } from '../../thirdPartyCode/three/TransformControls.js';
+import { ViewFrustum, frustumVertexShader, frustumFragmentShader, MAX_VIEW_FRUSTUMS, UNIFORMS } from './ViewFrustum.js';
+import { MapShaderSettingsUI } from "../measure/mapShaderSettingsUI.js";
+import GroundPlane from "./scene/GroundPlane.js";
+import AnchoredGroup from "./scene/AnchoredGroup.js";
+import { WebXRCamera, DefaultCamera, LayerConfig } from "./scene/Camera.js";
+import { Renderer } from "./scene/Renderer.js";
+import {setMatrixFromArray} from "./scene/utils.js";
+
+(function(exports) {
+
+ /**
+ * this layer renders the grid first
+ */
+ const RENDER_ORDER_SCAN = -2;
+
+ /**
+ * this will render the scanned scene second
+ */
+ const RENDER_ORDER_DEPTH_REPLACEMENT = -1;
+
+ exports.RENDER_ORDER_DEPTH_REPLACEMENT = RENDER_ORDER_DEPTH_REPLACEMENT;
+
+ var isProjectionMatrixSet = false;
+ const animationCallbacks = [];
+ let lastFrameTime = Date.now();
+ const worldObjectGroups = {}; // Parent objects for objects attached to world objects
+ const worldOcclusionObjects = {}; // Keeps track of initialized occlusion objects per world object
+ /**
+ * @type {GroundPlane}
+ */
+ let groundPlane;
+ let isGroundPlanePositionSet = false; // gets updated when the ground plane collider is added
+ let isWorldMeshLoadedAndProcessed = false; // gets updated when area target mesh and navmesh have been processed
+ let distanceRaycastVector = new THREE.Vector3();
+ let distanceRaycastResultPosition = new THREE.Vector3();
+ let originBoxes = {};
+ let allMeshes = [];
+ let isHeightMapOn = false;
+ let isSteepnessMapOn = false;
+ let navmesh = null;
+ let gltfBoundingBox = null;
+ let cssRenderer = null;
+ // other modules can subscribe to these events
+ let callbacks = {
+ onGltfDownloadProgress: [],
+ onGltfLoaded: [],
+ }
+ // values for the 'renderMode' property of objects
+ const RENDER_MODES = Object.freeze({
+ mesh: 'mesh',
+ ai: 'ai'
+ });
+
+ const DISPLAY_ORIGIN_BOX = true;
+
+ let customMaterials;
+ let materialCullingFrustums = {}; // used in remote operator to cut out points underneath the point-clouds
+
+ let areaTargetMaterials = [];
+
+ /**
+ * for now, this contains everything not attached to a specific world object
+ * @type {AnchoredGroup}
+ */
+ var threejsContainer;
+
+ /**
+ * @type {DefaultCamera}
+ */
+ var defaultCamera;
+
+ /**
+ * @type {WebXRCamera|null}
+ */
+ var webXRCamera;
+
+ /**
+ * @type {Renderer}
+ */
+ var mainRenderer;
+
+ function initService() {
+ // create a fullscreen webgl renderer for the threejs content
+ /** @type {HTMLCanvasElement} */
+ const domElement = document.getElementById('mainThreejsCanvas');
+ mainRenderer = new Renderer(domElement);
+
+ defaultCamera = new DefaultCamera("Default Camera", window.innerWidth / window.innerHeight);
+ webXRCamera = null; // can only be initilized if we have a webxr session
+ mainRenderer.add(defaultCamera); // Normally not needed, but needed in order to add child objects relative to camera
+ mainRenderer.setCamera(defaultCamera);
+
+ // create a parent 3D object to contain all the non-world-aligned three js objects
+ // we can apply the transform to this object and all of its children objects will be affected
+ threejsContainer = new AnchoredGroup("threejsContainer");
+ mainRenderer.add(threejsContainer);
+
+ mainRenderer.setAnchoredGroupForTools(threejsContainer);
+
+ customMaterials = new CustomMaterials();
+ let _mapShaderUI = new MapShaderSettingsUI();
+
+ // additional 3d content can be added to the scene like so:
+ // var radius = 75;
+ // var geometry = new THREE.IcosahedronGeometry( radius, 1 );
+ // var materials = [
+ // new THREE.MeshPhongMaterial( { color: 0xffffff, shading: THREE.FlatShading, vertexColors: THREE.VertexColors, shininess: 0 } ),
+ // new THREE.MeshBasicMaterial( { color: 0x000000, shading: THREE.FlatShading, wireframe: true, transparent: true } )
+ // ];
+ // mesh = SceneUtils.createMultiMaterialObject( geometry, materials );
+ // threejsContainerObj.add( mesh );
+ // mesh.position.setZ(150);
+
+ addGroundPlaneCollider(); // invisible object for raycasting intersections with ground plane
+
+ // this triggers with a requestAnimationFrame on remote operator,
+ // or at frequency of Vuforia updates on mobile
+ realityEditor.gui.ar.draw.addUpdateListener(renderScene);
+
+ if (DISPLAY_ORIGIN_BOX) {
+ realityEditor.gui.settings.addToggle('Display Origin Boxes', 'show debug cubes at origin', 'displayOriginCubes', '../../../svg/move.svg', false, function(newValue) {
+ toggleDisplayOriginBoxes(newValue);
+ }, { dontPersist: true });
+ }
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'n' || e.key === 'N') {
+ e.stopPropagation();
+ navmesh.visible = !navmesh.visible;
+ }
+ })
+
+ cssRenderer = new CSS2DRenderer();
+ cssRenderer.setSize(window.innerWidth, window.innerHeight);
+ const css3dCanvas = cssRenderer.domElement;
+ css3dCanvas.id = 'three-js-scene-css-3d-renderer';
+ // set the position style and pointer events none to complete the setup
+ css3dCanvas.style.position = 'absolute';
+ css3dCanvas.style.pointerEvents = 'none';
+ css3dCanvas.style.top = '0';
+ css3dCanvas.style.left = '0';
+ document.body.appendChild(css3dCanvas);
+ }
+
+ // use this helper function to update the camera matrix using the camera matrix from the sceneGraph
+ function setCameraPosition(matrix) {
+ defaultCamera.setCameraMatrixFromArray(matrix);
+ if (customMaterials) {
+ let forwardVector = realityEditor.gui.ar.utilities.getForwardVector(matrix);
+ customMaterials.updateCameraDirection(new THREE.Vector3(forwardVector[0], forwardVector[1], forwardVector[2]));
+ }
+ }
+
+ // adds an invisible plane to the ground that you can raycast against to fill in holes in the area target
+ // this is different from the ground plane visualizer element
+ function addGroundPlaneCollider() {
+ const sceneSizeInMeters = 100; // not actually infinite, but relative to any area target this should cover it
+
+ isGroundPlanePositionSet = true;
+ groundPlane = new GroundPlane(sceneSizeInMeters / mainRenderer.getGlobalScale().getSceneScale());
+ addToScene(groundPlane.getInternalObject(), {occluded: true});
+
+ let areaTargetNavmesh = null;
+ realityEditor.app.targetDownloader.onNavmeshCreated((navmesh) => {
+ areaTargetNavmesh = navmesh;
+ tryUpdatingGroundPlanePosition();
+ });
+
+ let areaTargetMesh = null;
+ realityEditor.avatar.network.onLoadOcclusionObject((_cachedWorldObject, cachedOcclusionObject) => {
+ areaTargetMesh = cachedOcclusionObject;
+ tryUpdatingGroundPlanePosition();
+ });
+
+ const tryUpdatingGroundPlanePosition = () => {
+ if (!areaTargetMesh || !areaTargetNavmesh) return; // only continue after both have been processed
+ isWorldMeshLoadedAndProcessed = true;
+
+ groundPlane.tryUpdatingGroundPlanePosition(areaTargetMesh, areaTargetNavmesh);
+
+ isGroundPlanePositionSet = true;
+ }
+ }
+
+ function renderScene() {
+ const deltaTime = Date.now() - lastFrameTime; // In ms
+ lastFrameTime = Date.now();
+
+ const globalScale = mainRenderer.getGlobalScale();
+ if (mainRenderer.isInWebXRMode()) {
+ // 1 meter is 1 device unit
+ if (globalScale.getDeviceScale() !== 1) {
+ globalScale.setDeviceScale(1);
+ webXRCamera = new WebXRCamera("WebXR Camera", mainRenderer);
+ mainRenderer.setCamera(webXRCamera);
+ console.log("webXR camera")
+ }
+ } else {
+ // 1 meter is 1000 device units
+ if (globalScale.getDeviceScale() !== 1000) {
+ globalScale.setDeviceScale(1000);
+ mainRenderer.setCamera(defaultCamera);
+ console.log("default camera")
+ }
+ }
+
+ cssRenderer.render(mainRenderer.getInternalScene(), mainRenderer.getCamera().getInternalObject());
+
+ // additional modules, e.g. spatialCursor, should trigger their update function with an animationCallback
+ animationCallbacks.forEach(callback => {
+ callback(deltaTime);
+ });
+
+ if (globalStates.realProjectionMatrix && globalStates.realProjectionMatrix.length > 0) {
+ defaultCamera.setProjectionMatrixFromArray(globalStates.realProjectionMatrix);
+ isProjectionMatrixSet = true;
+ }
+
+ const worldObjectIds = realityEditor.worldObjects.getWorldObjectKeys();
+ worldObjectIds.forEach(worldObjectId => {
+ if (!worldObjectGroups[worldObjectId]) {
+ const group = new THREE.Group();
+ group.name = worldObjectId + '_group';
+ worldObjectGroups[worldObjectId] = group;
+ group.matrixAutoUpdate = false; // this is needed to position it directly with matrices
+ mainRenderer.add(group);
+
+ // Helps visualize world object origin point for debugging
+ if (DISPLAY_ORIGIN_BOX && worldObjectId !== realityEditor.worldObjects.getLocalWorldId() && !realityEditor.device.environment.variables.hideOriginCube) {
+ const originBox = new THREE.Mesh(new THREE.BoxGeometry(10,10,10),new THREE.MeshNormalMaterial());
+ const xBox = new THREE.Mesh(new THREE.BoxGeometry(5,5,5),new THREE.MeshBasicMaterial({color:0xff0000}));
+ const yBox = new THREE.Mesh(new THREE.BoxGeometry(5,5,5),new THREE.MeshBasicMaterial({color:0x00ff00}));
+ const zBox = new THREE.Mesh(new THREE.BoxGeometry(5,5,5),new THREE.MeshBasicMaterial({color:0x0000ff}));
+ xBox.position.x = 15;
+ yBox.position.y = 15;
+ zBox.position.z = 15;
+ group.add(originBox);
+ originBox.scale.set(10,10,10);
+ originBox.add(xBox);
+ originBox.add(yBox);
+ originBox.add(zBox);
+
+ originBoxes[worldObjectId] = originBox;
+ if (typeof realityEditor.gui.settings.toggleStates.displayOriginCubes !== 'undefined') {
+ originBox.visible = realityEditor.gui.settings.toggleStates.displayOriginCubes;
+ }
+ }
+ }
+
+ // each of the world object containers has its origin set to the origin matrix of that world object
+ const group = worldObjectGroups[worldObjectId];
+ const worldMatrix = realityEditor.sceneGraph.getSceneNodeById(worldObjectId).worldMatrix;
+ if (worldMatrix) {
+ setMatrixFromArray(group.matrix, worldMatrix);
+ group.visible = true;
+
+ if (worldOcclusionObjects[worldObjectId]) {
+ setMatrixFromArray(worldOcclusionObjects[worldObjectId].matrix, worldMatrix);
+ worldOcclusionObjects[worldObjectId].visible = true;
+ }
+ } else {
+ group.visible = false;
+
+ if (worldOcclusionObjects[worldObjectId]) {
+ worldOcclusionObjects[worldObjectId].visible = false;
+ }
+ }
+ });
+
+ // the main three.js container object has its origin set to the ground plane origin
+ const rootMatrix = realityEditor.sceneGraph.getGroundPlaneNode().worldMatrix;
+ if (rootMatrix) {
+ threejsContainer.setMatrixFromArray(rootMatrix);
+ }
+
+ customMaterials.update();
+
+ // only render the scene if the projection matrix is initialized
+ if (isProjectionMatrixSet) {
+ mainRenderer.render();
+ }
+ }
+
+ function toggleDisplayOriginBoxes(newValue) {
+ Object.values(originBoxes).forEach((box) => {
+ box.visible = newValue;
+ });
+ }
+
+ /**
+ *
+ * @param {THREE.Object3D} obj
+ * @param {*} parameters
+ */
+ function addToScene(obj, parameters) {
+ if (!parameters) {
+ parameters = {};
+ }
+ const occluded = parameters.occluded;
+ const parentToCamera = parameters.parentToCamera;
+ const worldObjectId = parameters.worldObjectId;
+ const attach = parameters.attach;
+ if (occluded) {
+ const queue = [obj];
+ while (queue.length > 0) {
+ const currentObj = queue.pop();
+ currentObj.renderOrder = 2;
+ currentObj.children.forEach(child => queue.push(child));
+ }
+ }
+ if (parentToCamera) {
+ if (attach) {
+ defaultCamera.attach(obj);
+ } else {
+ defaultCamera.add(obj);
+ }
+ } else if (worldObjectId) {
+ if (attach) {
+ worldObjectGroups[worldObjectId].attach(obj);
+ } else {
+ worldObjectGroups[worldObjectId].add(obj);
+ }
+ } else {
+ if (attach) {
+ threejsContainer.attach(obj);
+ } else {
+ threejsContainer.add(obj);
+ }
+ }
+ }
+
+ /**
+ *
+ * @param {THREE.Object3D} obj
+ */
+ function removeFromScene(obj) {
+ if (obj && obj.parent) {
+ obj.parent.remove(obj);
+ }
+ }
+
+ function onAnimationFrame(callback) {
+ animationCallbacks.push(callback);
+ }
+
+ function removeAnimationCallback(callback) {
+ if (animationCallbacks.includes(callback)) {
+ animationCallbacks.splice(animationCallbacks.indexOf(callback), 1);
+ }
+ }
+
+ function addOcclusionGltf(pathToGltf, objectId) {
+ // Code remains here, but likely won't be used due to distance-based fading looking better
+
+ if (worldOcclusionObjects[objectId]) {
+ // occlusion gltf already loaded
+ return; // Don't try creating multiple occlusion objects for the same world object
+ }
+
+ const gltfLoader = new GLTFLoader();
+ gltfLoader.load(pathToGltf, function(gltf) {
+ const geometries = [];
+ gltf.scene.traverse(obj => {
+ if (obj.geometry) {
+ obj.geometry.deleteAttribute('uv'); // Messes with merge if present in some geometries but not others
+ obj.geometry.deleteAttribute('uv2'); // Messes with merge if present in some geometries but not others
+ geometries.push(obj.geometry);
+ }
+ });
+
+ let geometry = geometries[0];
+ if (geometries.length > 1) {
+ const mergedGeometry = mergeBufferGeometries(geometries);
+ geometry = mergedGeometry;
+ }
+
+ // SimplifyModifier seems to freeze app
+ // if (geometry.index) {
+ // geometry = new SimplifyModifier().modify(geometry, geometry.index.count * 0.2);
+ // } else {
+ // geometry = new SimplifyModifier().modify(geometry, geometry.attributes.position.count * 0.2);
+ // }
+ geometry.computeVertexNormals();
+
+ // Add the BVH to the boundsTree variable so that the acceleratedRaycast can work
+ geometry.boundsTree = new MeshBVH( geometry );
+
+ const material = new THREE.MeshNormalMaterial();
+ material.colorWrite = false; // Makes it invisible
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.renderOrder = 1;
+ mesh.scale.set(1000, 1000, 1000); // convert meters -> mm
+ const group = new THREE.Group(); // mesh needs to be in group so scale doesn't get overriden by model view matrix
+ group.add(mesh);
+ group.matrixAutoUpdate = false; // allows us to update with the model view matrix
+ mainRenderer.add(group);
+ worldOcclusionObjects[objectId] = group;
+ });
+ }
+
+ function getObjectForWorldRaycasts(objectId) {
+ return worldOcclusionObjects[objectId] || mainRenderer.getObjectByName('areaTargetMesh');
+ }
+
+ function isOcclusionActive(objectId) {
+ return !!worldOcclusionObjects[objectId];
+ }
+
+ /**
+ * Key function for the remote operator. Loads and adds a GLTF model to the
+ * scene as a static reference mesh.
+ * @param {string} pathToGltf - url of gltf
+ * @param {{x: number, y: number, z: number}} originOffset - offset of model for ground plane being aligned with y=0
+ * @param {{x: number, y: number, z: number}} originRotation - rotation for up to be up
+ * @param {number} maxHeight - maximum (ceiling) height of model
+ * @param {number} ceilingAndFloor - max y (ceiling) and min y (floor) value of model mesh
+ * @param {{x: number, y: number, z: number}} center - center of model for loading animation
+ * @param {function} callback - Called on load with gltf's threejs object
+ *
+ /* For my example area target:
+ pathToGltf = './svg/BenApt1_authoring.glb' // put in arbitrary local directory to test
+ originOffset = {x: -600, y: 0, z: -3300};
+ originRotation = {x: 0, y: 2.661627109291353, z: 0};
+ maxHeight = 2.3 // use to slice off the ceiling above this height (meters)
+ */
+ function addGltfToScene(pathToGltf, map, steepnessMap, heightMap, originOffset, originRotation, maxHeight, ceilingAndFloor, center, callback) {
+ const gltfLoader = new GLTFLoader();
+ gltfLoader.load(pathToGltf, function(gltf) {
+ let wireMesh;
+ let wireMaterial = customMaterials.areaTargetMaterialWithTextureAndHeight(new THREE.MeshStandardMaterial({
+ wireframe: true,
+ color: 0x777777,
+ }), {
+ maxHeight: maxHeight,
+ center: center,
+ animateOnLoad: true,
+ inverted: true,
+ useFrustumCulling: false
+ });
+
+ if (gltf.scene.geometry) {
+ allMeshes.push(gltf.scene);
+ if (typeof maxHeight !== 'undefined') {
+ if (!gltf.scene.material) {
+ console.warn('no material', gltf.scene);
+ } else {
+ // cache the original gltf material on mobile browsers, to improve performance
+ gltf.scene.originalMaterial = gltf.scene.material.clone();
+ if (realityEditor.device.environment.isDesktop()) {
+ gltf.scene.colorMaterial = customMaterials.areaTargetMaterialWithTextureAndHeight(gltf.scene.material, {
+ maxHeight: maxHeight,
+ center: center,
+ animateOnLoad: true,
+ inverted: false,
+ useFrustumCulling: false,
+ });
+ }
+ }
+ }
+ gltf.scene.geometry.computeVertexNormals();
+ gltf.scene.geometry.computeBoundingBox();
+ gltf.scene.heightMaterial = customMaterials.heightMapMaterial(gltf.scene.material, {ceilingAndFloor: ceilingAndFloor});
+ gltf.scene.gradientMaterial = customMaterials.gradientMapMaterial(gltf.scene.material);
+ gltf.scene.material = gltf.scene.colorMaterial || gltf.scene.originalMaterial;
+
+ // Add the BVH to the boundsTree variable so that the acceleratedRaycast can work
+ gltf.scene.geometry.boundsTree = new MeshBVH( gltf.scene.geometry );
+
+ wireMesh = new THREE.Mesh(gltf.scene.geometry, wireMaterial);
+ } else {
+ let meshesToRemove = [];
+ gltf.scene.traverse(child => {
+ if (child.material && child.geometry) {
+ // meshes scanned with ATC have additional untextured mesh(es) with this naming convention
+ // the scan may visually look better if they are removed. textured meshes are named "texture_N"
+ if (child.name && child.name.toLocaleLowerCase().startsWith('mesh_')) {
+ meshesToRemove.push(child);
+ return;
+ }
+ allMeshes.push(child);
+ }
+ });
+
+ // make sure we don't remove ALL meshes, if certain scanning software (e.g. Polycam) names all children mesh_X
+ if (allMeshes.length > 0) {
+ for (let mesh of meshesToRemove) {
+ mesh.removeFromParent();
+ }
+ } else {
+ for (let mesh of meshesToRemove) {
+ allMeshes.push(mesh);
+ }
+ }
+
+ allMeshes.forEach(child => {
+ if (typeof maxHeight !== 'undefined') {
+ // TODO: to re-enable frustum culling on desktop, add this: if (!realityEditor.device.environment.isDesktop())
+ // so that we don't swap to the original material on desktop. also need to update desktopRenderer.js
+ // cache the original gltf material on mobile browsers, to improve performance
+ child.originalMaterial = child.material.clone();
+ if (realityEditor.device.environment.isDesktop()) {
+ child.colorMaterial = customMaterials.areaTargetMaterialWithTextureAndHeight(child.material, {
+ maxHeight: maxHeight,
+ center: center,
+ animateOnLoad: true,
+ inverted: false,
+ useFrustumCulling: false,
+ });
+ }
+ }
+
+ child.geometry.computeVertexNormals();
+ child.heightMaterial = customMaterials.heightMapMaterial(child.material, {ceilingAndFloor: ceilingAndFloor});
+ child.gradientMaterial = customMaterials.gradientMapMaterial(child.material);
+ child.material = child.colorMaterial || child.originalMaterial;
+
+ // the attributes must be non-indexed in order to add a barycentric coordinate buffer
+ child.geometry = child.geometry.toNonIndexed();
+
+ // we assign barycentric coordinates to each vertex in order to render a wireframe shader
+ let positionAttribute = child.geometry.getAttribute('position');
+ let barycentricBuffer = [];
+ const count = positionAttribute.count / 3;
+ for (let i = 0; i < count; i++) {
+ barycentricBuffer.push(
+ 0, 0, 1,
+ 0, 1, 0,
+ 1, 0, 0
+ );
+ }
+
+ child.geometry.setAttribute('a_barycentric', new THREE.BufferAttribute(new Uint8Array(barycentricBuffer), 3));
+ });
+ const mergedGeometry = mergeBufferGeometries(allMeshes.map(child => {
+ let geo = child.geometry.clone();
+ geo.deleteAttribute('uv');
+ geo.deleteAttribute('uv2');
+ return geo;
+ }));
+ mergedGeometry.computeBoundingBox();
+ gltfBoundingBox = mergedGeometry.boundingBox;
+
+ // Add the BVH to the boundsTree variable so that the acceleratedRaycast can work
+ allMeshes.map(child => {
+ child.geometry.boundsTree = new MeshBVH(child.geometry);
+ });
+
+ wireMesh = new THREE.Mesh(mergedGeometry, wireMaterial);
+ }
+
+ navmesh = realityEditor.app.pathfinding.initService(map, steepnessMap, heightMap);
+ // add in the navmesh
+ // navmesh.scale.set(1000, 1000, 1000);
+ // navmesh.position.set(gltfBoundingBox.min.x * 1000, 0, gltfBoundingBox.min.z * 1000);
+ // navmesh.visible = false;
+ // threejsContainerObj.add(navmesh);
+
+ // align the coordinate systems
+ gltf.scene.scale.set(1000, 1000, 1000); // convert meters -> mm
+ wireMesh.scale.set(1000, 1000, 1000); // convert meters -> mm
+ if (typeof originOffset !== 'undefined') {
+ gltf.scene.position.set(originOffset.x, originOffset.y, originOffset.z);
+ wireMesh.position.set(originOffset.x, originOffset.y, originOffset.z);
+ }
+ if (typeof originRotation !== 'undefined') {
+ gltf.scene.rotation.set(originRotation.x, originRotation.y, originRotation.z);
+ wireMesh.rotation.set(originRotation.x, originRotation.y, originRotation.z);
+ }
+
+ wireMesh.renderOrder = RENDER_ORDER_SCAN;
+ gltf.scene.renderOrder = RENDER_ORDER_SCAN;
+ wireMesh.layers.set(LayerConfig.LAYER_SCAN);
+ gltf.scene.layers.set(LayerConfig.LAYER_SCAN);
+ gltf.scene.traverse(child => {
+ if (child.layers) {
+ child.layers.set(LayerConfig.LAYER_SCAN);
+ }
+ });
+
+ threejsContainer.add( wireMesh );
+ setTimeout(() => {
+ threejsContainer.remove(wireMesh);
+ }, 5000);
+ threejsContainer.add( gltf.scene );
+
+ realityEditor.network.addPostMessageHandler('getAreaTargetMesh', (_, fullMessageData) => {
+ realityEditor.network.postMessageIntoFrame(fullMessageData.frame, {
+ areaTargetMesh: {
+ mesh: gltf.scene.toJSON(),
+ }
+ });
+ });
+
+ if (callback) {
+ callback(gltf.scene, wireMesh);
+ }
+
+ // in addition to triggering the callback provided by the caller of this function,
+ // also trigger callbacks for any other modules listening for gltf loaded events
+ callbacks.onGltfLoaded.forEach((cb) => {
+ cb(pathToGltf);
+ });
+ }, (xhr) => {
+ // can be used to display download progress, useful if loading large gltf files on slow networks
+ callbacks.onGltfDownloadProgress.forEach((cb) => {
+ cb(pathToGltf, xhr.loaded, xhr.total);
+ });
+ });
+ }
+
+ function changeMeasureMapType(mapType) {
+ switch (mapType) {
+ case 'color':
+ isHeightMapOn = false;
+ isSteepnessMapOn = false;
+ realityEditor.forEachFrameInAllObjects(postHeightMapChangeEventIntoIframes);
+ allMeshes.forEach((child) => {
+ child.material.dispose();
+ child.material = child.colorMaterial || child.originalMaterial;
+ });
+ break;
+ case 'height':
+ isHeightMapOn = true;
+ isSteepnessMapOn = false;
+ realityEditor.forEachFrameInAllObjects(postHeightMapChangeEventIntoIframes);
+ allMeshes.forEach((child) => {
+ child.material.dispose();
+ child.material = child.heightMaterial;
+ });
+ break;
+ case 'steepness':
+ isHeightMapOn = false;
+ isSteepnessMapOn = true;
+ realityEditor.forEachFrameInAllObjects(postHeightMapChangeEventIntoIframes);
+ allMeshes.forEach((child) => {
+ child.material.dispose();
+ child.material = child.gradientMaterial;
+ });
+ break;
+ }
+ }
+
+ function postHeightMapChangeEventIntoIframes(objectkey, framekey) {
+ if (realityEditor.envelopeManager.getFrameTypeFromKey(objectkey, framekey) === 'spatialMeasure') {
+ let iframe = document.getElementById('iframe' + framekey);
+ iframe.contentWindow.postMessage(JSON.stringify({
+ isHeightMapOn: isHeightMapOn,
+ isSteepnessMapOn: isSteepnessMapOn,
+ }), '*');
+ }
+ }
+
+ function highlightWalkableArea(isOn) {
+ if (customMaterials) {
+ customMaterials.highlightWalkableArea(isOn);
+ }
+ }
+
+ function updateGradientMapThreshold(minAngle, maxAngle) {
+ if (customMaterials) {
+ customMaterials.updateGradientMapThreshold(minAngle, maxAngle);
+ }
+ }
+
+ /**
+ * Returns the 3D coordinate which is [distance] mm in front of the screen pixel coordinates [clientX, clientY]
+ * @param {number} clientX - in screen pixels
+ * @param {number} clientY - in screen pixels
+ * @param {number} distance - in millimeters
+ * @returns {Vector3} - position relative to camera
+ */
+ function getPointAtDistanceFromCamera(clientX, clientY, distance) {
+ distanceRaycastVector.set(
+ ( clientX / window.innerWidth ) * 2 - 1,
+ - ( clientY / window.innerHeight ) * 2 + 1,
+ 0
+ );
+ distanceRaycastVector.unproject(mainRenderer.getCamera().getInternalObject());
+ distanceRaycastVector.normalize();
+ distanceRaycastResultPosition.set(0, 0, 0).add(distanceRaycastVector.multiplyScalar(distance));
+ return distanceRaycastResultPosition;
+ }
+
+ function getObjectByName(name) {
+ return mainRenderer.getObjectByName(name);
+ }
+
+
+ function getObjectsByName(name) {
+ return mainRenderer.getObjectsByName(name);
+ }
+
+ function getGroundPlaneCollider() {
+ return groundPlane;
+ }
+
+ function getToolGroundPlaneShadowMatrix(objectKey, frameKey) {
+ let frame = realityEditor.getFrame(objectKey, frameKey);
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(frameKey);
+ if (!frame || !sceneNode) return [];
+ let groundPlaneNode = realityEditor.sceneGraph.getGroundPlaneNode();
+ let shadowMatrix = realityEditor.gui.ar.utilities.copyMatrix(sceneNode.worldMatrix);
+ shadowMatrix[13] = groundPlaneNode.worldMatrix[13];
+ return realignUpVector(shadowMatrix);
+ }
+
+ function getToolSurfaceShadowMatrix(objectKey, frameKey) {
+ let worldId = realityEditor.sceneGraph.getWorldId();
+ let worldOcclusionObject = getObjectForWorldRaycasts(worldId);
+ return getMatrixProjectedOntoObject(objectKey, frameKey, worldOcclusionObject);
+ }
+
+ function getMatrixProjectedOntoObject(objectKey, frameKey, collisionObject) {
+ let frame = realityEditor.getFrame(objectKey, frameKey);
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(frameKey);
+ if (!frame || !sceneNode) return [];
+
+ if (!collisionObject) return sceneNode.worldMatrix;
+
+ // let toolPosition = realityEditor.sceneGraph.getWorldPosition(frameKey);
+ let toolMatrixGP = sceneNode.getMatrixRelativeTo(realityEditor.sceneGraph.getGroundPlaneNode());
+ let toolPosition = new THREE.Vector3(toolMatrixGP[12], toolMatrixGP[13], toolMatrixGP[14]);
+
+ const raycaster = new THREE.Raycaster();
+ const direction = new THREE.Vector3(0, -1, 0); // Pointing downwards along Y-axis
+
+ // Set raycaster
+ raycaster.set(toolPosition, direction);
+ raycaster.firstHitOnly = true; // faster (using three-mesh-bvh)
+
+ // add object layer to raycast layer mask
+ raycaster.layers.mask = raycaster.layers.mask | collisionObject.layers.mask;
+
+ const intersects = raycaster.intersectObject(collisionObject);
+ if (intersects.length > 0) {
+ const shadowPosition = intersects[0].point;
+ let shadowMatrix = realityEditor.gui.ar.utilities.copyMatrix(sceneNode.worldMatrix);
+ shadowMatrix[12] = shadowPosition.x;
+ shadowMatrix[13] = shadowPosition.y;
+ shadowMatrix[14] = shadowPosition.z;
+
+ return realignUpVector(shadowMatrix);
+ }
+
+ return sceneNode.worldMatrix;
+ }
+
+ // removes rotation except along the Y axis, so it stays "flat" on the ground plane
+ function realignUpVector(originalMatrix) {
+ let matrix = new THREE.Matrix4();
+ setMatrixFromArray(matrix, originalMatrix);
+
+ // Decompose the matrix into position, rotation, and scale
+ const position = new THREE.Vector3();
+ const rotation = new THREE.Quaternion();
+ const scale = new THREE.Vector3();
+
+ matrix.decompose(position, rotation, scale);
+
+ // Convert Quaternion to Euler to easily zero out X and Z rotations
+ const euler = new THREE.Euler().setFromQuaternion(rotation, 'XYZ');
+
+ // Zero out X and Z rotations
+ euler.x = 0;
+ euler.z = 0;
+
+ // Convert back to Quaternion from Euler
+ rotation.setFromEuler(euler);
+
+ // Recompose the matrix
+ matrix.compose(position, rotation, scale);
+
+ return matrix.elements;
+ }
+
+ /**
+ * Helper function to create a new ViewFrustum instance with preset camera internals
+ * @returns {ViewFrustum}
+ */
+ const createCullingFrustum = function() {
+ areaTargetMaterials.forEach(material => {
+ material.transparent = true;
+ });
+
+ // TODO: get these camera parameters dynamically?
+ const iPhoneVerticalFOV = 41.22673; // https://discussions.apple.com/thread/250970597
+ const widthToHeightRatio = 1920/1080;
+
+ const MAX_DIST_OBSERVED = 5000;
+ const FAR_PLANE_MM = Math.min(MAX_DIST_OBSERVED, 5000) + 100; // extend it slightly beyond the extent of the LiDAR sensor
+ const NEAR_PLANE_MM = 10;
+
+ let frustum = new ViewFrustum();
+ frustum.setCameraInternals(iPhoneVerticalFOV * 0.95, widthToHeightRatio, NEAR_PLANE_MM / 1000, FAR_PLANE_MM / 1000);
+ return frustum;
+ }
+
+ /**
+ * Creates a frustum, or updates the existing frustum with this id, to move it to this position and orientation.
+ * Returns the parameters that define the planes of this frustum after moving it.
+ * @param {string} id โ id of the virtualizer
+ * @param {number[]} cameraPosition - position in model coordinates. this may be meters, not millimeters.
+ * @param {number[]} cameraLookAtPosition โ position where the camera is looking. if you subtract cameraPosition, you get direction
+ * @param {number[]} cameraUp - normalized up vector of camera orientation
+ * @param {number} maxDepthMeters - furthest point detected by the LiDAR sensor this frame
+ * @returns {{normal1: Vector3, normal2: Vector3, normal3: Vector3, normal4: Vector3, normal5: Vector3, normal6: Vector3, D1: number, D2: number, D3: number, D4: number, D5: number, D6: number}}
+ */
+ function updateMaterialCullingFrustum(id, cameraPosition, cameraLookAtPosition, cameraUp, maxDepthMeters) {
+ if (typeof materialCullingFrustums[id] === 'undefined') {
+ materialCullingFrustums[id] = createCullingFrustum();
+ }
+
+ let frustum = materialCullingFrustums[id];
+
+ if (typeof maxDepthMeters !== 'undefined') {
+ frustum.setCameraInternals(frustum.angle, frustum.ratio, frustum.nearD, (frustum.farD + maxDepthMeters) / 2, true);
+ }
+
+ frustum.setCameraDef(cameraPosition, cameraLookAtPosition, cameraUp);
+
+ let viewingCameraForwardVector = realityEditor.gui.ar.utilities.getForwardVector(realityEditor.sceneGraph.getCameraNode().worldMatrix);
+ let viewAngleSimilarity = realityEditor.gui.ar.utilities.dotProduct(materialCullingFrustums[id].planes[5].normal, viewingCameraForwardVector);
+ viewAngleSimilarity = Math.max(0, viewAngleSimilarity); // limit it to 0 instead of going to -1 if viewing from anti-parallel direction
+
+ return {
+ normal1: array3ToXYZ(materialCullingFrustums[id].planes[0].normal),
+ normal2: array3ToXYZ(materialCullingFrustums[id].planes[1].normal),
+ normal3: array3ToXYZ(materialCullingFrustums[id].planes[2].normal),
+ normal4: array3ToXYZ(materialCullingFrustums[id].planes[3].normal),
+ normal5: array3ToXYZ(materialCullingFrustums[id].planes[4].normal),
+ normal6: array3ToXYZ(materialCullingFrustums[id].planes[5].normal),
+ D1: materialCullingFrustums[id].planes[0].D,
+ D2: materialCullingFrustums[id].planes[1].D,
+ D3: materialCullingFrustums[id].planes[2].D,
+ D4: materialCullingFrustums[id].planes[3].D,
+ D5: materialCullingFrustums[id].planes[4].D,
+ D6: materialCullingFrustums[id].planes[5].D,
+ viewAngleSimilarity: viewAngleSimilarity
+ }
+ }
+
+ /**
+ * Helper function to convert [x,y,z] from toolbox math format to three.js vector
+ * @param {number[]} arr3 โ [x, y, z] array
+ * @returns {Vector3}
+ */
+ function array3ToXYZ(arr3) {
+ return new THREE.Vector3(arr3[0], arr3[1], arr3[2]);
+ }
+
+ /**
+ * Deletes the ViewFrustum that corresponds with the virtualizer id
+ * @param {string} id
+ */
+ function removeMaterialCullingFrustum(id) {
+ delete materialCullingFrustums[id];
+
+ let numFrustums = Object.keys(materialCullingFrustums).length;
+
+ areaTargetMaterials.forEach(material => {
+ material.uniforms[UNIFORMS.numFrustums].value = Math.min(numFrustums, MAX_VIEW_FRUSTUMS);
+ if (numFrustums === 0) {
+ material.transparent = false; // optimize by turning off transparency when no virtualizers are connected
+ }
+ });
+ }
+
+ class CustomMaterials {
+ constructor() {
+ this.materialsToAnimate = [];
+ this.heightMapMaterials = [];
+ this.gradientMapMaterials = [];
+ this.lastUpdate = -1;
+ }
+ areaTargetVertexShader({useFrustumCulling, useLoadingAnimation, center}) {
+ if (!useLoadingAnimation && !useFrustumCulling) return THREE.ShaderChunk.meshphysical_vert;
+ if (useLoadingAnimation && !useFrustumCulling) {
+ return this.loadingAnimationVertexShader(center);
+ }
+ return frustumVertexShader({useLoadingAnimation: useLoadingAnimation, center: center});
+ }
+ areaTargetFragmentShader({useFrustumCulling, useLoadingAnimation, inverted}) {
+ if (!useLoadingAnimation && !useFrustumCulling) return THREE.ShaderChunk.meshphysical_frag;
+ if (useLoadingAnimation && !useFrustumCulling) {
+ return this.loadingAnimationFragmentShader(inverted);
+ }
+ return frustumFragmentShader({useLoadingAnimation: useLoadingAnimation, inverted: inverted});
+ }
+ loadingAnimationVertexShader(center) {
+ return THREE.ShaderChunk.meshphysical_vert
+ .replace('#include ', `#include
+ len = length(position - vec3(${center.x}, ${center.y}, ${center.z}));
+ `).replace('#include ', `#include
+ varying float len;
+ `);
+ }
+ loadingAnimationFragmentShader(inverted) {
+ let condition = 'if (len > maxHeight) discard;';
+ if (inverted) {
+ // condition = 'if (len < maxHeight || len > (maxHeight + 8.0) / 2.0) discard;';
+ condition = 'if (len < maxHeight) discard;';
+ }
+ return THREE.ShaderChunk.meshphysical_frag
+ .replace('#include ', `
+ ${condition}
+ #include `)
+ .replace(`#include `, `
+ #include
+ varying float len;
+ uniform float maxHeight;
+ `);
+ }
+ buildDefaultFrustums(numFrustums) {
+ let frustums = [];
+ for (let i = 0; i < numFrustums; i++) {
+ frustums.push({
+ normal1: {x: 1, y: 0, z: 0},
+ normal2: {x: 1, y: 0, z: 0},
+ normal3: {x: 1, y: 0, z: 0},
+ normal4: {x: 1, y: 0, z: 0},
+ normal5: {x: 1, y: 0, z: 0},
+ normal6: {x: 1, y: 0, z: 0},
+ D1: 0,
+ D2: 0,
+ D3: 0,
+ D4: 0,
+ D5: 0,
+ D6: 0,
+ viewAngleSimilarity: 0
+ })
+ }
+ return frustums;
+ }
+ updateCameraDirection(cameraDirection) {
+ areaTargetMaterials.forEach(material => {
+ for (let i = 0; i < material.uniforms[UNIFORMS.numFrustums].value; i++) {
+ let thisFrustum = material.uniforms[UNIFORMS.frustums].value[i];
+ let frustumDir = [thisFrustum.normal6.x, thisFrustum.normal6.y, thisFrustum.normal6.z];
+ let viewingDir = [cameraDirection.x, cameraDirection.y, cameraDirection.z];
+ // set to 1 if parallel, 0 if perpendicular. lower bound clamped to 0 instead of going to -1 if antiparallel
+ thisFrustum.viewAngleSimilarity = Math.max(0, realityEditor.gui.ar.utilities.dotProduct(frustumDir, viewingDir));
+ }
+ });
+ }
+ heightMapMaterial(sourceMaterial, {ceilingAndFloor}) {
+ let material = sourceMaterial.clone();
+
+ material.uniforms = THREE.UniformsUtils.merge([
+ THREE.ShaderLib.physical.uniforms,
+ {
+ heightMap_maxY: {value: ceilingAndFloor.ceiling},
+ heightMap_minY: {value: ceilingAndFloor.floor},
+ distanceToCamera: {value: 0} // todo Steve; later in the code, need to set gltf.scene.material.uniforms['....'] to desired value
+ }
+ ]);
+
+ material.vertexShader = realityEditor.gui.shaders.heightMapVertexShader();
+
+ material.fragmentShader = realityEditor.gui.shaders.heightMapFragmentShader();
+
+ material.type = 'verycoolheightmapmaterial';
+
+ material.needsUpdate = true;
+
+ this.heightMapMaterials.push(material);
+
+ return material;
+ }
+ gradientMapMaterial(sourceMaterial) {
+ let material = sourceMaterial.clone();
+
+ material.uniforms = THREE.UniformsUtils.merge([
+ THREE.ShaderLib.physical.uniforms,
+ {
+ gradientMap_minAngle: {value: 0},
+ gradientMap_maxAngle: {value: 25},
+ gradientMap_outOfRangeAreaOriginalColor: {value: false},
+ distanceToCamera: {value: 0}
+ }
+ ]);
+
+ material.vertexShader = realityEditor.gui.shaders.gradientMapVertexShader();
+
+ material.fragmentShader = realityEditor.gui.shaders.gradientMapFragmentShader();
+
+ material.type = 'verycoolgradientmapmaterial';
+
+ material.needsUpdate = true;
+
+ this.gradientMapMaterials.push(material);
+
+ return material;
+ }
+ highlightWalkableArea(isOn) {
+ this.gradientMapMaterials.forEach((material) => {
+ material.uniforms['gradientMap_outOfRangeAreaOriginalColor'].value = isOn;
+ });
+ }
+ updateGradientMapThreshold(minAngle, maxAngle) {
+ this.gradientMapMaterials.forEach((material) => {
+ material.uniforms['gradientMap_minAngle'].value = minAngle;
+ material.uniforms['gradientMap_maxAngle'].value = maxAngle;
+ });
+ }
+ areaTargetMaterialWithTextureAndHeight(sourceMaterial, {maxHeight, center, animateOnLoad, inverted, useFrustumCulling}) {
+ let material = sourceMaterial.clone();
+
+ // for the shader to work, we must fully populate the frustums uniform array
+ // with placeholder data (e.g. normals and constants for all 5 frustums),
+ // but as long as numFrustums is 0 then it won't have any effect
+ let defaultFrustums = this.buildDefaultFrustums(MAX_VIEW_FRUSTUMS);
+
+ material.uniforms = THREE.UniformsUtils.merge([
+ THREE.ShaderLib.physical.uniforms,
+ {
+ maxHeight: {value: maxHeight},
+ numFrustums: {value: 0},
+ frustums: {value: defaultFrustums}
+ }
+ ]);
+
+ material.vertexShader = this.areaTargetVertexShader({
+ useFrustumCulling: useFrustumCulling,
+ useLoadingAnimation: animateOnLoad,
+ center: center
+ });
+ material.fragmentShader = this.areaTargetFragmentShader({
+ useFrustumCulling: useFrustumCulling,
+ useLoadingAnimation: animateOnLoad,
+ inverted: inverted
+ });
+
+ material.transparent = (Object.keys(materialCullingFrustums).length > 0);
+ areaTargetMaterials.push(material);
+
+ if (animateOnLoad) {
+ this.materialsToAnimate.push({
+ material: material,
+ currentHeight: -15, // -maxHeight,
+ maxHeight: maxHeight * 4,
+ animationSpeed: 0.02 / 2
+ });
+ }
+
+ material.type = 'thecoolermeshstandardmaterial';
+
+ material.needsUpdate = true;
+
+ return material;
+ }
+ update() {
+ if (this.materialsToAnimate.length === 0) { return; }
+
+ let now = window.performance.now();
+ if (this.lastUpdate < 0) {
+ this.lastUpdate = now;
+ }
+ let dt = now - this.lastUpdate;
+ this.lastUpdate = now;
+
+ let indicesToRemove = [];
+ this.materialsToAnimate.forEach(function(entry, index) {
+ let material = entry.material;
+ if (entry.currentHeight < entry.maxHeight) {
+ entry.currentHeight += entry.animationSpeed * dt;
+ material.uniforms['maxHeight'].value = entry.currentHeight;
+ } else {
+ indicesToRemove.push(index);
+ }
+ });
+
+ for (let i = indicesToRemove.length-1; i >= 0; i--) {
+ let matIndex = indicesToRemove[i];
+ this.materialsToAnimate.splice(matIndex, 1);
+ }
+ }
+ }
+
+ /**
+ * @param object {THREE.Mesh}
+ * @param options {{size: number?, hideX: boolean?, hideY: boolean?, hideZ: boolean?}}
+ * @param onChange {function?}
+ * @param onDraggingChanged {function?}
+ * @returns {TransformControls}
+ */
+ function addTransformControlsTo(object, options, onChange, onDraggingChanged) {
+ let transformControls = new TransformControls(defaultCamera.getInternalObject(), mainRenderer.getInternalCanvas());
+ if (options && typeof options.hideX !== 'undefined') {
+ transformControls.showX = !options.hideX;
+ }
+ if (options && typeof options.hideY !== 'undefined') {
+ transformControls.showY = !options.hideY;
+ }
+ if (options && typeof options.hideZ !== 'undefined') {
+ transformControls.showZ = !options.hideZ;
+ }
+ if (options && typeof options.size !== 'undefined') {
+ transformControls.size = options.size;
+ }
+ transformControls.attach(object);
+ mainRenderer.add(transformControls);
+
+ if (typeof onChange === 'function') {
+ transformControls.addEventListener('change', onChange);
+ }
+ if (typeof onDraggingChanged === 'function') {
+ transformControls.addEventListener('dragging-changed', onDraggingChanged)
+ }
+ return transformControls;
+ }
+
+ exports.getScreenXY = (meshPosition) => mainRenderer.getCamera().getScreenXY(meshPosition);
+
+
+ exports.isPointOnScreen = (pointPosition) => mainRenderer.getCamera().isPointOnScreen(pointPosition);
+
+ // gets the position relative to groundplane (common coord system for threejsScene)
+ exports.getToolPosition = function(toolId) {
+ let toolSceneNode = realityEditor.sceneGraph.getSceneNodeById(toolId);
+ let groundPlaneNode = realityEditor.sceneGraph.getGroundPlaneNode();
+ let tp = realityEditor.sceneGraph.convertToNewCoordSystem({x: 0, y: 0, z: 0}, toolSceneNode, groundPlaneNode);
+ return new THREE.Vector3(tp.x, tp.y, tp.z);
+ }
+
+ exports.getCameraPosition = function() {
+ let cameraSceneNode = realityEditor.sceneGraph.getCameraNode();
+ let groundPlaneNode = realityEditor.sceneGraph.getGroundPlaneNode();
+ let cp = realityEditor.sceneGraph.convertToNewCoordSystem({x: 0, y: 0, z: 0}, cameraSceneNode, groundPlaneNode);
+ return new THREE.Vector3(cp.x, cp.y, cp.z);
+ }
+
+ // gets the direction the tool is facing, within the coordinate system of the groundplane
+ // todo Steve: currently this is not correct. Need further debugging
+ exports.getToolDirection = function(toolId) {
+ let toolSceneNode = realityEditor.sceneGraph.getSceneNodeById(toolId);
+ let groundPlaneNode = realityEditor.sceneGraph.getGroundPlaneNode();
+ let toolMatrix = realityEditor.sceneGraph.convertToNewCoordSystem(realityEditor.gui.ar.utilities.newIdentityMatrix(), toolSceneNode, groundPlaneNode);
+ let forwardVector = realityEditor.gui.ar.utilities.getForwardVector(toolMatrix);
+ return new THREE.Vector3(forwardVector[0], forwardVector[1], forwardVector[2]);
+ }
+
+ exports.getGltfBoundingBox = function() {
+ return gltfBoundingBox;
+ }
+
+ /**
+ * @return {Renderer} Various internal objects necessary for advanced (hacky) functions
+ */
+ exports.getInternals = function getInternals() {
+ return mainRenderer;
+ };
+
+ /**
+ * Turns off the mesh rendering so that the scene can be rendered on another canvas by another technology.
+ * This should most likely only be called on non-AR clients.
+ * @param {boolean} broadcastToOthers - if true, posts the updated renderMode to the server to notify other clients
+ */
+ function enableExternalSceneRendering(broadcastToOthers) {
+ let areaMesh = getObjectByName('areaTargetMesh');
+ // hide the mesh
+ if (areaMesh) {
+ areaMesh.visible = false;
+ }
+ // hide the ground plane holodeck visualizer
+ realityEditor.gui.ar.groundPlaneRenderer.stopVisualization();
+ // update the spatial cursor internal state
+ realityEditor.spatialCursor.gsToggleActive(true);
+ // update the renderMode of the world object and broadcast to other clients
+ let worldObject = realityEditor.worldObjects.getBestWorldObject();
+ if (worldObject) {
+ worldObject.renderMode = RENDER_MODES.ai;
+ if (!broadcastToOthers) return;
+ realityEditor.network.postObjectRenderMode(worldObject.ip, worldObject.objectId, worldObject.renderMode).then(_response => {
+ // console.log('successfully sent renderMode to other clients via the server', response);
+ }).catch(err => {
+ console.warn('error in postObjectRenderMode', err);
+ });
+ }
+ }
+
+ /**
+ * Restores mesh rendering when external rendering canvas is removed
+ * This should most likely only be called on non-AR clients.
+ * @param {boolean} broadcastToOthers - if true, posts the updated renderMode to the server to notify other clients
+ */
+ function disableExternalSceneRendering(broadcastToOthers) {
+ let areaMesh = getObjectByName('areaTargetMesh');
+ if (areaMesh) {
+ areaMesh.visible = true;
+ }
+ realityEditor.gui.ar.groundPlaneRenderer.startVisualization();
+ realityEditor.spatialCursor.gsToggleActive(false);
+ let worldObject = realityEditor.worldObjects.getBestWorldObject();
+ if (worldObject) {
+ worldObject.renderMode = RENDER_MODES.mesh;
+ if (!broadcastToOthers) return;
+ realityEditor.network.postObjectRenderMode(worldObject.ip, worldObject.objectId, worldObject.renderMode).then(_response => {
+ // console.log('successfully sent renderMode to other clients via the server', response);
+ }).catch(err => {
+ console.warn('error in postObjectRenderMode', err);
+ });
+ }
+ }
+
+ exports.initService = initService;
+ exports.setCameraPosition = setCameraPosition;
+ exports.addOcclusionGltf = addOcclusionGltf;
+ exports.isOcclusionActive = isOcclusionActive;
+ exports.addGltfToScene = addGltfToScene;
+ exports.onAnimationFrame = onAnimationFrame;
+ exports.removeAnimationCallback = removeAnimationCallback;
+ exports.addToScene = addToScene;
+ exports.removeFromScene = removeFromScene;
+ exports.getRaycastIntersects = (clientX, clientY, objectsToCheck) => {return mainRenderer.getRaycastIntersects(clientX, clientY, objectsToCheck)};
+ exports.getPointAtDistanceFromCamera = getPointAtDistanceFromCamera;
+ exports.getObjectByName = getObjectByName;
+ exports.getObjectsByName = getObjectsByName;
+ exports.getGroundPlaneCollider = getGroundPlaneCollider;
+ exports.isGroundPlanePositionSet = () => { return isGroundPlanePositionSet; };
+ exports.isWorldMeshLoadedAndProcessed = () => { return isWorldMeshLoadedAndProcessed; }
+ exports.setMatrixFromArray = setMatrixFromArray;
+ exports.getObjectForWorldRaycasts = getObjectForWorldRaycasts;
+ exports.getToolGroundPlaneShadowMatrix = getToolGroundPlaneShadowMatrix;
+ exports.getToolSurfaceShadowMatrix = getToolSurfaceShadowMatrix;
+ exports.addTransformControlsTo = addTransformControlsTo;
+ exports.toggleDisplayOriginBoxes = toggleDisplayOriginBoxes;
+ exports.updateMaterialCullingFrustum = updateMaterialCullingFrustum;
+ exports.removeMaterialCullingFrustum = removeMaterialCullingFrustum;
+ exports.changeMeasureMapType = changeMeasureMapType;
+ exports.highlightWalkableArea = highlightWalkableArea;
+ exports.updateGradientMapThreshold = updateGradientMapThreshold;
+ exports.setMatrixFromArray = setMatrixFromArray;
+ exports.THREE = THREE;
+ exports.FBXLoader = FBXLoader;
+ exports.GLTFLoader = GLTFLoader;
+ exports.onGltfDownloadProgress = (cb) => {
+ callbacks.onGltfDownloadProgress.push(cb);
+ }
+ exports.onGltfLoaded = (cb) => {
+ callbacks.onGltfLoaded.push(cb);
+ }
+ exports.enableExternalSceneRendering = enableExternalSceneRendering;
+ exports.disableExternalSceneRendering = disableExternalSceneRendering;
+ exports.RENDER_MODES = RENDER_MODES;
+})(realityEditor.gui.threejsScene);
diff --git a/src/gui/utilities.js b/src/gui/utilities.js
new file mode 100644
index 000000000..8f492584f
--- /dev/null
+++ b/src/gui/utilities.js
@@ -0,0 +1,264 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/**
+ * @fileOverview realityEditor.gui.utilities.js
+ * Contains utility functions related to the onscreen graphics, such as line calculations and image preloading.
+ */
+
+createNameSpace("realityEditor.gui.utilities");
+
+/**
+ * Checks if the line (x11,y11) -> (x12,y12) intersects with the line (x21,y21) -> (x22,y22)
+ * @param {number} x11
+ * @param {number} y11
+ * @param {number} x12
+ * @param {number} y12
+ * @param {number} x21
+ * @param {number} y21
+ * @param {number} x22
+ * @param {number} y22
+ * @param {number} w - width of canvas
+ * @param {number} h - height of canvas (ignores intersections outside of canvas
+ * @return {boolean}
+ */
+realityEditor.gui.utilities.checkLineCross = function (x11, y11, x12, y12, x21, y21, x22, y22, w, h) {
+ var l1 = this.lineEq(x11, y11, x12, y12),
+ l2 = this.lineEq(x21, y21, x22, y22);
+
+ var interX = this.calculateX(l1, l2); //calculate the intersection X value
+ if (interX > w || interX < 0) {
+ return false; //false if intersection of lines is output of canvas
+ }
+ var interY = this.calculateY(l1, interX);
+ // cout("interX, interY",interX, interY);
+
+ if (!interY || !interX) {
+ return false;
+ }
+ if (interY > h || interY < 0) {
+ return false; //false if intersection of lines is output of canvas
+ }
+ // cout("point on line --- checking on segment now");
+ return (this.checkBetween(x11, x12, interX) && this.checkBetween(y11, y12, interY)
+ && this.checkBetween(x21, x22, interX) && this.checkBetween(y21, y22, interY));
+};
+
+/**
+ * function for calculating the line equation given the endpoints of a line.
+ * returns [m, b], where this corresponds to y = mx + b
+ * y = [(y1-y2)/(x1-x2), -(y1-y2)/(x1-x2)*x1 + y1]
+ * @param {number} x1
+ * @param {number} y1
+ * @param {number} x2
+ * @param {number} y2
+ * @return {Array.} - length 2 array. first entry is m (slope), seconds is b (y-intercept)
+ */
+realityEditor.gui.utilities.lineEq = function (x1, y1, x2, y2) {
+ var m = this.slopeCalc(x1, y1, x2, y2);
+ // if(m == 'vertical'){
+ // return ['vertical', 'vertical'];
+ // }
+ return [m, -1 * m * x1 + y1];
+
+};
+
+/**
+ * Calculates the slope of the line defined by the provided endpoints (x1,y1) -> (x2,y2)
+ * slope has to be multiplied by -1 because the y-axis value increases we we go down
+ * @param {number} x1
+ * @param {number} y1
+ * @param {number} x2
+ * @param {number} y2
+ * @return {number}
+ */
+realityEditor.gui.utilities.slopeCalc = function (x1, y1, x2, y2) {
+ if ((x1 - x2) === 0) {
+ return 9999; //handle cases when slope is infinity
+ }
+ return (y1 - y2) / (x1 - x2);
+};
+
+/**
+ * calculate the intersection x value given two line segment
+ * @param {Array.} seg1 - [slope of line 1, y-intercept of line 1]
+ * @param {Array.} seg2 - [slope of line 2, y-intercept of line 2]
+ * @return {number} - the x value of their intersection
+ */
+realityEditor.gui.utilities.calculateX = function (seg1, seg2) {
+ return (seg2[1] - seg1[1]) / (seg1[0] - seg2[0]);
+};
+
+/**
+ * calculate y given x and the line equation
+ * @param {Array.} seg1 - [slope of line 1, y-intercept of line 1]
+ * @param {number} x
+ * @return {number} - returns (y = mx + b)
+ */
+realityEditor.gui.utilities.calculateY = function (seg1, x) {
+ return seg1[0] * x + seg1[1];
+};
+
+/**
+ * Given two end points of the segment and some other point p,
+ * return true if p is between the two segment points.
+ * (utility that helps with e.g. checking if two lines cross)
+ * @param {number} e1
+ * @param {number} e2
+ * @param {number} p
+ * @return {boolean}
+ */
+realityEditor.gui.utilities.checkBetween = function (e1, e2, p) {
+ var marg2 = 2;
+
+ if (e1 - marg2 <= p && p <= e2 + marg2) {
+ return true;
+ }
+ if (e2 - marg2 <= p && p <= e1 + marg2) {
+ return true;
+ }
+
+ return false;
+};
+
+/**
+ * Utility that pre-loads a number of image resources so that they can be more quickly added when they are needed
+ * First parameter is the array to hold the pre-loaded references
+ * Any number of additional string parameters can be passed in as file paths that should be loaded
+ * @param {Array.} array
+ */
+realityEditor.gui.utilities.preload = function(array) {
+ var args = realityEditor.gui.utilities.preload.arguments;
+ for (var i = 0; i < arguments.length - 1; i++) {
+ array[i] = new Image();
+ array[i].src = args[i + 1];
+ }
+
+ cout("preload");
+};
+
+/**
+ * Very simply polyfill for webkitConvertPointFromPageToNode - but only works for divs with no 3D transformation
+ * @param {HTMLElement} elt - the div whose coordinate space you are converting into
+ * @param {number} pageX
+ * @param {number} pageY
+ * @return {{x: number, y: number}} matching coordinates within the elt's frame of reference
+ */
+realityEditor.gui.utilities.convertPointFromPageToNode = function(elt, pageX, pageY) {
+ var eltRect = elt.getClientRects()[0];
+ var nodeX = (pageX - eltRect.left) / eltRect.width * parseFloat(elt.style.width);
+ var nodeY = (pageY - eltRect.top) / eltRect.height * parseFloat(elt.style.height);
+ return {
+ x: nodeX,
+ y: nodeY
+ }
+};
+
+/**
+ * Tries to retrieve the target size from the given object.
+ * Defaults to 0.3 if any errors or can't find it, to avoid divide-by-zero errors
+ * @param {string} objectKey
+ * @return {{width: number, height: number}}
+ */
+realityEditor.gui.utilities.getTargetSize = function(objectKey) {
+ let targetSize = {
+ width: 0.3,
+ height: 0.3
+ };
+
+ let object = realityEditor.getObject(objectKey);
+ if (object) {
+ if (typeof object.targetSize !== 'undefined') {
+ if (typeof object.targetSize.width !== 'undefined') {
+ targetSize.width = object.targetSize.width;
+ } else if (typeof object.targetSize.x !== 'undefined') {
+ targetSize.width = object.targetSize.x;
+ }
+ if (typeof object.targetSize.height !== 'undefined') {
+ targetSize.height = object.targetSize.height;
+ } else if (typeof object.targetSize.y !== 'undefined') {
+ targetSize.height = object.targetSize.y;
+ }
+ }
+ }
+
+ return targetSize;
+};
+
+/**
+ * Smoothly animates a set of translations using the first-last-invert-play
+ * technique
+ * @param {Array} elements
+ * @param {Function} translationFn - called to apply translation
+ * @param {object} options - used for Web Animations API
+ */
+realityEditor.gui.utilities.animateTranslations = function(elements, translationFn, options) {
+ const starts = elements.map(element => element.getBoundingClientRect());
+ translationFn();
+ const ends = elements.map(element => element.getBoundingClientRect());
+
+ for (let i = 0; i < elements.length; i++) {
+ const element = elements[i];
+ const start = starts[i];
+ const end = ends[i];
+
+ const dx = start.left - end.left;
+ const dy = start.top - end.top;
+
+ element.animate([
+ {
+ transform: `translate(${dx}px, ${dy}px)`,
+ }, {
+ transform: `none`,
+ }
+ ], Object.assign({
+ fill: 'both', // Transform should persist afterwards and be applied before
+ }, options));
+ }
+};
diff --git a/src/humanPose/AccelerationLens.js b/src/humanPose/AccelerationLens.js
new file mode 100644
index 000000000..9631ea59c
--- /dev/null
+++ b/src/humanPose/AccelerationLens.js
@@ -0,0 +1,154 @@
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+import {MotionStudyLens} from "./MotionStudyLens.js";
+import {MotionStudyColors} from "./MotionStudyColors.js";
+import {JOINTS} from "./constants.js";
+
+const HIGH_CUTOFF = 35; // in m/s^2
+const MED_CUTOFF = 15; // in m/s^2
+
+export const MIN_ACCELERATION = 0; // in m/s^2
+export const MAX_ACCELERATION = 40; // in m/s^2
+
+/**
+ * AccelerationLens is a lens that calculates the acceleration of each joint in the pose history.
+ */
+export class AccelerationLens extends MotionStudyLens {
+ /**
+ * Creates a new AccelerationLens object.
+ */
+ constructor() {
+ super("Acceleration");
+ }
+
+ /**
+ * Checks if the given joint has had its velocity calculated.
+ * @param {Object} joint The joint to check.
+ * @return {boolean} True if the joint has had its velocity calculated, false otherwise.
+ */
+ velocityAppliedToJoint(joint) {
+ return joint.velocity && joint.speed && joint.speed !== -1;
+ }
+
+ /**
+ * Checks if the given joint has had its acceleration calculated.
+ * @param {Object} joint The joint to check.
+ * @return {boolean} True if the joint has had its acceleration calculated, false otherwise.
+ */
+ accelerationAppliedToJoint(joint) {
+ return joint.acceleration && joint.accelerationMagnitude && joint.accelerationMagnitude !== -1;
+ }
+
+ applyLensToPose(pose) {
+ const previousPose = pose.metadata.previousPose;
+ const previousPreviousPose = previousPose ? previousPose.metadata.previousPose : null;
+ if (!previousPose) {
+ pose.forEachJoint(joint => {
+ // Velocity and acceleration are zero for the first pose
+ joint.velocity = new THREE.Vector3();
+ joint.speed = -1; // -1 means that the metric cannot calculated based on data
+ joint.acceleration = new THREE.Vector3();
+ joint.accelerationMagnitude = -1; // -1 means that the metric cannot calculated based on data
+ });
+ } else if (!previousPreviousPose) {
+ pose.forEachJoint(joint => {
+ const previousJoint = previousPose.getJoint(joint.name);
+ if (!joint.valid || !previousJoint.valid) {
+ // Velocity and acceleration are zero if a joint is invalid in current or previous pose
+ joint.velocity = new THREE.Vector3();
+ joint.speed = -1;
+ joint.acceleration = new THREE.Vector3();
+ joint.accelerationMagnitude = -1;
+ } else { // joint.valid == true && previousJoint.valid == true
+ joint.velocity = joint.position.clone().sub(previousJoint.position).divideScalar(pose.timestamp - previousPose.timestamp); // mm/ms = m/s
+ joint.speed = joint.velocity.length();
+ // Acceleration is zero for the second pose
+ joint.acceleration = new THREE.Vector3();
+ joint.accelerationMagnitude = -1;
+ }
+ });
+ } else {
+ pose.forEachJoint(joint => {
+ const previousJoint = previousPose.getJoint(joint.name);
+ const previousPreviousJoint = previousPreviousPose.getJoint(joint.name);
+ if (!joint.valid || !previousJoint.valid) {
+ // Velocity and acceleration are zero if a joint is invalid in current or previous pose
+ joint.velocity = new THREE.Vector3();
+ joint.speed = -1;
+ joint.acceleration = new THREE.Vector3();
+ joint.accelerationMagnitude = -1;
+ } else if (!previousPreviousJoint.valid) { // joint.valid == true && previousJoint.valid == true
+ joint.velocity = joint.position.clone().sub(previousJoint.position).divideScalar(pose.timestamp - previousPose.timestamp); // mm/ms = m/s
+ joint.speed = joint.velocity.length();
+ // Acceleration is zero if a joint is invalid in previous previous pose
+ joint.acceleration = new THREE.Vector3();
+ joint.accelerationMagnitude = -1;
+ } else { // joint.valid == true && previousJoint.valid == true && previousPreviousJoint.valid == true
+ joint.velocity = joint.position.clone().sub(previousJoint.position).divideScalar(pose.timestamp - previousPose.timestamp); // mm/ms = m/s
+ joint.speed = joint.velocity.length();
+ joint.acceleration = joint.velocity.clone().sub(previousJoint.velocity).divideScalar((pose.timestamp - previousPose.timestamp) / 1000);
+ joint.accelerationMagnitude = joint.acceleration.length();
+ }
+ });
+ }
+ return true;
+ }
+
+ applyLensToHistoryMinimally(poseHistory) {
+ if (poseHistory.length === 0) {
+ return [];
+ }
+ const pose = poseHistory[poseHistory.length - 1];
+ this.applyLensToPose(pose);
+ return poseHistory.map((pose, index) => index === poseHistory.length - 1); // Only last pose was modified
+ }
+
+ applyLensToHistory(poseHistory) {
+ return poseHistory.map((pose) => {
+ this.applyLensToPose(pose);
+ return true;
+ });
+ }
+
+ /**
+ * Returns the UI color for a specific acceleration value.
+ * @param {Number} acceleration The acceleration value to get the color for.
+ * @return {Color} The color to use for the value.
+ */
+ getColorForAcceleration(acceleration) {
+ if (acceleration > HIGH_CUTOFF) {
+ return MotionStudyColors.red;
+ }
+ if (acceleration > MED_CUTOFF) {
+ return MotionStudyColors.yellow;
+ }
+ return MotionStudyColors.green;
+ }
+
+ getColorForJoint(joint) {
+ if (!this.accelerationAppliedToJoint(joint)) {
+ return MotionStudyColors.undefined;
+ }
+ const acceleration = joint.accelerationMagnitude;
+ return this.getColorForAcceleration(acceleration);
+ }
+
+ getColorForBone(bone) {
+ if (!this.accelerationAppliedToJoint(bone.joint0) || !this.accelerationAppliedToJoint(bone.joint1)) {
+ return MotionStudyColors.undefined;
+ }
+ const maxAcceleration = Math.max(bone.joint0.accelerationMagnitude, bone.joint1.accelerationMagnitude);
+ return this.getColorForAcceleration(maxAcceleration);
+ }
+
+ getColorForPose(pose) {
+ // 'head' joint is always valid when body is tracked. Thus, acceleration is calculated under normal circumstances.
+ if (!this.accelerationAppliedToJoint(pose.getJoint(JOINTS.HEAD))) {
+ return MotionStudyColors.undefined;
+ }
+ let maxAcceleration = 0;
+ pose.forEachJoint(joint => {
+ maxAcceleration = Math.max(maxAcceleration, joint.accelerationMagnitude);
+ });
+ return MotionStudyColors.fade(this.getColorForAcceleration(maxAcceleration));
+ }
+}
diff --git a/src/humanPose/Animation.js b/src/humanPose/Animation.js
new file mode 100644
index 000000000..229b9ca5f
--- /dev/null
+++ b/src/humanPose/Animation.js
@@ -0,0 +1,142 @@
+const AnimationState = {
+ video: 'video',
+ noVideo: 'noVideo',
+};
+
+/**
+ * Animation
+ * Organizes all the information about an HPA's animation in one location
+ * Coordinates propagating animation information to the motionStudy and its
+ * video player if present
+ *
+ * Notably hands off control of video playback to that video player during any
+ * region of the recording that it contains
+ *
+ * Time is in ms unless otherwise specified
+ */
+export class Animation {
+ constructor(humanPoseAnalyzer, motionStudy, startTime, endTime) {
+ this.humanPoseAnalyzer = humanPoseAnalyzer;
+ this.motionStudy = motionStudy;
+
+ this.startTime = startTime;
+ this.endTime = endTime;
+
+ this.cursorTime = this.startTime;
+ this.lastUpdate = -1;
+ this.playing = true;
+
+ this.animationState = AnimationState.noVideo;
+
+ if (this.videoPlayer) {
+ this.attachVideoPlayerEvents();
+ }
+ }
+
+ get videoPlayer() {
+ return this.motionStudy.videoPlayer;
+ }
+
+ get videoStartTime() {
+ return this.motionStudy.videoStartTime;
+ }
+
+ attachVideoPlayerEvents() {
+ const video = this.videoPlayer.colorVideo;
+ video.addEventListener('play', () => {
+ this.playing = true;
+ });
+ video.addEventListener('pause', () => {
+ if (this.animationState !== AnimationState.video) {
+ return;
+ }
+
+ if (this.humanPoseAnalyzer.active) {
+ this.playing = false;
+ } else if (this.playing) {
+ this.videoPlayer.play();
+ }
+ });
+ }
+
+ update(time) {
+ if (this.lastUpdate < 0) {
+ this.lastUpdate = time;
+ }
+ let dt = time - this.lastUpdate;
+ this.lastUpdate = time;
+ const duration = this.endTime - this.startTime;
+ if (this.playing) {
+ this.cursorTime += dt;
+ if (this.cursorTime < this.startTime) {
+ this.cursorTime = this.startTime;
+ }
+ }
+ let offset = this.cursorTime - this.startTime;
+ if (offset > duration) {
+ offset = 0;
+ this.cursorTime = this.startTime;
+ // Reset animation state
+ if (this.animationState === AnimationState.video) {
+ this.stopVideoPlayback();
+ }
+ }
+
+ // Adjusted cursorTime that might be tracking a
+ // wobbly-relative-to-real-time video playback
+ let cursorTimeAdj = this.cursorTime;
+ if (this.isVideoPlayerInControl(cursorTimeAdj) && this.playing) {
+ if (this.animationState !== AnimationState.video) {
+ this.startVideoPlayback(cursorTimeAdj);
+ } else {
+ cursorTimeAdj = this.cursorTimeFromVideo();
+ }
+ } else if (this.animationState === AnimationState.video) {
+ this.stopVideoPlayback();
+ }
+
+ // As the active HPA we control the shared cursor
+ if (this.humanPoseAnalyzer.active) {
+ this.motionStudy.setCursorTime(cursorTimeAdj, true);
+ } else {
+ // Otherwise display the clone without interfering
+ this.humanPoseAnalyzer.displayClonesByTimestamp(cursorTimeAdj);
+ }
+ }
+
+ startVideoPlayback(cursorTime) {
+ this.videoPlayer.currentTime = (cursorTime - this.videoStartTime) / 1000;
+ this.videoPlayer.play();
+ this.animationState = AnimationState.video;
+ }
+
+ clear() {
+ if (this.videoPlayer) {
+ this.stopVideoPlayback();
+ this.videoPlayer.hide();
+ }
+ }
+
+ stopVideoPlayback() {
+ this.animationState = AnimationState.noVideo;
+ this.videoPlayer.pause();
+ }
+
+ isVideoPlayerInControl(cursorTime) {
+ if (!this.videoPlayer) {
+ return false;
+ }
+ if (cursorTime < this.videoStartTime) {
+ return false;
+ }
+ let videoLengthMs = this.videoPlayer.videoLength * 1000;
+ if (cursorTime > this.videoStartTime + videoLengthMs) {
+ return false;
+ }
+ return true;
+ }
+
+ cursorTimeFromVideo() {
+ return this.videoPlayer.currentTime * 1000 + this.videoStartTime;
+ }
+}
diff --git a/src/humanPose/HumanPoseAnalyzer.js b/src/humanPose/HumanPoseAnalyzer.js
new file mode 100644
index 000000000..1aee669ef
--- /dev/null
+++ b/src/humanPose/HumanPoseAnalyzer.js
@@ -0,0 +1,1203 @@
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+import {JOINTS, JOINT_CONFIDENCE_THRESHOLD} from './constants.js';
+import {Spaghetti} from './spaghetti.js';
+import {RebaLens} from "./RebaLens.js";
+import {OverallRebaLens} from "./OverallRebaLens.js";
+import {ValueAddWasteTimeLens} from "./ValueAddWasteTimeLens.js";
+import {AccelerationLens} from "./AccelerationLens.js";
+import {PoseObjectIdLens} from "./PoseObjectIdLens.js";
+
+import {HumanPoseAnalyzerSettingsUi} from "./HumanPoseAnalyzerSettingsUi.js";
+import {HumanPoseRenderer} from './HumanPoseRenderer.js';
+import {HumanPoseRenderInstance} from './HumanPoseRenderInstance.js';
+import {MAX_POSE_INSTANCES, MAX_POSE_INSTANCES_MOBILE} from './constants.js';
+import {AnimationMode} from './draw.js';
+import {Animation} from './Animation.js';
+
+const POSE_OPACITY_BASE = 1;
+const POSE_OPACITY_BACKGROUND = 0.2;
+
+export class HumanPoseAnalyzer {
+ /**
+ * Creates a new HumanPoseAnalyzer
+ * @param {Object3D} parent - container to add the analyzer's containers to
+ */
+ constructor(motionStudy, parent) {
+ this.motionStudy = motionStudy;
+ this.animation = null;
+
+ this.setupContainers(parent);
+
+ this.rebaLens = new RebaLens();
+ this.overallRebaLens = new OverallRebaLens();
+ this.valueAddWasteTimeLens = new ValueAddWasteTimeLens(this.motionStudy);
+ this.accelerationLens = new AccelerationLens();
+ this.poseObjectIdLens = new PoseObjectIdLens();
+
+ /** @type {MotionStudyLens[]} */
+ this.lenses = [
+ this.rebaLens,
+ this.overallRebaLens,
+ this.valueAddWasteTimeLens,
+ this.accelerationLens,
+ this.poseObjectIdLens
+ ]
+ this.activeLensIndex = 0;
+
+ this.activeJointName = ""; // Used in the UI
+ this.poseRenderInstances = {};
+
+ // auxiliary human objects supporting fused human objects
+ this.childHumanObjectsVisible = false;
+
+ this.jointConfidenceThreshold = JOINT_CONFIDENCE_THRESHOLD;
+
+ this.historyLines = {}; // Dictionary of {lensName: {(all | historical | live): Spaghetti}}, separated by historical and live
+ this.historyLineContainers = {
+ historical: {},
+ live: {}
+ }; // Dictionary of {lensName: Object3D} present in historyLineContainer that contains the corresponding history lines
+ this.lenses.forEach(lens => {
+ this.historyLines[lens.name] = {
+ all: {},
+ historical: {},
+ live: {}
+ };
+ this.historyLineContainers.historical[lens.name] = new THREE.Group();
+ this.historyLineContainers.historical[lens.name].visible = lens === this.activeLens;
+ this.historicalHistoryLineContainer.add(this.historyLineContainers.historical[lens.name]);
+ this.historyLineContainers.live[lens.name] = new THREE.Group();
+ this.historyLineContainers.live[lens.name].visible = lens === this.activeLens;
+ this.liveHistoryLineContainer.add(this.historyLineContainers.live[lens.name]);
+ });
+
+ this.clones = {
+ all: [],
+ historical: [],
+ live: []
+ }; // Array of all clones, entry format: Object3Ds with a pose child
+ this.recordingClones = realityEditor.device.environment.isDesktop();
+ this.lastDisplayedClones = [];
+
+ this.prevAnimationState = null;
+ this.animationMode = AnimationMode.region;
+
+ const maxPoseInstances = realityEditor.device.environment.isDesktop() ?
+ MAX_POSE_INSTANCES :
+ MAX_POSE_INSTANCES_MOBILE;
+
+ // The renderer for poses that need to be rendered opaquely
+ this.opaquePoseRenderer = new HumanPoseRenderer(new THREE.MeshBasicMaterial(), maxPoseInstances);
+ this.opaquePoseRenderer.addToScene(this.opaqueContainer);
+
+ // Keeps track of the HumanPoseRenderInstances for the start and end of the current selection
+ this.selectionMarkPoseRenderInstances = {
+ start: new HumanPoseRenderInstance(this.opaquePoseRenderer, 'selectionMarkStart', this.activeLens),
+ end: new HumanPoseRenderInstance(this.opaquePoseRenderer, 'selectionMarkEnd', this.activeLens),
+ };
+
+ // Contains all historical poses
+ this.historicalPoseRenderers = [];
+ if (realityEditor.device.environment.isDesktop()) {
+ this.addHistoricalPoseRenderer();
+ }
+
+ // Contains all live-recorded poses
+ this.livePoseRenderers = [];
+ this.addLivePoseRenderer();
+
+ if (realityEditor.device.environment.isDesktop()) {
+ this.settingsUi = new HumanPoseAnalyzerSettingsUi(this);
+ this.setUiDefaults();
+ }
+
+ this.update = this.update.bind(this);
+ window.requestAnimationFrame(this.update);
+ }
+
+ get active() {
+ return realityEditor.motionStudy.getActiveHumanPoseAnalyzer() === this;
+ }
+
+ get activeLens() {
+ return this.lenses[this.activeLensIndex];
+ }
+
+ /**
+ * Sets up the containers for the history lines and clones
+ * @param {Object3D} parent - object to add the analyzer's containers to
+ */
+ setupContainers(parent) {
+ this.historicalHistoryLineContainer = new THREE.Group();
+ this.historicalHistoryLineContainer.visible = true;
+ if (parent) {
+ parent.add(this.historicalHistoryLineContainer);
+ } else {
+ realityEditor.gui.threejsScene.addToScene(this.historicalHistoryLineContainer);
+ }
+ this.liveHistoryLineContainer = new THREE.Group();
+ this.liveHistoryLineContainer.visible = false;
+ if (parent) {
+ parent.add(this.liveHistoryLineContainer);
+ } else {
+ realityEditor.gui.threejsScene.addToScene(this.liveHistoryLineContainer);
+ }
+ this.historicalContainer = new THREE.Group();
+ this.historicalContainer.visible = true;
+ if (parent) {
+ parent.add(this.historicalContainer);
+ } else {
+ realityEditor.gui.threejsScene.addToScene(this.historicalContainer);
+ }
+ this.liveContainer = new THREE.Group();
+ this.liveContainer.visible = true;
+ if (parent) {
+ parent.add(this.liveContainer);
+ } else {
+ realityEditor.gui.threejsScene.addToScene(this.liveContainer);
+ }
+ this.opaqueContainer = new THREE.Group();
+ this.opaqueContainer.visible = true;
+ if (parent) {
+ parent.add(this.opaqueContainer);
+ } else {
+ realityEditor.gui.threejsScene.addToScene(this.opaqueContainer);
+ }
+ }
+
+ /**
+ * Sets the settings UI to the current state of the analyzer
+ */
+ setUiDefaults() {
+ this.settingsUi.setActiveLens(this.activeLens);
+ this.settingsUi.setLiveHistoryLinesVisible(this.liveHistoryLineContainer.visible);
+ this.settingsUi.setHistoricalHistoryLinesVisible(this.historicalHistoryLineContainer.visible);
+ this.settingsUi.setActiveJointByName(this.activeJointName);
+ this.settingsUi.setChildHumanPosesVisible(this.childHumanObjectsVisible);
+ this.settingsUi.setJointConfidenceFilter(true);
+ //this.settingsUi.setJointConfidenceThreshold(this.jointConfidenceThreshold);
+ }
+
+ /**
+ * Adds a new historical pose renderer to the analyzer
+ * @return {HumanPoseRenderer} - the new renderer
+ */
+ addHistoricalPoseRenderer() {
+ const poseRendererHistorical = new HumanPoseRenderer(new THREE.MeshBasicMaterial({
+ transparent: true,
+ opacity: POSE_OPACITY_BASE,
+ }), MAX_POSE_INSTANCES);
+ poseRendererHistorical.addToScene(this.historicalContainer);
+ this.historicalPoseRenderers.push(poseRendererHistorical);
+ return poseRendererHistorical;
+ }
+
+ /**
+ * Gets the most recently created historical pose renderer, or creates a new one if the last one is full
+ * @return {HumanPoseRenderer} - the historical pose renderer
+ */
+ getHistoricalPoseRenderer() {
+ const hpr = this.historicalPoseRenderers[this.historicalPoseRenderers.length - 1];
+ if (hpr.isFull()) {
+ return this.addHistoricalPoseRenderer();
+ }
+ return hpr;
+ }
+
+ resetHistoricalPoseRenderers() {
+ this.poseRenderInstances = {};
+ this.historicalPoseRenderers.forEach((renderer) => {
+ renderer.removeFromParent();
+ });
+ this.historicalPoseRenderers = [];
+ this.addHistoricalPoseRenderer();
+ }
+
+ /**
+ * Adds a new live pose renderer to the analyzer
+ * @return {HumanPoseRenderer} - the new renderer
+ */
+ addLivePoseRenderer() {
+ const maxPoseInstances = realityEditor.device.environment.isDesktop() ?
+ MAX_POSE_INSTANCES :
+ MAX_POSE_INSTANCES_MOBILE;
+ const livePoseRenderer = new HumanPoseRenderer(new THREE.MeshBasicMaterial({
+ transparent: true,
+ opacity: POSE_OPACITY_BASE,
+ }), maxPoseInstances);
+ livePoseRenderer.addToScene(this.liveContainer);
+ this.livePoseRenderers.push(livePoseRenderer);
+ return livePoseRenderer;
+ }
+
+ /**
+ * Gets the most recently created live pose renderer, or creates a new one if the last one is full
+ * @return {HumanPoseRenderer} - the live pose renderer
+ */
+ getLivePoseRenderer() {
+ const lpr = this.livePoseRenderers[this.livePoseRenderers.length - 1];
+ if (lpr.isFull()) {
+ return this.addLivePoseRenderer();
+ }
+ return lpr;
+ }
+
+ /**
+ * Runs every frame to update the animation state
+ */
+ update() {
+ let anySpaghettiHovered = false;
+ for (let spaghetti of Object.values(this.historyLines[this.activeLens.name].all)) {
+ if (spaghetti.cursorIndex !== -1) {
+ anySpaghettiHovered = true;
+ }
+ }
+ if (!anySpaghettiHovered) {
+ if (this.animationMode === AnimationMode.cursor) {
+ this.restoreAnimationState();
+ } else if (this.animationMode === AnimationMode.region) {
+ this.updateAnimation();
+ }
+ }
+
+ window.requestAnimationFrame(this.update);
+ }
+
+ /**
+ * Processes new poses being added to the HumanPoseRenderer
+ * @param {Pose} pose - the pose renderer that was updated
+ * @param {boolean} historical - whether the pose is historical or live
+ */
+ poseUpdated(pose, historical) {
+ if (this.recordingClones || historical) {
+ this.addCloneFromPose(pose, historical);
+ }
+ if(!pose.metadata.poseHasParent) {
+ // add to spaghetti non-auxiliary poses
+ this.updateSpaghetti(pose, historical);
+ }
+ }
+
+ /**
+ * Processes new bulk historical poses being added to the HumanPoseRenderer
+ * @param {Pose[]} poses - the poses to be added
+ */
+ bulkHistoricalPosesUpdated(poses) {
+ this.lenses.forEach(lens => {
+ lens.reset();
+ });
+ poses.forEach(pose => {
+ this.addCloneFromPose(pose, true);
+ });
+ this.bulkUpdateSpaghetti(poses, true);
+ }
+
+ /**
+ * Creates a new clone from a pose and adds it to the analyzer
+ * @param {Pose} pose - the pose to clone
+ * @param {boolean} historical - whether the pose is historical or live
+ */
+ addCloneFromPose(pose, historical) {
+ const poseRenderer = historical ? this.getHistoricalPoseRenderer() : this.getLivePoseRenderer();
+ const instanceId = `${pose.timestamp}-${pose.metadata.poseObjectId}`;
+ if (!this.poseRenderInstances[instanceId]) {
+ this.poseRenderInstances[instanceId] = new HumanPoseRenderInstance(poseRenderer, instanceId, this.activeLens);
+ }
+ const poseRenderInstance = this.poseRenderInstances[instanceId];
+
+ this.clones.all.push(poseRenderInstance);
+ if (historical) {
+ this.clones.historical.push(poseRenderInstance);
+ } else {
+ this.clones.live.push(poseRenderInstance);
+ }
+
+ pose.setBodyPartValidity(this.jointConfidenceThreshold); // Needs to be called before setPose(), because it sets internal attributes needed by setPose()
+ poseRenderInstance.setPose(pose); // Needs to be set before visible is set, setting a pose always makes visible at the moment
+ const canBeVisible = this.childHumanObjectsVisible || !pose.metadata.poseHasParent;
+ if (this.animationMode === AnimationMode.all) {
+ poseRenderInstance.setVisible(canBeVisible);
+ } else {
+ poseRenderInstance.setVisible(false);
+ }
+
+ poseRenderInstance.renderer.markMatrixNeedsUpdate();
+
+ const relevantClones = historical ? this.clones.historical : this.clones.live;
+ const poseHistory = relevantClones.map(poseRenderInstance => poseRenderInstance.pose);
+ this.lenses.forEach(lens => {
+ const modifiedResult = lens.applyLensToHistoryMinimally(poseHistory); // Needed to efficiently update each pose frame, if we update everything it's not as performant
+ modifiedResult.forEach((wasModified, index) => {
+ if (wasModified) {
+ relevantClones[index].updateColorBuffers(lens);
+ relevantClones[index].renderer.markColorNeedsUpdate();
+ }
+ });
+ });
+ poseRenderInstance.setLens(this.activeLens);
+ poseRenderInstance.renderer.markColorNeedsUpdate();
+ }
+
+ /**
+ * Updates the history lines with the given pose
+ * @param {Pose} pose - the pose to be added
+ * @param {boolean} historical - whether the pose is historical or live
+ */
+ updateSpaghetti(pose, historical) {
+ this.addPointsToSpaghetti([pose], historical);
+ }
+
+ /**
+ * Updates the history lines with the given bulk poses
+ * @param {Pose[]} poses - the poses to be added
+ * @param {boolean} historical - whether the pose is historical or live
+ */
+ bulkUpdateSpaghetti(poses, historical) {
+ this.addPointsToSpaghetti(poses.filter(pose => !pose.metadata.poseHasParent), historical);
+ }
+
+ /**
+ * Adds a poseRenderInstance's point to the history line's points for the given lens, updating the history line if desired
+ * @param {Pose[]} poses - the poses to add the points from
+ * @param {boolean} historical - whether the pose is historical or live
+ */
+ addPointsToSpaghetti(poses, historical) {
+ this.lenses.forEach(lens => {
+ this.addPointsToSpaghettiForLens(lens, poses, historical);
+ });
+ }
+
+ /**
+ * Adds a poseRenderInstance's point to the history line's points for the given lens, updating the history line if desired
+ * @param {MotionStudyLens} lens - the lens to use for the spaghetti
+ * @param {Pose[]} poses - the poses to add the points from
+ * @param {boolean} historical - whether the pose is historical or live
+ */
+ addPointsToSpaghettiForLens(lens, poses, historical) {
+ const pointsById = {};
+ poses.forEach(pose => {
+ const timestamp = pose.timestamp;
+ const id = pose.metadata.poseObjectId;
+ let currentPoint = pose.getJoint(JOINTS.HEAD).position.clone();
+ currentPoint.y += 400; // mm
+ if (!this.historyLines[lens.name].all.hasOwnProperty(id)) {
+ this.createSpaghetti(lens, id, historical);
+ }
+
+ const color = lens.getColorForPose(pose);
+
+ /** @type {SpaghettiMeshPathPoint} */
+ const historyPoint = {
+ x: currentPoint.x,
+ y: currentPoint.y,
+ z: currentPoint.z,
+ color,
+ timestamp,
+ };
+ if (!pointsById[id]) {
+ pointsById[id] = [historyPoint];
+ } else {
+ pointsById[id].push(historyPoint);
+ }
+ });
+
+ Object.keys(pointsById).forEach(id => {
+ const spaghetti = this.historyLines[lens.name].all[id];
+ spaghetti.addPoints(pointsById[id]);
+ });
+ }
+
+ /**
+ * Resets spaghetti info with updated lens colors
+ * @param {MotionStudyLens} lens - the lens to use for the spaghetti
+ */
+ reprocessSpaghettiForLens(lens) {
+ const livePoses = this.clones.live.map(clone => clone.pose);
+ const historicalPoses = this.clones.historical.map(clone => clone.pose);
+
+ Object.values(this.historyLines[lens.name].all).forEach(spaghetti => {
+ spaghetti.resetPoints();
+ });
+ this.addPointsToSpaghettiForLens(lens, livePoses, false);
+ this.addPointsToSpaghettiForLens(lens, historicalPoses, true);
+ }
+
+ /**
+ * Creates a spaghetti line using a given lens
+ * Side effect: adds the spaghetti line to the appropriate historyLineContainer and historyLines
+ * @param {MotionStudyLens} lens - the lens to use for the spaghetti
+ * @param {string} id - key for spaghettis (the pose object id)
+ * @param {boolean} historical - whether the spaghetti is historical or live
+ * @return {Spaghetti} - the spaghetti line that was created
+ */
+ createSpaghetti(lens, id, historical) {
+ const motionStudy = this.motionStudy;
+ const spaghetti = new Spaghetti([], motionStudy, `spaghetti-${id}-${lens.name}-${historical ? 'historical' : 'live'}`, {
+ widthMm: 30,
+ heightMm: 30,
+ usePerVertexColors: true,
+ wallBrightness: 0.6,
+ });
+
+ this.historyLines[lens.name].all[id] = spaghetti;
+ if (historical) {
+ this.historyLineContainers.historical[lens.name].add(spaghetti);
+ this.historyLines[lens.name].historical[id] = spaghetti;
+ } else {
+ this.historyLineContainers.live[lens.name].add(spaghetti);
+ this.historyLines[lens.name].live[id] = spaghetti;
+ }
+ return spaghetti;
+ }
+
+ /**
+ * Clears all historical data
+ */
+ clearHistoricalData() {
+ this.resetHistoricalHistoryLines();
+ this.resetHistoricalHistoryClones();
+ this.resetHistoricalPoseRenderers();
+ this.lenses.forEach(lens => {
+ lens.reset();
+ })
+ }
+
+ /**
+ * Clears all historical history lines
+ */
+ resetHistoricalHistoryLines() {
+ this.lenses.forEach(lens => {
+ Object.keys(this.historyLines[lens.name].historical).forEach(key => {
+ const spaghetti = this.historyLines[lens.name].historical[key];
+ spaghetti.reset();
+ if (spaghetti.parent) {
+ spaghetti.parent.remove(spaghetti);
+ }
+ delete this.historyLines[lens.name].all[key];
+ });
+ this.historyLines[lens.name].historical = {};
+ });
+ }
+
+ /**
+ * Clears all live history lines
+ */
+ resetLiveHistoryLines() {
+ this.lenses.forEach(lens => {
+ Object.keys(this.historyLines[lens.name].live).forEach(key => {
+ const spaghetti = this.historyLines[lens.name].live[key];
+ spaghetti.reset();
+ spaghetti.parent.remove(spaghetti);
+ delete this.historyLines[lens.name].all[key];
+ });
+ this.historyLines[lens.name].live = {};
+ });
+ }
+
+ /**
+ * Clears all historical history clones
+ */
+ resetHistoricalHistoryClones() {
+ this.clones.historical.forEach(clone => {
+ if (this.lastDisplayedClones.includes(clone)) {
+ this.lastDisplayedClones.splice(this.lastDisplayedClones.indexOf(clone), 1);
+ }
+ clone.remove();
+ this.clones.all.splice(this.clones.all.indexOf(clone), 1);
+ });
+ this.clones.historical = [];
+ this.markHistoricalMatrixNeedsUpdate();
+ this.markHistoricalColorNeedsUpdate();
+ }
+
+ /**
+ * Clears all live history clones
+ */
+ resetLiveHistoryClones() {
+ this.clones.live.forEach(clone => {
+ if (this.lastDisplayedClones.includes(clone)) {
+ this.lastDisplayedClones.splice(this.lastDisplayedClones.indexOf(clone), 1);
+ }
+ clone.remove();
+ this.clones.all.splice(this.clones.all.indexOf(clone), 1);
+ });
+ this.clones.live = [];
+ this.markHistoricalMatrixNeedsUpdate();
+ }
+
+ /**
+ * Reprocesses the given lens, applying it to poses and spaghetti lines
+ * @param {MotionStudyLens} lens - the lens to reprocess
+ */
+ reprocessLens(lens) {
+ [this.clones.live, this.clones.historical].forEach(relevantClones => {
+ const posesChanged = lens.applyLensToHistory(relevantClones.map(clone => clone.pose));
+ posesChanged.forEach((wasChanged, index) => {
+ if (wasChanged) { // Only update colors if the pose data was modified
+ relevantClones[index].updateColorBuffers(lens);
+ relevantClones[index].renderer.markColorNeedsUpdate();
+ }
+ });
+ });
+ this.reprocessSpaghettiForLens(lens);
+ }
+
+ setJointConfidenceThreshold(confidence) {
+ this.jointConfidenceThreshold = confidence;
+ //console.info('jointConfidenceThreshold=', confidence);
+
+ // update all poses and derived clones for visualisation
+ [this.clones.live, this.clones.historical].forEach(relevantClones => {
+ relevantClones.forEach(clone => clone.pose.setBodyPartValidity(this.jointConfidenceThreshold));
+ // lens calculations need to be updated based on changed valid attribute of joints
+ let poses = relevantClones.map(clone => clone.pose);
+ this.lenses.forEach(lens => {
+ // need to upate whole history, although it takes a bit of time
+ lens.applyLensToHistory(poses, true /* force */);
+ });
+ relevantClones.forEach(clone => {
+ if (clone.visible) {
+ clone.updateJointPositions();
+ clone.updateBonePositions();
+ clone.renderer.markMatrixNeedsUpdate();
+ }
+ clone.updateColorBuffers(this.activeLens);
+ clone.renderer.markColorNeedsUpdate();
+ });
+
+ });
+
+ // update all spaghetti lines
+ this.lenses.forEach(lens => {
+ this.reprocessSpaghettiForLens(lens);
+ });
+
+ this.motionStudy.updateRegionCards();
+ }
+
+ getJointConfidenceThreshold() {
+ return this.jointConfidenceThreshold;
+ }
+
+ /**
+ * Sets the active lens
+ * @param {MotionStudyLens} lens - the lens to set as active
+ */
+ setActiveLens(lens) {
+ const previousLens = this.activeLens;
+
+ this.activeLensIndex = this.lenses.indexOf(lens);
+ this.applyCurrentLensToHistory();
+
+ // Swap hpri colors
+ // Sets lens to individual render instances
+ this.clones.all.forEach(clone => {
+ clone.setLens(lens);
+ clone.renderer.markColorNeedsUpdate();
+ });
+
+ // Swap history lines
+ this.historyLineContainers.historical[previousLens.name].visible = false;
+ this.historyLineContainers.live[previousLens.name].visible = false;
+ this.historyLineContainers.historical[lens.name].visible = true;
+ this.historyLineContainers.live[lens.name].visible = true;
+
+ // Update corresponding spaghettis to match previous selection state
+ Object.keys(this.historyLines[previousLens.name].all).forEach(key => {
+ const previousSpaghetti = this.historyLines[previousLens.name].all[key];
+ const nextSpaghetti = this.historyLines[lens.name].all[key];
+ previousSpaghetti.transferStateTo(nextSpaghetti);
+ });
+
+ // Update UI
+ if (this.settingsUi) {
+ this.settingsUi.setActiveLens(lens);
+ }
+ }
+
+ /**
+ * Sets the active lens by name
+ * @param {string} lensName - the name of the lens to set as active
+ */
+ setActiveLensByName(lensName) {
+ const lens = this.lenses.find(lens => lens.name === lensName);
+ this.setActiveLens(lens);
+ }
+
+ /**
+ * Sets the active joint by name
+ * @param {string} jointName - the name of the joint to set as active
+ */
+ setActiveJointByName(jointName) {
+ this.activeJointName = jointName;
+ if (this.settingsUi) {
+ this.settingsUi.setActiveJointByName(jointName);
+ }
+ // TODO: Create history line for joint
+ }
+
+ /**
+ * Sets the active joint
+ * @param {Object} joint - the joint to set as active
+ */
+ setActiveJoint(joint) {
+ this.setActiveJointByName(joint.name);
+ }
+
+ /**
+ * Sets the interval which controls what history is highlighted
+ * @param {TimeRegion} highlightRegion - the time region to highlight
+ * @param {boolean} fromSpaghetti - whether a history mesh originated this change
+ */
+ setHighlightRegion(highlightRegion, fromSpaghetti) {
+ // Reset animation's playing so that we default to always playing
+ if (this.animation) {
+ this.animation.playing = true;
+ }
+ if (!highlightRegion) {
+ this.setAnimationMode(AnimationMode.cursor);
+ if (!fromSpaghetti) {
+ for (let mesh of Object.values(this.historyLines[this.activeLens.name].all)) {
+ mesh.setHighlightRegion(null);
+ }
+ }
+ // Clear prevAnimationState because we're no longer in a
+ // highlighting state
+ this.prevAnimationState = null;
+ this.clearAnimation();
+ return;
+ }
+ if (this.animationMode !== AnimationMode.region &&
+ this.animationMode !== AnimationMode.regionAll) {
+ this.setAnimationMode(AnimationMode.region);
+ }
+ this.setAnimationRange(highlightRegion.startTime, highlightRegion.endTime);
+ if (!fromSpaghetti) {
+ for (let mesh of Object.values(this.historyLines[this.activeLens.name].all)) {
+ mesh.setHighlightRegion(highlightRegion);
+ }
+ }
+ }
+
+ /**
+ * Sets the interval which controls what history is displayed, hides history outside of the interval
+ * @param {TimeRegion} displayRegion - the time region to display
+ */
+ setDisplayRegion(displayRegion) {
+ const firstTimestamp = displayRegion.startTime;
+ const secondTimestamp = displayRegion.endTime;
+
+ this.lenses.forEach(lens => {
+ for (let spaghetti of Object.values(this.historyLines[lens.name].historical)) { // This feature only enabled for historical history lines
+ spaghetti.setDisplayRegion(displayRegion);
+ if (spaghetti.getStartTime() > secondTimestamp || spaghetti.getEndTime() < firstTimestamp) {
+ spaghetti.visible = false;
+ continue;
+ }
+ if (this.activeLens === lens) {
+ spaghetti.visible = true;
+ }
+ }
+ });
+ }
+
+ /**
+ * Sets the hover time for the relevant history line
+ * @param {number} timestamp - timestamp in ms
+ * @param {boolean} fromSpaghetti - prevents infinite recursion from modifying human pose spaghetti which calls
+ * this function
+ */
+ setCursorTime(timestamp, fromSpaghetti) {
+ if (timestamp < 0) {
+ if (this.animationMode === AnimationMode.cursor) {
+ this.restoreAnimationState();
+ }
+ return;
+ }
+ for (let spaghetti of Object.values(this.historyLines[this.activeLens.name].all)) {
+ if (spaghetti.getStartTime() > timestamp || spaghetti.getEndTime() < timestamp) {
+ continue;
+ }
+ if (!fromSpaghetti) {
+ spaghetti.setCursorTime(timestamp);
+
+ if (this.animationMode !== AnimationMode.cursor) {
+ this.setAnimationMode(AnimationMode.cursor);
+ }
+ }
+ }
+ this.displayClonesByTimestamp(timestamp);
+ }
+
+ /**
+ * Returns a list of poses in the time interval, preferring the historical
+ * data source where available
+ * @param {number} firstTimestamp - start of time interval in ms
+ * @param {number} secondTimestamp - end of time interval in ms
+ * @return {Pose[]} - all poses in the time interval
+ */
+ getPosesInTimeInterval(firstTimestamp, secondTimestamp) {
+ function getPoses(clonesList) {
+ const poses = clonesList.map(clone => clone.pose).filter(pose => {
+ return pose.timestamp >= firstTimestamp &&
+ pose.timestamp <= secondTimestamp;
+ });
+ poses.sort((a, b) => a.timestamp - b.timestamp);
+ return poses;
+ }
+
+ const live = getPoses(this.clones.live);
+ if (live.length > 0) {
+ return live;
+ }
+
+ return getPoses(this.clones.historical);
+ }
+
+ /**
+ * Makes the live human poses visible or invisible
+ * @param {boolean} visible - whether to show or not
+ */
+ setLiveHumanPosesVisible(visible) {
+
+ this.opaqueContainer.visible = visible;
+ /*
+ for (let id in this.livePoseRenderers) {
+ this.livePoseRenderers[id].container.visible = visible;
+ }
+ */
+ }
+
+ /**
+ * Makes the historical history lines visible or invisible
+ * @param {boolean} visible - whether to show the history lines
+ */
+ setHistoricalHistoryLinesVisible(visible) {
+ this.historicalHistoryLineContainer.visible = visible;
+ if (this.settingsUi) {
+ this.settingsUi.setHistoricalHistoryLinesVisible(visible);
+ }
+ }
+
+ /**
+ * Makes the live history lines visible or invisible
+ * @param {boolean} visible - whether to show the history lines
+ */
+ setLiveHistoryLinesVisible(visible) {
+ this.liveHistoryLineContainer.visible = visible;
+ if (this.settingsUi) {
+ this.settingsUi.setLiveHistoryLinesVisible(visible);
+ }
+ }
+
+ /**
+ * Gets the visibility of the historical history lines
+ * @return {boolean} - whether the historical history lines are visible
+ */
+ getHistoricalHistoryLinesVisible() {
+ return this.historicalHistoryLineContainer.visible;
+ }
+
+ /**
+ * Gets the visibility of the live history lines
+ * @return {boolean} - whether the live history lines are visible
+ */
+ getLiveHistoryLinesVisible() {
+ return this.liveHistoryLineContainer.visible;
+ }
+
+ /**
+ * Gets the visibility of the history lines
+ * @return {boolean} - whether the history lines are visible
+ * @deprecated
+ * @see getLiveHistoryLinesVisible
+ * @see getHistoricalHistoryLinesVisible
+ */
+ getHistoryLinesVisible() {
+ console.warn('getHistoryLinesVisible is deprecated. Use getLiveHistoryLinesVisible or getHistoricalHistoryLinesVisible instead.');
+ return this.getLiveHistoryLinesVisible();
+ }
+
+ /**
+ * Advances the active lens to the next one
+ */
+ advanceLens() {
+ this.nextLensIndex = (this.activeLensIndex + 1) % this.lenses.length;
+ this.setActiveLens(this.lenses[this.nextLensIndex]);
+ }
+
+ /**
+ * Applies the current lens to the history, updating the clones' colors if needed
+ */
+ applyCurrentLensToHistory() {
+ [this.clones.live, this.clones.historical].forEach(relevantClones => {
+ const posesChanged = this.activeLens.applyLensToHistory(relevantClones.map(clone => clone.pose));
+ posesChanged.forEach((wasChanged, index) => {
+ if (wasChanged) { // Only update colors if the pose data was modified
+ relevantClones[index].updateColorBuffers(this.activeLens);
+ relevantClones[index].renderer.markColorNeedsUpdate();
+ }
+ });
+ });
+ }
+
+ /**
+ * Sets the animation mode for rendering clones
+ * @param {AnimationMode} animationMode - the animation mode to set
+ */
+ setAnimationMode(animationMode) {
+ if (this.animationMode === animationMode) {
+ return;
+ }
+ if (animationMode === AnimationMode.cursor) {
+ this.saveAnimationState();
+ }
+
+ this.animationMode = animationMode;
+
+ if (this.clones.all.length === 0) {
+ return;
+ }
+
+ if (this.animationMode === AnimationMode.all) {
+ for (let clone of this.clones.all) {
+ const canBeVisible = this.childHumanObjectsVisible || !clone.pose.metadata.poseHasParent;
+ clone.setVisible(canBeVisible);
+ clone.renderer.markMatrixNeedsUpdate();
+ }
+ return;
+ }
+
+ if (this.animationMode === AnimationMode.cursor || this.animationMode === AnimationMode.region) {
+ for (let clone of this.clones.all) {
+ clone.setVisible(false);
+ clone.renderer.markMatrixNeedsUpdate();
+ }
+ }
+
+ if (this.animationMode === AnimationMode.regionAll) {
+ this.setHistoricalPoseRenderersOpacity(POSE_OPACITY_BACKGROUND);
+ } else {
+ this.setHistoricalPoseRenderersOpacity(POSE_OPACITY_BASE);
+ this.selectionMarkPoseRenderInstances.start.setVisible(false);
+ this.selectionMarkPoseRenderInstances.start.renderer.markNeedsUpdate();
+ this.selectionMarkPoseRenderInstances.end.setVisible(false);
+ this.selectionMarkPoseRenderInstances.end.renderer.markNeedsUpdate();
+ }
+ }
+
+ /**
+ * Saves the animation state while in the temporary cursor mode
+ */
+ saveAnimationState() {
+ if (this.animationMode === AnimationMode.cursor) {
+ return;
+ }
+ // May have not set an animation state
+ if (!this.animation) {
+ return;
+ }
+
+ this.prevAnimationState = {
+ animationMode: this.animationMode,
+ animationStart: this.animation.startTime,
+ animationEnd: this.animation.endTime,
+ };
+
+ this.animation.clear();
+ }
+
+ /**
+ * Resets to the saved animation state after exiting the temporary cursor mode
+ */
+ restoreAnimationState() {
+ this.hideLastDisplayedClones();
+ if (!this.prevAnimationState) {
+ return;
+ }
+ this.setAnimationMode(this.prevAnimationState.animationMode);
+ this.setAnimationRange(
+ this.prevAnimationState.animationStart,
+ this.prevAnimationState.animationEnd);
+ this.prevAnimationState = null;
+ }
+
+ isAnimationPlaying() {
+ return this.animation && this.animation.playing;
+ }
+
+ setHistoricalPoseRenderersOpacity(opacity) {
+ for (let hpr of this.historicalPoseRenderers) {
+ if (hpr.material.opacity !== opacity) {
+ hpr.material.opacity = opacity;
+ }
+ }
+ }
+
+ /**
+ * Marks the historical pose renderers as needing a color update
+ */
+ markHistoricalColorNeedsUpdate() {
+ for (let hpr of this.historicalPoseRenderers) {
+ hpr.markColorNeedsUpdate();
+ }
+ }
+
+ /**
+ * Marks the historical pose renderers as needing a matrix update
+ */
+ markHistoricalMatrixNeedsUpdate() {
+ for (let hpr of this.historicalPoseRenderers) {
+ hpr.markMatrixNeedsUpdate();
+ }
+ }
+
+ /**
+ * Marks the historical pose renderers as needing both a color and a matrix update
+ */
+ markHistoricalNeedsUpdate() {
+ for (let hpr of this.historicalPoseRenderers) {
+ hpr.markNeedsUpdate();
+ }
+ }
+
+ /**
+ * Sets the animation range, updating the animation position if necessary
+ * @param {number} start - start time of animation in ms
+ * @param {number} end - end time of animation in ms
+ */
+ setAnimationRange(start, end) {
+ if (this.animation &&
+ this.animation.startTime === start &&
+ this.animation.endTime === end) {
+ return;
+ }
+
+ switch (this.animationMode) {
+ case AnimationMode.region:
+ // Fully reset the animation when changing
+ this.hideLastDisplayedClones();
+ this.lastDisplayedClones = [];
+ break;
+ case AnimationMode.regionAll: {
+ this.hideAllClones();
+ this.setCloneVisibleInInterval(true, start, end);
+ const startClone = this.getCloneByTimestamp(start);
+ if (startClone) {
+ this.selectionMarkPoseRenderInstances.start.copy(startClone, this.jointConfidenceThreshold);
+ this.selectionMarkPoseRenderInstances.start.setVisible(true);
+ this.selectionMarkPoseRenderInstances.start.renderer.markNeedsUpdate();
+ }
+ const endClone = this.getCloneByTimestamp(end);
+ if (endClone) {
+ this.selectionMarkPoseRenderInstances.end.copy(endClone, this.jointConfidenceThreshold);
+ this.selectionMarkPoseRenderInstances.end.setVisible(true);
+ this.selectionMarkPoseRenderInstances.end.renderer.markNeedsUpdate();
+ }
+ }
+ break;
+ case AnimationMode.all:
+ // no effect
+ break;
+ case AnimationMode.cursor:
+ // no effect
+ break;
+ }
+
+ let cursorTime = -1;
+ if (this.animation) {
+ cursorTime = this.animation.cursorTime;
+ }
+ this.animation = new Animation(this, this.motionStudy, start, end);
+ if (cursorTime > start && cursorTime < end) {
+ this.animation.cursorTime = cursorTime;
+ }
+ }
+
+ /**
+ * Plays the current frame of the animation
+ */
+ updateAnimation() {
+ if (!this.animation || this.animation.startTime < 0 || this.animation.endTime < 0) {
+ this.clearAnimation();
+ return;
+ }
+ const now = Date.now();
+ this.animation.update(now);
+ }
+
+ /**
+ * Resets the animation data and stops playback
+ */
+ clearAnimation() {
+ if (this.animation) {
+ this.animation.clear();
+ this.animation = null;
+ }
+ this.hideLastDisplayedClones();
+ }
+
+ /**
+ * Displays or hides all clones in a given range
+ * @param {boolean} visible - whether to show or hide the clones
+ * @param {number} start - start time of animation in ms
+ * @param {number} end - end time of animation in ms
+ */
+ setCloneVisibleInInterval(visible, start, end) {
+ if (start < 0 || end < 0 ||
+ this.clones.all.length < 0) {
+ return;
+ }
+
+ for (let i = 0; i < this.clones.all.length; i++) {
+ let clone = this.clones.all[i];
+ if (clone.pose.timestamp < start) {
+ continue;
+ }
+ if (clone.pose.timestamp > end) {
+ break;
+ }
+ const canBeVisible = this.childHumanObjectsVisible || !clone.pose.metadata.poseHasParent;
+ if (clone.visible === (visible && canBeVisible)) {
+ continue;
+ }
+ clone.renderer.markMatrixNeedsUpdate();
+ clone.setVisible(visible && canBeVisible);
+ }
+ }
+
+ /**
+ * Displays all clones
+ */
+ showAllClones() {
+ this.clones.all.forEach(clone => {
+ const canBeVisible = this.childHumanObjectsVisible || !clone.pose.metadata.poseHasParent;
+ clone.setVisible(canBeVisible);
+ clone.renderer.markMatrixNeedsUpdate();
+ });
+ }
+
+ /**
+ * Hides all clones
+ */
+ hideAllClones() {
+ this.clones.all.forEach(clone => {
+ clone.setVisible(false);
+ clone.renderer.markMatrixNeedsUpdate();
+ });
+ }
+
+ /**
+ * Hides the current displayed clones
+ */
+ hideLastDisplayedClones() {
+ this.lastDisplayedClones.forEach(clone => {
+ clone.setVisible(false);
+ clone.renderer.markMatrixNeedsUpdate();
+ });
+ }
+
+ /**
+ * Displays the clones with the closest timestamp to the given timestamp per objectId
+ * @param {number} timestamp - the timestamp to display
+ */
+ displayClonesByTimestamp(timestamp) {
+ if (this.animationMode === AnimationMode.all || this.animationMode === AnimationMode.regionAll) { // Don't do anything if we're rendering all clones
+ return;
+ }
+
+ if (this.clones.all.length < 2) {
+ return;
+ }
+
+ const bestClones = this.getClonesByTimestamp(timestamp);
+ if (bestClones.length === 0) {
+ this.hideLastDisplayedClones();
+ this.lastDisplayedClones = [];
+ return;
+ }
+
+ const clonesToHide = this.lastDisplayedClones.filter(clone => !bestClones.includes(clone));
+ const clonesToShow = bestClones.filter(clone => !this.lastDisplayedClones.includes(clone));
+
+ clonesToHide.forEach(clone => {
+ clone.setVisible(false);
+ //clone.renderer.markMatrixNeedsUpdate();
+ clone.renderer.markNeedsUpdate();
+ });
+ clonesToShow.forEach(clone => {
+ const canBeVisible = this.childHumanObjectsVisible || !clone.pose.metadata.poseHasParent;
+ clone.setVisible(canBeVisible);
+ //clone.renderer.markMatrixNeedsUpdate();
+ clone.renderer.markNeedsUpdate();
+ });
+
+ this.lastDisplayedClones = bestClones;
+ }
+
+ /**
+ * Returns the clone with the closest timestamp to the given timestamp, independent of objectId
+ * @param {number} timestamp - time in ms
+ * @return {HumanPoseRenderInstance | null} - the clone with the closest timestamp
+ */
+ getCloneByTimestamp(timestamp) {
+ if (this.clones.all.length < 2) {
+ return null;
+ }
+
+ let bestClone = this.clones.all[0];
+ let bestDeltaT = Math.abs(this.clones.all[0].pose.timestamp - timestamp);
+
+ // Dan: This used to be more optimized, but required a sorted array of clones, which we don't have when mixing historical and live data (could be added though)
+ for (let i = 0; i < this.clones.all.length; i++) {
+ const clone = this.clones.all[i];
+ if (clone.pose.metadata.poseHasParent)
+ continue;
+ const deltaT = Math.abs(clone.pose.timestamp - timestamp);
+ if (deltaT < bestDeltaT) {
+ bestClone = clone;
+ bestDeltaT = deltaT;
+ }
+ }
+
+ return bestClone;
+ }
+
+ /**
+ * Returns the clones per objectId with the closest timestamp to the given timestamp
+ * @param {number} timestamp - time in ms
+ * @return {HumanPoseRenderInstance[]} - the clones with the closest timestamp per objectId
+ */
+ getClonesByTimestamp(timestamp) {
+ if (this.clones.all.length < 2) {
+ return [];
+ }
+
+ const maxDeltaT = 200; // ms, don't show clones that are more than some time interval away from the current time
+ let bestData = [];
+
+ // Dan: This used to be more optimized, but required a sorted array of clones, which we don't have when mixing historical and live data (could be added though)
+ for (let i = 0; i < this.clones.all.length; i++) {
+ const clone = this.clones.all[i];
+ const distance = Math.abs(clone.pose.timestamp - timestamp);
+ if (distance > maxDeltaT) {
+ continue;
+ }
+ const objectId = clone.pose.metadata.poseObjectId;
+ const bestDatum = bestData.find(data => data.objectId === objectId);
+ if (!bestDatum) {
+ bestData.push({
+ clone,
+ distance,
+ objectId
+ });
+ } else {
+ if (distance < bestDatum.distance) {
+ bestDatum.clone = clone;
+ bestDatum.distance = distance;
+ }
+ }
+ }
+ return bestData.map(bestDatum => bestDatum.clone);
+ }
+}
diff --git a/src/humanPose/HumanPoseAnalyzerSettingsUi.js b/src/humanPose/HumanPoseAnalyzerSettingsUi.js
new file mode 100644
index 000000000..0ae9f4bb0
--- /dev/null
+++ b/src/humanPose/HumanPoseAnalyzerSettingsUi.js
@@ -0,0 +1,358 @@
+import {JOINTS, JOINT_CONFIDENCE_THRESHOLD} from './constants.js';
+import {setChildHumanPosesVisible} from "./draw.js"
+
+export class HumanPoseAnalyzerSettingsUi {
+ constructor(humanPoseAnalyzer) {
+ this.humanPoseAnalyzer = humanPoseAnalyzer;
+
+ this.root = document.createElement('div');
+ this.root.id = 'hpa-settings';
+
+ // Styled via css/humanPoseAnalyzerSettingsUi.css
+ this.root.innerHTML = `
+
+
+
+
Lens Settings
+
+
+
Select Lens
+
+ This should only display if something is broken with this.populateSelects()
+
+
+
+
View auxiliary poses
+
+
+
+
+
+
Filter unreliable joints
+
+
+
+
+
+
Motion Path Settings
+
+
+
+
Motion Path Settings
+
+
+
+
Joint Settings
+
+
+
Select Joint
+
+ This should only display if something is broken with this.populateSelects()
+
+
+
+
+
+ `;
+
+ this.populateSelects();
+ this.setUpEventListeners();
+ this.enableDrag();
+ document.body.appendChild(this.root);
+ this.setInitialPosition();
+ this.root.querySelector('#hpa-joint-settings').remove(); // TODO: implement joint selection and remove this line
+ this.hide(); // It is important to set the menu's position before hiding it, otherwise its width will be calculated as 0
+ }
+
+ /**
+ * Sets the initial position of the settings UI to be in the top right corner of the screen, under the navbar and menu button
+ */
+ setInitialPosition() {
+ const navbar = document.querySelector('.desktopMenuBar');
+ const navbarHeight = navbar ? navbar.offsetHeight : 0;
+ const sessionMenuContainer = document.querySelector('#sessionMenuContainer');
+ const sessionMenuLeft = sessionMenuContainer ? sessionMenuContainer.offsetLeft : 0;
+ if (sessionMenuContainer) { // Avoid the top right menu
+ this.root.style.top = `calc(${navbarHeight}px + 2em)`;
+ this.root.style.left = `calc(${sessionMenuLeft - this.root.offsetWidth}px - 6em)`;
+ return;
+ }
+ this.root.style.top = `calc(${navbarHeight}px + 2em)`;
+ this.root.style.left = `calc(${window.innerWidth - this.root.offsetWidth}px - 2em)`;
+ this.snapToFitScreen();
+ }
+
+ populateSelects() {
+ this.root.querySelector('#hpa-settings-select-lens').innerHTML = this.humanPoseAnalyzer.lenses.map((lens) => {
+ return `${lens.name} `;
+ }).join('');
+
+ const jointNames = ['', ...Object.values(JOINTS)];
+ this.root.querySelector('#hpa-settings-select-joint').innerHTML = jointNames.map((jointName) => {
+ return `${jointName} `;
+ }).join('');
+ }
+
+ setUpEventListeners() {
+ // Toggle menu minimization when clicking on the header, but only if not dragging
+ this.root.querySelector('.hpa-settings-header').addEventListener('mousedown', event => {
+ let mouseDownX = event.clientX;
+ let mouseDownY = event.clientY;
+ const mouseUpListener = event => {
+ const mouseUpX = event.clientX;
+ const mouseUpY = event.clientY;
+ if (mouseDownX === mouseUpX && mouseDownY === mouseUpY) {
+ this.toggleMinimized();
+ }
+ this.root.querySelector('.hpa-settings-header').removeEventListener('mouseup', mouseUpListener);
+ };
+ this.root.querySelector('.hpa-settings-header').addEventListener('mouseup', mouseUpListener);
+ });
+
+ this.root.querySelector('#hpa-settings-toggle-live-history-lines').addEventListener('change', (event) => {
+ this.humanPoseAnalyzer.setLiveHistoryLinesVisible(event.target.checked);
+ });
+
+ this.root.querySelector('#hpa-settings-toggle-child-human-poses').addEventListener('change', (event) => {
+ setChildHumanPosesVisible(event.target.checked);
+ });
+
+ this.root.querySelector('#hpa-settings-toggle-historical-history-lines').addEventListener('change', (event) => {
+ this.humanPoseAnalyzer.setHistoricalHistoryLinesVisible(event.target.checked);
+ });
+
+ this.root.querySelector('#hpa-settings-reset-history').addEventListener('mouseup', () => {
+ this.humanPoseAnalyzer.resetLiveHistoryLines();
+ this.humanPoseAnalyzer.resetLiveHistoryClones();
+ });
+
+ this.root.querySelector('#hpa-settings-select-lens').addEventListener('change', (event) => {
+ this.humanPoseAnalyzer.setActiveLensByName(event.target.value);
+ });
+
+ /*
+ // for debugging purposes
+ this.root.querySelector('#hpa-settings-set-joint-confidence').addEventListener('keydown', (event) => {
+ event.stopPropagation();
+ });
+
+ this.root.querySelector('#hpa-settings-set-joint-confidence').addEventListener('change', (event) => {
+ let num = parseFloat(event.target.value);
+ if(!isNaN(num)) {
+ if (num < 0.0) {
+ num = 0.0;
+ }
+ if (num > 1.0) {
+ num = 1.0;
+ }
+ this.humanPoseAnalyzer.setJointConfidenceThreshold(num);
+ this.setJointConfidenceThreshold(num);
+ }
+ });
+ */
+
+ this.root.querySelector('#hpa-settings-toggle-joint-confidence').addEventListener('change', (event) => {
+ if (event.target.checked) {
+ this.humanPoseAnalyzer.setJointConfidenceThreshold(JOINT_CONFIDENCE_THRESHOLD);
+ }
+ else {
+ this.humanPoseAnalyzer.setJointConfidenceThreshold(0.0);
+ }
+ });
+
+ this.root.querySelector('#hpa-settings-select-joint').addEventListener('change', (event) => {
+ this.humanPoseAnalyzer.setActiveJointByName(event.target.value);
+ });
+
+ // Add listeners to aid with clicking checkboxes
+ this.root.querySelectorAll('.hpa-settings-section-row-checkbox').forEach((checkbox) => {
+ const checkboxContainer = checkbox.parentElement;
+ checkboxContainer.addEventListener('click', () => {
+ checkbox.checked = !checkbox.checked;
+ checkbox.dispatchEvent(new Event('change'));
+ });
+ checkbox.addEventListener('click', (event) => {
+ event.stopPropagation(); // Prevent double-counting clicks
+ });
+ });
+
+ // Add click listeners to selects to stop propagation to rest of app
+ this.root.querySelectorAll('.hpa-settings-section-row-select').forEach((select) => {
+ select.addEventListener('click', (event) => {
+ event.stopPropagation();
+ });
+ });
+ }
+
+ enableDrag() {
+ let dragStartX = 0;
+ let dragStartY = 0;
+ let dragStartLeft = 0;
+ let dragStartTop = 0;
+
+ this.root.querySelector('.hpa-settings-header').addEventListener('mousedown', (event) => {
+ dragStartX = event.clientX;
+ dragStartY = event.clientY;
+ dragStartLeft = this.root.offsetLeft;
+ dragStartTop = this.root.offsetTop;
+
+ const mouseMoveListener = (event) => {
+ this.root.style.left = `${dragStartLeft + event.clientX - dragStartX}px`;
+ this.root.style.top = `${dragStartTop + event.clientY - dragStartY}px`;
+ this.snapToFitScreen();
+ }
+ const mouseUpListener = () => {
+ document.removeEventListener('mousemove', mouseMoveListener);
+ document.removeEventListener('mouseup', mouseUpListener);
+ }
+ document.addEventListener('mousemove', mouseMoveListener);
+ document.addEventListener('mouseup', mouseUpListener);
+ });
+ }
+
+ isOutOfBounds() {
+ const navbar = document.querySelector('.desktopMenuBar');
+ const navbarHeight = navbar ? navbar.offsetHeight : 0;
+ if (this.root.offsetTop < navbarHeight) {
+ return true;
+ }
+ if (this.root.offsetLeft < 0) {
+ return true;
+ }
+ if (this.root.offsetLeft + this.root.offsetWidth > window.innerWidth) {
+ return true;
+ }
+ if (this.root.offsetTop + this.root.querySelector('.hpa-settings-header').offsetHeight > window.innerHeight) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * If the settings menu is out of bounds, snap it back into the screen
+ */
+ snapToFitScreen() {
+ const navbar = document.querySelector('.desktopMenuBar');
+ const navbarHeight = navbar ? navbar.offsetHeight : 0;
+ if (this.root.offsetTop < navbarHeight) {
+ this.root.style.top = `${navbarHeight}px`;
+ }
+ if (this.root.offsetLeft < 0) {
+ this.root.style.left = '0px';
+ }
+ if (this.root.offsetLeft + this.root.offsetWidth > window.innerWidth) {
+ this.root.style.left = `${window.innerWidth - this.root.offsetWidth}px`;
+ }
+ // Keep the header visible on the screen off the bottom
+ if (this.root.offsetTop + this.root.querySelector('.hpa-settings-header').offsetHeight > window.innerHeight) {
+ this.root.style.top = `${window.innerHeight - this.root.querySelector('.hpa-settings-header').offsetHeight}px`;
+ }
+ }
+
+ show() {
+ this.root.classList.remove('hidden');
+ if (this.isOutOfBounds()) {
+ // Only happens with initial set up of the live analytics menu, since it thinks the session menu has 0
+ // offset as it hasn't fully initialized yet, and ends up far to the left of the screen
+ this.setInitialPosition();
+ }
+ }
+
+ hide() {
+ this.root.classList.add('hidden');
+ }
+
+ toggle() {
+ if (this.root.classList.contains('hidden')) {
+ this.show();
+ } else {
+ this.hide();
+ }
+ }
+
+ minimize() {
+ if (this.root.classList.contains('hidden')) {
+ return;
+ }
+ const previousWidth = this.root.offsetWidth;
+ this.root.classList.add('hpa-settings-minimized');
+ this.root.style.width = `${previousWidth}px`;
+ this.root.querySelector('.hpa-settings-header-icon').innerText = '+';
+ }
+
+ maximize() {
+ if (this.root.classList.contains('hidden')) {
+ return;
+ }
+ this.root.classList.remove('hpa-settings-minimized');
+ this.root.querySelector('.hpa-settings-header-icon').innerText = '_';
+ }
+
+ toggleMinimized() {
+ if (this.root.classList.contains('hpa-settings-minimized')) {
+ this.maximize();
+ } else {
+ this.minimize();
+ }
+ }
+
+ setActiveLens(lens) {
+ this.root.querySelector('#hpa-settings-select-lens').value = lens.name;
+ }
+
+ setLiveHistoryLinesVisible(historyLinesVisible) {
+ this.root.querySelector('#hpa-settings-toggle-live-history-lines').checked = historyLinesVisible;
+ }
+
+ setChildHumanPosesVisible(visible) {
+ this.root.querySelector('#hpa-settings-toggle-child-human-poses').checked = visible;
+ }
+
+ setHistoricalHistoryLinesVisible(historyLinesVisible) {
+ this.root.querySelector('#hpa-settings-toggle-historical-history-lines').checked = historyLinesVisible;
+ }
+
+ setActiveJointByName(_jointName) {
+ // this.root.querySelector('#hpa-settings-select-joint').value = jointName; // TODO: re-add once implemented
+ }
+
+ /*
+ // for debugging purposes
+ setJointConfidenceThreshold(confidence) {
+ this.root.querySelector('#hpa-settings-set-joint-confidence').value = confidence;
+ }
+ */
+ setJointConfidenceFilter(filterOn) {
+ this.root.querySelector('#hpa-settings-toggle-joint-confidence').checked = filterOn;
+ }
+
+ markLive() {
+ this.root.querySelector('#hpa-live-settings').classList.remove('hidden');
+ this.root.querySelector('#hpa-historical-settings').classList.add('hidden');
+ this.root.querySelector('.hpa-settings-title').innerText = 'Live Analytics Settings';
+ }
+}
diff --git a/src/humanPose/HumanPoseRenderInstance.js b/src/humanPose/HumanPoseRenderInstance.js
new file mode 100644
index 000000000..29e7ce766
--- /dev/null
+++ b/src/humanPose/HumanPoseRenderInstance.js
@@ -0,0 +1,294 @@
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+import {
+ JOINT_TO_INDEX,
+ BONE_TO_INDEX,
+ SCALE,
+ HIDDEN_JOINTS,
+ HIDDEN_BONES,
+ DISPLAY_HIDDEN_ELEMENTS,
+ DISPLAY_INVALID_ELEMENTS
+} from './constants.js';
+import {MotionStudyColors} from "./MotionStudyColors.js";
+
+/**
+ * A single 3d skeleton rendered in a HumanPoseRenderer's slot
+ */
+export class HumanPoseRenderInstance {
+ /**
+ * @param {HumanPoseRenderer} renderer
+ * @param {string} id - Unique identifier of human pose being rendered
+ * @param {MotionStudyLens} lens - The initial lens to use for this instance
+ */
+ constructor(renderer, id, lens) {
+ this.renderer = renderer;
+ this.id = id;
+ this.updated = true;
+ this.lens = lens;
+ this.lensColors = {};
+ this.pose = null;
+ this.slot = -1;
+ this.visible = this.add();
+ }
+
+ /**
+ * Occupies a slot on the renderer, uploading initial values
+ * @param {number?} slot - manually assigned slot, taken from renderer otherwise
+ * @param {boolean} Success
+ */
+ add(slot) {
+ if (typeof slot === 'number') {
+ this.slot = slot;
+ } else {
+ this.slot = this.renderer.takeSlot();
+ }
+ if (this.slot < 0) {
+ return false;
+ }
+
+ Object.values(JOINT_TO_INDEX).forEach(index => {
+ this.renderer.setJointColorAt(this.slot, index, MotionStudyColors.base);
+ });
+
+ Object.values(BONE_TO_INDEX).forEach(index => {
+ this.renderer.setBoneColorAt(this.slot, index, MotionStudyColors.base);
+ });
+
+ return true;
+ }
+
+ /**
+ * Sets the position of a joint
+ * @param {string} jointId - ID of joint to set position of
+ * @param {Vector3} position - Position to set joint to
+ * @param {boolean} visible - whether the joint is displayed
+ */
+ setJointPosition(jointId, position, visible = true) {
+ const index = JOINT_TO_INDEX[jointId];
+ let matrix = new THREE.Matrix4().set(
+ 0, 0, 0, 0,
+ 0, 0, 0, 0,
+ 0, 0, 0, 0,
+ 0, 0, 0, 1,
+ );
+ if (visible) {
+ matrix.makeTranslation(
+ position.x,
+ position.y,
+ position.z,
+ );
+ }
+
+ this.renderer.setJointMatrixAt(
+ this.slot,
+ index,
+ matrix
+ );
+ }
+
+ /**
+ * Updates joint positions based on this.pose
+ */
+ updateJointPositions() {
+ if (this.slot < 0) {
+ return;
+ }
+ this.pose.forEachJoint(joint => {
+ let visible = (DISPLAY_HIDDEN_ELEMENTS || !HIDDEN_JOINTS.includes(joint.name)) &&
+ (DISPLAY_INVALID_ELEMENTS || joint.valid);
+ this.setJointPosition(joint.name, joint.position, visible);
+ });
+ }
+
+ /**
+ * Updates bone (stick between joints) position based on this.joints' positions.
+ * @param {Object} bone - bone from this.pose
+ * @param {boolean} visible - whether the bone is displayed
+ */
+ updateBonePosition(bone, visible = true) {
+ const boneIndex = BONE_TO_INDEX[bone.name];
+ let matrix = new THREE.Matrix4().set(
+ 0, 0, 0, 0,
+ 0, 0, 0, 0,
+ 0, 0, 0, 0,
+ 0, 0, 0, 1,
+ );
+
+ if (visible) {
+ const jointAPos = bone.joint0.position;
+ const jointBPos = bone.joint1.position;
+
+ const pos = jointAPos.clone().add(jointBPos).divideScalar(2);
+
+ const scale = jointBPos.clone().sub(jointAPos).length();
+ const scaleVector = new THREE.Vector3(1, scale / SCALE, 1);
+
+ const direction = jointBPos.clone().sub(jointAPos).normalize();
+ const rot = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction);
+
+ matrix.compose(pos, rot, scaleVector);
+ }
+ this.renderer.setBoneMatrixAt(this.slot, boneIndex, matrix);
+ }
+
+ /**
+ * Updates bone (stick between joints) positions based on this.joints' positions.
+ */
+ updateBonePositions() {
+ if (this.slot < 0) {
+ return;
+ }
+
+ this.pose.forEachBone(bone => {
+ // hides hands in general at the moment. But one could use this also for hiding joints based on their low confidence.
+ let visible = (DISPLAY_HIDDEN_ELEMENTS || !HIDDEN_BONES.includes(bone.name)) &&
+ (DISPLAY_INVALID_ELEMENTS || bone.valid);
+ this.updateBonePosition(bone, visible);
+ });
+ }
+
+ /**
+ * Updates the pose displayed by the pose renderer
+ * @param {Pose} pose The pose to display
+ */
+ setPose(pose) {
+ this.pose = pose;
+
+ this.updateJointPositions();
+ this.updateBonePositions();
+ this.updateColorBuffers(this.lens);
+ }
+
+ /**
+ * Sets the active lens for pose coloring
+ * @param {MotionStudyLens} lens - lens to set
+ */
+ setLens(lens) {
+ this.lens = lens;
+ this.updateColorBuffers(this.lens);
+ }
+
+ /**
+ * Annotates joint using material based on jointColor
+ * @param {string} jointName
+ * @param {Color} jointColor
+ */
+ setJointColor(jointName, jointColor) {
+ if (typeof JOINT_TO_INDEX[jointName] === 'undefined') {
+ return;
+ }
+ const index = JOINT_TO_INDEX[jointName];
+ this.renderer.setJointColorAt(this.slot, index, jointColor);
+ }
+
+ /**
+ * Annotates bone using material based on boneColor
+ * @param {string} boneName
+ * @param {Color} boneColor
+ */
+ setBoneColor(boneName, boneColor) {
+ if (typeof BONE_TO_INDEX[boneName] === 'undefined') {
+ return;
+ }
+ const index = BONE_TO_INDEX[boneName];
+ this.renderer.setBoneColorAt(this.slot, index, boneColor);
+ }
+
+ /**
+ * Sets the colors of the pose based on the current lens
+ * @param {MotionStudyLens} lens - lens to use for updating colors
+ */
+ updateColorBuffers(lens) {
+ if (this.slot < 0) {
+ return;
+ }
+
+ if (!this.lensColors[lens.name]) {
+ this.lensColors[lens.name] = {
+ joints: Object.values(JOINT_TO_INDEX).map(() => MotionStudyColors.undefined),
+ bones: Object.values(BONE_TO_INDEX).map(() => MotionStudyColors.undefined),
+ };
+ }
+ this.pose.forEachJoint(joint => {
+ this.lensColors[lens.name].joints[JOINT_TO_INDEX[joint.name]] = lens.getColorForJoint(joint);
+ if (!joint.valid) {
+ this.lensColors[lens.name].joints[JOINT_TO_INDEX[joint.name]] = MotionStudyColors.undefined;
+ }
+ });
+ this.pose.forEachBone(bone => {
+ this.lensColors[lens.name].bones[BONE_TO_INDEX[bone.name]] = lens.getColorForBone(bone);
+ if (!bone.valid) {
+ this.lensColors[lens.name].bones[BONE_TO_INDEX[bone.name]] = MotionStudyColors.undefined;
+ }
+ });
+ // MK - why this condition (lens === this.lens)? When switching lens this is not true and this is not applied.
+ // Extra code needs to call this again after this.lens is updated to new lens.
+ if (lens === this.lens) {
+ this.pose.forEachJoint(joint => {
+ this.setJointColor(joint.name, this.lensColors[this.lens.name].joints[JOINT_TO_INDEX[joint.name]]);
+ });
+ this.pose.forEachBone(bone => {
+ this.setBoneColor(bone.name, this.lensColors[this.lens.name].bones[BONE_TO_INDEX[bone.name]]);
+ });
+ }
+ }
+
+ setVisible(visible) {
+ // MK HACK: too strict to do nothing if the visibility did not change. Other code can 'unhide' the slot
+ /*
+ if (this.visible === visible) {
+ return;
+ }
+ */
+
+ if (this.slot < 0) {
+ return;
+ }
+
+ if (visible) {
+ this.renderer.showSlot(this.slot);
+ this.updateJointPositions();
+ this.updateBonePositions();
+ } else {
+ this.renderer.hideSlot(this.slot);
+ }
+ this.visible = visible;
+ }
+
+ /**
+ * Clones itself into a new HumanPoseRenderer
+ * @param {HumanPoseRenderer} newRenderer - the renderer to clone into
+ * @return {HumanPoseRenderInstance} The new instance
+ */
+ cloneToRenderer(newRenderer) {
+ let clone = new HumanPoseRenderInstance(newRenderer, this.id, this.lens);
+ clone.copy(this);
+ return clone;
+ }
+
+ /**
+ * Copy all elements of the other pose render instance
+ * @param {HumanPoseRenderInstance} other - the instance to copy from
+ */
+ copy(other) {
+ this.lens = other.lens;
+ this.lensColors = {};
+ Object.keys(other.lensColors).forEach(lensName => {
+ this.lensColors[lensName] = {
+ joints: other.lensColors[lensName].joints.slice(),
+ bones: other.lensColors[lensName].bones.slice(),
+ };
+ });
+ this.setPose(other.pose);
+ return this;
+ }
+
+ /**
+ * Removes from container and disposes resources
+ */
+ remove() {
+ this.renderer.leaveSlot(this.slot);
+ this.slot = -1;
+ this.visible = false;
+ }
+}
+
diff --git a/src/humanPose/HumanPoseRenderer.js b/src/humanPose/HumanPoseRenderer.js
new file mode 100644
index 000000000..a2000f0ea
--- /dev/null
+++ b/src/humanPose/HumanPoseRenderer.js
@@ -0,0 +1,381 @@
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+import {
+ COLOR_BASE,
+ SCALE,
+ JOINT_RADIUS,
+ BONE_RADIUS,
+ JOINTS_PER_POSE,
+ BONES_PER_POSE,
+ SMALL_JOINT_FLAGS,
+ SMALL_JOINT_SCALE_VEC,
+ THIN_BONE_FLAGS,
+ THIN_BONE_SCALE_VEC
+} from './constants.js';
+
+/**
+ * @param {THREE.InstancedBufferAttribute} instancedBufferAttribute
+ * @param {number} slot - offset within iba in units of slots
+ * @param {number} slotWidth - width of each slot in units of iba's item size
+ * @return {TypedArray} array of length `slotWidth * itemSize`
+ */
+function getSliceSlot(instancedBufferAttribute, slot, slotWidth) {
+ const itemSize = instancedBufferAttribute.itemSize;
+ const start = slot * slotWidth * itemSize;
+ return instancedBufferAttribute.array.slice(start, start + slotWidth * itemSize);
+}
+
+function setSliceSlot(instancedBufferAttribute, slot, slotWidth, items) {
+ const itemSize = instancedBufferAttribute.itemSize;
+ const start = slot * slotWidth * itemSize;
+
+ unionUpdateRange(instancedBufferAttribute, start, slotWidth * itemSize);
+
+ for (let i = 0; i < slotWidth * itemSize; i++) {
+ instancedBufferAttribute.array[start + i] = items[i];
+ }
+}
+
+function unionUpdateRange(instancedBufferAttribute, offset, count) {
+ if (instancedBufferAttribute.updateRange.count === -1) {
+ instancedBufferAttribute.updateRange.offset = offset;
+ instancedBufferAttribute.updateRange.count = count;
+ return;
+ }
+ let curMin = instancedBufferAttribute.updateRange.offset;
+ let curMax = curMin + instancedBufferAttribute.updateRange.count;
+ let plusMin = offset;
+ let plusMax = offset + count;
+ let newMin = Math.min(curMin, plusMin);
+ let newMax = Math.max(curMax, plusMax);
+ instancedBufferAttribute.updateRange.offset = newMin;
+ instancedBufferAttribute.updateRange.count = newMax - newMin;
+}
+
+/**
+ * Manager of multiple HumanPoseRenderInstances within two instanced meshes
+ */
+export class HumanPoseRenderer {
+ /**
+ * @param {THREE.Material} material - Material for all instanced meshes
+ * @param {number} maxInstances - Maximum number of instances to render
+ */
+ constructor(material, maxInstances) {
+ this.container = new THREE.Group();
+ // A stack of free instance slots (indices) that a PoseRenderInstance
+ // can reuse
+ this.freeInstanceSlots = [];
+ this.shownSlots = new Array(maxInstances);
+ for (let i = 0; i < maxInstances; i++) {
+ this.shownSlots[i] = false;
+ }
+ this.nextInstanceSlot = 0;
+ this.maxInstances = maxInstances;
+ this.material = material;
+ this.createMeshes(material);
+ }
+
+ /**
+ * Creates all THREE.Meshes representing the spheres/joint balls of the
+ * pose
+ * @param {THREE.Material} material - Material for all instanced meshes
+ */
+ createMeshes(material) {
+ const geo = new THREE.SphereGeometry(JOINT_RADIUS * SCALE, 12, 12);
+
+ this.jointsMesh = new THREE.InstancedMesh(
+ geo,
+ material,
+ JOINTS_PER_POSE * this.maxInstances,
+ );
+ // Initialize instanceColor
+ this.jointsMesh.setColorAt(0, COLOR_BASE);
+ this.jointsMesh.count = 0;
+ this.jointsMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
+ this.jointsMesh.instanceColor.setUsage(THREE.DynamicDrawUsage);
+
+ this.container.add(this.jointsMesh);
+
+ const geoCyl = new THREE.CylinderGeometry(BONE_RADIUS * SCALE, BONE_RADIUS * SCALE, SCALE, 3);
+ this.bonesMesh = new THREE.InstancedMesh(
+ geoCyl,
+ material,
+ BONES_PER_POSE * this.maxInstances,
+ );
+ // Initialize instanceColor
+ this.bonesMesh.setColorAt(0, COLOR_BASE);
+ this.bonesMesh.count = 0;
+ this.bonesMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
+ this.bonesMesh.instanceColor.setUsage(THREE.DynamicDrawUsage);
+
+ this.container.add(this.bonesMesh);
+ }
+
+ /**
+ * @return {boolean} whether every slot is taken
+ */
+ isFull() {
+ return this.nextInstanceSlot >= this.maxInstances &&
+ this.freeInstanceSlots.length === 0;
+ }
+
+ /**
+ * @return {number} index of the taken slot
+ */
+ takeSlot() {
+ if (this.freeInstanceSlots.length > 0) {
+ const takenSlot = this.freeInstanceSlots.pop();
+ this.showSlot(takenSlot);
+ return takenSlot;
+ }
+
+ if (this.nextInstanceSlot >= this.maxInstances) {
+ console.error('out of render instances');
+ return -1;
+ }
+
+ const takenSlot = this.nextInstanceSlot;
+ this.nextInstanceSlot += 1;
+
+ this.showSlot(takenSlot);
+
+ this.jointsMesh.count = JOINTS_PER_POSE * this.nextInstanceSlot;
+ this.bonesMesh.count = BONES_PER_POSE * this.nextInstanceSlot;
+
+ return takenSlot;
+ }
+
+ showSlot(slot) {
+ this.shownSlots[slot] = true;
+
+ this.setVisible(true);
+ }
+
+ hideSlot(slot) {
+ /* MK HACK - auxiliary poses get quickly shown again even if disabled
+ if (!this.shownSlots[slot]) {
+ return;
+ } */
+
+ this.shownSlots[slot] = false;
+
+ let anyShown = false;
+ for (let i = 0; i < this.maxInstances; i++) {
+ if (this.shownSlots[i]) {
+ anyShown = true;
+ break;
+ }
+ }
+ if (!anyShown) {
+ this.setVisible(false);
+ }
+
+ let zeros = new THREE.Matrix4().set(
+ 0, 0, 0, 0,
+ 0, 0, 0, 0,
+ 0, 0, 0, 0,
+ 0, 0, 0, 1,
+ );
+ for (let i = 0; i < JOINTS_PER_POSE; i++) {
+ this.setJointMatrixAt(slot, i, zeros);
+ }
+ for (let i = 0; i < BONES_PER_POSE; i++) {
+ this.setBoneMatrixAt(slot, i, zeros);
+ }
+ }
+
+ /**
+ * Hides `slot` and adds it to the free list for reuse
+ * @param {number} slot to free up
+ */
+ leaveSlot(slot) {
+ this.freeInstanceSlots.push(slot);
+ this.hideSlot(slot);
+ }
+
+ /**
+ * Sets global visibility of human pose renderer
+ * @param {boolean} visible
+ */
+ setVisible(visible) {
+ this.jointsMesh.visible = visible;
+ this.bonesMesh.visible = visible;
+ }
+
+ /**
+ * @param {number} slot - assigned rendering slot
+ * @param {number} index - index of joint within slot
+ * @param {THREE.Vector3} position
+ */
+ setJointMatrixAt(slot, index, matrix) {
+ if(SMALL_JOINT_FLAGS[index]) { // scale down geometry if it is a small joint
+ matrix.scale(SMALL_JOINT_SCALE_VEC);
+ }
+ const offset = slot * JOINTS_PER_POSE + index;
+ this.jointsMesh.setMatrixAt(
+ offset,
+ matrix,
+ );
+ const itemSize = this.jointsMesh.instanceMatrix.itemSize;
+ const updateOffset = offset * itemSize;
+ unionUpdateRange(this.jointsMesh.instanceMatrix, updateOffset, itemSize);
+ }
+
+ /**
+ * @param {number} slot - assigned rendering slot
+ * @param {number} index - index of bone within slot
+ * @param {THREE.Vector3} position
+ */
+ setBoneMatrixAt(slot, index, matrix) {
+ if(THIN_BONE_FLAGS[index]) { // scale down geometry if it is a thin bone
+ matrix.scale(THIN_BONE_SCALE_VEC);
+ }
+ const offset = slot * BONES_PER_POSE + index;
+ this.bonesMesh.setMatrixAt(
+ offset,
+ matrix,
+ );
+
+ const itemSize = this.bonesMesh.instanceMatrix.itemSize;
+ const updateOffset = offset * itemSize;
+ unionUpdateRange(this.bonesMesh.instanceMatrix, updateOffset, itemSize);
+ }
+
+ /**
+ * @param {number} slot - assigned rendering slot
+ * @param {number} index - index of joint within slot
+ * @param {THREE.Color} color
+ */
+ setJointColorAt(slot, index, color) {
+ this.jointsMesh.setColorAt(
+ slot * JOINTS_PER_POSE + index,
+ color,
+ );
+ }
+
+ /**
+ * @param {number} slot - assigned rendering slot
+ * @param {number} index - index of bone within slot
+ * @param {THREE.Color} color
+ */
+ setBoneColorAt(slot, index, color) {
+ this.bonesMesh.setColorAt(
+ slot * BONES_PER_POSE + index,
+ color,
+ );
+ }
+
+ /**
+ * @param {number} slot
+ * @return {Float32Array}
+ */
+ getSlotJointMatrices(slot) {
+ return getSliceSlot(this.jointsMesh.instanceMatrix, slot, JOINTS_PER_POSE);
+ }
+
+ /**
+ * @param {number} slot
+ * @return {Float32Array}
+ */
+ getSlotBoneMatrices(slot) {
+ return getSliceSlot(this.bonesMesh.instanceMatrix, slot, BONES_PER_POSE);
+ }
+
+ /**
+ * @param {number} slot
+ * @param {Float32Array} matrices
+ */
+ setSlotJointMatrices(slot, matrices) {
+ setSliceSlot(this.jointsMesh.instanceMatrix, slot, JOINTS_PER_POSE, matrices);
+ }
+
+ /**
+ * @param {number} slot
+ * @param {Float32Array} matrices
+ */
+ setSlotBoneMatrices(slot, matrices) {
+ setSliceSlot(this.bonesMesh.instanceMatrix, slot, BONES_PER_POSE, matrices);
+ }
+
+
+ /**
+ * @param {number} slot
+ * @return {Float32Array}
+ */
+ getSlotJointColors(slot) {
+ return getSliceSlot(this.jointsMesh.instanceColor, slot, JOINTS_PER_POSE);
+ }
+
+ /**
+ * @param {number} slot
+ * @return {Float32Array}
+ */
+ getSlotBoneColors(slot) {
+ return getSliceSlot(this.bonesMesh.instanceColor, slot, BONES_PER_POSE);
+ }
+
+ /**
+ * @param {number} slot
+ * @param {Float32Array} colors
+ */
+ setSlotJointColors(slot, colors) {
+ setSliceSlot(this.jointsMesh.instanceColor, slot, JOINTS_PER_POSE, colors);
+ }
+
+ /**
+ * @param {number} slot
+ * @param {Float32Array} colors
+ */
+ setSlotBoneColors(slot, colors) {
+ setSliceSlot(this.bonesMesh.instanceColor, slot, BONES_PER_POSE, colors);
+ }
+
+ addToScene(container) {
+ if (container) {
+ container.add(this.container);
+ } else {
+ realityEditor.gui.threejsScene.addToScene(this.container);
+ }
+ }
+
+ removeFromParent() {
+ this.removeFromScene(this.container.parent);
+ }
+
+ /**
+ * Removes from container and disposes resources
+ */
+ removeFromScene(container) {
+ if (container) {
+ container.remove(this.container);
+ } else {
+ realityEditor.gui.threejsScene.removeFromScene(this.container);
+ }
+ this.bonesMesh.dispose();
+ this.jointsMesh.dispose();
+ }
+
+ markNeedsUpdate() {
+ this.markMatrixNeedsUpdate();
+ this.markColorNeedsUpdate();
+ }
+
+ markMatrixNeedsUpdate() {
+ this.jointsMesh.instanceMatrix.needsUpdate = true;
+ this.bonesMesh.instanceMatrix.needsUpdate = true;
+ }
+
+ markColorNeedsUpdate() {
+ this.jointsMesh.instanceColor.needsUpdate = true;
+ this.bonesMesh.instanceColor.needsUpdate = true;
+ }
+
+ /**
+ * For debugging purposes
+ */
+ toString() {
+ return JSON.stringify({
+ jointsCount: this.jointsMesh.count,
+ bonesCount: this.bonesMesh.count,
+ });
+ }
+}
diff --git a/src/humanPose/MotionStudyColors.js b/src/humanPose/MotionStudyColors.js
new file mode 100644
index 000000000..85a95ee12
--- /dev/null
+++ b/src/humanPose/MotionStudyColors.js
@@ -0,0 +1,34 @@
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+
+/**
+ * A collection of colors used often in the motion study system.
+ * They are created here to ensure that they are only created once.
+ */
+export const MotionStudyColors = {
+ undefined: new THREE.Color(1, 0, 1),
+ base: new THREE.Color(0, 0.5, 1),
+ red: new THREE.Color(1, 0, 0),
+ yellow: new THREE.Color(1, 1, 0),
+ green: new THREE.Color(0, 1, 0),
+ blue: new THREE.Color(0, 0, 1),
+ gray: new THREE.Color(0.8, 0.8, 0.8),
+ /**
+ * Fades a color to a faded version of itself.
+ * @param {Color} color The color to fade.
+ * @param {number} saturation Target saturation.
+ * @return {Color} The faded color.
+ */
+ fade: (color, saturation = 0.8) => {
+ const hsl = color.getHSL({});
+ return new THREE.Color().setHSL(hsl.h, hsl.s * saturation, hsl.l * 0.6);
+ },
+ /**
+ * Highlights a color to a brighter version of itself.
+ * @param {Color} color The color to highlight.
+ * @return {Color} The highlighted color.
+ */
+ highlight: (color) => {
+ const h = color.getHSL({}).h;
+ return new THREE.Color().setHSL(h, 1, 0.6);
+ }
+};
diff --git a/src/humanPose/MotionStudyLens.js b/src/humanPose/MotionStudyLens.js
new file mode 100644
index 000000000..ba7968251
--- /dev/null
+++ b/src/humanPose/MotionStudyLens.js
@@ -0,0 +1,75 @@
+/**
+ * MotionStudyLens is a class that represents a lens in the motion study system.
+ * Inherit from this class to create new lenses.
+ */
+import {MotionStudyColors} from "./MotionStudyColors.js";
+
+export class MotionStudyLens {
+ /**
+ * Creates a new MotionStudyLens object.
+ * @param {string} name The name of the lens, used in menus.
+ */
+ constructor(name) {
+ this.name = name;
+ }
+
+ /**
+ * Resets the lens to its initial state.
+ */
+ reset() {
+ }
+
+ /**
+ * Applies the lens to a single pose by adding new properties to the pose object.
+ * @param {Pose} _pose The pose to apply the lens to.
+ * @return {boolean} True if the pose was modified, false otherwise.
+ */
+ applyLensToPose(_pose, _force = false) {
+ return false;
+ }
+
+ /**
+ * Applies the lens to the most recent pose, but reads the pose history as well. Only the minimum number of poses are visited.
+ * @param {Pose[]} poseHistory An array of pose objects.
+ * @return {boolean[]} An array of booleans, one for each pose in the history, indicating whether the pose was modified.
+ */
+ applyLensToHistoryMinimally(poseHistory, _force = false) {
+ return poseHistory.map(() => false);
+ }
+
+ /**
+ * Applies the lens to the pose history by adding new properties to the pose objects.
+ * @param {Pose[]} poseHistory An array of pose objects.
+ * @return {boolean[]} An array of booleans, one for each pose in the history, indicating whether the pose was modified.
+ */
+ applyLensToHistory(poseHistory, _force = false) {
+ return poseHistory.map(() => false);
+ }
+
+ /**
+ * Calculates the color for a given joint.
+ * @param {Object} _joint The joint to calculate the color for.
+ * @return {Color} The color to use for the value.
+ */
+ getColorForJoint(_joint) {
+ return MotionStudyColors.undefined;
+ }
+
+ /**
+ * Calculates the color for a given bone.
+ * @param {Object} _bone The bone to calculate the color for.
+ * @return {Color} The color to use for the value.
+ */
+ getColorForBone(_bone) {
+ return MotionStudyColors.undefined;
+ }
+
+ /**
+ * Calculates the color for a given pose.
+ * @param {Pose} _pose The pose to calculate the color for.
+ * @return {Color} The color to use for the value.
+ */
+ getColorForPose(_pose) {
+ return MotionStudyColors.undefined;
+ }
+}
diff --git a/src/humanPose/OverallRebaLens.js b/src/humanPose/OverallRebaLens.js
new file mode 100644
index 000000000..1452dea1b
--- /dev/null
+++ b/src/humanPose/OverallRebaLens.js
@@ -0,0 +1,66 @@
+import {MotionStudyLens} from "./MotionStudyLens.js";
+import * as Reba from "./rebaScore.js";
+import {MotionStudyColors} from "./MotionStudyColors.js";
+import {JOINTS} from "./constants.js";
+
+export const MIN_REBA_SCORE = 1;
+export const MAX_REBA_SCORE = 12;
+
+/**
+ * OverallRebaLens is a lens that calculates the overall REBA score for the pose
+ */
+export class OverallRebaLens extends MotionStudyLens {
+ /**
+ * Creates a new OverallRebaLens object.
+ */
+ constructor() {
+ super("REBA Ergonomics (Overall)");
+ }
+
+ applyLensToPose(pose, force = false) {
+ if (!force && Object.values(pose.joints).every(joint => joint.overallRebaScore)) {
+ return false;
+ }
+ const rebaData = Reba.calculateForPose(pose);
+ pose.forEachJoint(joint => {
+ joint.overallRebaScore = rebaData.overallRebaScore;
+ joint.overallRebaColor = rebaData.overallRebaColor;
+ });
+ pose.forEachBone(bone => {
+ bone.overallRebaScore = rebaData.overallRebaScore;
+ bone.overallRebaColor = rebaData.overallRebaColor;
+ });
+ return true;
+ }
+
+ applyLensToHistoryMinimally(poseHistory, force = false) {
+ const modified = this.applyLensToPose(poseHistory[poseHistory.length - 1], force);
+ const modifiedArray = poseHistory.map(() => false);
+ modifiedArray[modifiedArray.length - 1] = modified;
+ return modifiedArray;
+ }
+
+ applyLensToHistory(poseHistory, force = false) {
+ return poseHistory.map(pose => {
+ return this.applyLensToPose(pose, force);
+ });
+ }
+
+ getColorForJoint(joint) {
+ if (typeof joint.overallRebaColor === "undefined") {
+ return MotionStudyColors.undefined;
+ }
+ return joint.overallRebaColor;
+ }
+
+ getColorForBone(bone) {
+ if (typeof bone.overallRebaColor === "undefined") {
+ return MotionStudyColors.undefined;
+ }
+ return bone.overallRebaColor;
+ }
+
+ getColorForPose(pose) {
+ return this.getColorForJoint(pose.getJoint(JOINTS.HEAD));
+ }
+}
diff --git a/src/humanPose/Pose.js b/src/humanPose/Pose.js
new file mode 100644
index 000000000..47753568c
--- /dev/null
+++ b/src/humanPose/Pose.js
@@ -0,0 +1,126 @@
+/**
+ * Pose is a class that represents a human pose.
+ * It keeps track of the positions of each joint in the pose.
+ * It also keeps track of the timestamp of when the pose was recorded.
+ */
+import {JOINT_CONNECTIONS, JOINTS, LEFT_HAND_JOINTS, RIGHT_HAND_JOINTS} from "./constants.js";
+
+export class Pose {
+ /**
+ * Creates a new Pose object.
+ * @param {Object} jointPositions An object that maps joint names to joint positions (in ground-plane space).
+ * @param {Object} jointConfidences An object that maps joint names to joint confidences.
+ * @param {Number} timestamp The timestamp of when the pose was recorded.
+ * @param {Object} metadata An object that contains additional metadata about the pose.
+ */
+ constructor(jointPositions, jointConfidences, timestamp, metadata) {
+ this.joints = {}; // Maps joint names to joint data
+ Object.keys(jointPositions).forEach(jointName => {
+ this.joints[jointName] = {
+ position: jointPositions[jointName],
+ confidence: jointConfidences[jointName],
+ name: jointName,
+ valid: true
+ }
+ });
+ this.bones = {}; // Maps bone names to bone data
+ Object.keys(JOINT_CONNECTIONS).forEach(boneName => {
+ const [joint0, joint1] = JOINT_CONNECTIONS[boneName];
+ if (this.joints[joint0] && this.joints[joint1]) {
+ this.bones[boneName] = {
+ joint0: this.joints[joint0],
+ joint1: this.joints[joint1],
+ name: boneName,
+ valid: true
+ };
+ }
+ });
+ this.timestamp = timestamp;
+ this.metadata = metadata;
+ }
+
+ /**
+ * Returns a specific joint in the pose.
+ * @param {string} jointName The name of the joint to return.
+ */
+ getJoint(jointName) {
+ return this.joints[jointName];
+ }
+
+ /**
+ * Returns a specific bone in the pose.
+ * @param {string} boneName The name of the bone to return.
+ */
+ getBone(boneName) {
+ return this.bones[boneName];
+ }
+
+ /**
+ * Applies a function to each joint in the pose.
+ * @param {Function} callback The function to apply to each joint.
+ */
+ forEachJoint(callback) {
+ Object.keys(this.joints).forEach((jointName, index) => {
+ callback(this.joints[jointName], index);
+ });
+ }
+
+ /**
+ * Applies a function to each bone in the pose.
+ * @param {Function} callback The function to apply to each bone.
+ */
+ forEachBone(callback) {
+ Object.keys(this.bones).forEach((boneName, index) => {
+ callback(this.bones[boneName], index);
+ });
+ }
+
+ setBodyPartValidity(jointConfidenceThreshold) {
+
+ // compute validity only for limbs (head and torso are valid by default)
+ const limbJoints = [JOINTS.LEFT_ANKLE, JOINTS.LEFT_KNEE,
+ JOINTS.RIGHT_ANKLE, JOINTS.RIGHT_KNEE,
+ JOINTS.LEFT_ELBOW, JOINTS.LEFT_WRIST, ...LEFT_HAND_JOINTS,
+ JOINTS.RIGHT_ELBOW, JOINTS.RIGHT_WRIST, ...RIGHT_HAND_JOINTS
+ ];
+
+ limbJoints.forEach((jointName) => {
+ this.joints[jointName].valid = (this.joints[jointName].confidence >= jointConfidenceThreshold);
+ });
+
+ // when knees are not valid, whole legs are invalid including ankles
+ if (!this.joints[JOINTS.LEFT_KNEE].valid) {
+ this.joints[JOINTS.LEFT_ANKLE].valid = false;
+ }
+ if (!this.joints[JOINTS.RIGHT_KNEE].valid) {
+ this.joints[JOINTS.RIGHT_ANKLE].valid = false;
+ }
+ // when wrists are not valid, whole hands are invalid
+ if (!this.joints[JOINTS.LEFT_WRIST].valid) {
+ LEFT_HAND_JOINTS.forEach((jointName) => {
+ this.joints[jointName].valid = false;
+ });
+ }
+ if (!this.joints[JOINTS.RIGHT_WRIST].valid) {
+ RIGHT_HAND_JOINTS.forEach((jointName) => {
+ this.joints[jointName].valid = false;
+ });
+ }
+ // when the hand and elbow are not valid, the wrist is invalid as well
+ if (!this.joints[JOINTS.LEFT_ELBOW].valid && !this.joints[JOINTS.LEFT_THUMB_CMC].valid) {
+ this.joints[JOINTS.LEFT_WRIST].valid = false;
+ }
+ if (!this.joints[JOINTS.RIGHT_ELBOW].valid && !this.joints[JOINTS.RIGHT_THUMB_CMC].valid) {
+ this.joints[JOINTS.RIGHT_WRIST].valid = false;
+ }
+
+ // make invalid the bones adjacent to invalid joints
+ Object.keys(this.bones).forEach((boneName) => {
+ const jointName0 = this.bones[boneName].joint0.name;
+ const jointName1 = this.bones[boneName].joint1.name;
+ this.bones[boneName].valid = this.joints[jointName0].valid && this.joints[jointName1].valid;
+ });
+
+ }
+
+}
diff --git a/src/humanPose/PoseObjectIdLens.js b/src/humanPose/PoseObjectIdLens.js
new file mode 100644
index 000000000..8a13f1cee
--- /dev/null
+++ b/src/humanPose/PoseObjectIdLens.js
@@ -0,0 +1,124 @@
+import * as THREE from "../../thirdPartyCode/three/three.module.js";
+import {MotionStudyColors} from "./MotionStudyColors.js";
+import {MotionStudyLens} from "./MotionStudyLens.js";
+
+/**
+ * PoseObjectIdLens is a lens that visually distinguishes between poses generated by different pose objects.
+ */
+export class PoseObjectIdLens extends MotionStudyLens {
+ /**
+ * Creates a new PoseObjectIdLens object.
+ */
+ constructor() {
+ super('Distinct People (Debug)');
+
+ this.poseObjectIds = [];
+ this.numeratorsForDenominator = {};
+ }
+
+
+ /**
+ * Calculates the following sequence: 0, 1, 0.5, 0.25, 0.75, 0.125, 0.625, 0.375, 0.875...
+ * This fills the range such that the distance between two values is maximally different without modifying earlier
+ * values. This is necessary because we do not know ahead of time how many insertions there will be, otherwise we could
+ * space the values evenly. Another benefit is that it ensures that values that are near each other in index order
+ * are far apart in value order.
+ * @param {number} index The index to calculate the value for.
+ * @see https://en.wikipedia.org/wiki/Van_der_Corput_sequence for more details on a very similar sequence, and why this is useful.
+ */
+ maximallyDifferentPositionFromIndex(index) {
+ if (index === 0) {
+ return 0;
+ }
+ if (index === 1) {
+ return 1;
+ }
+ // Smallest power of 2 greater than or equal to index
+ const denominator = Math.pow(2, Math.ceil(Math.log2(index)));
+ if (!this.numeratorsForDenominator[denominator]) {
+ if (denominator === 2) {
+ this.numeratorsForDenominator[denominator] = [1];
+ } else {
+ this.numeratorsForDenominator[denominator] = this.numeratorsForDenominator[denominator / 2].reduce((acc, numerator) => {
+ acc.push(numerator);
+ acc.push(numerator + denominator / 2);
+ return acc;
+ }, []);
+ }
+ }
+ const numeratorIndex = index - (denominator / 2) - 1;
+ return this.numeratorsForDenominator[denominator][numeratorIndex] / denominator;
+ }
+
+ /**
+ * Calculates the color for a given index.
+ * @param {number} index The index to calculate the color for.
+ * @return {Color} The color for the given index.
+ */
+ maximallyDifferentColorFromIndex(index) {
+ const position = this.maximallyDifferentPositionFromIndex(index);
+ const minH = 0; // red
+ const maxH = 2/3; // blue
+ return new THREE.Color().setHSL(minH + position * (maxH - minH), 1, 0.5);
+ }
+
+ applyLensToPose(pose) {
+ if (Object.values(pose.joints).every(joint => joint.poseObjectId)) {
+ return false;
+ }
+ if (!this.poseObjectIds.includes(pose.metadata.poseObjectId)) {
+ this.poseObjectIds.push(pose.metadata.poseObjectId);
+ }
+ pose.forEachJoint(joint => {
+ joint.poseObjectId = pose.metadata.poseObjectId;
+ });
+ pose.forEachBone(bone => {
+ bone.poseObjectId = pose.metadata.poseObjectId;
+ bone.poseHasParent = pose.metadata.poseHasParent;
+ });
+ return true;
+ }
+
+ applyLensToHistoryMinimally(poseHistory) {
+ const modified = this.applyLensToPose(poseHistory[poseHistory.length - 1]);
+ const modifiedArray = poseHistory.map(() => false);
+ modifiedArray[modifiedArray.length - 1] = modified;
+ return modifiedArray;
+ }
+
+ applyLensToHistory(poseHistory) {
+ return poseHistory.map(pose => {
+ return this.applyLensToPose(pose);
+ });
+ }
+
+ /**
+ * Gets the color for a given pose object id.
+ * @param {string} id The pose object id.
+ * @return {Color} The color for the given pose object id.
+ */
+ getColorFromId(id) {
+ const index = this.poseObjectIds.indexOf(id);
+ if (index === -1) {
+ return MotionStudyColors.undefined;
+ }
+ return this.maximallyDifferentColorFromIndex(index);
+ }
+
+ getColorForJoint(joint) {
+ const baseColor = this.getColorFromId(joint.poseObjectId);
+ let baseColorHSL = baseColor.getHSL({});
+ baseColorHSL.l = baseColorHSL.l * joint.confidence;
+ return new THREE.Color().setHSL(baseColorHSL.h, baseColorHSL.s, baseColorHSL.l);
+ }
+
+ getColorForBone(bone) {
+ const color = this.getColorFromId(bone.poseObjectId);
+ return (bone.poseHasParent) ? MotionStudyColors.fade(color, 0.1) : color;
+ }
+
+ getColorForPose(pose) {
+ return MotionStudyColors.fade(this.getColorFromId(pose.metadata.poseObjectId));
+ }
+
+}
diff --git a/src/humanPose/RebaLens.js b/src/humanPose/RebaLens.js
new file mode 100644
index 000000000..8f98e40f6
--- /dev/null
+++ b/src/humanPose/RebaLens.js
@@ -0,0 +1,68 @@
+import {MotionStudyLens} from "./MotionStudyLens.js";
+import * as Reba from "./rebaScore.js";
+import {MotionStudyColors} from "./MotionStudyColors.js";
+import {JOINTS} from "./constants.js";
+
+/**
+ * RebaLens is a lens that calculates the REBA score for each bone in the pose history.
+ */
+export class RebaLens extends MotionStudyLens {
+ /**
+ * Creates a new RebaLens object.
+ */
+ constructor() {
+ super("REBA Ergonomics");
+ }
+
+ applyLensToPose(pose, force = false) {
+ if (!force && Object.values(pose.joints).every(joint => joint.rebaScore)) {
+ return false;
+ }
+ const rebaData = Reba.calculateForPose(pose);
+ pose.forEachJoint(joint => {
+ joint.rebaScore = rebaData.scores[joint.name];
+ joint.rebaColor = rebaData.colors[joint.name];
+ joint.rebaScoreOverall = rebaData.overallRebaScore; // not really used, overallRebaScore from REBA Ergonomics (Overall) is propagated into stats
+ joint.rebaColorOverall = rebaData.overallRebaColor;
+ });
+ pose.forEachBone(bone => {
+ bone.rebaScore = rebaData.boneScores[bone.name];
+ bone.rebaColor = rebaData.boneColors[bone.name];
+ });
+ return true;
+ }
+
+ applyLensToHistoryMinimally(poseHistory, force = false) {
+ const modified = this.applyLensToPose(poseHistory[poseHistory.length - 1], force);
+ const modifiedArray = poseHistory.map(() => false);
+ modifiedArray[modifiedArray.length - 1] = modified;
+ return modifiedArray;
+ }
+
+ applyLensToHistory(poseHistory, force = false) {
+ return poseHistory.map(pose => {
+ return this.applyLensToPose(pose, force);
+ });
+ }
+
+ getColorForJoint(joint) {
+ if (typeof joint.rebaColor === "undefined") {
+ return MotionStudyColors.undefined;
+ }
+ return joint.rebaColor;
+ }
+
+ getColorForBone(bone) {
+ if (typeof bone.rebaColor === "undefined") {
+ return MotionStudyColors.undefined;
+ }
+ return bone.rebaColor;
+ }
+
+ getColorForPose(pose) {
+ if (typeof pose.getJoint(JOINTS.HEAD).rebaColorOverall === "undefined") {
+ return MotionStudyColors.undefined;
+ }
+ return pose.getJoint(JOINTS.HEAD).rebaColorOverall;
+ }
+}
diff --git a/src/humanPose/TimeLens.js b/src/humanPose/TimeLens.js
new file mode 100644
index 000000000..465be3673
--- /dev/null
+++ b/src/humanPose/TimeLens.js
@@ -0,0 +1,72 @@
+import {MotionStudyLens} from "./MotionStudyLens.js";
+import {MotionStudyColors} from "./MotionStudyColors.js";
+import {JOINTS} from "./constants.js";
+
+const TIME_INTERVAL_DURATION = 10000; // 10 seconds
+
+/**
+ * TimeLens is a lens that colors poses based on when they were recorded.
+ */
+class TimeLens extends MotionStudyLens {
+ /**
+ * Creates a new TimeLens object.
+ */
+ constructor() {
+ super("Time");
+ }
+
+ applyLensToPose(pose) {
+ if (pose.getJoint(JOINTS.HEAD).timeFrac) {
+ return false;
+ }
+ const intervalProgress = pose.timestamp % TIME_INTERVAL_DURATION;
+ const timeFrac = intervalProgress / TIME_INTERVAL_DURATION;
+ pose.forEachJoint(joint => {
+ joint.timeFrac = timeFrac;
+ });
+ pose.forEachBone(bone => {
+ bone.timeFrac = timeFrac;
+ });
+ return true;
+ }
+
+ applyLensToHistoryMinimally(poseHistory) {
+ const modified = this.applyLensToPose(poseHistory[poseHistory.length - 1]);
+ const modifiedResult = poseHistory.map(() => false);
+ modifiedResult[modifiedResult.length - 1] = modified;
+ return modifiedResult;
+ }
+
+ applyLensToHistory(poseHistory) {
+ return poseHistory.map(pose => {
+ return this.applyLensToPose(pose);
+ });
+ }
+
+ getColorForJoint(joint) {
+ if (typeof joint.timeFrac === "undefined") {
+ return MotionStudyColors.undefined;
+ }
+ const startColor = MotionStudyColors.red;
+ const endColor = MotionStudyColors.blue;
+ return startColor.clone().lerpHSL(endColor, joint.timeFrac);
+ }
+
+ getColorForBone(bone) {
+ if (typeof bone.timeFrac === "undefined") {
+ return MotionStudyColors.undefined;
+ }
+ const startColor = MotionStudyColors.red;
+ const endColor = MotionStudyColors.blue;
+ return startColor.clone().lerpHSL(endColor, bone.timeFrac);
+ }
+
+ getColorForPose(pose) {
+ if (typeof pose.getJoint(JOINTS.HEAD).timeFrac === "undefined") {
+ return MotionStudyColors.undefined;
+ }
+ return this.getColorForJoint(pose.getJoint(JOINTS.HEAD));
+ }
+}
+
+export {TimeLens};
diff --git a/src/humanPose/ValueAddWasteTimeLens.js b/src/humanPose/ValueAddWasteTimeLens.js
new file mode 100644
index 000000000..7898cdccb
--- /dev/null
+++ b/src/humanPose/ValueAddWasteTimeLens.js
@@ -0,0 +1,76 @@
+import {MotionStudyLens} from "./MotionStudyLens.js";
+import {MotionStudyColors} from "./MotionStudyColors.js";
+import {JOINTS} from "./constants.js";
+import {ValueAddWasteTimeTypes} from "../motionStudy/ValueAddWasteTimeManager.js";
+
+function colorFromValue(value) {
+ if (value === ValueAddWasteTimeTypes.VALUE_ADD) {
+ return MotionStudyColors.green;
+ }
+ if (value === ValueAddWasteTimeTypes.WASTE_TIME) {
+ return MotionStudyColors.red;
+ }
+ return MotionStudyColors.gray;
+}
+
+/**
+ * RebaLens is a lens that calculates the REBA score for each bone in the pose history.
+ */
+export class ValueAddWasteTimeLens extends MotionStudyLens {
+ /**
+ * Creates a new ValueAddWasteTimeLens object.
+ * @param {MotionStudy} motionStudy
+ */
+ constructor(motionStudy) {
+ super("Value Add/Waste Time");
+ this.motionStudy = motionStudy;
+ }
+
+ applyLensToPose(pose) {
+ // if (Object.values(pose.joints).every(joint => joint.valueAddWasteTimeValue)) {
+ // return false;
+ // }
+ const value = this.motionStudy.valueAddWasteTimeManager.getValue(pose.timestamp);
+ pose.forEachJoint(joint => {
+ joint.valueAddWasteTimeValue = value;
+ });
+ pose.forEachBone(bone => {
+ bone.valueAddWasteTimeValue = value;
+ });
+ return true;
+ }
+
+ applyLensToHistoryMinimally(poseHistory) {
+ const modified = this.applyLensToPose(poseHistory[poseHistory.length - 1]);
+ const modifiedResult = poseHistory.map(() => false);
+ modifiedResult[modifiedResult.length - 1] = modified;
+ return modifiedResult;
+ }
+
+ applyLensToHistory(poseHistory) {
+ return poseHistory.map(pose => {
+ return this.applyLensToPose(pose);
+ });
+ }
+
+ getColorForJoint(joint) {
+ if (typeof joint.valueAddWasteTimeValue === "undefined") {
+ return MotionStudyColors.undefined;
+ }
+ return colorFromValue(joint.valueAddWasteTimeValue);
+ }
+
+ getColorForBone(bone) {
+ if (typeof bone.valueAddWasteTimeValue === "undefined") {
+ return MotionStudyColors.undefined;
+ }
+ return colorFromValue(bone.valueAddWasteTimeValue);
+ }
+
+ getColorForPose(pose) {
+ if (typeof pose.getJoint(JOINTS.HEAD).valueAddWasteTimeValue === "undefined") {
+ return MotionStudyColors.undefined;
+ }
+ return MotionStudyColors.fade(colorFromValue(pose.getJoint(JOINTS.HEAD).valueAddWasteTimeValue));
+ }
+}
diff --git a/src/humanPose/constants.js b/src/humanPose/constants.js
new file mode 100644
index 000000000..f0084a499
--- /dev/null
+++ b/src/humanPose/constants.js
@@ -0,0 +1,522 @@
+/**
+ * Constants used in the behavior of humanPose modules
+ */
+
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+
+/* Previous joint scheme without simple hands. */
+export const JOINTS_V1 = {
+ NOSE: 'nose',
+ LEFT_EYE: 'left_eye',
+ RIGHT_EYE: 'right_eye',
+ LEFT_EAR: 'left_ear',
+ RIGHT_EAR: 'right_ear',
+ LEFT_SHOULDER: 'left_shoulder',
+ RIGHT_SHOULDER: 'right_shoulder',
+ LEFT_ELBOW: 'left_elbow',
+ RIGHT_ELBOW: 'right_elbow',
+ LEFT_WRIST: 'left_wrist',
+ RIGHT_WRIST: 'right_wrist',
+ LEFT_HIP: 'left_hip',
+ RIGHT_HIP: 'right_hip',
+ LEFT_KNEE: 'left_knee',
+ RIGHT_KNEE: 'right_knee',
+ LEFT_ANKLE: 'left_ankle',
+ RIGHT_ANKLE: 'right_ankle',
+ HEAD: 'head', // synthetic
+ NECK: 'neck', // synthetic
+ CHEST: 'chest', // synthetic
+ NAVEL: 'navel', // synthetic
+ PELVIS: 'pelvis', // synthetic
+};
+
+export const JOINTS_V1_COUNT = Object.keys(JOINTS_V1).length;
+
+/* Previous joint scheme with simple hands. */
+export const JOINTS_V2 = {
+ NOSE: 'nose',
+ LEFT_EYE: 'left_eye',
+ RIGHT_EYE: 'right_eye',
+ LEFT_EAR: 'left_ear',
+ RIGHT_EAR: 'right_ear',
+ LEFT_SHOULDER: 'left_shoulder',
+ RIGHT_SHOULDER: 'right_shoulder',
+ LEFT_ELBOW: 'left_elbow',
+ RIGHT_ELBOW: 'right_elbow',
+ LEFT_WRIST: 'left_wrist',
+ RIGHT_WRIST: 'right_wrist',
+ LEFT_HIP: 'left_hip',
+ RIGHT_HIP: 'right_hip',
+ LEFT_KNEE: 'left_knee',
+ RIGHT_KNEE: 'right_knee',
+ LEFT_ANKLE: 'left_ankle',
+ RIGHT_ANKLE: 'right_ankle',
+ LEFT_PINKY: 'left_pinky',
+ RIGHT_PINKY: 'right_pinky',
+ LEFT_INDEX: 'left_index',
+ RIGHT_INDEX: 'right_index',
+ LEFT_THUMB: 'left_thumb',
+ RIGHT_THUMB: 'right_thumb',
+ HEAD: 'head', // synthetic
+ NECK: 'neck', // synthetic
+ CHEST: 'chest', // synthetic
+ NAVEL: 'navel', // synthetic
+ PELVIS: 'pelvis', // synthetic
+};
+
+export const JOINTS_V2_COUNT = Object.keys(JOINTS_V2).length;
+
+/** Current joint scheme with detailed hands.
+ * Medical naming for hand joints (https://en.wikipedia.org/wiki/Interphalangeal_joints_of_the_hand).
+ * Finger: Wrist -> MetaCarpoPhalangeal (MCP) -> Proximal InterPhalangeal (PIP) -> Distal InterPhalangeal (DIP) -> Tip of finger
+ * Thumb: Wrist -> CarpoMetaCarpal (CMC) -> MetaCarpoPhalangeal (MCP) -> InterPhalangeal (IP) -> Tip of thumb
+ */
+export const JOINTS = {
+ /* body joints */
+ NOSE: 'nose',
+ LEFT_EYE: 'left_eye',
+ RIGHT_EYE: 'right_eye',
+ LEFT_EAR: 'left_ear',
+ RIGHT_EAR: 'right_ear',
+ LEFT_SHOULDER: 'left_shoulder',
+ RIGHT_SHOULDER: 'right_shoulder',
+ LEFT_ELBOW: 'left_elbow',
+ RIGHT_ELBOW: 'right_elbow',
+ LEFT_WRIST: 'left_wrist',
+ RIGHT_WRIST: 'right_wrist',
+ LEFT_HIP: 'left_hip',
+ RIGHT_HIP: 'right_hip',
+ LEFT_KNEE: 'left_knee',
+ RIGHT_KNEE: 'right_knee',
+ LEFT_ANKLE: 'left_ankle',
+ RIGHT_ANKLE: 'right_ankle',
+ /* left hand joints (from a wrist to a finger tip) */
+ LEFT_THUMB_CMC: 'left_thumb_cmc',
+ LEFT_THUMB_MCP: 'left_thumb_mcp',
+ LEFT_THUMB_IP: 'left_thumb_ip',
+ LEFT_THUMB_TIP: 'left_thumb_tip',
+ LEFT_INDEX_FINGER_MCP: 'left_index_finger_mcp',
+ LEFT_INDEX_FINGER_PIP: 'left_index_finger_pip',
+ LEFT_INDEX_FINGER_DIP: 'left_index_finger_dip',
+ LEFT_INDEX_FINGER_TIP: 'left_index_finger_tip',
+ LEFT_MIDDLE_FINGER_MCP: 'left_middle_finger_mcp',
+ LEFT_MIDDLE_FINGER_PIP: 'left_middle_finger_pip',
+ LEFT_MIDDLE_FINGER_DIP: 'left_middle_finger_dip',
+ LEFT_MIDDLE_FINGER_TIP: 'left_middle_finger_tip',
+ LEFT_RING_FINGER_MCP: 'left_ring_finger_mcp',
+ LEFT_RING_FINGER_PIP: 'left_ring_finger_pip',
+ LEFT_RING_FINGER_DIP: 'left_ring_finger_dip',
+ LEFT_RING_FINGER_TIP: 'left_ring_finger_tip',
+ LEFT_PINKY_MCP: 'left_pinky_mcp',
+ LEFT_PINKY_PIP: 'left_pinky_pip',
+ LEFT_PINKY_DIP: 'left_pinky_dip',
+ LEFT_PINKY_TIP: 'left_pinky_tip',
+ /* right hand joints (from a wrist to a finger tip) */
+ RIGHT_THUMB_CMC: 'right_thumb_cmc',
+ RIGHT_THUMB_MCP: 'right_thumb_mcp',
+ RIGHT_THUMB_IP: 'right_thumb_ip',
+ RIGHT_THUMB_TIP: 'right_thumb_tip',
+ RIGHT_INDEX_FINGER_MCP: 'right_index_finger_mcp',
+ RIGHT_INDEX_FINGER_PIP: 'right_index_finger_pip',
+ RIGHT_INDEX_FINGER_DIP: 'right_index_finger_dip',
+ RIGHT_INDEX_FINGER_TIP: 'right_index_finger_tip',
+ RIGHT_MIDDLE_FINGER_MCP: 'right_middle_finger_mcp',
+ RIGHT_MIDDLE_FINGER_PIP: 'right_middle_finger_pip',
+ RIGHT_MIDDLE_FINGER_DIP: 'right_middle_finger_dip',
+ RIGHT_MIDDLE_FINGER_TIP: 'right_middle_finger_tip',
+ RIGHT_RING_FINGER_MCP: 'right_ring_finger_mcp',
+ RIGHT_RING_FINGER_PIP: 'right_ring_finger_pip',
+ RIGHT_RING_FINGER_DIP: 'right_ring_finger_dip',
+ RIGHT_RING_FINGER_TIP: 'right_ring_finger_tip',
+ RIGHT_PINKY_MCP: 'right_pinky_mcp',
+ RIGHT_PINKY_PIP: 'right_pinky_pip',
+ RIGHT_PINKY_DIP: 'right_pinky_dip',
+ RIGHT_PINKY_TIP: 'right_pinky_tip',
+ /* synthetic spine joints */
+ HEAD: 'head',
+ NECK: 'neck',
+ CHEST: 'chest',
+ NAVEL: 'navel',
+ PELVIS: 'pelvis',
+};
+
+export const JOINT_CONNECTIONS = {
+ // connections between body joints
+ elbowWristLeft: [JOINTS.LEFT_WRIST, JOINTS.LEFT_ELBOW], // 0
+ shoulderElbowLeft: [JOINTS.LEFT_ELBOW, JOINTS.LEFT_SHOULDER],
+ shoulderSpan: [JOINTS.LEFT_SHOULDER, JOINTS.RIGHT_SHOULDER],
+ shoulderElbowRight: [JOINTS.RIGHT_ELBOW, JOINTS.RIGHT_SHOULDER],
+ elbowWristRight: [JOINTS.RIGHT_WRIST, JOINTS.RIGHT_ELBOW],
+ chestLeft: [JOINTS.LEFT_SHOULDER, JOINTS.LEFT_HIP],
+ hipSpan: [JOINTS.LEFT_HIP, JOINTS.RIGHT_HIP],
+ chestRight: [JOINTS.RIGHT_HIP, JOINTS.RIGHT_SHOULDER],
+ hipKneeLeft: [JOINTS.LEFT_HIP, JOINTS.LEFT_KNEE],
+ kneeAnkleLeft: [JOINTS.LEFT_KNEE, JOINTS.LEFT_ANKLE],
+ hipKneeRight: [JOINTS.RIGHT_HIP, JOINTS.RIGHT_KNEE],
+ kneeAnkleRight: [JOINTS.RIGHT_KNEE, JOINTS.RIGHT_ANKLE],
+ earSpan: [JOINTS.LEFT_EAR, JOINTS.RIGHT_EAR],
+ eyeSpan: [JOINTS.LEFT_EYE, JOINTS.RIGHT_EYE],
+ eyeNoseLeft: [JOINTS.LEFT_EYE, JOINTS.NOSE],
+ eyeNoseRight: [JOINTS.RIGHT_EYE, JOINTS.NOSE],
+ // connections between left hand joints
+ thumb1Left: [JOINTS.LEFT_WRIST, JOINTS.LEFT_THUMB_CMC], // 16
+ thumb2Left: [JOINTS.LEFT_THUMB_CMC, JOINTS.LEFT_THUMB_MCP],
+ thumb3Left: [JOINTS.LEFT_THUMB_MCP, JOINTS.LEFT_THUMB_IP],
+ thumb4Left: [JOINTS.LEFT_THUMB_IP, JOINTS.LEFT_THUMB_TIP],
+ index1Left: [JOINTS.LEFT_WRIST, JOINTS.LEFT_INDEX_FINGER_MCP],
+ index2Left: [JOINTS.LEFT_INDEX_FINGER_MCP, JOINTS.LEFT_INDEX_FINGER_PIP],
+ index3Left: [JOINTS.LEFT_INDEX_FINGER_PIP, JOINTS.LEFT_INDEX_FINGER_DIP],
+ index4Left: [JOINTS.LEFT_INDEX_FINGER_DIP, JOINTS.LEFT_INDEX_FINGER_TIP],
+ middle2Left: [JOINTS.LEFT_MIDDLE_FINGER_MCP, JOINTS.LEFT_MIDDLE_FINGER_PIP],
+ middle3Left: [JOINTS.LEFT_MIDDLE_FINGER_PIP, JOINTS.LEFT_MIDDLE_FINGER_DIP],
+ middle4Left: [JOINTS.LEFT_MIDDLE_FINGER_DIP, JOINTS.LEFT_MIDDLE_FINGER_TIP],
+ ring2Left: [JOINTS.LEFT_RING_FINGER_MCP, JOINTS.LEFT_RING_FINGER_PIP],
+ ring3Left: [JOINTS.LEFT_RING_FINGER_PIP, JOINTS.LEFT_RING_FINGER_DIP],
+ ring4Left: [JOINTS.LEFT_RING_FINGER_DIP, JOINTS.LEFT_RING_FINGER_TIP],
+ pinky1Left: [JOINTS.LEFT_WRIST, JOINTS.LEFT_PINKY_MCP],
+ pinky2Left: [JOINTS.LEFT_PINKY_MCP, JOINTS.LEFT_PINKY_PIP],
+ pinky3Left: [JOINTS.LEFT_PINKY_PIP, JOINTS.LEFT_PINKY_DIP],
+ pinky4Left: [JOINTS.LEFT_PINKY_DIP, JOINTS.LEFT_PINKY_TIP],
+ handSpan1Left: [JOINTS.LEFT_INDEX_FINGER_MCP, JOINTS.LEFT_MIDDLE_FINGER_MCP],
+ handSpan2Left: [JOINTS.LEFT_MIDDLE_FINGER_MCP, JOINTS.LEFT_RING_FINGER_MCP],
+ handSpan3Left: [JOINTS.LEFT_RING_FINGER_MCP, JOINTS.LEFT_PINKY_MCP],
+ // connections between right hand joints
+ thumb1Right: [JOINTS.RIGHT_WRIST, JOINTS.RIGHT_THUMB_CMC], // 37
+ thumb2Right: [JOINTS.RIGHT_THUMB_CMC, JOINTS.RIGHT_THUMB_MCP],
+ thumb3Right: [JOINTS.RIGHT_THUMB_MCP, JOINTS.RIGHT_THUMB_IP],
+ thumb4Right: [JOINTS.RIGHT_THUMB_IP, JOINTS.RIGHT_THUMB_TIP],
+ index1Right: [JOINTS.RIGHT_WRIST, JOINTS.RIGHT_INDEX_FINGER_MCP],
+ index2Right: [JOINTS.RIGHT_INDEX_FINGER_MCP, JOINTS.RIGHT_INDEX_FINGER_PIP],
+ index3Right: [JOINTS.RIGHT_INDEX_FINGER_PIP, JOINTS.RIGHT_INDEX_FINGER_DIP],
+ index4Right: [JOINTS.RIGHT_INDEX_FINGER_DIP, JOINTS.RIGHT_INDEX_FINGER_TIP],
+ middle2Right: [JOINTS.RIGHT_MIDDLE_FINGER_MCP, JOINTS.RIGHT_MIDDLE_FINGER_PIP],
+ middle3Right: [JOINTS.RIGHT_MIDDLE_FINGER_PIP, JOINTS.RIGHT_MIDDLE_FINGER_DIP],
+ middle4Right: [JOINTS.RIGHT_MIDDLE_FINGER_DIP, JOINTS.RIGHT_MIDDLE_FINGER_TIP],
+ ring2Right: [JOINTS.RIGHT_RING_FINGER_MCP, JOINTS.RIGHT_RING_FINGER_PIP],
+ ring3Right: [JOINTS.RIGHT_RING_FINGER_PIP, JOINTS.RIGHT_RING_FINGER_DIP],
+ ring4Right: [JOINTS.RIGHT_RING_FINGER_DIP, JOINTS.RIGHT_RING_FINGER_TIP],
+ pinky1Right: [JOINTS.RIGHT_WRIST, JOINTS.RIGHT_PINKY_MCP],
+ pinky2Right: [JOINTS.RIGHT_PINKY_MCP, JOINTS.RIGHT_PINKY_PIP],
+ pinky3Right: [JOINTS.RIGHT_PINKY_PIP, JOINTS.RIGHT_PINKY_DIP],
+ pinky4Right: [JOINTS.RIGHT_PINKY_DIP, JOINTS.RIGHT_PINKY_TIP],
+ handSpan1Right: [JOINTS.RIGHT_INDEX_FINGER_MCP, JOINTS.RIGHT_MIDDLE_FINGER_MCP],
+ handSpan2Right: [JOINTS.RIGHT_MIDDLE_FINGER_MCP, JOINTS.RIGHT_RING_FINGER_MCP],
+ handSpan3Right: [JOINTS.RIGHT_RING_FINGER_MCP, JOINTS.RIGHT_PINKY_MCP],
+ // connections between synthetic joints
+ headNeck: [JOINTS.HEAD, JOINTS.NECK], // 58
+ neckChest: [JOINTS.NECK, JOINTS.CHEST],
+ chestNavel: [JOINTS.CHEST, JOINTS.NAVEL],
+ navelPelvis: [JOINTS.NAVEL, JOINTS.PELVIS],
+ face: [JOINTS.HEAD, JOINTS.NOSE]
+}
+
+export const JOINTS_PER_POSE = Object.keys(JOINTS).length;
+export const BONES_PER_POSE = Object.keys(JOINT_CONNECTIONS).length;
+
+// Option to hide joints (+ adjacent bones) which have low confidence (thus considered poorly tracked)
+// This affects dynamic visualisation based on a given pose.
+export const DISPLAY_INVALID_ELEMENTS = false;
+
+// Flag for switching on/off an experimental feature of hand tracking
+export const TRACK_HANDS = true;
+
+// Option to hide joints/bones which are for example considered poorly tracked in general or redundant for a use case
+// This affects visualisation of all poses the same way.
+// Currently, defined according to debug switch TRACK_HANDS
+export const DISPLAY_HIDDEN_ELEMENTS = TRACK_HANDS;
+export const LEFT_HAND_JOINTS = [
+ JOINTS.LEFT_THUMB_CMC,
+ JOINTS.LEFT_THUMB_MCP,
+ JOINTS.LEFT_THUMB_IP,
+ JOINTS.LEFT_THUMB_TIP,
+ JOINTS.LEFT_INDEX_FINGER_MCP,
+ JOINTS.LEFT_INDEX_FINGER_PIP,
+ JOINTS.LEFT_INDEX_FINGER_DIP,
+ JOINTS.LEFT_INDEX_FINGER_TIP,
+ JOINTS.LEFT_MIDDLE_FINGER_MCP,
+ JOINTS.LEFT_MIDDLE_FINGER_PIP,
+ JOINTS.LEFT_MIDDLE_FINGER_DIP,
+ JOINTS.LEFT_MIDDLE_FINGER_TIP,
+ JOINTS.LEFT_RING_FINGER_MCP,
+ JOINTS.LEFT_RING_FINGER_PIP,
+ JOINTS.LEFT_RING_FINGER_DIP,
+ JOINTS.LEFT_RING_FINGER_TIP,
+ JOINTS.LEFT_PINKY_MCP,
+ JOINTS.LEFT_PINKY_PIP,
+ JOINTS.LEFT_PINKY_DIP,
+ JOINTS.LEFT_PINKY_TIP
+];
+export const RIGHT_HAND_JOINTS = [
+ JOINTS.RIGHT_THUMB_CMC,
+ JOINTS.RIGHT_THUMB_MCP,
+ JOINTS.RIGHT_THUMB_IP,
+ JOINTS.RIGHT_THUMB_TIP,
+ JOINTS.RIGHT_INDEX_FINGER_MCP,
+ JOINTS.RIGHT_INDEX_FINGER_PIP,
+ JOINTS.RIGHT_INDEX_FINGER_DIP,
+ JOINTS.RIGHT_INDEX_FINGER_TIP,
+ JOINTS.RIGHT_MIDDLE_FINGER_MCP,
+ JOINTS.RIGHT_MIDDLE_FINGER_PIP,
+ JOINTS.RIGHT_MIDDLE_FINGER_DIP,
+ JOINTS.RIGHT_MIDDLE_FINGER_TIP,
+ JOINTS.RIGHT_RING_FINGER_MCP,
+ JOINTS.RIGHT_RING_FINGER_PIP,
+ JOINTS.RIGHT_RING_FINGER_DIP,
+ JOINTS.RIGHT_RING_FINGER_TIP,
+ JOINTS.RIGHT_PINKY_MCP,
+ JOINTS.RIGHT_PINKY_PIP,
+ JOINTS.RIGHT_PINKY_DIP,
+ JOINTS.RIGHT_PINKY_TIP
+];
+export const HIDDEN_JOINTS = [...LEFT_HAND_JOINTS, ...RIGHT_HAND_JOINTS];
+
+export const HIDDEN_BONES = [
+ getBoneName(JOINT_CONNECTIONS.thumb1Left),
+ getBoneName(JOINT_CONNECTIONS.thumb2Left),
+ getBoneName(JOINT_CONNECTIONS.thumb3Left),
+ getBoneName(JOINT_CONNECTIONS.thumb4Left),
+ getBoneName(JOINT_CONNECTIONS.index1Left),
+ getBoneName(JOINT_CONNECTIONS.index2Left),
+ getBoneName(JOINT_CONNECTIONS.index3Left),
+ getBoneName(JOINT_CONNECTIONS.index4Left),
+ getBoneName(JOINT_CONNECTIONS.middle2Left),
+ getBoneName(JOINT_CONNECTIONS.middle3Left),
+ getBoneName(JOINT_CONNECTIONS.middle4Left),
+ getBoneName(JOINT_CONNECTIONS.ring2Left),
+ getBoneName(JOINT_CONNECTIONS.ring3Left),
+ getBoneName(JOINT_CONNECTIONS.ring4Left),
+ getBoneName(JOINT_CONNECTIONS.pinky1Left),
+ getBoneName(JOINT_CONNECTIONS.pinky2Left),
+ getBoneName(JOINT_CONNECTIONS.pinky3Left),
+ getBoneName(JOINT_CONNECTIONS.pinky4Left),
+ getBoneName(JOINT_CONNECTIONS.handSpan1Left),
+ getBoneName(JOINT_CONNECTIONS.handSpan2Left),
+ getBoneName(JOINT_CONNECTIONS.handSpan3Left),
+ getBoneName(JOINT_CONNECTIONS.thumb1Right),
+ getBoneName(JOINT_CONNECTIONS.thumb2Right),
+ getBoneName(JOINT_CONNECTIONS.thumb3Right),
+ getBoneName(JOINT_CONNECTIONS.thumb4Right),
+ getBoneName(JOINT_CONNECTIONS.index1Right),
+ getBoneName(JOINT_CONNECTIONS.index2Right),
+ getBoneName(JOINT_CONNECTIONS.index3Right),
+ getBoneName(JOINT_CONNECTIONS.index4Right),
+ getBoneName(JOINT_CONNECTIONS.middle2Right),
+ getBoneName(JOINT_CONNECTIONS.middle3Right),
+ getBoneName(JOINT_CONNECTIONS.middle4Right),
+ getBoneName(JOINT_CONNECTIONS.ring2Right),
+ getBoneName(JOINT_CONNECTIONS.ring3Right),
+ getBoneName(JOINT_CONNECTIONS.ring4Right),
+ getBoneName(JOINT_CONNECTIONS.pinky1Right),
+ getBoneName(JOINT_CONNECTIONS.pinky2Right),
+ getBoneName(JOINT_CONNECTIONS.pinky3Right),
+ getBoneName(JOINT_CONNECTIONS.pinky4Right),
+ getBoneName(JOINT_CONNECTIONS.handSpan1Right),
+ getBoneName(JOINT_CONNECTIONS.handSpan2Right),
+ getBoneName(JOINT_CONNECTIONS.handSpan3Right)
+];
+
+export const COLOR_BASE = new THREE.Color(0, 0.5, 1);
+export const COLOR_RED = new THREE.Color(1, 0, 0);
+export const COLOR_YELLOW = new THREE.Color(1, 1, 0);
+export const COLOR_GREEN = new THREE.Color(0, 1, 0);
+
+export const JOINT_TO_INDEX = {};
+for (const [i, jointId] of Object.values(JOINTS).entries()) {
+ JOINT_TO_INDEX[jointId] = i;
+}
+
+export const BONE_TO_INDEX = {};
+for (const [i, boneName] of Object.keys(JOINT_CONNECTIONS).entries()) {
+ BONE_TO_INDEX[boneName] = i;
+}
+
+/*
+export const SMALL_JOINT_FLAGS = [
+ true, // NOSE
+ true, // LEFT_EYE
+ true, // RIGHT_EYE
+ true, // LEFT_EAR
+ true, // RIGHT_EAR
+ false, // LEFT_SHOULDER
+ false, // RIGHT_SHOULDER
+ false, // LEFT_ELBOW
+ false, // RIGHT_ELBOW
+ false, // LEFT_WRIST
+ false, // RIGHT_WRIST
+ false, // LEFT_HIP
+ false, // RIGHT_HIP
+ false, // LEFT_KNEE
+ false, // RIGHT_KNEE
+ false, // LEFT_ANKLE
+ false, // RIGHT_ANKLE
+ true, // LEFT_PINKY
+ true, // RIGHT_PINKY
+ true, // LEFT_INDEX
+ true, // RIGHT_INDEX
+ true, // LEFT_THUMB
+ true, // RIGHT_THUMB
+ false, // HEAD
+ false, // NECK
+ false, // CHEST
+ false, // NAVEL
+ false // PELVIS
+];
+*/
+
+export const SMALL_JOINT_FLAGS = [
+ /* body joints */
+ true, // NOSE
+ true, // LEFT_EYE
+ true, // RIGHT_EYE
+ true, // LEFT_EAR
+ true, // RIGHT_EAR
+ false, // LEFT_SHOULDER
+ false, // RIGHT_SHOULDER
+ false, // LEFT_ELBOW
+ false, // RIGHT_ELBOW
+ false, // LEFT_WRIST
+ false, // RIGHT_WRIST
+ false, // LEFT_HIP
+ false, // RIGHT_HIP
+ false, // LEFT_KNEE
+ false, // RIGHT_KNEE
+ false, // LEFT_ANKLE
+ false, // RIGHT_ANKLE
+ /* left hand joints */
+ true, // LEFT_THUMB_CMC
+ true, // LEFT_THUMB_MCP
+ true, // LEFT_THUMB_IP
+ true, // LEFT_THUMB_TIP
+ true, // LEFT_INDEX_FINGER_MCP
+ true, // LEFT_INDEX_FINGER_PIP
+ true, // LEFT_INDEX_FINGER_DIP
+ true, // LEFT_INDEX_FINGER_TIP
+ true, // LEFT_MIDDLE_FINGER_MCP
+ true, // LEFT_MIDDLE_FINGER_PIP
+ true, // LEFT_MIDDLE_FINGER_DIP
+ true, // LEFT_MIDDLE_FINGER_TIP
+ true, // LEFT_RING_FINGER_MCP
+ true, // LEFT_RING_FINGER_PIP
+ true, // LEFT_RING_FINGER_DIP
+ true, // LEFT_RING_FINGER_TIP
+ true, // LEFT_PINKY_MCP
+ true, // LEFT_PINKY_PIP
+ true, // LEFT_PINKY_DIP
+ true, // LEFT_PINKY_TIP
+ /* right hand joints */
+ true, // RIGHT_THUMB_CMC
+ true, // RIGHT_THUMB_MCP
+ true, // RIGHT_THUMB_IP
+ true, // RIGHT_THUMB_TIP
+ true, // RIGHT_INDEX_FINGER_MCP
+ true, // RIGHT_INDEX_FINGER_PIP
+ true, // RIGHT_INDEX_FINGER_DIP
+ true, // RIGHT_INDEX_FINGER_TIP
+ true, // RIGHT_MIDDLE_FINGER_MCP
+ true, // RIGHT_MIDDLE_FINGER_PIP
+ true, // RIGHT_MIDDLE_FINGER_DIP
+ true, // RIGHT_MIDDLE_FINGER_TIP
+ true, // RIGHT_RING_FINGER_MCP
+ true, // RIGHT_RING_FINGER_PIP
+ true, // RIGHT_RING_FINGER_DIP
+ true, // RIGHT_RING_FINGER_TIP
+ true, // RIGHT_PINKY_MCP
+ true, // RIGHT_PINKY_PIP
+ true, // RIGHT_PINKY_DIP
+ true, // RIGHT_PINKY_TIP
+ /* synthetic spine joints */
+ false, // HEAD
+ false, // NECK
+ false, // CHEST
+ false, // NAVEL
+ false // PELVIS
+];
+
+export const THIN_BONE_FLAGS = [
+ /* connections between body joints */
+ false, // elbowWristLeft
+ false, // shoulderElbowLeft
+ false, // shoulderSpan
+ false, // shoulderElbowRight
+ false, // elbowWristRight
+ false, // chestLeft
+ false, // hipSpan
+ false, // chestRight
+ false, // hipKneeLeft
+ false, // kneeAnkleLeft
+ false, // hipKneeRight
+ false, // kneeAnkleRight
+ /* connections between face joints */
+ true, // earSpan
+ true, // eyeSpan
+ true, // eyeNoseLeft
+ true, // eyeNoseRight
+ // connections between left hand joints
+ true, // thumb1Left
+ true, // thumb2Left
+ true, // thumb3Left
+ true, // thumb4Left
+ true, // index1Left
+ true, // index2Left
+ true, // index3Left
+ true, // index4Left
+ true, // middle2Left
+ true, // middle3Left
+ true, // middle4Left
+ true, // ring2Left
+ true, // ring3Left
+ true, // ring4Left
+ true, // pinky1Left
+ true, // pinky2Left
+ true, // pinky3Left
+ true, // pinky4Left
+ true, // handSpan1Left
+ true, // handSpan2Left
+ true, // handSpan3Left
+ // connections between right hand joints
+ true, // thumb1Right
+ true, // thumb2Right
+ true, // thumb3Right
+ true, // thumb4Right
+ true, // index1Right
+ true, // index2Right
+ true, // index3Right
+ true, // index4Right
+ true, // middle2Right
+ true, // middle3Right
+ true, // middle4Right
+ true, // ring2Right
+ true, // ring3Right
+ true, // ring4Right
+ true, // pinky1Right
+ true, // pinky2Right
+ true, // pinky3Right
+ true, // pinky4Right
+ true, // handSpan1Right
+ true, // handSpan2Right
+ true, // handSpan3Right
+ // connections between synthetic joints
+ false, // headNeck
+ false, // neckChest
+ false, // chestNavel
+ false, // navelPelvis
+ false // face
+];
+
+export const SMALL_JOINT_SCALE_VEC = new THREE.Vector3(0.5, 0.5, 0.5);
+export const THIN_BONE_SCALE_VEC = new THREE.Vector3(0.5, 1.0, 0.5);
+
+export const JOINT_RADIUS = 0.02; // unit: meters
+export const BONE_RADIUS = 0.01; // unit: meters
+export const SCALE = 1000; // we want to scale up the size of individual joints to milimeters, but not apply the scale to their positions
+
+// Amount of pose instances per historical HumanPoseRenderer
+export const MAX_POSE_INSTANCES = 512;
+export const MAX_POSE_INSTANCES_MOBILE = 8;
+
+// Threshold for joint confidence which determines validity of a joint and associated bones.
+export const JOINT_CONFIDENCE_THRESHOLD = 0.25;
+
+export function getBoneName(bone) {
+ return Object.keys(JOINT_CONNECTIONS).find(boneName => JOINT_CONNECTIONS[boneName] === bone);
+}
diff --git a/src/humanPose/draw.js b/src/humanPose/draw.js
new file mode 100644
index 000000000..52e3b8d06
--- /dev/null
+++ b/src/humanPose/draw.js
@@ -0,0 +1,523 @@
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+import {
+ JOINT_PUBLIC_DATA_KEYS,
+ getJointNodeInfo,
+ getGroundPlaneRelativeMatrix,
+ setMatrixFromArray
+} from './utils.js';
+import {JOINTS,JOINT_CONNECTIONS, JOINT_TO_INDEX} from './constants.js';
+import {Pose} from "./Pose.js";
+import {HumanPoseRenderInstance} from './HumanPoseRenderInstance.js';
+
+/**
+ * @typedef {string} AnimationMode
+ */
+
+/**
+ * Enum for the different clone rendering modes for the HumanPoseAnalyzer:
+ * cursor: The single historical pose at the cursor time is visible,
+ * region: A single historical pose within the highlight region is visible, it animates through the movements it made,
+ * regionAll: Every historical pose within the highlight region is visible,
+ * all: Every historical pose is visible
+ * @type {{cursor: AnimationMode, region: AnimationMode, regionAll: AnimationMode, all: AnimationMode}}
+ */
+export const AnimationMode = {
+ cursor: 'cursor',
+ region: 'region',
+ regionAll: 'regionAll',
+ all: 'all',
+};
+
+/**
+ * Processes the poseObject given and renders them into the corresponding poseRenderInstances
+ * @param {HumanPoseObject[]} poseObjects - the poseObjects to render
+ * @param {number} timestamp - the timestamp of the poseObjects
+ */
+function renderLiveHumanPoseObjects(poseObjects, timestamp) {
+ //if (is2DPoseRendered()) return;
+
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+
+ if (!activeHumanPoseAnalyzer) {
+ console.error('No active HPA');
+ return;
+ }
+
+ for (let poseObject of poseObjects) {
+ updatePoseRenderer(poseObject, timestamp);
+ }
+ activeHumanPoseAnalyzer.getLivePoseRenderer().markNeedsUpdate();
+}
+
+let hidePoseRenderInstanceTimeoutIds = {};
+
+/**
+ * Updates the corresponding poseRenderer with the poseObject given
+ * @param {HumanPoseObject} poseObject - the poseObject to render
+ * @param {number} timestamp - the timestamp of when the poseObject was recorded
+ */
+function updatePoseRenderer(poseObject, timestamp) {
+ let activeMotionStudy = realityEditor.motionStudy.getActiveMotionStudy();
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+
+ if (!activeHumanPoseAnalyzer) {
+ console.error('No active HPA');
+ return;
+ }
+
+ const renderer = activeHumanPoseAnalyzer.opaquePoseRenderer;
+ const poseRenderInstances = activeHumanPoseAnalyzer.poseRenderInstances;
+
+ const identifier = poseObject.objectId;
+ if (!poseRenderInstances[identifier]) {
+ poseRenderInstances[identifier] = new HumanPoseRenderInstance(renderer, identifier, activeHumanPoseAnalyzer.activeLens);
+ }
+ const poseRenderInstance = poseRenderInstances[identifier];
+ const hideId = activeMotionStudy.frame + '-' + poseRenderInstance.id;
+ updateJointsAndBones(poseRenderInstance, poseObject, timestamp);
+ if (hidePoseRenderInstanceTimeoutIds[hideId]) {
+ clearTimeout(hidePoseRenderInstanceTimeoutIds[hideId]);
+ hidePoseRenderInstanceTimeoutIds[hideId] = null;
+ }
+ hidePoseRenderInstanceTimeoutIds[hideId] = setTimeout(() => {
+ poseRenderInstance.remove();
+ poseRenderInstance.renderer.markNeedsUpdate();
+ delete poseRenderInstances[poseRenderInstance.id];
+ }, 1000);
+
+ renderer.markNeedsUpdate();
+}
+
+const mostRecentPoseByObjectId = {};
+
+/**
+ * Updates the pose renderer with the current pose data
+ * @param {HumanPoseRenderInstance} poseRenderInstance - the pose renderer to update
+ * @param {HumanPoseObject} poseObject - the pose object to get the data from
+ * @param {number} timestamp - when the pose was recorded
+ */
+function updateJointsAndBones(poseRenderInstance, poseObject, timestamp) {
+ let liveHumanPoseAnalyzer = realityEditor.motionStudy.getDefaultMotionStudy().humanPoseAnalyzer;
+
+ let groundPlaneRelativeMatrix = getGroundPlaneRelativeMatrix();
+
+ const jointPositions = {};
+ const jointConfidences = {};
+
+ for (const jointId of Object.values(JOINTS)) {
+ // assume that all sub-objects are of the form poseObject.id + joint name
+ let sceneNode = realityEditor.sceneGraph.getSceneNodeById(`${poseObject.objectId}${jointId}`);
+
+ // poses are in world space, three.js meshes get added to groundPlane space, so convert from world->groundPlane
+ let jointMatrixThree = new THREE.Matrix4();
+ setMatrixFromArray(jointMatrixThree, sceneNode.worldMatrix);
+ jointMatrixThree.premultiply(groundPlaneRelativeMatrix);
+
+ let jointPosition = new THREE.Vector3();
+ jointPosition.setFromMatrixPosition(jointMatrixThree);
+
+ jointPositions[jointId] = jointPosition;
+
+ let keys = getJointNodeInfo(poseObject, jointId);
+ // zero confidence if node's public data are not available
+ let confidence = 0.0;
+ if (keys) {
+ const node = poseObject.frames[keys.frameKey].nodes[keys.nodeKey];
+ if (node && node.publicData[JOINT_PUBLIC_DATA_KEYS.data].confidence !== undefined) {
+ confidence = node.publicData[JOINT_PUBLIC_DATA_KEYS.data].confidence;
+ }
+ }
+ jointConfidences[jointId] = confidence;
+ }
+
+ const poseHasParent = poseObject.parent && (poseObject.parent !== 'none');
+ const pose = new Pose(jointPositions, jointConfidences, timestamp, {poseObjectId: poseObject.objectId, poseHasParent: poseHasParent});
+ pose.metadata.previousPose = mostRecentPoseByObjectId[poseObject.objectId];
+ mostRecentPoseByObjectId[poseObject.objectId] = pose;
+ // setBodyPartValidity() needs to be called before applyLensToPose(pose) and setPose(pose)
+ pose.setBodyPartValidity(liveHumanPoseAnalyzer.getJointConfidenceThreshold());
+ liveHumanPoseAnalyzer.activeLens.applyLensToPose(pose);
+ poseRenderInstance.setPose(pose);
+ poseRenderInstance.setLens(liveHumanPoseAnalyzer.activeLens);
+ poseRenderInstance.setVisible(liveHumanPoseAnalyzer.childHumanObjectsVisible || !poseHasParent);
+ poseRenderInstance.renderer.markNeedsUpdate();
+
+ liveHumanPoseAnalyzer.poseUpdated(pose, false);
+}
+
+/**
+ * Resets the HumanPoseAnalyzer's live history lines
+ */
+function resetLiveHistoryLines() {
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!activeHumanPoseAnalyzer) {
+ console.warn('No active HPA');
+ return;
+ }
+ activeHumanPoseAnalyzer.resetLiveHistoryLines();
+}
+
+/**
+ * Resets the HumanPoseAnalyzer's live history lines
+ * @deprecated
+ * @see resetLiveHistoryLines
+ */
+function resetHistoryLines() {
+ console.warn('resetHistoryLines is deprecated, use resetLiveHistoryLines instead');
+ resetLiveHistoryLines();
+}
+
+/**
+ * Resets the HumanPoseAnalyzer's live history clones
+ */
+function resetLiveHistoryClones() {
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!activeHumanPoseAnalyzer) {
+ console.warn('No active HPA');
+ return;
+ }
+ activeHumanPoseAnalyzer.resetLiveHistoryClones();
+}
+
+/**
+ * Resets the HumanPoseAnalyzer's live history clones
+ * @deprecated
+ * @see resetLiveHistoryClones
+ */
+function resetHistoryClones() {
+ console.warn('resetHistoryClones is deprecated, use resetLiveHistoryClones instead');
+ resetLiveHistoryClones();
+}
+
+/**
+ * Gets the poses that are within the given time interval
+ * @param {number} firstTimestamp - start of time interval in ms
+ * @param {number} secondTimestamp - end of time interval in ms
+ * @return {Pose[]} - the poses that are within the given time interval
+ */
+function getPosesInTimeInterval(firstTimestamp, secondTimestamp) {
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!activeHumanPoseAnalyzer) {
+ return [];
+ }
+ return activeHumanPoseAnalyzer.getPosesInTimeInterval(firstTimestamp, secondTimestamp);
+}
+
+/**
+ * Sets the visibility of the historical history lines
+ * @param {boolean} visible - whether to show the historical history lines
+ */
+function setHistoricalHistoryLinesVisible(visible) {
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!activeHumanPoseAnalyzer) {
+ console.warn('No active HPA');
+ return;
+ }
+ activeHumanPoseAnalyzer.setHistoricalHistoryLinesVisible(visible);
+}
+
+/**
+ * Sets the visibility of the live history lines
+ * @param {boolean} visible - whether to show the live history lines
+ */
+function setLiveHistoryLinesVisible(visible) {
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!activeHumanPoseAnalyzer) {
+ console.warn('No active HPA');
+ return;
+ }
+ activeHumanPoseAnalyzer.setLiveHistoryLinesVisible(visible);
+}
+
+/**
+ * @param {AnimationMode} animationMode
+ */
+function setAnimationMode(animationMode) {
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!activeHumanPoseAnalyzer) {
+ console.warn('No active HPA');
+ return;
+ }
+ activeHumanPoseAnalyzer.setAnimationMode(animationMode);
+}
+
+/**
+ * Sets the clone rendering mode // TODO: not in use, remove?
+ * @param {boolean} enabled - whether to render all clones or just one
+ */
+function setRecordingClonesEnabled(enabled) {
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!activeHumanPoseAnalyzer) {
+ console.warn('No active HPA');
+ return;
+ }
+
+ if (enabled) {
+ activeHumanPoseAnalyzer.setAnimationMode(AnimationMode.all);
+ } else {
+ activeHumanPoseAnalyzer.setAnimationMode(AnimationMode.cursor);
+ }
+}
+
+/**
+ * Advances the human pose analyzer's motion study lens
+ */
+function advanceLens() {
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!activeHumanPoseAnalyzer) {
+ console.warn('No active HPA');
+ return;
+ }
+
+ activeHumanPoseAnalyzer.advanceLens();
+}
+
+/**
+ * Advances the human pose analyzer's clone material
+ * @deprecated
+ * @see advanceLens
+ */
+function advanceCloneMaterial() {
+ console.warn('advanceCloneMaterial is deprecated, use advanceLens instead');
+ advanceLens();
+}
+
+/**
+ * Sets the hover time for the HumanPoseAnalyzer
+ * @param {number} time - the hover time in ms
+ * @param {boolean} fromSpaghetti - prevents infinite recursion from modifying human pose spaghetti which calls this
+ * function
+ */
+function setCursorTime(time, fromSpaghetti) {
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!activeHumanPoseAnalyzer) {
+ console.warn('No active HPA');
+ return;
+ }
+ activeHumanPoseAnalyzer.setCursorTime(time, fromSpaghetti);
+}
+
+/**
+ * Shows the HumanPoseAnalyzer's settings UI
+ */
+function showAnalyzerSettingsUI() {
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!activeHumanPoseAnalyzer) {
+ console.warn('No active HPA');
+ return;
+ }
+ if (activeHumanPoseAnalyzer.settingsUi) {
+ activeHumanPoseAnalyzer.settingsUi.show();
+ }
+}
+
+/**
+ * Hides the HumanPoseAnalyzer's settings UI
+ */
+function hideAnalyzerSettingsUI() {
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!activeHumanPoseAnalyzer) {
+ console.warn('No active HPA');
+ return;
+ }
+ if (activeHumanPoseAnalyzer.settingsUi) {
+ activeHumanPoseAnalyzer.settingsUi.hide();
+ }
+}
+
+/**
+ * Toggles the HumanPoseAnalyzer's settings UI
+ * Used by the remote operator menu bar
+ */
+function toggleAnalyzerSettingsUI() {
+ let defaultHumanPoseAnalyzer = realityEditor.motionStudy.getDefaultMotionStudy().humanPoseAnalyzer;
+ if (!defaultHumanPoseAnalyzer) {
+ console.warn('No default HPA');
+ return;
+ }
+ if (defaultHumanPoseAnalyzer.settingsUi) {
+ defaultHumanPoseAnalyzer.settingsUi.toggle();
+ }
+}
+
+/**
+ * Sets the visibility of the child human pose objects
+ * Note: Used in live mode so far
+ * @param {boolean} visible - whether to show or not
+ */
+function setChildHumanPosesVisible(visible) {
+ let activeHumanPoseAnalyzer = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (!activeHumanPoseAnalyzer) {
+ console.warn('No active HPA');
+ return;
+ }
+ activeHumanPoseAnalyzer.childHumanObjectsVisible = visible;
+ if (activeHumanPoseAnalyzer.settingsUi) {
+ activeHumanPoseAnalyzer.settingsUi.setChildHumanPosesVisible(visible);
+ }
+}
+
+const DEBUG = false;
+/**
+ * Determines whether 2D pose is or can be rendered at all on videobackground (possible on mobile devices only)
+ */
+function is2DPoseRendered() {
+ return DEBUG && !realityEditor.device.environment.requiresMouseEvents();
+}
+
+/**
+ * Renders original 2D skeleton from Swift side (for debug purposes).
+ * @param {Array} poses
+ * @param {[number, number]} imageSize
+ */
+function draw2DPoses(poses, imageSize) {
+
+ if (!is2DPoseRendered()) return;
+
+ let canvas = document.getElementById('supercooldebugcanvas');
+ let gfx;
+ if (!canvas) {
+ canvas = document.createElement('canvas');
+ canvas.id = 'supercooldebugcanvas';
+ canvas.style.position = 'absolute';
+ canvas.style.top = 0;
+ canvas.style.left = 0;
+ canvas.width = canvas.style.width = window.innerWidth;
+ canvas.height = canvas.style.height = window.innerHeight;
+ canvas.style.margin = 0;
+ canvas.style.padding = 0;
+ canvas.style.pointerEvents = 'none';
+ document.body.appendChild(canvas);
+ gfx = canvas.getContext('2d');
+ gfx.width = window.innerWidth;
+ gfx.height = window.innerHeight;
+ }
+
+ if (!gfx) {
+ gfx = canvas.getContext('2d');
+ }
+ gfx.clearRect(0, 0, gfx.width, gfx.height);
+ gfx.fillStyle = '#00ffff';
+ gfx.font = '16px sans-serif';
+ gfx.strokeStyle = '#00ffff';
+ gfx.lineWidth = 1;
+
+ const jointSize = 5;
+
+ if (poses.length === 0) {
+ return;
+ }
+
+ // following naming is for landscape images (width is a longer side)
+ // image resolution associated with 2D point positions (always defined in landscape - x: longer side, y: shorter side)
+ const pointWidth = imageSize[0]; // 1920; // 960;
+ const pointHeight = imageSize[1]; // 1080; // 540;
+ let outWidth = 0, outHeight = 0;
+ let halfCanvasWidth = 0, halfCanvasHeight = 0;
+ const portrait = gfx.width < gfx.height;
+
+ if (globalStates.device.startsWith('iPad')) {
+ // ipads crop camera image along longer side. Thus, shorter side is taken to calulate scaling factor from camera image to display canvas
+ if (!portrait) {
+ outHeight = gfx.height;
+ outWidth = (outHeight / pointHeight) * pointWidth;
+ }
+ else {
+ outHeight = gfx.width;
+ outWidth = (outHeight / pointHeight) * pointWidth;
+ }
+ }
+ else {
+ // iphones crop camera image along shorter side. Thus, longer side is taken to calulate scaling factor from camera image to display canvas (gfx.width/height)
+ if (!portrait) {
+ outWidth = gfx.width;
+ outHeight = (outWidth / pointWidth) * pointHeight;
+ }
+ else {
+ outWidth = gfx.height;
+ outHeight = (outWidth / pointWidth) * pointHeight;
+ }
+ }
+
+ if (!portrait) {
+ halfCanvasWidth = gfx.width / 2;
+ halfCanvasHeight = gfx.height / 2;
+ }
+ else {
+ halfCanvasWidth = gfx.height / 2;
+ halfCanvasHeight = gfx.width / 2;
+ }
+
+ // gfx.fillText(`${format(coords[0].x)} ${format(coords[0].y)} ${format(coords[0].z)} ${format(poses[0].rotX * 180 / Math.PI)} ${format(poses[0].rotY * 180 / Math.PI)}`, 16, 64);
+ let debug = false;
+ let points2D = [];
+ for (let point of poses) {
+ gfx.beginPath();
+
+ let x = (point.imgX - pointWidth / 2) * (outWidth / pointWidth) + halfCanvasWidth;
+ let y = 0;
+ if (portrait) {
+ y = ((pointHeight - point.imgY) - pointHeight / 2) * (outHeight / pointHeight) + halfCanvasHeight;
+ let tmp = x; x = y; y = tmp;
+ }
+ else {
+ y = (point.imgY - pointHeight / 2) * (outHeight / pointHeight) + halfCanvasHeight;
+ }
+
+ let valid = (Math.abs(point.imgX) > 1e-6 && Math.abs(point.imgY) > 1e-6);
+ points2D.push([x, y, valid]);
+
+ // point 2D position is not valid if it is [0,0]. Don't draw in this case.
+ if (valid) {
+ gfx.fillStyle = `hsl(180, 100%, ${point.score * 50.0}%`;
+ gfx.arc(x, y, jointSize, 0, 2 * Math.PI);
+ gfx.fill();
+ if (debug) {
+ gfx.fillText(`${Math.round(point.imgX)} ${Math.round(point.imgY)}`, x + jointSize, y - jointSize);
+ debug = false;
+ }
+ }
+ }
+
+ gfx.fillStyle = '#00ffff';
+ gfx.strokeStyle = '#00ffff';
+ gfx.lineWidth = 3;
+
+ gfx.beginPath();
+ let conns = Object.values(JOINT_CONNECTIONS);
+ for (let i = 0; i < 58; i++) { // skipping connections between synthetic joints which do not exist yet
+
+ let a = points2D[JOINT_TO_INDEX[conns[i][0]]];
+ let b = points2D[JOINT_TO_INDEX[conns[i][1]]];
+
+ // skip draw lines between invalid points
+ if (a[2] && b[2]) {
+ gfx.moveTo(a[0], a[1]);
+ gfx.lineTo(b[0], b[1]);
+ }
+ }
+ gfx.stroke();
+}
+
+// TODO: Remove deprecated API use
+export {
+ renderLiveHumanPoseObjects,
+ resetLiveHistoryLines,
+ resetHistoryLines,
+ resetLiveHistoryClones,
+ resetHistoryClones,
+ setAnimationMode,
+ setCursorTime,
+ setLiveHistoryLinesVisible,
+ setHistoricalHistoryLinesVisible,
+ setRecordingClonesEnabled,
+ advanceLens,
+ advanceCloneMaterial,
+ getPosesInTimeInterval,
+ showAnalyzerSettingsUI,
+ hideAnalyzerSettingsUI,
+ toggleAnalyzerSettingsUI,
+ setChildHumanPosesVisible,
+ draw2DPoses,
+ is2DPoseRendered
+};
diff --git a/src/humanPose/index.js b/src/humanPose/index.js
new file mode 100644
index 000000000..2544e1c1f
--- /dev/null
+++ b/src/humanPose/index.js
@@ -0,0 +1,521 @@
+import * as THREE from "../../thirdPartyCode/three/three.module.js";
+
+createNameSpace("realityEditor.humanPose");
+
+import * as network from './network.js'
+import * as draw from './draw.js'
+import * as utils from './utils.js'
+import {JOINTS, JOINTS_V1_COUNT, JOINTS_V2_COUNT, JOINTS_PER_POSE} from "./constants.js";
+import {Pose} from "./Pose.js";
+
+(function(exports) {
+ // Re-export submodules for use in legacy code
+ exports.network = network;
+ exports.draw = draw;
+ exports.utils = utils;
+
+ const MAX_FPS = 20;
+ const IDLE_TIMEOUT_MS = 2000;
+
+ let myHumanPoseId = null; // objectId
+
+ let humanPoseObjects = {};
+ let nameIdMap = {};
+ let lastRenderTime = Date.now();
+ let lastUpdateTime = Date.now();
+ let lastRenderedPoses = {};
+ let inHistoryPlayback = false;
+
+ function initService() {
+ realityEditor.app.callbacks.subscribeToPoses((poseJoints, frameData) => {
+ let pose = utils.makePoseData('device' + globalStates.tempUuid + '_pose1', poseJoints, frameData);
+ let poseObjectName = utils.getPoseObjectName(pose);
+
+ if (typeof nameIdMap[poseObjectName] === 'undefined') {
+ //create new human object only if pose is detected
+ if (pose.joints.length > 0) {
+ tryCreatingObjectFromPose(poseObjectName);
+ }
+ } else {
+ let objectId = nameIdMap[poseObjectName];
+ if (humanPoseObjects[objectId]) {
+ tryUpdatingPoseObject(pose, humanPoseObjects[objectId]);
+ }
+ }
+ });
+
+ network.onHumanPoseObjectDiscovered((object, objectKey) => {
+ handleDiscoveredHumanPose(object, objectKey);
+ });
+
+ network.onHumanPoseObjectDeleted((objectKey) => {
+ let objectToDelete = humanPoseObjects[objectKey];
+ if (!objectToDelete) return;
+
+ delete nameIdMap[objectToDelete.name];
+ delete humanPoseObjects[objectKey];
+ // TODO: clean out live pose render instance for this object
+ });
+
+ realityEditor.gui.ar.draw.addUpdateListener(() => {
+ if (inHistoryPlayback) {
+ return;
+ }
+
+ try {
+ // main update runs at ~60 FPS, but we can save some compute by limiting the pose rendering FPS
+ if (Date.now() - lastRenderTime < (1000.0 / MAX_FPS)) return;
+ lastRenderTime = Date.now();
+
+ if (lastRenderTime - lastUpdateTime > IDLE_TIMEOUT_MS) {
+ // Clear out all human pose renderers because we've
+ // received no updates from any of them
+ draw.renderLiveHumanPoseObjects([], Date.now());
+ lastUpdateTime = Date.now();
+ return;
+ }
+
+ // further reduce rendering redundant poses by only rendering pose data that has been updated
+ let updatedHumanPoseObjects = [];
+ for (const [id, obj] of Object.entries(humanPoseObjects)) {
+ let newPoseHash = utils.getPoseStringFromObject(obj);
+ if (typeof lastRenderedPoses[id] === 'undefined') {
+ updatedHumanPoseObjects.push(obj);
+ lastRenderedPoses[id] = newPoseHash;
+ }
+ else {
+ if (newPoseHash !== lastRenderedPoses[id]) {
+ updatedHumanPoseObjects.push(obj);
+ lastRenderedPoses[id] = newPoseHash;
+ }
+ }
+ }
+ if (updatedHumanPoseObjects.length == 0) return;
+
+ lastUpdateTime = Date.now();
+
+ draw.renderLiveHumanPoseObjects(updatedHumanPoseObjects, Date.now());
+
+ } catch (e) {
+ console.warn('error in renderLiveHumanPoseObjects', e);
+ }
+ });
+ }
+
+ function applyDiffRecur(objects, diff) {
+ let diffKeys = Object.keys(diff);
+ for (let key of diffKeys) {
+ if (diff[key] === null) {
+ continue; // JSON encodes undefined as null so just skip (problem if we try to encode null)
+ }
+ if (typeof diff[key] === 'object' && objects.hasOwnProperty(key)) {
+ applyDiffRecur(objects[key], diff[key]);
+ continue;
+ }
+ objects[key] = diff[key];
+ }
+ }
+
+ function applyDiff(objects, diff) {
+ applyDiffRecur(objects, diff);
+ }
+
+ /**
+ * @param {TimeRegion} historyRegion
+ * @param {MotionStudy} motionStudy
+ */
+ async function loadHistory(historyRegion, motionStudy) {
+ if (!realityEditor.sceneGraph || !realityEditor.sceneGraph.getWorldId() || !realityEditor.device || !realityEditor.device.environment) {
+ setTimeout(() => {
+ loadHistory(historyRegion, motionStudy);
+ }, 500);
+ return;
+ }
+ if (!realityEditor.device.environment.isDesktop()) {
+ return;
+ }
+ const regionStartTime = historyRegion.startTime;
+ const regionEndTime = historyRegion.endTime;
+
+ const worldObject = realityEditor.worldObjects.getBestWorldObject();
+ const historyLogsUrl = realityEditor.network.getURL(worldObject.ip, realityEditor.network.getPort(worldObject), '/history/logs');
+ let logs = [];
+ for (let retry = 0; retry < 3; retry++) {
+ try {
+ const resLogs = await fetch(historyLogsUrl);
+ logs = await resLogs.json();
+ break;
+ } catch (e) {
+ console.error('Unable to load list of history logs', e);
+ }
+ }
+
+ for (const logName of logs) {
+ let matches = logName.match(/objects_(\d+)-(\d+)/);
+ if (!matches) {
+ continue;
+ }
+ let logStartTime = parseInt(matches[1]);
+ let logEndTime = parseInt(matches[2]);
+ if (isNaN(logStartTime) || isNaN(logEndTime)) {
+ continue;
+ }
+ if (logEndTime < regionStartTime) {
+ continue;
+ }
+ if (logStartTime > regionEndTime && regionEndTime >= 0) {
+ continue;
+ }
+ let log;
+ for (let retry = 0; retry < 3; retry++) {
+ try {
+ const resLog = await fetch(`${historyLogsUrl}/${logName}`);
+ log = await resLog.json();
+ break;
+ } catch (e) {
+ console.error('Unable to fetch history log', `${historyLogsUrl}/${logName}`, e);
+ }
+ }
+ if (log) {
+ await replayHistory(log, motionStudy);
+ } else {
+ console.error('Unable to load history log after retries', `${historyLogsUrl}/${logName}`);
+ }
+ }
+
+ motionStudy.humanPoseAnalyzer.markHistoricalColorNeedsUpdate();
+ }
+
+ async function replayHistory(history, motionStudy) {
+ inHistoryPlayback = true;
+ const timeObjects = {};
+ const timestampStrings = Object.keys(history);
+ const poses = [];
+ const mostRecentPoseByObjectId = {};
+ timestampStrings.forEach(timestampString => {
+ let historyEntry = history[timestampString];
+ let objectNames = Object.keys(historyEntry);
+ let presentHumanNames = objectNames.filter(name => name.startsWith('_HUMAN_'));
+ applyDiff(timeObjects, historyEntry);
+ if (presentHumanNames.length === 0) {
+ return;
+ }
+ for (let objectName of presentHumanNames) {
+ const poseObject = timeObjects[objectName];
+ let groundPlaneRelativeMatrix = utils.getGroundPlaneRelativeMatrix();
+ let jointPositions = {};
+ let jointConfidences = {};
+ if (poseObject.matrix && poseObject.matrix.length > 0) {
+ let objectRootMatrix = new THREE.Matrix4();
+ utils.setMatrixFromArray(objectRootMatrix, poseObject.matrix);
+ groundPlaneRelativeMatrix.multiply(objectRootMatrix);
+ }
+
+ for (let jointId of Object.values(JOINTS)) {
+ let frame = poseObject.frames[poseObject.objectId + jointId];
+ if (!frame || !frame.ar.matrix) {
+ continue;
+ }
+ // poses are in world space, three.js meshes get added to groundPlane space, so convert from world->groundPlane
+ let jointMatrixThree = new THREE.Matrix4();
+ utils.setMatrixFromArray(jointMatrixThree, frame.ar.matrix);
+ jointMatrixThree.premultiply(groundPlaneRelativeMatrix);
+ let jointPosition = new THREE.Vector3();
+ jointPosition.setFromMatrixPosition(jointMatrixThree);
+ jointPositions[jointId] = jointPosition;
+
+ let keys = utils.getJointNodeInfo(poseObject, jointId);
+ // zero confidence if node's public data are not available
+ let confidence = 0.0;
+ if (keys) {
+ const node = poseObject.frames[keys.frameKey].nodes[keys.nodeKey];
+ if (node && node.publicData[utils.JOINT_PUBLIC_DATA_KEYS.data].confidence !== undefined) {
+ confidence = node.publicData[utils.JOINT_PUBLIC_DATA_KEYS.data].confidence;
+ }
+ }
+ jointConfidences[jointId] = confidence;
+ }
+ let length = Object.keys(jointPositions).length;
+ if (length === 0) {
+ return;
+ }
+ if (length !== JOINTS_PER_POSE) {
+ if (length == JOINTS_V1_COUNT) {
+ utils.convertFromJointsV1(jointPositions, jointConfidences);
+ }
+ else if (length == JOINTS_V2_COUNT) {
+ utils.convertFromJointsV2(jointPositions, jointConfidences);
+ }
+ else {
+ console.error('Unknown joint schema of a recorded pose.');
+ return;
+ }
+ }
+
+ const identifier = `historical-${poseObject.objectId}`; // This is necessary to distinguish between data recorded live and by a tool at the same time
+ const timestamp = Math.round(poseObject.lastUpdateDataTS); // parseInt(timestampString)
+ const pose = new Pose(jointPositions, jointConfidences, timestamp, {
+ poseObjectId: identifier,
+ poseHasParent: poseObject.parent && (poseObject.parent !== 'none'),
+ });
+ pose.metadata.previousPose = mostRecentPoseByObjectId[poseObject.objectId];
+ mostRecentPoseByObjectId[poseObject.objectId] = pose;
+ poses.push(pose);
+ }
+ });
+ motionStudy.bulkRenderHistoricalPoses(poses);
+ inHistoryPlayback = false;
+ }
+
+ /**
+ * @param {Array<{x: number, y: number, z: number, confidence: number}>} input joints
+ * @return {{x: number, y: number, z: number, confidence: number}} average attributes of all
+ * input joints
+ */
+ function averageJoints(joints) {
+ let avg = { x: 0, y: 0, z: 0, confidence: 0 };
+ for (let joint of joints) {
+ avg.x += joint.x;
+ avg.y += joint.y;
+ avg.z += joint.z;
+ avg.confidence += joint.confidence;
+ }
+ avg.x /= joints.length;
+ avg.y /= joints.length;
+ avg.z /= joints.length;
+ avg.confidence /= joints.length;
+ return avg;
+ }
+
+ /**
+ * @param {Array<{x: number, y: number, z: number, confidence: number}>} joints - all joints
+ * @param {Array} jointNames - selected joint names
+ * @return {Array<{x: number, y: number, z: number, confidence: number}>} selected joints
+ */
+ function extractJoints(joints, jointNames) {
+ let arr = [];
+ for (let name of jointNames) {
+ let index = Object.values(JOINTS).indexOf(name);
+ arr.push(joints[index]);
+ }
+ return arr;
+ }
+
+ /** Extends original tracked set of joints with derived synthetic joints
+ * @param {Object} pose - 23 real joints
+ */
+ function addSyntheticJoints(pose) {
+
+ if (pose.joints.length <= 0) {
+ // if no pose is detected, cannot add
+ return;
+ }
+
+ // head
+ pose.joints.push(averageJoints(extractJoints(pose.joints, [
+ JOINTS.LEFT_EAR,
+ JOINTS.RIGHT_EAR,
+ ])));
+ // neck
+ pose.joints.push(averageJoints(extractJoints(pose.joints, [
+ JOINTS.LEFT_SHOULDER,
+ JOINTS.RIGHT_SHOULDER,
+ ])));
+ // chest
+ pose.joints.push(averageJoints(extractJoints(pose.joints, [
+ JOINTS.LEFT_SHOULDER,
+ JOINTS.RIGHT_SHOULDER,
+ JOINTS.LEFT_SHOULDER,
+ JOINTS.RIGHT_SHOULDER,
+ JOINTS.LEFT_HIP,
+ JOINTS.RIGHT_HIP,
+ ])));
+ // navel
+ pose.joints.push(averageJoints(extractJoints(pose.joints, [
+ JOINTS.LEFT_SHOULDER,
+ JOINTS.RIGHT_SHOULDER,
+ JOINTS.LEFT_HIP,
+ JOINTS.RIGHT_HIP,
+ JOINTS.LEFT_HIP,
+ JOINTS.RIGHT_HIP,
+ ])));
+ // pelvis
+ pose.joints.push(averageJoints(extractJoints(pose.joints, [
+ JOINTS.LEFT_HIP,
+ JOINTS.RIGHT_HIP,
+ ])));
+ }
+
+ function updateObjectFromRawPose(humanPoseObject, pose) {
+
+ if (pose.joints.length <= 0) {
+ // if no pose is detected, don't update (even update timestamp)
+ return;
+ }
+
+ // store timestamp of update in the object (this is capture time of the image used to compute the pose in this update)
+ humanPoseObject.lastUpdateDataTS = pose.timestamp;
+
+ // update overall object position (currently defined by 1. joint - nose)
+ var objPosition = {
+ x: pose.joints[0].x,
+ y: pose.joints[0].y,
+ z: pose.joints[0].z
+ };
+
+ humanPoseObject.matrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ objPosition.x, objPosition.y, objPosition.z, 1
+ ];
+
+ // updating scene graph with new pose
+ let objectSceneNode = realityEditor.sceneGraph.getSceneNodeById(humanPoseObject.objectId);
+ objectSceneNode.dontBroadcastNext = true; // this will prevent broadcast of matrix to remote servers in the function call below
+ objectSceneNode.setLocalMatrix(humanPoseObject.matrix);
+
+ // update relative positions of all joints/frames wrt. object positions
+ pose.joints.forEach((jointInfo, index) => {
+ let jointName = Object.values(JOINTS)[index];
+ let frameId = Object.keys(humanPoseObject.frames).find(key => {
+ return key.endsWith(jointName);
+ });
+ if (!frameId) {
+ console.warn('couldn\'t find frame for joint ' + jointName + ' (' + index + ')');
+ return;
+ }
+
+ // set position of jointFrame
+ let positionMatrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ jointInfo.x - objPosition.x, jointInfo.y - objPosition.y, jointInfo.z - objPosition.z, 1,
+ ];
+
+ // updating scene graph with new pose
+ let frameSceneNode = realityEditor.sceneGraph.getSceneNodeById(frameId);
+ frameSceneNode.dontBroadcastNext = true; // this will prevent broadcast of matrix to remote servers in the function call below
+ frameSceneNode.setLocalMatrix(positionMatrix);
+
+ // updating a node data of tool/frame of a joint
+ let keys = utils.getJointNodeInfo(humanPoseObject, jointName);
+ if (keys) {
+ let node = realityEditor.getNode(keys.objectKey, keys.frameKey, keys.nodeKey);
+ if (node) {
+ node.publicData[utils.JOINT_PUBLIC_DATA_KEYS.data] = { confidence: jointInfo.confidence };
+ }
+ }
+ });
+
+ }
+
+ function tryUpdatingPoseObject(pose, humanPoseObject) {
+
+ //console.log('try updating pose object', pose, humanPoseObject);
+
+ addSyntheticJoints(pose);
+
+ // update local instance of HumanPoseObject with new pose data
+ updateObjectFromRawPose(humanPoseObject, pose);
+
+ // updating a 'transfer' node data of selected joint (the first one at the moment).
+ // This public data contain the whole pose (joint 3D positions and confidences) to transfer in one go to servers
+ let keys = utils.getJointNodeInfo(humanPoseObject, JOINTS.NOSE);
+ if (keys) {
+ realityEditor.network.realtime.writePublicData(keys.objectKey, keys.frameKey, keys.nodeKey, utils.JOINT_PUBLIC_DATA_KEYS.transferData, pose);
+ }
+
+ }
+
+ let objectsInProgress = {};
+
+ function tryCreatingObjectFromPose(poseObjectName) {
+
+ if (objectsInProgress[poseObjectName]) { return; }
+ objectsInProgress[poseObjectName] = true;
+
+ let worldObject = realityEditor.worldObjects.getBestWorldObject(); // subscribeToPoses only triggers after we localize within a world
+
+ realityEditor.network.utilities.verifyObjectNameNotOnWorldServer(worldObject, poseObjectName, () => {
+ network.addHumanPoseObject(worldObject.objectId, poseObjectName, (data) => {
+ nameIdMap[poseObjectName] = data.id;
+ myHumanPoseId = data.id;
+ delete objectsInProgress[poseObjectName];
+
+ }, (err) => {
+ console.warn('unable to add human pose object to server', err);
+ delete objectsInProgress[poseObjectName];
+
+ });
+ }, () => {
+ console.warn('human pose already exists on server');
+ delete objectsInProgress[poseObjectName];
+
+ });
+ }
+
+ // initialize the human pose object
+ function handleDiscoveredHumanPose(object, objectKey) {
+ if (!utils.isHumanPoseObject(object)) { return; }
+ if (typeof humanPoseObjects[objectKey] !== 'undefined') { return; }
+ humanPoseObjects[objectKey] = object; // keep track of which human pose objects we've processed so far
+
+
+ if (objectKey === myHumanPoseId) {
+ // no action for now
+ } else {
+ // subscribe to public data of a selected joint node in remote HumanPoseObject which transfers whole pose
+ let keys = utils.getJointNodeInfo(object, JOINTS.NOSE);
+ if (!keys) { return; }
+
+ let subscriptionCallback = (msgContent) => {
+ // update public data of node in local human pose object
+ let node = realityEditor.getNode(msgContent.object, msgContent.frame, msgContent.node);
+ if (!node) {
+ console.warn('couldn\'t find the node ' + msgContent.node + ' which stores whole pose data');
+ return;
+ }
+ // MK TODO: is it necessary to store all transfered data into the node of local object? on top of updateObjectFromRawPose below?
+ node.publicData[utils.JOINT_PUBLIC_DATA_KEYS.transferData] = msgContent.publicData[utils.JOINT_PUBLIC_DATA_KEYS.transferData];
+
+ let object = realityEditor.getObject(msgContent.object)
+ if (!object) {
+ console.warn('couldn\'t find the human pose object ' + msgContent.object);
+ return;
+ }
+
+ // update local instance of HumanPoseObject with new pose data transferred through a selected node
+ updateObjectFromRawPose(object, node.publicData[utils.JOINT_PUBLIC_DATA_KEYS.transferData]);
+ }
+
+ realityEditor.network.realtime.subscribeToPublicData(keys.objectKey, keys.frameKey, keys.nodeKey, utils.JOINT_PUBLIC_DATA_KEYS.transferData, (msg) => {
+ subscriptionCallback(JSON.parse(msg));
+ });
+ }
+ }
+
+ function deleteLocalHumanObjects() {
+ myHumanPoseId = null;
+
+ for (let objectId of Object.values(nameIdMap)) {
+ delete humanPoseObjects[objectId];
+ delete realityEditor.objects[objectId];
+ }
+ nameIdMap = {}
+ }
+
+ function returnHumanPoseObjects() {
+ return humanPoseObjects;
+ }
+
+ exports.initService = initService;
+ exports.loadHistory = loadHistory;
+ exports.deleteLocalHumanObjects = deleteLocalHumanObjects;
+ exports.returnHumanPoseObjects = returnHumanPoseObjects;
+
+}(realityEditor.humanPose));
+
+export const initService = realityEditor.humanPose.initService;
+export const loadHistory = realityEditor.humanPose.loadHistory;
diff --git a/src/humanPose/network.js b/src/humanPose/network.js
new file mode 100644
index 000000000..3562606e4
--- /dev/null
+++ b/src/humanPose/network.js
@@ -0,0 +1,54 @@
+import {isHumanPoseObject} from './utils.js';
+import {JOINTS} from './constants.js';
+
+// Tell the server (corresponding to this world object) to create a new human object with the specified ID
+function addHumanPoseObject(worldId, objectName, onSuccess, onError) {
+ let worldObject = realityEditor.getObject(worldId);
+ if (!worldObject) {
+ console.warn('Unable to add human pose object because no world with ID: ' + worldId);
+ return;
+ }
+
+ let postUrl = realityEditor.network.getURL(worldObject.ip, realityEditor.network.getPort(worldObject), '/');
+ let poseJointSchema = JSON.stringify(JOINTS);
+ let params = new URLSearchParams({action: 'new', name: objectName, isHuman: JSON.stringify(true), worldId: worldId, poseJointSchema: poseJointSchema});
+ // TODO: we may need to include the pose joints or at least a list of which joints are provided by this source
+ fetch(postUrl, {
+ method: 'POST',
+ body: params
+ }).then(response => response.json())
+ .then(data => {
+ onSuccess(data);
+ }).catch(err => {
+ onError(err);
+ });
+}
+
+// helper function that will trigger the callback for each human pose object previously or in-future discovered
+function onHumanPoseObjectDiscovered(callback) {
+ // first check if any previously discovered objects are human poses
+ for (let [objectKey, object] of Object.entries(objects)) {
+ if (isHumanPoseObject(object)) {
+ callback(object, objectKey);
+ }
+ }
+
+ // next, listen to newly discovered objects
+ realityEditor.network.addObjectDiscoveredCallback(function(object, objectKey) {
+ if (isHumanPoseObject(object)) {
+ callback(object, objectKey);
+ }
+ });
+}
+
+function onHumanPoseObjectDeleted(callback) {
+ realityEditor.network.registerCallback('objectDeleted', (params) => {
+ callback(params.objectKey);
+ });
+}
+
+export {
+ addHumanPoseObject,
+ onHumanPoseObjectDiscovered,
+ onHumanPoseObjectDeleted,
+};
diff --git a/src/humanPose/rebaScore.js b/src/humanPose/rebaScore.js
new file mode 100644
index 000000000..0c68e3485
--- /dev/null
+++ b/src/humanPose/rebaScore.js
@@ -0,0 +1,982 @@
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+import {JOINT_CONNECTIONS, JOINTS, getBoneName, TRACK_HANDS} from './constants.js';
+import {MotionStudyColors} from "./MotionStudyColors.js";
+
+// https://www.physio-pedia.com/Rapid_Entire_Body_Assessment_(REBA)
+// https://ergo-plus.com/reba-assessment-tool-guide/
+// ^ Sample REBA scoring tables
+
+/** Calculations assume human poses defined in Y-up CS. */
+
+/**
+ * Clamp a value between a minimum and maximum.
+ * @param {number} value The value to clamp.
+ * @param {number} min The minimum value.
+ * @param {number} max The maximum value.
+ * @return {number} The clamped value.
+ */
+function clamp(value, min, max) {
+ return Math.min(Math.max(value, min), max);
+}
+
+/**
+ * Calculates the angle between two vectors in degrees.
+ * @param {THREE.Vector3} vector1 The first vector.
+ * @param {THREE.Vector3} vector2 The second vector.
+ * @return {number} The angle between the two vectors in degrees [0, +180].
+ */
+function angleBetween(vector1, vector2) {
+ return vector1.angleTo(vector2) * 180 / Math.PI;
+}
+
+/**
+ * Sets the score and color for the neck reba.
+ * Starting with score=1
+ * +1 for forward bending > 20 degrees or backward bending > 5 degrees
+ * +1 if side bending or twisting wrt shoulders
+ * @param {RebaData} rebaData The rebaData to calculate the score and color for.
+ */
+function neckReba(rebaData) {
+ let neckScore = 1;
+ let neckColor = MotionStudyColors.undefined;
+
+ // NOTE: not checking if all needed joints have a valid position (head and neck joints are always valid)
+
+ const headUp = rebaData.orientations.head.up;
+ const headForward = rebaData.orientations.head.forward;
+
+ // +1 for side-bending (greater than 20 degrees), back-bending (any degrees), twisting, or greater than 20 degrees in general
+ const upMisalignmentAngle = angleBetween(headUp, rebaData.orientations.chest.up);
+ // all vectors are normalised, so dot() == cos(angle between vectors)
+ const forwardBendingAlignment = headUp.clone().dot(rebaData.orientations.chest.forward);
+ const backwardBendingAlignment = headUp.clone().dot(rebaData.orientations.chest.forward.clone().negate());
+ const rightBendingAlignment = headUp.clone().dot(rebaData.orientations.chest.right);
+ const leftBendingAlignment = headUp.clone().dot(rebaData.orientations.chest.right.clone().negate());
+
+ // Check for bending
+ let sideBend = false;
+ const bendingThreshold = 20;
+ if (upMisalignmentAngle > bendingThreshold) {
+ neckScore++; // +1 for greater than threshold
+
+ // check for side-bending only when above the overall bending threshold
+ // true when above +-45 deg from chest forward or chest backward direction (when looking from above)
+ sideBend = ((forwardBendingAlignment < rightBendingAlignment || forwardBendingAlignment < leftBendingAlignment) &&
+ (backwardBendingAlignment < rightBendingAlignment || backwardBendingAlignment < leftBendingAlignment));
+ } else {
+ if (forwardBendingAlignment < backwardBendingAlignment &&
+ upMisalignmentAngle > 5) { // (5 deg not in standard REBA but small deviation from upright 0 deg is needed to account for imperfection of measurement)
+ neckScore++; // +1 for back-bending more than few degrees
+ }
+ }
+
+ // Check for twisting of more degrees than bendingThreshold from straight ahead
+ const twistRightAngle = angleBetween(headForward, rebaData.orientations.chest.right); // Angle from full twist right
+ const twistLeftAngle = 180 - twistRightAngle;
+ const twist = (twistRightAngle < (90 - bendingThreshold) || twistLeftAngle < (90 - bendingThreshold));
+
+ // +1 for twisting or side-bending
+ if (sideBend || twist) {
+ neckScore++;
+ }
+
+ //console.log(`Neck: upMisalignmentAngle=${upMisalignmentAngle.toFixed(0)}deg; twistRightAngle=${twistRightAngle.toFixed(0)}deg; sideBend=${sideBend}; twist=${twist}; neckScore=${neckScore}`);
+
+ neckScore = clamp(neckScore, 1, 3);
+
+ if (neckScore === 1 ) {
+ neckColor = MotionStudyColors.green;
+ } else if (neckScore === 2) {
+ neckColor = MotionStudyColors.yellow;
+ } else {
+ neckColor = MotionStudyColors.red;
+ }
+
+ [JOINTS.NECK,
+ JOINTS.HEAD,
+ JOINTS.LEFT_EYE,
+ JOINTS.RIGHT_EYE,
+ JOINTS.LEFT_EAR,
+ JOINTS.RIGHT_EAR,
+ JOINTS.NOSE
+ ].forEach(joint => {
+ rebaData.scores[joint] = neckScore;
+ rebaData.colors[joint] = neckColor;
+ });
+
+ [JOINT_CONNECTIONS.headNeck,
+ JOINT_CONNECTIONS.face,
+ JOINT_CONNECTIONS.earSpan,
+ JOINT_CONNECTIONS.eyeSpan,
+ JOINT_CONNECTIONS.eyeNoseLeft,
+ JOINT_CONNECTIONS.eyeNoseRight
+ ].forEach(bone => {
+ rebaData.boneScores[getBoneName(bone)] = neckScore;
+ rebaData.boneColors[getBoneName(bone)] = neckColor;
+ });
+
+}
+
+/**
+ * Sets the score and color for the trunk reba.
+ * Starting with score=1
+ * +1 for any bending > 5 degrees
+ * +1 for forward or backwards bending > 20 degrees
+ * +1 for forward or backwards bending > 60 degrees
+ * +1 if side bending or twisting shoulders wrt hips
+ * @param {RebaData} rebaData The rebaData to calculate the score and color for.
+ */
+function trunkReba(rebaData) {
+ let trunkScore = 1;
+ let trunkColor = MotionStudyColors.undefined;
+
+ // Comparisons should be relative to directions determined by hips
+ // NOTE: not checking if all needed joints have a valid position (trunk/torso joints are always valid)
+ const chestUp = rebaData.orientations.chest.up;
+ const chestForward = rebaData.orientations.chest.forward;
+ const up = new THREE.Vector3(0, 1, 0);
+ const upMisalignmentAngle = angleBetween(chestUp, up);
+ // all vectors are normalised, so dot() == cos(angle between vectors)
+ const forwardBendingAlignment = chestUp.clone().dot(rebaData.orientations.hips.forward);
+ const backwardBendingAlignment = chestUp.clone().dot(rebaData.orientations.hips.forward.clone().negate());
+ const rightBendingAlignment = chestUp.clone().dot(rebaData.orientations.hips.right);
+ const leftBendingAlignment = chestUp.clone().dot(rebaData.orientations.hips.right.clone().negate());
+
+ // Check for bending
+ let sideBend = false;
+ if (upMisalignmentAngle > 5) {
+ trunkScore++; // +1 for greater than 5 degrees (not in standard REBA but small deviation from upright 0 deg is needed to account for imperfection of measurement)
+ if (upMisalignmentAngle > 20) {
+ trunkScore++; // +1 for greater than 20 degrees
+ // check for side-bending only when above some overall bending threshold
+ // true when above +-45 deg from hip forward or hip backward direction (when looking from above)
+ sideBend = ((forwardBendingAlignment < rightBendingAlignment || forwardBendingAlignment < leftBendingAlignment) &&
+ (backwardBendingAlignment < rightBendingAlignment || backwardBendingAlignment < leftBendingAlignment));
+ if (upMisalignmentAngle > 60) {
+ trunkScore++; // +1 for greater than 60 degrees
+ }
+ }
+ }
+
+ // Check for twisting of more than twistThreshold from straight ahead
+ const twistThreshold = 25;
+ const twistRightAngle = angleBetween(chestForward, rebaData.orientations.hips.right); // Angle from full twist right
+ const twistLeftAngle = 180 - twistRightAngle;
+ const twist = (twistRightAngle < (90 - twistThreshold) || twistLeftAngle < (90 - twistThreshold));
+
+ // +1 for twisting or side-bending
+ if (sideBend || twist) {
+ trunkScore++;
+ }
+
+ // console.log(`Trunk: upMisalignmentAngle=${upMisalignmentAngle.toFixed(0)}deg; twistRightAngle=${twistRightAngle.toFixed(0)}deg; sideBend=${sideBend}; twist=${twist}; trunkScore=${trunkScore}`);
+
+ trunkScore = clamp(trunkScore, 1, 5);
+
+ if (trunkScore === 1 ) {
+ trunkColor = MotionStudyColors.green;
+ } else if (trunkScore < 4) {
+ trunkColor = MotionStudyColors.yellow;
+ } else {
+ trunkColor = MotionStudyColors.red;
+ }
+
+ [JOINTS.CHEST,
+ JOINTS.NAVEL,
+ JOINTS.PELVIS,
+ ].forEach(joint => {
+ rebaData.scores[joint] = trunkScore;
+ rebaData.colors[joint] = trunkColor;
+ });
+
+ [JOINT_CONNECTIONS.neckChest,
+ JOINT_CONNECTIONS.chestNavel,
+ JOINT_CONNECTIONS.navelPelvis,
+ JOINT_CONNECTIONS.shoulderSpan,
+ JOINT_CONNECTIONS.chestRight,
+ JOINT_CONNECTIONS.chestLeft,
+ JOINT_CONNECTIONS.hipSpan,
+ ].forEach(bone => {
+ rebaData.boneScores[getBoneName(bone)] = trunkScore;
+ rebaData.boneColors[getBoneName(bone)] = trunkColor;
+ });
+}
+
+/**
+ * Sets the score and color for the arms reba.
+ * Starting with score=1
+ * +1 for knee bending > 30 degrees
+ * +1 for knee bending > 60 degrees
+ * +1 if one leg is raised above other (+1 applied to both legs)
+ * @param {RebaData} rebaData The rebaData to calculate the score and color for.
+ */
+function legsReba(rebaData) {
+ let leftLegScore = 1;
+ let leftLegColor = MotionStudyColors.undefined;
+ let rightLegScore = 1;
+ let rightLegColor = MotionStudyColors.undefined;
+
+ // Height difference for leg raise is not specified in REBA standard
+ const footHeightDifferenceThreshold = 100; // mm
+
+ // Check for unilateral bearing of the body weight
+ let _onelegged = false;
+ // check if all needed joints have a valid position
+ if (rebaData.jointValidities[JOINTS.LEFT_ANKLE] &&
+ rebaData.jointValidities[JOINTS.RIGHT_ANKLE]) {
+ const footHeightDifference = Math.abs(rebaData.joints[JOINTS.RIGHT_ANKLE].y - rebaData.joints[JOINTS.LEFT_ANKLE].y);
+ if (footHeightDifference > footHeightDifferenceThreshold) {
+ leftLegScore++; // this raises score of both legs, so max() works correctly in neckLegTrunkScore()
+ rightLegScore++;
+ _onelegged = true;
+ }
+ //console.log(`Legs: footHeightDifference: ${footHeightDifference.toFixed(0)}mm; onelegged=${_onelegged}`);
+ }
+
+ /* left leg */
+ // check if all needed joints have a valid position
+ if (rebaData.jointValidities[JOINTS.LEFT_KNEE] &&
+ rebaData.jointValidities[JOINTS.LEFT_ANKLE] &&
+ rebaData.jointValidities[JOINTS.LEFT_HIP]) {
+
+ // calculate knee angle
+ const leftKneeUp = rebaData.joints[JOINTS.LEFT_HIP].clone().sub(rebaData.joints[JOINTS.LEFT_KNEE]);
+ const leftFootUp = rebaData.joints[JOINTS.LEFT_KNEE].clone().sub(rebaData.joints[JOINTS.LEFT_ANKLE]);
+ const leftKneeUpAngle = angleBetween(leftKneeUp, leftFootUp);
+
+ // Check for knee bending
+ if (leftKneeUpAngle > 30) {
+ leftLegScore++; // +1 for greater than 30 degrees
+ if (leftKneeUpAngle > 60) {
+ leftLegScore++; // +1 for greater than 60 degrees
+ }
+ }
+
+ //console.log(`Left leg: leftKneeUpAngle=${leftKneeUpAngle.toFixed(0)}; leftLegScore=${leftLegScore}`);
+
+ leftLegScore = clamp(leftLegScore, 1, 4);
+ if (leftLegScore === 1) {
+ leftLegColor = MotionStudyColors.green;
+ } else if (leftLegScore === 2) {
+ leftLegColor = MotionStudyColors.yellow;
+ } else {
+ leftLegColor = MotionStudyColors.red;
+ }
+ }
+
+ /* right leg */
+ if (rebaData.jointValidities[JOINTS.RIGHT_KNEE] &&
+ rebaData.jointValidities[JOINTS.RIGHT_ANKLE] &&
+ rebaData.jointValidities[JOINTS.RIGHT_HIP]) {
+
+ const rightKneeUp = rebaData.joints[JOINTS.RIGHT_HIP].clone().sub(rebaData.joints[JOINTS.RIGHT_KNEE]);
+ const rightFootUp = rebaData.joints[JOINTS.RIGHT_KNEE].clone().sub(rebaData.joints[JOINTS.RIGHT_ANKLE]);
+ const rightKneeUpAngle = angleBetween(rightKneeUp, rightFootUp);
+
+ if (rightKneeUpAngle > 30) {
+ rightLegScore++; // +1 for greater than 30 degrees
+ if (rightKneeUpAngle > 60) {
+ rightLegScore++; // +1 for greater than 60 degrees
+ }
+ }
+
+ //console.log(`Right leg: rightKneeUpAngle=${rightKneeUpAngle.toFixed(0)}; rightLegScore=${rightLegScore}`);
+
+ rightLegScore = clamp(rightLegScore, 1, 4);
+ if (rightLegScore === 1) {
+ rightLegColor = MotionStudyColors.green;
+ } else if (rightLegScore === 2) {
+ rightLegColor = MotionStudyColors.yellow;
+ } else {
+ rightLegColor = MotionStudyColors.red;
+ }
+ }
+
+ [JOINTS.LEFT_HIP,
+ JOINTS.LEFT_KNEE,
+ JOINTS.LEFT_ANKLE,
+ ].forEach(joint => {
+ rebaData.scores[joint] = leftLegScore;
+ rebaData.colors[joint] = leftLegColor;
+ });
+
+ [JOINTS.RIGHT_HIP,
+ JOINTS.RIGHT_KNEE,
+ JOINTS.RIGHT_ANKLE,
+ ].forEach(joint => {
+ rebaData.scores[joint] = rightLegScore;
+ rebaData.colors[joint] = rightLegColor;
+ });
+
+ [JOINT_CONNECTIONS.hipKneeLeft,
+ JOINT_CONNECTIONS.kneeAnkleLeft,
+ ].forEach(bone => {
+ rebaData.boneScores[getBoneName(bone)] = leftLegScore;
+ rebaData.boneColors[getBoneName(bone)] = leftLegColor;
+ });
+
+ [JOINT_CONNECTIONS.hipKneeRight,
+ JOINT_CONNECTIONS.kneeAnkleRight,
+ ].forEach(bone => {
+ rebaData.boneScores[getBoneName(bone)] = rightLegScore;
+ rebaData.boneColors[getBoneName(bone)] = rightLegColor;
+ });
+}
+
+/**
+ * Sets the score and color for the upper arms reba.
+ * Starting with score=1
+ * +1 for upper arm angle raised > 20 degrees
+ * +1 for upper arm angle raised > 45 degrees
+ * +1 for upper arm angle raised > 90 degrees
+ * +1 if shoulder is raised
+ * +1 if arm is abducted
+ * -1 if arm is aligned with gravity and it is raised > 45 degrees from trunk
+ * Cannot implement: -1 if arm is supported
+ * @param {RebaData} rebaData The rebaData to calculate the score and color for.
+ */
+function upperArmReba(rebaData) {
+ let leftArmScore = 1;
+ let leftArmColor = MotionStudyColors.undefined;
+ let rightArmScore = 1;
+ let rightArmColor = MotionStudyColors.undefined;
+
+ // Angles for upper arm should be measured relative to trunk direction
+ const chestDown = rebaData.orientations.chest.up.clone().negate();
+ const down = new THREE.Vector3(0, -1, 0);
+
+ /* left uppper arm */
+ // check if all needed joints have a valid position
+ if (rebaData.jointValidities[JOINTS.LEFT_ELBOW] && rebaData.jointValidities[JOINTS.LEFT_SHOULDER]) {
+ // calculate arm angles
+ const leftUpperArmDown = rebaData.joints[JOINTS.LEFT_ELBOW].clone().sub(rebaData.joints[JOINTS.LEFT_SHOULDER]);
+ const leftArmAngle = angleBetween(leftUpperArmDown, chestDown);
+ const leftShoulderAngle = angleBetween(rebaData.joints[JOINTS.LEFT_SHOULDER].clone().sub(rebaData.joints[JOINTS.NECK]), rebaData.orientations.chest.up);
+ const leftArmGravityAngle = angleBetween(leftUpperArmDown, down);
+ // all vectors are normalised, so dot() == cos(angle between vectors)
+ const flexionAlignment = leftUpperArmDown.clone().dot(rebaData.orientations.chest.forward);
+ const extensionAlignment = leftUpperArmDown.clone().dot(rebaData.orientations.chest.forward.clone().negate());
+ const rightAbductionAlignment = leftUpperArmDown.clone().dot(rebaData.orientations.chest.right);
+ const leftAbductionAlignment = leftUpperArmDown.clone().dot(rebaData.orientations.chest.right.clone().negate());
+
+ let abduction = false;
+ let gravityAlign = false;
+ if (leftArmAngle > 20) {
+ leftArmScore++; // +1 for greater than 20 degrees
+ // check for abduction only when the arm angle is above the small threshold
+ // true when above +-45 deg from chest forward or chest backward direction (when looking from above)
+ abduction = ((flexionAlignment < rightAbductionAlignment || flexionAlignment < leftAbductionAlignment) &&
+ (extensionAlignment < rightAbductionAlignment || extensionAlignment < leftAbductionAlignment));
+ if (abduction) {
+ leftArmScore++; // +1 for arm abducted
+ }
+ if (leftArmAngle > 45) {
+ leftArmScore++; // +1 for greater than 45 degrees
+ // Check for gravity assistance
+ // -1 for upper arm aligned with gravity (less than 20 degress from gravity vector)
+ gravityAlign = (leftArmGravityAngle < 20);
+ if (gravityAlign) {
+ leftArmScore--;
+ }
+ if (leftArmAngle > 90) {
+ leftArmScore++; // +1 for greater than 90 degrees
+ }
+ }
+ }
+
+ // Check for shoulder raising
+ let _raise = false;
+ if (leftShoulderAngle < 80) {
+ leftArmScore++; // +1 for shoulder raised (less than 80 degress from chest up)
+ _raise = true;
+ }
+
+ //console.log(`Left upper arm: leftArmAngle=${leftArmAngle.toFixed(0)}; leftShoulderAngle: ${leftShoulderAngle.toFixed(0)}; raise=${_raise}; abduction=${abduction}; gravityAlign=${gravityAlign}; leftArmScore=${leftArmScore}`);
+
+ leftArmScore = clamp(leftArmScore, 1, 6);
+ if (leftArmScore < 3) {
+ leftArmColor = MotionStudyColors.green;
+ } else if (leftArmScore < 5) {
+ leftArmColor = MotionStudyColors.yellow;
+ } else {
+ leftArmColor = MotionStudyColors.red;
+ }
+ }
+
+ /* right uppper arm */
+ if (rebaData.jointValidities[JOINTS.RIGHT_ELBOW] && rebaData.jointValidities[JOINTS.RIGHT_SHOULDER]) {
+ const rightUpperArmDown = rebaData.joints[JOINTS.RIGHT_ELBOW].clone().sub(rebaData.joints[JOINTS.RIGHT_SHOULDER]);
+ const rightArmAngle = angleBetween(rightUpperArmDown, chestDown);
+ const rightShoulderAngle = angleBetween(rebaData.joints[JOINTS.RIGHT_SHOULDER].clone().sub(rebaData.joints[JOINTS.NECK]), rebaData.orientations.chest.up);
+ const rightArmGravityAngle = angleBetween(rightUpperArmDown, down);
+ // all vectors are normalised, so dot() == cos(angle between vectors)
+ const flexionAlignment = rightUpperArmDown.clone().dot(rebaData.orientations.chest.forward);
+ const extensionAlignment = rightUpperArmDown.clone().dot(rebaData.orientations.chest.forward.clone().negate());
+ const rightAbductionAlignment = rightUpperArmDown.clone().dot(rebaData.orientations.chest.right);
+ const leftAbductionAlignment = rightUpperArmDown.clone().dot(rebaData.orientations.chest.right.clone().negate());
+
+ let abduction = false;
+ let gravityAlign = false;
+ if (rightArmAngle > 20) {
+ rightArmScore++; // +1 for greater than 20 degrees
+ // check for abduction only when the arm angle is above the small threshold
+ // true when above +-45 deg from chest forward or chest backward direction (when looking from above)
+ abduction = ((flexionAlignment < rightAbductionAlignment || flexionAlignment < leftAbductionAlignment) &&
+ (extensionAlignment < rightAbductionAlignment || extensionAlignment < leftAbductionAlignment));
+ if (abduction) {
+ rightArmScore++; // +1 for arm abducted
+ }
+ if (rightArmAngle > 45) {
+ rightArmScore++; // +1 for greater than 45 degrees
+ // Check for gravity assistance
+ // -1 for upper arm aligned with gravity (less than 20 degress from gravity vector)
+ gravityAlign = (rightArmGravityAngle < 20);
+ if (gravityAlign) {
+ rightArmScore--;
+ }
+ if (rightArmAngle > 90) {
+ rightArmScore++; // +1 for greater than 90 degrees
+ }
+ }
+ }
+
+ let _raise = false;
+ if (rightShoulderAngle < 80) {
+ rightArmScore++; // +1 for shoulder raised (less than 80 degress from chest up)
+ _raise = true;
+ }
+
+ //console.log(`Right upper arm: rightArmAngle=${rightArmAngle.toFixed(0)}; rightShoulderAngle: ${rightShoulderAngle.toFixed(0)}; raise=${_raise}; abduction=${abduction}; gravityAlign=${gravityAlign}; rightArmScore=${rightArmScore}`);
+
+ rightArmScore = clamp(rightArmScore, 1, 6);
+ if (rightArmScore < 3) {
+ rightArmColor = MotionStudyColors.green;
+ } else if (rightArmScore < 5) {
+ rightArmColor = MotionStudyColors.yellow;
+ } else {
+ rightArmColor = MotionStudyColors.red;
+ }
+ }
+
+ rebaData.scores[JOINTS.LEFT_SHOULDER] = leftArmScore;
+ rebaData.colors[JOINTS.LEFT_SHOULDER] = leftArmColor;
+ rebaData.scores[JOINTS.RIGHT_SHOULDER] = rightArmScore;
+ rebaData.colors[JOINTS.RIGHT_SHOULDER] = rightArmColor;
+
+ rebaData.boneScores[getBoneName(JOINT_CONNECTIONS.shoulderElbowLeft)] = leftArmScore;
+ rebaData.boneColors[getBoneName(JOINT_CONNECTIONS.shoulderElbowLeft)] = leftArmColor;
+ rebaData.boneScores[getBoneName(JOINT_CONNECTIONS.shoulderElbowRight)] = rightArmScore;
+ rebaData.boneColors[getBoneName(JOINT_CONNECTIONS.shoulderElbowRight)] = rightArmColor;
+}
+
+/**
+ * Sets the score and color for the lower arms reba.
+ * Starting with score=1
+ * +1 for elbow bent < 60 or > 100 degrees
+ * @param {RebaData} rebaData The rebaData to calculate the score and color for.
+ */
+function lowerArmReba(rebaData) {
+ let leftArmScore = 1;
+ let leftArmColor = MotionStudyColors.undefined;
+ let rightArmScore = 1;
+ let rightArmColor = MotionStudyColors.undefined;
+
+ /* left lower arm */
+ // check if all needed joints have a valid position
+ if (rebaData.jointValidities[JOINTS.LEFT_WRIST] &&
+ rebaData.jointValidities[JOINTS.LEFT_ELBOW] &&
+ rebaData.jointValidities[JOINTS.LEFT_SHOULDER]
+ ) {
+
+ // calculate elbow angle
+ const leftForearmDown = rebaData.joints[JOINTS.LEFT_WRIST].clone().sub(rebaData.joints[JOINTS.LEFT_ELBOW]);
+ const leftUpperArmDown = rebaData.joints[JOINTS.LEFT_ELBOW].clone().sub(rebaData.joints[JOINTS.LEFT_SHOULDER]);
+ const leftElbowAngle = angleBetween(leftForearmDown, leftUpperArmDown);
+
+ // Standard REBA calculation marks arms straight down as higher score (can be confusing for new users)
+ if (leftElbowAngle < 60 || leftElbowAngle > 100) {
+ leftArmScore++; // +1 for left elbow bent < 60 or > 100 degrees
+ }
+
+ //console.log(`Left lower arm: leftElbowAngle=${leftElbowAngle.toFixed(0)}; leftArmScore=${leftArmScore}`);
+
+ leftArmScore = clamp(leftArmScore, 1, 2);
+ if (leftArmScore === 1) {
+ leftArmColor = MotionStudyColors.green;
+ } else {
+ leftArmColor = MotionStudyColors.yellow;
+ }
+ }
+
+ /* right lower arm */
+ // check if all needed joints have a valid position
+ if (rebaData.jointValidities[JOINTS.RIGHT_WRIST] &&
+ rebaData.jointValidities[JOINTS.RIGHT_ELBOW] &&
+ rebaData.jointValidities[JOINTS.RIGHT_SHOULDER]
+ ) {
+
+ const rightForearmDown = rebaData.joints[JOINTS.RIGHT_WRIST].clone().sub(rebaData.joints[JOINTS.RIGHT_ELBOW]);
+ const rightUpperArmDown = rebaData.joints[JOINTS.RIGHT_ELBOW].clone().sub(rebaData.joints[JOINTS.RIGHT_SHOULDER]);
+ const rightElbowAngle = angleBetween(rightForearmDown, rightUpperArmDown);
+
+ // Standard REBA calculation marks arms straight down as higher score (can be confusing for new users)
+ if (rightElbowAngle < 60 || rightElbowAngle > 100) {
+ rightArmScore++; // +1 for left elbow bent < 60 or > 100 degrees
+ }
+
+ //console.log(`Right lower arm: rightElbowAngle=${rightElbowAngle.toFixed(0)}; rightArmScore=${rightArmScore}`);
+
+ rightArmScore = clamp(rightArmScore, 1, 2);
+ if (rightArmScore === 1) {
+ rightArmColor = MotionStudyColors.green;
+ } else {
+ rightArmColor = MotionStudyColors.yellow;
+ }
+ }
+
+ rebaData.scores[JOINTS.LEFT_ELBOW] = leftArmScore;
+ rebaData.colors[JOINTS.LEFT_ELBOW] = leftArmColor;
+ rebaData.scores[JOINTS.RIGHT_ELBOW] = rightArmScore;
+ rebaData.colors[JOINTS.RIGHT_ELBOW] = rightArmColor;
+
+ rebaData.boneScores[getBoneName(JOINT_CONNECTIONS.elbowWristLeft)] = leftArmScore;
+ rebaData.boneColors[getBoneName(JOINT_CONNECTIONS.elbowWristLeft)] = leftArmColor;
+ rebaData.boneScores[getBoneName(JOINT_CONNECTIONS.elbowWristRight)] = rightArmScore;
+ rebaData.boneColors[getBoneName(JOINT_CONNECTIONS.elbowWristRight)] = rightArmColor;
+}
+
+/**
+ * Sets the score and color for the wrist reba.
+ * Starting with score=1
+ * +1 for wrist flexion/extention > 15 degrees
+ * +1 if bending from midline or twisting wrt elbow
+ * @param {RebaData} rebaData The rebaData to calculate the score and color for.
+ */
+function wristReba(rebaData) {
+ let leftWristScore = 1;
+ let leftWristColor = MotionStudyColors.undefined;
+ let rightWristScore = 1;
+ let rightWristColor = MotionStudyColors.undefined;
+
+
+ /* left wrist */
+ // checking if hand has a real pose (eg. it was detected or it is not just dummy hands for pose with JOINTS_V1 schema)
+ // if hand is a valid pose
+ const leftHandIsValid = (rebaData.joints[JOINTS.LEFT_INDEX_FINGER_MCP].clone().sub(rebaData.joints[JOINTS.LEFT_WRIST]).length() > 1e-6) &&
+ rebaData.jointValidities[JOINTS.LEFT_MIDDLE_FINGER_MCP];
+
+ // check if all needed joints have a valid position
+ if ((TRACK_HANDS && leftHandIsValid) &&
+ rebaData.jointValidities[JOINTS.LEFT_WRIST] &&
+ rebaData.jointValidities[JOINTS.LEFT_ELBOW]
+ ) {
+
+ // compute main direction vectors
+ const leftHandDirection = rebaData.joints[JOINTS.LEFT_MIDDLE_FINGER_MCP].clone().sub(rebaData.joints[JOINTS.LEFT_WRIST]).normalize();
+ const leftHandPinky2Index = rebaData.joints[JOINTS.LEFT_INDEX_FINGER_MCP].clone().sub(rebaData.joints[JOINTS.LEFT_PINKY_MCP]).normalize();
+ const leftForearmDirection = rebaData.joints[JOINTS.LEFT_WRIST].clone().sub(rebaData.joints[JOINTS.LEFT_ELBOW]).normalize();
+ const leftUpperarmDirection = rebaData.joints[JOINTS.LEFT_SHOULDER].clone().sub(rebaData.joints[JOINTS.LEFT_ELBOW]).normalize();
+
+ // check if wrist position is outside +-15 deg, then +1
+ const leftHandUp = new THREE.Vector3();
+ leftHandUp.crossVectors(leftHandPinky2Index, leftHandDirection).normalize(); // note: swapped order compared to right hand
+ const wristPositionAngle = angleBetween(leftHandUp, leftForearmDirection) - 90;
+ if (Math.abs(wristPositionAngle) > 15) {
+ leftWristScore++;
+ }
+
+ // check if the hand is bent away from midline
+ // the angle limit from midline is not specified in REBA definition (chosen by us)
+ const wristBendAngle = 90 - angleBetween(leftHandPinky2Index, leftForearmDirection);
+ const sideBend = (Math.abs(wristBendAngle) > 30);
+
+ // check if the hand is twisted (palm up)
+ // the twist angle limit is not specified in REBA definition (120 deg chosen by us to score when there is definitive twist)
+ const leftElbowAxis = new THREE.Vector3(); // direction towards the body
+ leftElbowAxis.crossVectors(leftForearmDirection, leftUpperarmDirection).normalize(); // note: swapped order compared to right hand
+ const wristTwistAngle = angleBetween(leftElbowAxis, leftHandPinky2Index);
+ const twist = (wristTwistAngle > 120);
+
+ // +1 for twisting or side-bending
+ if (sideBend || twist) {
+ leftWristScore++;
+ }
+
+ //console.log(`Left wrist: wristPositionAngle=${wristPositionAngle.toFixed(0)}; wristBendAngle=${wristBendAngle.toFixed(0)}; wristTwistAngle=${wristTwistAngle.toFixed(0)} deg; sideBend=${sideBend}; twist=${twist}; leftWristScore=${leftWristScore}`);
+
+ leftWristScore = clamp(leftWristScore, 1, 3);
+
+ if (leftWristScore === 1) {
+ leftWristColor = MotionStudyColors.green;
+ } else if (leftWristScore == 2) {
+ leftWristColor = MotionStudyColors.yellow;
+ } else {
+ leftWristColor = MotionStudyColors.red;
+ }
+ }
+
+
+ /* right wrist */
+ const rightHandIsValid = (rebaData.joints[JOINTS.RIGHT_INDEX_FINGER_MCP].clone().sub(rebaData.joints[JOINTS.RIGHT_WRIST]).length() > 1e-6) &&
+ rebaData.jointValidities[JOINTS.RIGHT_MIDDLE_FINGER_MCP];
+
+ if ((TRACK_HANDS && rightHandIsValid) &&
+ rebaData.jointValidities[JOINTS.RIGHT_WRIST] &&
+ rebaData.jointValidities[JOINTS.RIGHT_ELBOW]
+ ) {
+ // compute main direction vectors
+ const rightHandDirection = rebaData.joints[JOINTS.RIGHT_MIDDLE_FINGER_MCP].clone().sub(rebaData.joints[JOINTS.RIGHT_WRIST]).normalize();
+ const rightHandPinky2Index = rebaData.joints[JOINTS.RIGHT_INDEX_FINGER_MCP].clone().sub(rebaData.joints[JOINTS.RIGHT_PINKY_MCP]).normalize();
+ const rightForearmDirection = rebaData.joints[JOINTS.RIGHT_WRIST].clone().sub(rebaData.joints[JOINTS.RIGHT_ELBOW]).normalize();
+ const rightUpperarmDirection = rebaData.joints[JOINTS.RIGHT_SHOULDER].clone().sub(rebaData.joints[JOINTS.RIGHT_ELBOW]).normalize();
+
+ // check if wrist position is outside +-15 deg, then +1
+ const rightHandUp = new THREE.Vector3();
+ rightHandUp.crossVectors(rightHandDirection, rightHandPinky2Index).normalize();
+ const wristPositionAngle = angleBetween(rightHandUp, rightForearmDirection) - 90;
+ if (Math.abs(wristPositionAngle) > 15) {
+ rightWristScore++;
+ }
+
+ // check if the hand is bent away from midline
+ // the angle limit from midline is not specified in REBA definition (chosen by us)
+ const wristBendAngle = 90 - angleBetween(rightHandPinky2Index, rightForearmDirection);
+ const sideBend = (Math.abs(wristBendAngle) > 30);
+
+ // check if the hand is twisted (palm up)
+ // the twist angle limit is not specified in REBA definition (120 deg chosen by us to score when there is definitive twist)
+ const rightElbowAxis = new THREE.Vector3(); // direction towards the body
+ rightElbowAxis.crossVectors(rightUpperarmDirection, rightForearmDirection).normalize();
+ const wristTwistAngle = angleBetween(rightElbowAxis, rightHandPinky2Index);
+ const twist = (wristTwistAngle > 120);
+
+ // +1 for twisting or side-bending
+ if (sideBend || twist) {
+ rightWristScore++;
+ }
+
+ //console.log(`Right wrist: wristPositionAngle=${wristPositionAngle.toFixed(0)}; wristBendAngle=${wristBendAngle.toFixed(0)}; wristTwistAngle=${wristTwistAngle.toFixed(0)} deg; sideBend=${sideBend}; twist=${twist}; rightWristScore=${rightWristScore}`);
+
+ rightWristScore = clamp(rightWristScore, 1, 3);
+
+ if (rightWristScore === 1) {
+ rightWristColor = MotionStudyColors.green;
+ } else if (rightWristScore == 2) {
+ rightWristColor = MotionStudyColors.yellow;
+ } else {
+ rightWristColor = MotionStudyColors.red;
+ }
+ }
+
+ /* set score and color to hand joints and bones */
+
+ [JOINTS.LEFT_WRIST, JOINTS.LEFT_THUMB_CMC, JOINTS.LEFT_THUMB_MCP, JOINTS.LEFT_THUMB_IP, JOINTS.LEFT_THUMB_TIP,
+ JOINTS.LEFT_INDEX_FINGER_MCP, JOINTS.LEFT_INDEX_FINGER_PIP, JOINTS.LEFT_INDEX_FINGER_DIP, JOINTS.LEFT_INDEX_FINGER_TIP,
+ JOINTS.LEFT_MIDDLE_FINGER_MCP, JOINTS.LEFT_MIDDLE_FINGER_PIP, JOINTS.LEFT_MIDDLE_FINGER_DIP, JOINTS.LEFT_MIDDLE_FINGER_TIP,
+ JOINTS.LEFT_RING_FINGER_MCP, JOINTS.LEFT_RING_FINGER_PIP, JOINTS.LEFT_RING_FINGER_DIP, JOINTS.LEFT_RING_FINGER_TIP,
+ JOINTS.LEFT_PINKY_MCP, JOINTS.LEFT_PINKY_PIP, JOINTS.LEFT_PINKY_DIP, JOINTS.LEFT_PINKY_TIP
+ ].forEach(joint => {
+ rebaData.scores[joint] = leftWristScore;
+ rebaData.colors[joint] = leftWristColor;
+ });
+
+ [JOINT_CONNECTIONS.thumb1Left, JOINT_CONNECTIONS.thumb2Left, JOINT_CONNECTIONS.thumb3Left, JOINT_CONNECTIONS.thumb4Left,
+ JOINT_CONNECTIONS.index1Left, JOINT_CONNECTIONS.index2Left, JOINT_CONNECTIONS.index3Left, JOINT_CONNECTIONS.index4Left,
+ JOINT_CONNECTIONS.middle2Left, JOINT_CONNECTIONS.middle3Left, JOINT_CONNECTIONS.middle4Left,
+ JOINT_CONNECTIONS.ring2Left, JOINT_CONNECTIONS.ring3Left, JOINT_CONNECTIONS.ring4Left,
+ JOINT_CONNECTIONS.pinky1Left, JOINT_CONNECTIONS.pinky2Left, JOINT_CONNECTIONS.pinky3Left, JOINT_CONNECTIONS.pinky4Left,
+ JOINT_CONNECTIONS.handSpan1Left, JOINT_CONNECTIONS.handSpan2Left, JOINT_CONNECTIONS.handSpan3Left
+ ].forEach(bone => {
+ rebaData.boneScores[getBoneName(bone)] = leftWristScore;
+ rebaData.boneColors[getBoneName(bone)] = leftWristColor;
+ });
+
+ [JOINTS.RIGHT_WRIST, JOINTS.RIGHT_THUMB_CMC, JOINTS.RIGHT_THUMB_MCP, JOINTS.RIGHT_THUMB_IP, JOINTS.RIGHT_THUMB_TIP,
+ JOINTS.RIGHT_INDEX_FINGER_MCP, JOINTS.RIGHT_INDEX_FINGER_PIP, JOINTS.RIGHT_INDEX_FINGER_DIP, JOINTS.RIGHT_INDEX_FINGER_TIP,
+ JOINTS.RIGHT_MIDDLE_FINGER_MCP, JOINTS.RIGHT_MIDDLE_FINGER_PIP, JOINTS.RIGHT_MIDDLE_FINGER_DIP, JOINTS.RIGHT_MIDDLE_FINGER_TIP,
+ JOINTS.RIGHT_RING_FINGER_MCP, JOINTS.RIGHT_RING_FINGER_PIP, JOINTS.RIGHT_RING_FINGER_DIP, JOINTS.RIGHT_RING_FINGER_TIP,
+ JOINTS.RIGHT_PINKY_MCP, JOINTS.RIGHT_PINKY_PIP, JOINTS.RIGHT_PINKY_DIP, JOINTS.RIGHT_PINKY_TIP
+ ].forEach(joint => {
+ rebaData.scores[joint] = rightWristScore;
+ rebaData.colors[joint] = rightWristColor;
+ });
+
+ [JOINT_CONNECTIONS.thumb1Right, JOINT_CONNECTIONS.thumb2Right, JOINT_CONNECTIONS.thumb3Right, JOINT_CONNECTIONS.thumb4Right,
+ JOINT_CONNECTIONS.index1Right, JOINT_CONNECTIONS.index2Right, JOINT_CONNECTIONS.index3Right, JOINT_CONNECTIONS.index4Right,
+ JOINT_CONNECTIONS.middle2Right, JOINT_CONNECTIONS.middle3Right, JOINT_CONNECTIONS.middle4Right,
+ JOINT_CONNECTIONS.ring2Right, JOINT_CONNECTIONS.ring3Right, JOINT_CONNECTIONS.ring4Right,
+ JOINT_CONNECTIONS.pinky1Right, JOINT_CONNECTIONS.pinky2Right, JOINT_CONNECTIONS.pinky3Right, JOINT_CONNECTIONS.pinky4Right,
+ JOINT_CONNECTIONS.handSpan1Right, JOINT_CONNECTIONS.handSpan2Right, JOINT_CONNECTIONS.handSpan3Right
+ ].forEach(bone => {
+ rebaData.boneScores[getBoneName(bone)] = rightWristScore;
+ rebaData.boneColors[getBoneName(bone)] = rightWristColor;
+ });
+
+}
+
+const startColor = MotionStudyColors.fade(MotionStudyColors.green);
+const endColor = MotionStudyColors.fade(MotionStudyColors.red);
+
+function getOverallRebaColor(rebaScore) {
+ const lowCutoff = 4;
+ const highCutoff = 8;
+ // console.log(`Overall Reba Score: ${rebaScore}\nlowCutoff: ${lowCutoff}\nhighCutoff: ${highCutoff}`); // TODO: experiment with cutoffs
+ const rebaFrac = (clamp(rebaScore, lowCutoff, highCutoff) - lowCutoff) / (highCutoff - lowCutoff);
+ return startColor.clone().lerpHSL(endColor, rebaFrac);
+}
+
+function calculateReba(rebaData) {
+ // call all helper functions to annotate the individual scores of each bone
+ neckReba(rebaData);
+ trunkReba(rebaData);
+ legsReba(rebaData);
+ upperArmReba(rebaData);
+ lowerArmReba(rebaData);
+ wristReba(rebaData);
+
+ rebaData.overallRebaScore = overallRebaCalculation(rebaData);
+ rebaData.overallRebaColor = getOverallRebaColor(rebaData.overallRebaScore);
+}
+
+function neckLegTrunkScore(rebaData) {
+ const neck = rebaData.scores[JOINTS.NECK];
+ const legs = Math.max(rebaData.scores[JOINTS.LEFT_HIP], rebaData.scores[JOINTS.RIGHT_HIP]);
+ const trunk = rebaData.scores[JOINTS.CHEST];
+
+ let key = `${neck},${legs},${trunk}`;
+
+ const scoreTable = {
+ '1,1,1': 1,
+ '1,1,2': 2,
+ '1,1,3': 2,
+ '1,1,4': 3,
+ '1,1,5': 4,
+ '1,2,1': 2,
+ '1,2,2': 3,
+ '1,2,3': 4,
+ '1,2,4': 5,
+ '1,2,5': 6,
+ '1,3,1': 3,
+ '1,3,2': 4,
+ '1,3,3': 5,
+ '1,3,4': 6,
+ '1,3,5': 7,
+ '1,4,1': 4,
+ '1,4,2': 5,
+ '1,4,3': 6,
+ '1,4,4': 7,
+ '1,4,5': 8,
+ '2,1,1': 1,
+ '2,1,2': 3,
+ '2,1,3': 4,
+ '2,1,4': 5,
+ '2,1,5': 6,
+ '2,2,1': 2,
+ '2,2,2': 4,
+ '2,2,3': 5,
+ '2,2,4': 6,
+ '2,2,5': 7,
+ '2,3,1': 3,
+ '2,3,2': 5,
+ '2,3,3': 6,
+ '2,3,4': 7,
+ '2,3,5': 8,
+ '2,4,1': 4,
+ '2,4,2': 6,
+ '2,4,3': 7,
+ '2,4,4': 8,
+ '2,4,5': 9,
+ '3,1,1': 3,
+ '3,1,2': 4,
+ '3,1,3': 5,
+ '3,1,4': 6,
+ '3,1,5': 7,
+ '3,2,1': 3,
+ '3,2,2': 5,
+ '3,2,3': 6,
+ '3,2,4': 7,
+ '3,2,5': 8,
+ '3,3,1': 5,
+ '3,3,2': 6,
+ '3,3,3': 7,
+ '3,3,4': 8,
+ '3,3,5': 9,
+ '3,4,1': 6,
+ '3,4,2': 7,
+ '3,4,3': 8,
+ '3,4,4': 9,
+ '3,4,5': 9
+ };
+ return scoreTable[key];
+}
+
+function armAndWristScore(rebaData) {
+ const lowerArm = Math.max(rebaData.scores[JOINTS.LEFT_ELBOW], rebaData.scores[JOINTS.RIGHT_ELBOW]);
+ const wrist = Math.max(rebaData.scores[JOINTS.LEFT_WRIST], rebaData.scores[JOINTS.RIGHT_WRIST]);
+ const upperArm = Math.max(rebaData.scores[JOINTS.LEFT_SHOULDER], rebaData.scores[JOINTS.RIGHT_SHOULDER]);
+
+ let key = `${lowerArm},${wrist},${upperArm}`;
+
+ const scoreTable = {
+ '1,1,1': 1,
+ '1,1,2': 1,
+ '1,1,3': 3,
+ '1,1,4': 4,
+ '1,1,5': 6,
+ '1,1,6': 7,
+ '1,2,1': 2,
+ '1,2,2': 2,
+ '1,2,3': 4,
+ '1,2,4': 5,
+ '1,2,5': 7,
+ '1,2,6': 8,
+ '1,3,1': 2,
+ '1,3,2': 3,
+ '1,3,3': 5,
+ '1,3,4': 5,
+ '1,3,5': 8,
+ '1,3,6': 8,
+ '2,1,1': 1,
+ '2,1,2': 2,
+ '2,1,3': 4,
+ '2,1,4': 5,
+ '2,1,5': 7,
+ '2,1,6': 8,
+ '2,2,1': 2,
+ '2,2,2': 3,
+ '2,2,3': 5,
+ '2,2,4': 6,
+ '2,2,5': 8,
+ '2,2,6': 9,
+ '2,3,1': 3,
+ '2,3,2': 4,
+ '2,3,3': 5,
+ '2,3,4': 7,
+ '2,3,5': 8,
+ '2,3,6': 9,
+ }
+ return scoreTable[key];
+}
+
+function overallRebaCalculation(rebaData) {
+ const forceScore = 0; // We cannot calculate this at the moment, ranges from 0 - 3
+ let scoreA = neckLegTrunkScore(rebaData) + forceScore;
+
+ const couplingScore = 0; // We cannot calculate this at the moment, ranges from 0 - 3
+ let scoreB = armAndWristScore(rebaData) + couplingScore;
+
+ // Effective output range is 1 - 11, since scoreA and scoreB are 1 - 9
+ const scoreTable = [
+ [1, 1, 1, 2, 3, 3, 4, 5, 6, 7, 7, 7],
+ [1, 2, 2, 3, 4, 4, 5, 6, 6, 7, 7, 8],
+ [2, 3, 3, 3, 4, 5, 6, 7, 7, 8, 8, 8],
+ [3, 4, 4, 4, 5, 6, 7, 8, 8, 9, 9, 9],
+ [4, 4, 4, 5, 6, 7, 8, 8, 9, 9, 9, 9],
+ [6, 6, 6, 7, 8, 8, 9, 9, 10, 10, 10, 10],
+ [7, 7, 7, 8, 9, 9, 9, 10, 10, 11, 11, 11],
+ [8, 8, 8, 9, 10, 10, 10, 10, 10, 11, 11, 11],
+ [9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12],
+ [10, 10, 10, 11, 11, 11, 11, 12, 12, 12, 12, 12],
+ [11, 11, 11, 11, 12, 12, 12, 12, 12, 12, 12, 12],
+ [12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12]
+ ];
+
+ return scoreTable[scoreA - 1][scoreB - 1];
+}
+
+/**
+ * @typedef {Object} Orientation
+ * @property {Vector3} forward The forward direction of the orientation
+ * @property {Vector3} up The up direction of the orientation
+ * @property {Vector3} right The right direction of the orientation
+ */
+
+/**
+ * @typedef {Object} RebaData
+ * @property {number} overallRebaScore The overall reba score of the pose
+ * @property {Color} overallRebaColor The overall reba color of the pose
+ * @property {Object.} joints The joints of the pose
+ * @property {Object.} scores The scores of the pose
+ * @property {Object.} colors The colors of the pose
+ * @property {Object.} boneScores The bone scores of the pose
+ * @property {Object.} boneColors The bone colors of the pose
+ * @property {Object.} orientations The orientations of the pose
+ */
+
+/**
+ * Generates a rebaData object from a pose
+ * @param {Pose} pose The pose to extract the rebaData from
+ * @return {RebaData} The rebaData object
+ */
+function extractRebaData(pose) {
+ let rebaData = {
+ overallRebaScore: 0,
+ overallRebaColor: MotionStudyColors.undefined,
+ joints: {},
+ jointValidities: {},
+ scores: {},
+ colors: {},
+ boneScores: {},
+ boneColors: {},
+ orientations: {
+ head: {
+ forward: new THREE.Vector3(),
+ up: new THREE.Vector3(),
+ right: new THREE.Vector3()
+ },
+ chest: {
+ forward: new THREE.Vector3(),
+ up: new THREE.Vector3(),
+ right: new THREE.Vector3()
+ },
+ hips: {
+ forward: new THREE.Vector3(),
+ up: new THREE.Vector3(),
+ right: new THREE.Vector3()
+ }
+ }
+ };
+ for (let jointId of Object.values(JOINTS)) {
+ rebaData.joints[jointId] = pose.getJoint(jointId).position;
+ rebaData.jointValidities[jointId] = pose.getJoint(jointId).valid;
+ rebaData.scores[jointId] = 0;
+ rebaData.colors[jointId] = MotionStudyColors.undefined;
+ }
+ for (let boneId of Object.keys(JOINT_CONNECTIONS)) {
+ rebaData.boneScores[boneId] = 0;
+ rebaData.boneColors[boneId] = MotionStudyColors.undefined;
+ }
+
+ // make sure these coord systems have orthogonal axes
+ rebaData.orientations.head.up = rebaData.joints[JOINTS.HEAD].clone().sub(rebaData.joints[JOINTS.NECK]).normalize();
+ rebaData.orientations.head.forward = rebaData.joints[JOINTS.NOSE].clone().sub(rebaData.joints[JOINTS.HEAD]).normalize();
+ rebaData.orientations.head.right = rebaData.orientations.head.forward.clone().cross(rebaData.orientations.head.up).normalize();
+ rebaData.orientations.head.forward = rebaData.orientations.head.up.clone().cross(rebaData.orientations.head.right).normalize(); // make perpendicular
+
+ rebaData.orientations.chest.up = rebaData.joints[JOINTS.NECK].clone().sub(rebaData.joints[JOINTS.CHEST]).normalize();
+ rebaData.orientations.chest.right = rebaData.joints[JOINTS.RIGHT_SHOULDER].clone().sub(rebaData.joints[JOINTS.LEFT_SHOULDER]).normalize();
+ rebaData.orientations.chest.forward = rebaData.orientations.chest.up.clone().cross(rebaData.orientations.chest.right).normalize();
+ rebaData.orientations.chest.right = rebaData.orientations.chest.forward.clone().cross(rebaData.orientations.chest.up).normalize(); // make perpendicular
+
+ rebaData.orientations.hips.up = new THREE.Vector3(0, 1, 0); // Hips do not really have an up direction (i.e., even when sitting, the hips are always up)
+ rebaData.orientations.hips.right = rebaData.joints[JOINTS.RIGHT_HIP].clone().sub(rebaData.joints[JOINTS.LEFT_HIP]).normalize();
+ rebaData.orientations.hips.forward = rebaData.orientations.hips.up.clone().cross(rebaData.orientations.hips.right).normalize();
+ rebaData.orientations.hips.right = rebaData.orientations.hips.forward.clone().cross(rebaData.orientations.hips.up).normalize(); // make perpendicular
+
+ return rebaData;
+}
+
+/**
+ * Calculates the Reba score for a given pose
+ * @param {Pose} pose The pose to calculate the score for
+ * @return {RebaData} The rebaData object
+ */
+function calculateForPose(pose) {
+ const rebaData = extractRebaData(pose);
+ calculateReba(rebaData);
+ return rebaData;
+}
+
+export {
+ calculateForPose
+};
diff --git a/src/humanPose/spaghetti.js b/src/humanPose/spaghetti.js
new file mode 100644
index 000000000..aae8dfdea
--- /dev/null
+++ b/src/humanPose/spaghetti.js
@@ -0,0 +1,766 @@
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+import { MeshPath } from "../gui/ar/meshPath.js";
+import {
+ setAnimationMode,
+ AnimationMode,
+} from './draw.js';
+import {MotionStudyColors} from "./MotionStudyColors.js";
+
+const spaghettiListsByMotionStudyFrame = {};
+const activeSpaghettiByMotionStudyFrame = {};
+function updateAllSpaghettiColorsByMotionStudyFrame(frame) {
+ spaghettiListsByMotionStudyFrame[frame].forEach((spaghetti) => {
+ spaghetti.updateColors();
+ });
+}
+
+// Approximate milliseconds between points (10 fps)
+const POINT_RES_MS = 100;
+
+// we will lazily instantiate a shared label that all SpaghettiMeshPaths can use
+let sharedMeasurementLabel = null;
+
+export function getMeasurementTextLabel(distanceMm, timeMs) {
+ // round time and distance to 1 decimal place
+ let distanceMeters = (distanceMm / 1000).toFixed(1);
+ let timeSeconds = (timeMs / 1000).toFixed(1);
+ let timeString = '';
+ if (timeSeconds > 0) {
+ timeString = ' traveled in ' + timeSeconds + 's';
+ } else {
+ timeString = ' traveled in < 1s';
+ }
+
+ return distanceMeters + 'm' + timeString;
+}
+
+class MeasurementLabel {
+ constructor() {
+ this.container = this.createTextLabel();
+ this.visibilityRequests = {};
+ }
+
+ requestVisible(wantsVisible, pathId) {
+ this.visibilityRequests[pathId] = wantsVisible;
+
+ // go through all visibility requests, and hide the label if nothing needs it
+ let anythingWantsVisible = Object.values(this.visibilityRequests).reduce((a, b) => a || b, false);
+ this.container.style.display = anythingWantsVisible ? 'inline' : 'none';
+ }
+
+ goToPointer(pageX, pageY) {
+ this.container.style.left = pageX + 'px'; // position it centered on the pointer sphere
+ this.container.style.top = (pageY - 10) + 'px'; // slightly offset in y
+ }
+
+ updateTextLabel(distanceMm, timeMs) {
+ this.container.children[0].innerText = getMeasurementTextLabel(distanceMm, timeMs);
+ }
+
+ createTextLabel(text, width = 240, fontSize = 18, scale = 1.33) {
+ let labelContainer = document.createElement('div');
+ labelContainer.classList.add('avatarBeamLabel');
+ labelContainer.style.width = width + 'px';
+ labelContainer.style.fontSize = fontSize + 'px';
+ labelContainer.style.transform = 'translateX(-50%) translateY(-135%) translateZ(3000px) scale(' + scale + ')';
+ document.body.appendChild(labelContainer);
+
+ let label = document.createElement('div');
+ labelContainer.appendChild(label);
+
+ if (text) {
+ label.innerText = text;
+ labelContainer.classList.remove('displayNone');
+ } else {
+ label.innerText = text;
+ labelContainer.classList.add('displayNone');
+ }
+
+ return labelContainer;
+ }
+}
+
+const SpaghettiSelectionState = {
+ NONE: {
+ onPointerDown: (spaghetti, e) => {
+ if (e.deselectedSpaghetti) {
+ // If another spaghetti was deselected on this click, do nothing
+ // This prevents the user from selecting a new invisible spaghetti when they are trying to deselect one,
+ // and the deselection happens first
+ return;
+ }
+ const intersects = realityEditor.gui.threejsScene.getRaycastIntersects(e.pageX, e.pageY, spaghetti.meshPaths);
+ const index = spaghetti.getValidPointFromIntersects(intersects);
+ if (index === -1) {
+ spaghetti.highlightRegion = {
+ startIndex: -1,
+ endIndex: -1,
+ regionExists: false
+ }
+ spaghetti.updateColors();
+ return;
+ }
+ SpaghettiSelectionState.SINGLE.transition(spaghetti, index);
+ },
+ onPointerMove: (spaghetti, e) => {
+ const intersects = realityEditor.gui.threejsScene.getRaycastIntersects(e.pageX, e.pageY, spaghetti.meshPaths);
+ spaghetti.cursorIndex = spaghetti.getValidPointFromIntersects(intersects);
+ if (spaghetti.cursorIndex === -1) {
+ // Note: Cannot set motionStudy cursor time to -1 here because other spaghettis may be hovering, handled by HPA
+ return;
+ }
+ setAnimationMode(AnimationMode.cursor);
+ if (spaghetti.motionStudy) {
+ spaghetti.motionStudy.setCursorTime(spaghetti.points[spaghetti.cursorIndex].timestamp, true);
+ }
+ },
+ colorPoints: (spaghetti) => {
+ spaghetti.points.forEach((point, index) => {
+ if (spaghetti.isActive()) {
+ if (spaghetti.highlightRegion.regionExists) {
+ // If a region outside of the spaghetti is selected by the timeline
+ point.color = [...point.selectableOutOfRangeColor];
+ } else {
+ if (index === spaghetti.cursorIndex) {
+ point.color = [...point.cursorColor];
+ } else {
+ point.color = [...point.originalColor];
+ }
+ }
+ } else {
+ point.color = [...point.inactiveColor];
+ }
+ });
+ },
+ isIndexSelectable: (spaghetti, _index) => {
+ return spaghetti.isActive();
+ },
+ transition: (spaghetti) => {
+ spaghetti.highlightRegion = {
+ startIndex: -1,
+ endIndex: -1,
+ regionExists: false
+ }
+ spaghetti.selectionState = SpaghettiSelectionState.NONE;
+
+ spaghetti.getMeasurementLabel().requestVisible(false, spaghetti.pathId);
+
+ if (activeSpaghettiByMotionStudyFrame[spaghetti.frame] === spaghetti) {
+ activeSpaghettiByMotionStudyFrame[spaghetti.frame] = null;
+ updateAllSpaghettiColorsByMotionStudyFrame(spaghetti.frame);
+ }
+
+ if (spaghetti.motionStudy) {
+ spaghetti.motionStudy.setHighlightRegion(null, true);
+ spaghetti.motionStudy.setCursorTime(-1, true);
+ }
+ }
+ },
+ SINGLE: {
+ onPointerDown: (spaghetti, e) => {
+ let intersects = realityEditor.gui.threejsScene.getRaycastIntersects(e.pageX, e.pageY, spaghetti.meshPaths);
+ const index = spaghetti.getValidPointFromIntersects(intersects);
+ if (index === -1) {
+ SpaghettiSelectionState.NONE.transition(spaghetti);
+ e.deselectedSpaghetti = true;
+ return;
+ }
+ const initialSelectionIndex = spaghetti.highlightRegion.startIndex;
+ if (index === initialSelectionIndex) {
+ SpaghettiSelectionState.NONE.transition(spaghetti);
+ } else {
+ const minIndex = Math.min(index, initialSelectionIndex);
+ const maxIndex = Math.max(index, initialSelectionIndex);
+ SpaghettiSelectionState.RANGE.transition(spaghetti, minIndex, maxIndex);
+ }
+ },
+ onPointerMove: (spaghetti, e) => {
+ const intersects = realityEditor.gui.threejsScene.getRaycastIntersects(e.pageX, e.pageY, spaghetti.meshPaths);
+ spaghetti.cursorIndex = spaghetti.getValidPointFromIntersects(intersects);
+ if (spaghetti.cursorIndex === -1) {
+ // Note: Cannot set cursor time to -1 here because other spaghettis may be hovering, handled by HPA
+ return;
+ }
+
+ const initialSelectionIndex = spaghetti.highlightRegion.startIndex
+ const minIndex = Math.min(spaghetti.cursorIndex, initialSelectionIndex);
+ const maxIndex = Math.max(spaghetti.cursorIndex, initialSelectionIndex);
+
+ if (spaghetti.motionStudy) {
+ spaghetti.motionStudy.setCursorTime(spaghetti.points[spaghetti.cursorIndex].timestamp, true);
+ spaghetti.motionStudy.setHighlightRegion({
+ startTime: spaghetti.points[minIndex].timestamp,
+ endTime: spaghetti.points[maxIndex].timestamp
+ }, true);
+ }
+ setAnimationMode(AnimationMode.regionAll);
+
+ const points = spaghetti.points;
+ const distanceMm = spaghetti.getDistanceAlongPath(minIndex, maxIndex);
+ const timeMs = points[maxIndex].timestamp - points[minIndex].timestamp;
+ spaghetti.getMeasurementLabel().updateTextLabel(distanceMm, timeMs);
+ spaghetti.getMeasurementLabel().requestVisible(true, spaghetti.pathId);
+ spaghetti.getMeasurementLabel().goToPointer(e.pageX, e.pageY);
+ },
+ colorPoints: (spaghetti) => {
+ spaghetti.points.forEach((point, index) => {
+ if (index === spaghetti.cursorIndex || index === spaghetti.highlightRegion.startIndex) {
+ // Highlight handles (cursor point and selected point)
+ point.color = [...point.cursorColor];
+ } else {
+ if (spaghetti.cursorIndex === -1 || spaghetti.cursorIndex === spaghetti.highlightRegion.startIndex) {
+ // If no cursor, or cursor still on selection point, show faded color everywhere
+ point.color = [...point.selectableOutOfRangeColor];
+ } else {
+ // If cursor, show original color for points within handles, faded color for points outside
+ const minIndex = Math.min(spaghetti.cursorIndex, spaghetti.highlightRegion.startIndex);
+ const maxIndex = Math.max(spaghetti.cursorIndex, spaghetti.highlightRegion.startIndex);
+ if (index >= minIndex && index <= maxIndex) {
+ point.color = [...point.originalColor];
+ } else {
+ point.color = [...point.selectableOutOfRangeColor];
+ }
+ }
+ }
+ });
+ },
+ isIndexSelectable: (spaghetti, _index) => {
+ return spaghetti.isActive();
+ },
+ transition: (spaghetti, index) => {
+ spaghetti.highlightRegion = {
+ startIndex: index,
+ endIndex: index,
+ regionExists: true
+ }
+ spaghetti.selectionState = SpaghettiSelectionState.SINGLE;
+
+ spaghetti.getMeasurementLabel().requestVisible(false, spaghetti.pathId);
+
+ if (activeSpaghettiByMotionStudyFrame[spaghetti.frame] !== spaghetti) {
+ activeSpaghettiByMotionStudyFrame[spaghetti.frame] = spaghetti;
+ updateAllSpaghettiColorsByMotionStudyFrame(spaghetti.frame);
+ }
+ }
+ },
+ RANGE: {
+ onPointerDown: (spaghetti, e) => {
+ let intersects = realityEditor.gui.threejsScene.getRaycastIntersects(e.pageX, e.pageY, spaghetti.meshPaths);
+ const index = spaghetti.getValidPointFromIntersects(intersects);
+ if (index === -1) {
+ SpaghettiSelectionState.NONE.transition(spaghetti);
+ e.deselectedSpaghetti = true;
+ return;
+ }
+
+ SpaghettiSelectionState.SINGLE.transition(spaghetti, index);
+ },
+ onPointerMove: (spaghetti, e) => {
+ const intersects = realityEditor.gui.threejsScene.getRaycastIntersects(e.pageX, e.pageY, spaghetti.meshPaths);
+ const index = spaghetti.getValidPointFromIntersects(intersects); // Ensures if index is returned, it is within the selection range
+ if (index === -1) {
+ spaghetti.cursorIndex = -1;
+ return;
+ }
+
+ spaghetti.cursorIndex = index;
+ setAnimationMode(AnimationMode.cursor);
+ if (spaghetti.motionStudy) {
+ spaghetti.motionStudy.setCursorTime(spaghetti.points[spaghetti.cursorIndex].timestamp, true);
+ }
+ },
+ colorPoints: (spaghetti) => {
+ spaghetti.points.forEach((point, index) => {
+ if (index === spaghetti.highlightRegion.startIndex || index === spaghetti.highlightRegion.endIndex) {
+ // Highlight handles (selected points)
+ point.color = [...point.cursorColor];
+ } else if (index === spaghetti.cursorIndex && spaghetti.cursorIndex >= spaghetti.highlightRegion.startIndex && spaghetti.cursorIndex <= spaghetti.highlightRegion.endIndex) {
+ // Highlight cursor point as well if it is within the selection range
+ point.color = [...point.cursorColor];
+ } else {
+ if (index >= spaghetti.highlightRegion.startIndex && index <= spaghetti.highlightRegion.endIndex) {
+ point.color = [...point.originalColor];
+ } else {
+ point.color = [...point.unselectableColor];
+ }
+ }
+ });
+ },
+ isIndexSelectable: (spaghetti, index) => {
+ return spaghetti.isActive() && index >= spaghetti.highlightRegion.startIndex && index <= spaghetti.highlightRegion.endIndex;
+ },
+ transition: (spaghetti, startIndex, endIndex) => {
+ spaghetti.highlightRegion = {
+ startIndex: startIndex,
+ endIndex: endIndex,
+ regionExists: true
+ }
+ spaghetti.selectionState = SpaghettiSelectionState.RANGE;
+
+ spaghetti.getMeasurementLabel().requestVisible(false, spaghetti.pathId);
+
+ if (spaghetti.motionStudy) {
+ spaghetti.motionStudy.setCursorTime(spaghetti.points[spaghetti.highlightRegion.startIndex].timestamp, true);
+ spaghetti.motionStudy.setHighlightRegion({
+ startTime: spaghetti.points[startIndex].timestamp,
+ endTime: spaghetti.points[endIndex].timestamp
+ }, true);
+ }
+ setAnimationMode(AnimationMode.region);
+
+ if (activeSpaghettiByMotionStudyFrame[spaghetti.frame] !== spaghetti) {
+ activeSpaghettiByMotionStudyFrame[spaghetti.frame] = spaghetti;
+ updateAllSpaghettiColorsByMotionStudyFrame(spaghetti.frame);
+ }
+ }
+ }
+}
+
+/**
+ * A spaghetti object handles the rendering of multiple paths and the touch events for selecting on those paths
+ */
+export class Spaghetti extends THREE.Group {
+ constructor(points, motionStudy, name, params) {
+ super();
+ this.points = [];
+ this.motionStudy = motionStudy; // Null when used outside of pose motionStudy, e.g. camera spaghetti lines
+ this.name = name;
+ this.meshPathParams = params; // Used for mesh path creation
+ this.meshPaths = [];
+ this.pathId = realityEditor.device.utilities.uuidTime(); // Used for measurement label
+
+ this._selectionState = SpaghettiSelectionState.NONE;
+ this.highlightRegion = {
+ startIndex: -1,
+ endIndex: -1,
+ regionExists: false // Used to determine the difference between no region and a region that has no overlap with the spaghetti
+ }
+ this._cursorIndex = -1;
+
+ this.setupPointerEvents();
+ this.addPoints(points);
+
+ if (!spaghettiListsByMotionStudyFrame[this.frame]) {
+ spaghettiListsByMotionStudyFrame[this.frame] = [];
+ }
+ spaghettiListsByMotionStudyFrame[this.frame].push(this);
+ }
+
+ get selectionState() {
+ return this._selectionState;
+ }
+
+ set selectionState(state) {
+ this._selectionState = state;
+ this.updateColors();
+ }
+
+ get cursorIndex() {
+ return this._cursorIndex;
+ }
+
+ set cursorIndex(index) {
+ if (index === this._cursorIndex) {
+ return;
+ }
+ this._cursorIndex = index;
+ this.updateColors();
+ }
+
+ get frame() {
+ if (this.motionStudy) {
+ return this.motionStudy.frame;
+ }
+ return null;
+ }
+
+ /**
+ * Adds points to the spaghetti line, creating new mesh paths as needed.
+ * Only supports appending points to the end of the spaghetti line.
+ * @param {Array} points - points to add
+ */
+ addPoints(points) {
+ if (points.length === 0) {
+ return;
+ }
+ let pointsToAdd = []; // Queue up points that will be part of the same MeshPath into a buffer to enable adding them in bulk
+ points.forEach((point) => {
+ // [0-255, 0-255, 0-255] format
+ if (!point.color.isColor) {
+ point.color = new THREE.Color(point.color[0] / 255, point.color[1] / 255, point.color[2] / 255);
+ }
+ point.originalColor = [point.color.r * 255, point.color.g * 255, point.color.b * 255, 255];
+ const fadeColor = MotionStudyColors.fade(point.color, 0.2);
+ point.selectableOutOfRangeColor = [fadeColor.r * 255, fadeColor.g * 255, fadeColor.b * 255, 0.5 * 255];
+ point.unselectableColor = [fadeColor.r * 255, fadeColor.g * 255, fadeColor.b * 255, 0.3 * 255];
+ point.inactiveColor = [fadeColor.r * 255, fadeColor.g * 255, fadeColor.b * 255, 0];
+ const cursorColor = MotionStudyColors.highlight(point.color);
+ point.cursorColor = [cursorColor.r * 255, cursorColor.g * 255, cursorColor.b * 255, 255];
+
+ if (this.points.length === 0) {
+ // Create a new mesh path for the first point of the spaghetti line
+ const initialMeshPath = new MeshPath([point], this.meshPathParams);
+ this.meshPaths.push(initialMeshPath);
+ this.add(initialMeshPath);
+ this.points.push(point);
+ return;
+ }
+
+ // Get the most recent point on the spaghetti line including the points we plan to add
+ const lastPoint = pointsToAdd.length > 0 ?
+ pointsToAdd[pointsToAdd.length - 1] :
+ this.points[this.points.length - 1];
+ const lastPointVector = new THREE.Vector3(lastPoint.x, lastPoint.y, lastPoint.z);
+ const currentPointVector = new THREE.Vector3(point.x, point.y, point.z);
+
+ // Split into separate mesh paths if the distance between points is too large
+ if (lastPointVector.distanceToSquared(currentPointVector) > 800 * 800) {
+ // lastMeshPath is guaranteed to exist if there is a lastPoint
+ const lastMeshPath = this.meshPaths[this.meshPaths.length - 1];
+ // Add bulk points to most recent path
+ lastMeshPath.addPoints(pointsToAdd);
+ this.points.push(...pointsToAdd);
+ pointsToAdd = [];
+ // Create a new path for the current point
+ const newMeshPath = new MeshPath([point], this.meshPathParams);
+ this.meshPaths.push(newMeshPath);
+ this.add(newMeshPath);
+ this.points.push(point);
+ } else {
+ // Queue up the point to be added in bulk if close enough to be part of the same MeshPath
+ pointsToAdd.push(point);
+ }
+ });
+ // Add any remaining points to the last mesh path
+ if (pointsToAdd.length > 0) {
+ const lastMeshPath = this.meshPaths[this.meshPaths.length - 1];
+ lastMeshPath.addPoints(pointsToAdd);
+ this.points.push(...pointsToAdd);
+ }
+
+ this.updateColors();
+ }
+
+ setPoints(points) {
+ this.reset();
+ this.addPoints(points);
+ }
+
+ transferStateTo(otherSpaghetti) {
+ if (activeSpaghettiByMotionStudyFrame[this.frame] === this) {
+ activeSpaghettiByMotionStudyFrame[this.frame] = otherSpaghetti;
+ }
+ otherSpaghetti.selectionState = this.selectionState;
+ otherSpaghetti.highlightRegion = {
+ startIndex: this.highlightRegion.startIndex,
+ endIndex: this.highlightRegion.endIndex,
+ regionExists: this.highlightRegion.regionExists
+ }
+ otherSpaghetti.cursorIndex = this.cursorIndex;
+ otherSpaghetti.updateColors();
+ this.selectionState = SpaghettiSelectionState.NONE;
+ this.highlightRegion = {
+ startIndex: -1,
+ endIndex: -1,
+ regionExists: false
+ }
+ this.cursorIndex = -1;
+ this.updateColors();
+ }
+
+ /**
+ * Deallocates all mesh paths and points, and removes them from the scene
+ */
+ resetPoints() {
+ this.meshPaths.forEach((meshPath) => {
+ meshPath.resetPoints();
+ this.remove(meshPath);
+ });
+ this.points = [];
+ this.meshPaths = [];
+ }
+
+ /**
+ * Resets the points and state of the Spaghetti line
+ */
+ reset() {
+ this.resetPoints();
+ SpaghettiSelectionState.NONE.transition(this);
+ this.cursorIndex = -1;
+ if (activeSpaghettiByMotionStudyFrame[this.frame] === this) {
+ activeSpaghettiByMotionStudyFrame[this.frame] = null;
+ }
+ }
+
+ /**
+ * Returns true if the user is currently interacting with this spaghetti line or with no spaghetti line.
+ * Returns false otherwise.
+ */
+ isActive() {
+ const activeSpaghetti = activeSpaghettiByMotionStudyFrame[this.frame];
+ return activeSpaghetti === this || activeSpaghetti === null || activeSpaghetti === undefined;
+ }
+
+ isVisible() {
+ let ancestorsAllVisible = true;
+ let parent = this.parent;
+ while (parent) {
+ if (!parent.visible) {
+ ancestorsAllVisible = false;
+ break;
+ }
+ parent = parent.parent;
+ }
+ return this.visible && ancestorsAllVisible;
+ }
+
+ setupPointerEvents() {
+ document.addEventListener('pointerdown', (e) => {
+ if (!e.target.classList.contains('mainProgram')) {
+ return;
+ }
+ if (realityEditor.device.isMouseEventCameraControl(e)) {
+ this.getMeasurementLabel().requestVisible(false, this.pathId);
+ return;
+ }
+ if (this.motionStudy && realityEditor.motionStudy.getActiveMotionStudy() !== this.motionStudy) {
+ return;
+ }
+ if (!this.isVisible()) {
+ return;
+ }
+ this.onPointerDown(e);
+ });
+ document.addEventListener('pointermove', (e) => {
+ if (!e.target.classList.contains('mainProgram')) {
+ return;
+ }
+ if (realityEditor.device.isMouseEventCameraControl(e)) return;
+ if (this.motionStudy && realityEditor.motionStudy.getActiveMotionStudy() !== this.motionStudy) {
+ return;
+ }
+ if (!this.isVisible()) {
+ return;
+ }
+ this.onPointerMove(e);
+ });
+ }
+
+ /**
+ * @param {number} timestamp - time that is hovered in ms
+ */
+ setCursorTime(timestamp) {
+ let index = -1;
+ for (let i = 0; i < this.points.length; i++) {
+ let point = this.points[i];
+ if (Math.abs(point.timestamp - timestamp) < 0.9 * POINT_RES_MS) {
+ index = i;
+ break;
+ }
+ if (point.timestamp > timestamp) {
+ // Exit early if we will never find a matching point
+ break;
+ }
+ }
+
+ this.cursorIndex = index;
+ }
+
+ /**
+ * @param {{startTime: number, endTime: number}} highlightRegion
+ */
+ setHighlightRegion(highlightRegion) {
+ if (!highlightRegion) {
+ SpaghettiSelectionState.NONE.transition(this);
+ return;
+ }
+ const firstTimestamp = highlightRegion.startTime;
+ const secondTimestamp = highlightRegion.endTime;
+
+ let startIndex = -1;
+ let endIndex = -1;
+ for (let i = 0; i < this.points.length; i++) {
+ let point = this.points[i];
+ if (startIndex < 0 && point.timestamp >= firstTimestamp) {
+ startIndex = i;
+ }
+ if (endIndex < 0 && point.timestamp >= secondTimestamp) {
+ endIndex = i;
+ break;
+ }
+ }
+ if (startIndex >= 0 && endIndex < 0) {
+ endIndex = this.points.length - 1;
+ }
+ if (startIndex < 0 || endIndex < 0 || startIndex === endIndex) {
+ if (this.selectionState !== SpaghettiSelectionState.NONE) {
+ SpaghettiSelectionState.NONE.transition(this);
+ }
+ this.highlightRegion = {
+ startIndex: -1,
+ endIndex: -1,
+ regionExists: true
+ };
+ this.updateColors();
+ return;
+ }
+
+ // NOTE: this transition is done manually to prevent animation modes from being replaced when timeline sets it
+ // to regionAll during a drag selection
+ this.highlightRegion = {
+ startIndex,
+ endIndex,
+ regionExists: true
+ }
+ this.selectionState = SpaghettiSelectionState.RANGE;
+ }
+
+ /**
+ * Limits currentPoints to a subset of allPoints based on the display
+ * region
+ *
+ * @param {{startTime: number, endTime: number}} displayRegion
+ */
+ setDisplayRegion(displayRegion) {
+ const firstTimestamp = displayRegion.startTime;
+ const secondTimestamp = displayRegion.endTime;
+
+ let firstIndex = -1;
+ let secondIndex = -1;
+ for (let i = 0; i < this.points.length; i++) {
+ let point = this.points[i];
+ if (firstIndex < 0 && point.timestamp >= firstTimestamp) {
+ firstIndex = i;
+ }
+ if (secondIndex < 0 && point.timestamp >= secondTimestamp) {
+ secondIndex = i;
+ break;
+ }
+ }
+ if (firstIndex >= 0 && secondIndex < 0) {
+ secondIndex = this.points.length - 1;
+ }
+ if (firstIndex < 0 || secondIndex < 0 || firstIndex === secondIndex) {
+ return;
+ }
+
+ this.setPoints(this.points.slice(firstIndex, secondIndex + 1));
+ }
+
+ getDistanceAlongPath(index1, index2) {
+ const minIndex = Math.min(index1, index2);
+ const maxIndex = Math.max(index1, index2);
+ let distance = 0;
+ let meshPath = this.getMeshPathFromIndex(minIndex);
+ let finalMeshPath = this.getMeshPathFromIndex(maxIndex);
+ if (meshPath === finalMeshPath) {
+ return meshPath.getDistanceAlongPath(this.getIndexWithinPath(minIndex), this.getIndexWithinPath(maxIndex));
+ }
+ distance += meshPath.getDistanceAlongPath(this.getIndexWithinPath(minIndex), meshPath.currentPoints.length - 1);
+ for (let i = this.meshPaths.indexOf(meshPath) + 1; i < this.meshPaths.indexOf(finalMeshPath); i++) {
+ distance += this.meshPaths[i].getDistanceAlongPath(0, this.meshPaths[i].currentPoints.length - 1);
+ }
+ distance += finalMeshPath.getDistanceAlongPath(0, this.getIndexWithinPath(maxIndex));
+ return distance;
+ }
+
+ getMeasurementLabel() {
+ if (!sharedMeasurementLabel) {
+ sharedMeasurementLabel = new MeasurementLabel();
+ }
+ return sharedMeasurementLabel;
+ }
+
+ onPointerDown(e) {
+ this.selectionState.onPointerDown(this, e);
+ }
+
+ onPointerMove(e) {
+ this.selectionState.onPointerMove(this, e);
+ }
+
+ updateColors() {
+ this.selectionState.colorPoints(this)
+ this.meshPaths.forEach((meshPath) => {
+ meshPath.updateColors(meshPath.currentPoints.map((pt, index) => index));
+ });
+ }
+
+ getMeshPathFromIndex(index) {
+ if (index < 0) {
+ return this.meshPaths[0];
+ } else if (index >= this.points.length) {
+ return this.meshPaths[this.meshPaths.length - 1];
+ }
+ let meshPathIndex = 0;
+ let meshPath = this.meshPaths[meshPathIndex];
+ let i = 0;
+ while (i + meshPath.currentPoints.length - 1 < index) {
+ i += meshPath.currentPoints.length;
+ meshPathIndex++;
+ meshPath = this.meshPaths[meshPathIndex];
+ }
+ return meshPath;
+ }
+
+ getIndexWithinPath(index) {
+ if (index < 0) {
+ return 0;
+ } else if (index >= this.points.length) {
+ return this.meshPaths[this.meshPaths.length - 1].currentPoints.length - 1;
+ }
+ let meshPathIndex = 0;
+ let meshPath = this.meshPaths[meshPathIndex];
+ let i = 0;
+ while (i + meshPath.currentPoints.length - 1 < index) {
+ i += meshPath.currentPoints.length;
+ meshPathIndex++;
+ meshPath = this.meshPaths[meshPathIndex];
+ }
+ return index - i;
+ }
+
+ /**
+ * Get the index of the point in the currentPoints array that the closest valid intersect is closest to
+ * @param {Array} intersects - the array of intersect objects returned by three.js raycasting
+ * @return {number} index of the point in the currentPoints array that the closest valid intersect is closest to
+ */
+ getValidPointFromIntersects(intersects) {
+ for (let i = 0; i < intersects.length; i++) {
+ const intersect = intersects[i];
+ const index = this.getPointFromIntersect(intersect);
+ if (this.selectionState.isIndexSelectable(this, index)) {
+ return index;
+ }
+ }
+ return -1;
+ }
+
+ getPointFromIntersect(intersect) {
+ const meshPath = intersect.object.parent;
+ const indexWithinMeshPath = meshPath.getPointFromIntersect(intersect);
+ const meshPathIndex = this.meshPaths.indexOf(meshPath);
+ const priorMeshPathPointCount = this.meshPaths.slice(0, meshPathIndex).reduce((sum, meshPath) => sum + meshPath.currentPoints.length, 0);
+ return priorMeshPathPointCount + indexWithinMeshPath;
+ }
+
+ /**
+ * @return {number} start time of mesh path or -1 if zero-length
+ */
+ getStartTime() {
+ if (this.points.length === 0) {
+ return -1;
+ }
+ return this.points[0].timestamp;
+ }
+
+ /**
+ * @return {number} end time of mesh path or -1 if zero-length
+ */
+ getEndTime() {
+ if (this.points.length === 0) {
+ return -1;
+ }
+ return this.points[this.points.length - 1].timestamp;
+ }
+}
diff --git a/src/humanPose/utils.js b/src/humanPose/utils.js
new file mode 100644
index 000000000..aa16ecd14
--- /dev/null
+++ b/src/humanPose/utils.js
@@ -0,0 +1,315 @@
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+import {
+ SCALE,
+ JOINTS,
+ JOINT_CONNECTIONS,
+ JOINT_RADIUS,
+ BONE_RADIUS
+} from './constants.js';
+
+const HUMAN_POSE_ID_PREFIX = '_HUMAN_';
+
+const JOINT_NODE_NAME = 'storage';
+const JOINT_PUBLIC_DATA_KEYS = {
+ data: 'data',
+ transferData: 'whole_pose'
+};
+
+// other modules in the project can use this to reliably check whether an object is a humanPose object
+function isHumanPoseObject(object) {
+ if (!object) { return false; }
+ return object.type === 'human' || object.objectId.indexOf(HUMAN_POSE_ID_PREFIX) === 0;
+}
+
+function makePoseData(name, poseJoints, frameData) {
+ return {
+ name: name,
+ joints: poseJoints,
+ timestamp: frameData.timestamp,
+ imageSize: frameData.imageSize,
+ focalLength: frameData.focalLength,
+ principalPoint: frameData.principalPoint,
+ transformW2C: frameData.transformW2C
+ }
+}
+
+function getPoseObjectName(pose) {
+ return HUMAN_POSE_ID_PREFIX + pose.name;
+}
+
+function getPoseStringFromObject(poseObject) {
+ let jointPositions = Object.keys(poseObject.frames).map(jointFrameId => realityEditor.sceneGraph.getWorldPosition(jointFrameId));
+ return jointPositions.map(position => positionToRoundedString(position)).join()
+}
+
+function positionToRoundedString(position) {
+ return 'x' + position.x.toFixed(0) + 'y' + position.y.toFixed(0) + 'z' + position.z.toFixed(0);
+}
+
+// a single piece of pose test data saved from a previous session
+/*
+function getMockPoseStandingFarAway() {
+ let joints = JSON.parse(
+ "[{\"x\":-0.7552632972083383,\"y\":0.2644442929211472,\"z\":0.7913752977850149},{\"x\":-0.7845470806021233,\"y\":0.2421982759192687,\"z\":0.8088129628325323},{\"x\":-0.7702630884492285,\"y\":0.3001014048608925,\"z\":0.7688086082955945},{\"x\":-0.8222937248161452,\"y\":0.24623275325440866,\"z\":0.9550474860100973},{\"x\":-0.7833413553528865,\"y\":0.3678937178976209,\"z\":0.8505136192483953},{\"x\":-0.6329333926426419,\"y\":0.12993628611940003,\"z\":1.003037519866321},{\"x\":-0.5857144750949138,\"y\":0.4589454778216688,\"z\":0.8355459885338103},{\"x\":-0.3674280483465843,\"y\":-0.015621332976114535,\"z\":1.0097465238602046},{\"x\":-0.3089154169856956,\"y\":0.5132346709005703,\"z\":0.7849136963889392},{\"x\":-0.1927517400895856,\"y\":-0.17818293753755024,\"z\":0.9756865047079787},{\"x\":-0.16714735686176868,\"y\":0.5735810435150129,\"z\":0.6760789908531224},{\"x\":-0.1250018136428199,\"y\":-0.3687589763164842,\"z\":-0.9344674156160389},{\"x\":-0.12229286355074954,\"y\":-0.3292508923208693,\"z\":-0.8945665731201982},{\"x\":-0.10352244950174398,\"y\":-0.382806122564826,\"z\":-0.9740523344761574},{\"x\":-0.09227820167479968,\"y\":-0.34637551009676415,\"z\":-0.9339987027591811},{\"x\":-0.09457788170460725,\"y\":-0.3891481311166776,\"z\":-0.9955435991385165},{\"x\":-0.07832232108450882,\"y\":-0.35210246362115816,\"z\":-0.957316956868217}]"
+ );
+ return joints;
+}
+*/
+
+// compute the index of the minimum element of the array
+function indexOfMin(arr) {
+ if (arr.length === 0) return -1;
+ let min = arr[0];
+ let minIndex = 0;
+ for (let i = 1; i < arr.length; i++) {
+ if (arr[i] < min) {
+ minIndex = i;
+ min = arr[i];
+ }
+ }
+ return minIndex;
+}
+
+// returns the {objectKey, frameKey, nodeKey} address of the storeData node on this object
+function getJointNodeInfo(humanObject, jointName) {
+ if (!humanObject) { return null; }
+
+ let humanObjectKey = humanObject.objectId;
+ let humanFrameKey = Object.keys(humanObject.frames).find(name => name.includes(jointName));
+ if (!humanObject.frames || !humanFrameKey) { return null; }
+ let humanNodeKey = Object.keys(humanObject.frames[humanFrameKey].nodes).find(name => name.includes(JOINT_NODE_NAME));
+ if (!humanNodeKey) { return null; }
+ return {
+ objectKey: humanObjectKey,
+ frameKey: humanFrameKey,
+ nodeKey: humanNodeKey
+ }
+}
+
+function getDummyJointMatrix(jointId) {
+ const matrix = new THREE.Matrix4();
+ switch (jointId) {
+ case JOINTS.NOSE:
+ matrix.setPosition(0, 0, 0.1 * SCALE);
+ return matrix;
+ case JOINTS.LEFT_EYE:
+ matrix.setPosition(-0.05 * SCALE, 0.05 * SCALE, 0.075 * SCALE);
+ return matrix;
+ case JOINTS.RIGHT_EYE:
+ matrix.setPosition(0.05 * SCALE, 0.05 * SCALE, 0.075 * SCALE);
+ return matrix;
+ case JOINTS.LEFT_EAR:
+ matrix.setPosition(-0.1 * SCALE, 0, 0);
+ return matrix;
+ case JOINTS.RIGHT_EAR:
+ matrix.setPosition(0.1 * SCALE, 0, 0);
+ return matrix;
+ case JOINTS.LEFT_SHOULDER:
+ matrix.setPosition(-0.25 * SCALE, -0.2 * SCALE, 0);
+ return matrix;
+ case JOINTS.RIGHT_SHOULDER:
+ matrix.setPosition(0.25 * SCALE, -0.2 * SCALE, 0);
+ return matrix;
+ case JOINTS.LEFT_ELBOW:
+ matrix.setPosition(-0.3 * SCALE, -0.6 * SCALE, 0);
+ return matrix;
+ case JOINTS.RIGHT_ELBOW:
+ matrix.setPosition(0.3 * SCALE, -0.6 * SCALE, 0);
+ return matrix;
+ case JOINTS.LEFT_WRIST:
+ matrix.setPosition(-0.3 * SCALE, -0.9 * SCALE, 0);
+ return matrix;
+ case JOINTS.RIGHT_WRIST:
+ matrix.setPosition(0.3 * SCALE, -0.9 * SCALE, 0);
+ return matrix;
+ case JOINTS.LEFT_HIP:
+ matrix.setPosition(-0.175 * SCALE, -0.8 * SCALE, 0);
+ return matrix;
+ case JOINTS.RIGHT_HIP:
+ matrix.setPosition(0.175 * SCALE, -0.8 * SCALE, 0);
+ return matrix;
+ case JOINTS.LEFT_KNEE:
+ matrix.setPosition(-0.2 * SCALE, -1.15 * SCALE, 0);
+ return matrix;
+ case JOINTS.RIGHT_KNEE:
+ matrix.setPosition(0.2 * SCALE, -1.15 * SCALE, 0);
+ return matrix;
+ case JOINTS.LEFT_ANKLE:
+ matrix.setPosition(-0.2 * SCALE, -1.6 * SCALE, 0);
+ return matrix;
+ case JOINTS.RIGHT_ANKLE:
+ matrix.setPosition(0.2 * SCALE, -1.6 * SCALE, 0);
+ return matrix;
+ case JOINTS.LEFT_PINKY:
+ matrix.setPosition(-0.3 * SCALE, -1.0 * SCALE, -0.04 * SCALE);
+ return matrix;
+ case JOINTS.RIGHT_PINKY:
+ matrix.setPosition(0.3 * SCALE, -1.0 * SCALE, -0.04 * SCALE);
+ return matrix;
+ case JOINTS.LEFT_INDEX:
+ matrix.setPosition(-0.3 * SCALE, -1.0 * SCALE, 0);
+ return matrix;
+ case JOINTS.RIGHT_INDEX:
+ matrix.setPosition(0.3 * SCALE, -1.0 * SCALE, 0);
+ return matrix;
+ case JOINTS.LEFT_THUMB:
+ matrix.setPosition(-0.3 * SCALE, -0.95 * SCALE, 0.04 * SCALE);
+ return matrix;
+ case JOINTS.RIGHT_THUMB:
+ matrix.setPosition(0.3 * SCALE, -0.95 * SCALE, 0.04 * SCALE);
+ return matrix;
+ case JOINTS.HEAD:
+ return matrix;
+ case JOINTS.NECK:
+ matrix.setPosition(0, -0.2 * SCALE, 0);
+ return matrix;
+ case JOINTS.CHEST:
+ matrix.setPosition(0, -0.4 * SCALE, 0);
+ return matrix;
+ case JOINTS.NAVEL:
+ matrix.setPosition(0, -0.6 * SCALE, 0);
+ return matrix;
+ case JOINTS.PELVIS:
+ matrix.setPosition(0, -0.8 * SCALE, 0);
+ return matrix;
+ default:
+ console.error(`Cannot create dummy joint for joint ${jointId}, not implemented`)
+ return matrix;
+ }
+}
+
+function getDummyBoneMatrix(bone) {
+ const matrix = new THREE.Matrix4();
+ let jointA = new THREE.Vector3().setFromMatrixPosition(getDummyJointMatrix(bone[0]));
+ let jointB = new THREE.Vector3().setFromMatrixPosition(getDummyJointMatrix(bone[1]));
+
+ let pos = new THREE.Vector3(
+ (jointA.x + jointB.x) / 2,
+ (jointA.y + jointB.y) / 2,
+ (jointA.z + jointB.z) / 2,
+ );
+
+ let diff = new THREE.Vector3(jointB.x - jointA.x, jointB.y - jointA.y,
+ jointB.z - jointA.z);
+ let scale = new THREE.Vector3(1, diff.length() / SCALE, 1);
+ diff.normalize();
+
+ let rot = new THREE.Quaternion();
+ rot.setFromUnitVectors(new THREE.Vector3(0, 1, 0),
+ diff);
+
+ matrix.compose(pos, rot, scale);
+
+ return matrix;
+}
+
+function createDummySkeleton() {
+ const dummySkeleton = new THREE.Group();
+
+ dummySkeleton.joints = {};
+ const jointGeometry = new THREE.SphereGeometry(JOINT_RADIUS * SCALE, 12, 12);
+ const material = new THREE.MeshLambertMaterial();
+ dummySkeleton.jointInstancedMesh = new THREE.InstancedMesh(jointGeometry, material, Object.values(JOINTS).length);
+ Object.values(JOINTS).forEach((jointId, i) => {
+ dummySkeleton.joints[jointId] = i;
+ dummySkeleton.jointInstancedMesh.setMatrixAt(i, getDummyJointMatrix(jointId));
+ });
+
+ const boneGeometry = new THREE.CylinderGeometry(BONE_RADIUS * SCALE, BONE_RADIUS * SCALE, SCALE, 3);
+ dummySkeleton.boneInstancedMesh = new THREE.InstancedMesh(boneGeometry, material, Object.values(JOINT_CONNECTIONS).length);
+ Object.values(JOINT_CONNECTIONS).forEach((bone, i) => {
+ dummySkeleton.boneInstancedMesh.setMatrixAt(i, getDummyBoneMatrix(bone));
+ });
+
+ dummySkeleton.add(dummySkeleton.jointInstancedMesh);
+ dummySkeleton.add(dummySkeleton.boneInstancedMesh);
+
+ dummySkeleton.jointNameFromIndex = (index) => {
+ return Object.keys(dummySkeleton.joints).find(key => dummySkeleton.joints[key] === index);
+ }
+
+ dummySkeleton.jointInstancedMesh.joints = dummySkeleton.joints;
+ return dummySkeleton;
+}
+
+/**
+ * Helper function to get the matrix of the ground plane relative to the world
+ * @return {Matrix4} - the matrix of the ground plane relative to the world
+ */
+function getGroundPlaneRelativeMatrix() {
+ let worldSceneNode = realityEditor.sceneGraph.getSceneNodeById(realityEditor.sceneGraph.getWorldId());
+ let groundPlaneSceneNode = realityEditor.sceneGraph.getGroundPlaneNode();
+ let groundPlaneRelativeMatrix = new THREE.Matrix4();
+ setMatrixFromArray(groundPlaneRelativeMatrix, worldSceneNode.getMatrixRelativeTo(groundPlaneSceneNode));
+ return groundPlaneRelativeMatrix;
+}
+
+/**
+ * Helper function to set a matrix from an array
+ * @param {THREE.Matrix4} matrix - the matrix to set
+ * @param {number[]} array - the array to set the matrix from
+ */
+function setMatrixFromArray(matrix, array) {
+ matrix.set( array[0], array[4], array[8], array[12],
+ array[1], array[5], array[9], array[13],
+ array[2], array[6], array[10], array[14],
+ array[3], array[7], array[11], array[15]
+ );
+}
+
+/**
+ * Converts joint positions and confidences from schema JOINTS_V1 to the current JOINTS schema
+ * @param {Object.} jointPositions - dictionary of positions (in/out param)
+ * @param {Object.} jointConfidences - dictionary of confidences (in/out param)
+ */
+function convertFromJointsV1(jointPositions, jointConfidences) {
+
+ // expand with dummy hand joints which positions are collapsed to wrist joint
+
+ [JOINTS.LEFT_THUMB_CMC, JOINTS.LEFT_THUMB_MCP, JOINTS.LEFT_THUMB_IP, JOINTS.LEFT_THUMB_TIP,
+ JOINTS.LEFT_INDEX_FINGER_MCP, JOINTS.LEFT_INDEX_FINGER_PIP, JOINTS.LEFT_INDEX_FINGER_DIP, JOINTS.LEFT_INDEX_FINGER_TIP,
+ JOINTS.LEFT_MIDDLE_FINGER_MCP, JOINTS.LEFT_MIDDLE_FINGER_PIP, JOINTS.LEFT_MIDDLE_FINGER_DIP, JOINTS.LEFT_MIDDLE_FINGER_TIP,
+ JOINTS.LEFT_RING_FINGER_MCP, JOINTS.LEFT_RING_FINGER_PIP, JOINTS.LEFT_RING_FINGER_DIP, JOINTS.LEFT_RING_FINGER_TIP,
+ JOINTS.LEFT_PINKY_MCP, JOINTS.LEFT_PINKY_PIP, JOINTS.LEFT_PINKY_DIP, JOINTS.LEFT_PINKY_TIP
+ ].forEach(joint => {
+ jointPositions[joint] = jointPositions[JOINTS.LEFT_WRIST];
+ jointConfidences[joint] = 0.0;
+ });
+
+ [JOINTS.RIGHT_THUMB_CMC, JOINTS.RIGHT_THUMB_MCP, JOINTS.RIGHT_THUMB_IP, JOINTS.RIGHT_THUMB_TIP,
+ JOINTS.RIGHT_INDEX_FINGER_MCP, JOINTS.RIGHT_INDEX_FINGER_PIP, JOINTS.RIGHT_INDEX_FINGER_DIP, JOINTS.RIGHT_INDEX_FINGER_TIP,
+ JOINTS.RIGHT_MIDDLE_FINGER_MCP, JOINTS.RIGHT_MIDDLE_FINGER_PIP, JOINTS.RIGHT_MIDDLE_FINGER_DIP, JOINTS.RIGHT_MIDDLE_FINGER_TIP,
+ JOINTS.RIGHT_RING_FINGER_MCP, JOINTS.RIGHT_RING_FINGER_PIP, JOINTS.RIGHT_RING_FINGER_DIP, JOINTS.RIGHT_RING_FINGER_TIP,
+ JOINTS.RIGHT_PINKY_MCP, JOINTS.RIGHT_PINKY_PIP, JOINTS.RIGHT_PINKY_DIP, JOINTS.RIGHT_PINKY_TIP
+ ].forEach(joint => {
+ jointPositions[joint] = jointPositions[JOINTS.RIGHT_WRIST];
+ jointConfidences[joint] = 0.0;
+ });
+
+}
+
+/**
+ * Converts joint positions and confidences from schema JOINTS_V2 to the current JOINTS schema
+ * @param {Object.} jointPositions - dictionary of positions (in/out param)
+ * @param {Object.} jointConfidences - dictionary of confidences (in/out param)
+ */
+function convertFromJointsV2(jointPositions, jointConfidences) {
+ convertFromJointsV1(jointPositions, jointConfidences)
+}
+
+export {
+ JOINT_NODE_NAME,
+ JOINT_PUBLIC_DATA_KEYS,
+ isHumanPoseObject,
+ makePoseData,
+ getPoseObjectName,
+ getPoseStringFromObject,
+ //getMockPoseStandingFarAway,
+ getGroundPlaneRelativeMatrix,
+ setMatrixFromArray,
+ indexOfMin,
+ getJointNodeInfo,
+ createDummySkeleton,
+ convertFromJointsV1,
+ convertFromJointsV2
+};
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 000000000..f56c0e169
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,538 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/**********************************************************************************************************************
+ ******************************************** global namespace *******************************************************
+ **********************************************************************************************************************/
+
+window.objects = {}; // TODO: this is a duplicate definition from src/objects.js
+
+// this is an empty template that mirrors the src/ file tree. Used for auto-completion.
+// the code will run correctly without this assuming you call:
+// createNameSpace("realityEditor.[module].[etc]") correctly at the top of each file
+window.realityEditor = {
+ ai: {
+ mapping: {},
+ crc: {},
+ },
+ app: {
+ callbacks: {},
+ promises: {},
+ targetDownloader: {},
+ pathfinding: {}
+ },
+ device: {
+ distanceScaling: {},
+ environment: {},
+ keyboardEvents: {},
+ layout: {},
+ modeTransition: {},
+ onLoad: {},
+ profiling: {},
+ touchInputs: {},
+ touchPropagation: {},
+ tracking: {},
+ utilities: {},
+ videoRecording: {}
+ },
+ gui: {
+ ar: {
+ anchors: {},
+ areaCreator: {},
+ areaTargetScanner: {},
+ draw: {},
+ frameHistoryRenderer: {},
+ groundPlaneAnchors: {},
+ groundPlaneRenderer: {},
+ grouping: {},
+ lines: {},
+ meshLine: {},
+ moveabilityOverlay: {},
+ positioning: {},
+ utilities: {},
+ videoPlayback: {}
+ },
+ spatial: {
+ whereIs: {},
+ draw: {},
+ timeRecorder: {},
+ },
+ crafting: {
+ blockMenu: {},
+ eventHandlers: {},
+ eventHelper: {},
+ grid: {},
+ utilities: {}
+ },
+ memory: {
+ nodeMemories: {},
+ pointer: {}
+ },
+ settings: { // todo: combine gui/settings/index.js with gui/settings.js
+ logo: {},
+ states: {},
+ setupSettingsMenu: {}
+ },
+ buttons: {},
+ dropdown: {},
+ glRenderer: {},
+ menus:{},
+ modal: {},
+ moveabilityCorners: {},
+ navigation: {},
+ pocket: {},
+ screenExtension : {},
+ shaders: {},
+ threejsScene: {},
+ spatialIndicator: {},
+ spatialArrow: {},
+ utilities: {}
+ },
+ measure: {
+ clothSimulation: {},
+ },
+ network: {
+ discovery: {},
+ frameContentAPI: {},
+ availableFrames: {},
+ realtime: {},
+ search: {},
+ utilities: {}
+ },
+ sceneGraph: {
+ sceneNode: {},
+ network: {}
+ },
+ envelopeManager: {},
+ moduleCallbacks: {},
+ worldObjects: {},
+ spatialCursor: {
+ shader: {
+ normalCursorFragmentShader: {},
+ colorCursorFragmentShader: {},
+ vertexShader: {},
+ }
+ },
+ statusPage: {},
+ spatialCapture: {},
+ avatar: {
+ network: {},
+ draw: {},
+ iconMenu: {},
+ utils: {}
+ },
+ humanPose: {
+ network: {},
+ draw: {},
+ rebaScore: {},
+ utils: {}
+ },
+ oauth: {}
+};
+
+/**
+ * @desc This function generates all required namespaces and initializes a namespace if not existing.
+ * Additional it includes pointers to each subspace.
+ *
+ * Inspired by code examples from:
+ * https://www.kenneth-truyers.net/2013/04/27/javascript-namespaces-and-modules/
+ *
+ * @param {string} namespace string of the full namespace path
+ * @return {*} object that presents the actual used namespace
+ **/
+window.createNameSpace = function createNameSpace(namespace) {
+ var splitNameSpace = namespace.split("."), object = this, object2;
+ for (var i = 0; i < splitNameSpace.length; i++) {
+ object = object[splitNameSpace[i]] = object[splitNameSpace[i]] || {};
+ object2 = this;
+ for (var e = 0; e < i; e++) {
+ object2 = object2[splitNameSpace[e]];
+ object[splitNameSpace[e]] = object[splitNameSpace[e]] || object2;
+ object.cout = this.cout;
+ }
+ }
+ return object;
+};
+
+createNameSpace("realityEditor");
+
+realityEditor.objects = objects;
+
+if (typeof shadowObjects !== "undefined") {
+ realityEditor.shadowObjects = shadowObjects;
+}
+
+realityEditor.getShadowObject = function (objectKey){
+ if(!objectKey) return null;
+
+ if(!this.shadowObjects[objectKey]){
+ this.shadowObjects[objectKey] = {};
+ this.shadowObjects[objectKey].frames = {};
+ }
+ return this.shadowObjects[objectKey];
+};
+
+realityEditor.getShadowFrame = function (objectKey, frameKey){
+ if(!objectKey) return null;
+ if(!frameKey) return null;
+
+ if(!this.shadowObjects[objectKey]){
+ this.shadowObjects[objectKey] = {};
+ this.shadowObjects[objectKey].frames = {};
+ }
+ if(!this.shadowObjects[objectKey].frames[frameKey]){
+ this.shadowObjects[objectKey].frames[frameKey] = {};
+ this.shadowObjects[objectKey].links = {};
+ this.shadowObjects[objectKey].nodes = {};
+ }
+ return this.shadowObjects[objectKey].frames[frameKey];
+};
+
+realityEditor.getShadowNode = function (objectKey, frameKey, nodeKey){
+ if(!objectKey) return null;
+ if(!frameKey) return null;
+ if(!nodeKey) return null;
+
+ if(!this.shadowObjects[objectKey]){
+ this.shadowObjects[objectKey] = {};
+ this.shadowObjects[objectKey].frames = {};
+ }
+ if(!this.shadowObjects[objectKey].frames[frameKey]){
+ this.shadowObjects[objectKey].frames[frameKey] = {};
+ this.shadowObjects[objectKey].links = {};
+ this.shadowObjects[objectKey].nodes = {};
+ }
+
+ if(!this.shadowObjects[objectKey].frames[frameKey].nodes[nodeKey]){
+ this.shadowObjects[objectKey].frames[frameKey].nodes[nodeKey] = {};
+ }
+ return this.shadowObjects[objectKey].frames[frameKey].nodes[nodeKey] ;
+};
+
+/**
+ * return the object given its uuid
+ * @param {string} objectKey
+ * @return {Objects|null}
+ */
+realityEditor.getObject = function (objectKey) {
+ if(!objectKey) return null;
+ if(!(objectKey in this.objects)) return null;
+ return this.objects[objectKey];
+};
+
+/**
+ * return a frame located in the object given both uuids
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @return {Frame|null}
+ */
+realityEditor.getFrame = function (objectKey, frameKey) {
+ if(!objectKey) return null;
+ if(!frameKey) return null;
+ if(!(objectKey in this.objects)) return null;
+ if(!(frameKey in this.objects[objectKey].frames)) return null;
+ return this.objects[objectKey].frames[frameKey];
+};
+
+/**
+ * return a node located in the object frame given all their uuids
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @return {Node|null}
+ */
+realityEditor.getNode = function (objectKey, frameKey, nodeKey) {
+ if(!objectKey) return null;
+ if(!frameKey) return null;
+ if(!nodeKey) return null;
+ if(!(objectKey in this.objects)) return null;
+ if(!(frameKey in this.objects[objectKey].frames)) return null;
+ if(!(nodeKey in this.objects[objectKey].frames[frameKey].nodes)) return null;
+ return this.objects[objectKey].frames[frameKey].nodes[nodeKey];
+};
+
+/**
+ * Returns the frame or node specified by the path, if one exists.
+ * Pass in null for nodeKey (or exclude it altogether) to get a frame, otherwise tries to find the node
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string|undefined} nodeKey
+ * @return {Frame|Node|null}
+ */
+realityEditor.getVehicle = function(objectKey, frameKey, nodeKey) {
+ if (nodeKey) {
+ return realityEditor.getNode(objectKey, frameKey, nodeKey);
+ } else {
+ return realityEditor.getFrame(objectKey, frameKey);
+ }
+};
+
+/**
+ * return a link located in a frame
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} linkKey
+ * @return {Link|null}
+ */
+realityEditor.getLink = function (objectKey, frameKey, linkKey){
+ if(!objectKey) return null;
+ if(!frameKey) return null;
+ if(!linkKey) return null;
+ if(!(objectKey in this.objects)) return null;
+ if(!(frameKey in this.objects[objectKey].frames)) return null;
+ if(!(linkKey in this.objects[objectKey].frames[frameKey].links)) return null;
+ return this.objects[objectKey].frames[frameKey].links[linkKey];
+};
+
+/**
+ * return a block in a logic node
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {Block} block
+ * @return {Block|null}
+ */
+realityEditor.getBlock = function (objectKey, frameKey, nodeKey, block){
+ if(!objectKey) return null;
+ if(!frameKey) return null;
+ if(!nodeKey) return null;
+ if(!block) return null;
+ if(!(objectKey in this.objects)) return null;
+ if(!(frameKey in this.objects[objectKey].frames)) return null;
+ if(!(nodeKey in this.objects[objectKey].frames[frameKey].nodeKey)) return null;
+ if(!(block in this.objects[objectKey].frames[frameKey].nodes[nodeKey].blocks)) return null;
+ return this.objects[objectKey].frames[frameKey].nodes[nodeKey].blocks[block];
+};
+
+/**
+ * return a block link in a logic node
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {string} linkKey
+ * @return {BlockLink|null}
+ */
+realityEditor.getBlockLink = function (objectKey, frameKey, nodeKey, linkKey){
+ if(!objectKey) return null;
+ if(!frameKey) return null;
+ if(!nodeKey) return null;
+ if(!linkKey) return null;
+ if(!(objectKey in this.objects)) return null;
+ if(!(frameKey in this.objects[objectKey].frames)) return null;
+ if(!(nodeKey in this.objects[objectKey].frames[frameKey].nodeKey)) return null;
+ if(!(linkKey in this.objects[objectKey].frames[frameKey].nodes[nodeKey].links)) return null;
+ return this.objects[objectKey].frames[frameKey].nodes[nodeKey].links[linkKey];
+};
+
+// helper methods to cleanly iterate over all objects / frames / nodes
+
+/**
+ * Perform the callback with each (object, objectKey) pair for all objects
+ * @param {function} callback
+ */
+realityEditor.forEachObject = function(callback){
+ for (var objectKey in objects) {
+ var object = realityEditor.getObject(objectKey);
+ if (object) {
+ callback(object, objectKey);
+ }
+ }
+};
+
+/**
+ * Perform the callback on each (objectKey, frameKey, nodeKey) pair for all objects, frames, and nodes
+ * @param {function} callback
+ */
+realityEditor.forEachNodeInAllObjects = function(callback) {
+ for (var objectKey in objects) {
+ realityEditor.forEachNodeInObject(objectKey, callback);
+ }
+};
+
+/**
+ * Perform the callback on each (objectKey, frameKey, nodeKey) pair for the given object
+ * @param {string} objectKey
+ * @param {function} callback
+ */
+realityEditor.forEachNodeInObject = function(objectKey, callback) {
+ var object = realityEditor.getObject(objectKey);
+ if (!object) return;
+ for (var frameKey in object.frames) {
+ // if (!object.frames.hasOwnProperty(frameKey)) continue;
+ realityEditor.forEachNodeInFrame(objectKey, frameKey, callback);
+ }
+};
+
+/**
+ * Perform the callback for each (objectKey, frameKey, nodeKey) pair for the given frame
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {function} callback
+ */
+realityEditor.forEachNodeInFrame = function(objectKey, frameKey, callback) {
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ if (!frame) return;
+ for (var nodeKey in frame.nodes) {
+ // if (!frame.nodes.hasOwnProperty(nodeKey)) continue;
+ callback(objectKey, frameKey, nodeKey);
+ }
+};
+
+/**
+ * Perform the callback on each (objectKey, frameKey, nodeKey) pair for all objects, frames, and nodes
+ * @param {function} callback
+ */
+realityEditor.forEachFrameInAllObjects = function(callback) {
+ for (var objectKey in objects) {
+ realityEditor.forEachFrameInObject(objectKey, callback);
+ }
+};
+
+/**
+ * Perform the callback for each (objectKey, frameKey) pair for the given object
+ * @param {string} objectKey
+ * @param {function} callback
+ * @todo: simplify signature: doesnt need to include objectKey in callback since its an arg
+ */
+realityEditor.forEachFrameInObject = function(objectKey, callback) {
+ var object = realityEditor.getObject(objectKey);
+ if (!object) return;
+ for (var frameKey in object.frames) {
+ // if (!object.frames.hasOwnProperty(frameKey)) continue;
+ callback(objectKey, frameKey);
+ }
+};
+
+realityEditor.vehicleKeyCache = {}; // improves efficiency of getKeysFromVehicle by saving the search results
+
+/**
+ * Extracts the object and/or frame and/or node keys depending on the type of vehicle
+ * @param {Objects|Frame|Node} vehicle
+ * @return {{objectKey: string|null, frameKey: string|null, nodeKey: string|null}}
+ */
+realityEditor.getKeysFromVehicle = function(vehicle) {
+
+ // load from cache if possible
+ if (typeof vehicle.uuid !== 'undefined') {
+ if (typeof this.vehicleKeyCache[vehicle.uuid] !== 'undefined') {
+ return this.vehicleKeyCache[vehicle.uuid];
+ }
+ }
+
+ var objectKey = null;
+ var frameKey = null;
+ var nodeKey = null;
+
+ if (typeof vehicle.objectId !== 'undefined') {
+ objectKey = vehicle.objectId;
+ }
+ if (typeof vehicle.frameId !== 'undefined') {
+ frameKey = vehicle.frameId;
+ }
+ if (typeof vehicle.uuid !== 'undefined' || (typeof vehicle.type !== 'undefined' && vehicle.type !== 'ui')) {
+ if (objectKey && frameKey) {
+ if (typeof vehicle.uuid === 'undefined') {
+ vehicle.uuid = frameKey + vehicle.name;
+ }
+ nodeKey = vehicle.uuid;
+ } else if (objectKey) {
+ frameKey = vehicle.uuid;
+ } else {
+ objectKey = vehicle.uuid;
+ }
+ }
+
+ this.vehicleKeyCache[vehicle.uuid] = {
+ objectKey: objectKey,
+ frameKey: frameKey,
+ nodeKey: nodeKey
+ };
+
+ return this.vehicleKeyCache[vehicle.uuid];
+};
+
+/**
+ * Helper function to check if the argument is a frame or if it's a node
+ * @param {Frame|Node} vehicle
+ * @return {boolean}
+ */
+realityEditor.isVehicleAFrame = function(vehicle) {
+ return (vehicle.type === 'ui' || typeof vehicle.type === 'undefined');
+};
+
+/**
+ * Helper function loops over all links on all objects to find ones starting or ending at this node
+ * @param {string} nodeKey
+ * @return {{linksToNode: Array. , linksFromNode: Array. }}
+ */
+realityEditor.getLinksToAndFromNode = function(nodeKey) {
+ let linksToNode = [];
+ let linksFromNode = [];
+
+ // loop through all frames
+ realityEditor.forEachFrameInAllObjects(function(thatObjectKey, thatFrameKey) {
+ var thatFrame = realityEditor.getFrame(thatObjectKey, thatFrameKey);
+
+ // loop through all links in that frame
+ for (var linkKey in thatFrame.links) {
+ var link = thatFrame.links[linkKey];
+
+ if (link.nodeA === nodeKey) {
+ linksFromNode.push(link);
+ } else if (link.nodeB === nodeKey) {
+ linksToNode.push(link);
+ }
+ }
+ });
+
+ return {
+ linksToNode: linksToNode,
+ linksFromNode: linksFromNode
+ };
+};
diff --git a/src/measure/clothSimulation.js b/src/measure/clothSimulation.js
new file mode 100644
index 000000000..0e3721b57
--- /dev/null
+++ b/src/measure/clothSimulation.js
@@ -0,0 +1,910 @@
+createNameSpace("realityEditor.measure.clothSimulation");
+
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+import { CSS2DObject } from '../../thirdPartyCode/three/CSS2DRenderer.js';
+
+(function (exports) {
+
+ const UNIT_SCALE = 1;
+
+ let CLOTH_INTERVAL_ID = null;
+ let CLOTH_INTERVAL_MULTIPLIER = 30;
+ let CLOTH_COUNT = 0;
+ let worldId = null;
+ let cachedOcclusionObject = null, inverseGroundPlaneMatrix = null, intervalId = null;
+ let raycastPosOffset = null;
+ let isOnDesktop = undefined;
+
+ function initService() {
+ if (cachedOcclusionObject === null || cachedOcclusionObject === undefined || inverseGroundPlaneMatrix === null || inverseGroundPlaneMatrix === undefined) {
+ intervalId = setInterval(() => {
+ if (cachedOcclusionObject !== null && cachedOcclusionObject !== undefined && inverseGroundPlaneMatrix !== null && inverseGroundPlaneMatrix !== undefined) {
+ if (realityEditor.device.environment.isDesktop()) {
+ isOnDesktop = true;
+ raycastPosOffset = new THREE.Vector3().setFromMatrixPosition(inverseGroundPlaneMatrix).y;
+ } else {
+ isOnDesktop = false;
+ raycastPosOffset = 0;
+ }
+ clearInterval(intervalId);
+ }
+ if (worldId === null || worldId === undefined || cachedOcclusionObject === null || cachedOcclusionObject === undefined) {
+ worldId = realityEditor.sceneGraph.getWorldId();
+ if (worldId === null) return;
+ cachedOcclusionObject = realityEditor.gui.threejsScene.getObjectForWorldRaycasts(worldId);
+ }
+ if (realityEditor.sceneGraph.getGroundPlaneNode() !== undefined) {
+ let groundPlaneMatrix = realityEditor.sceneGraph.getGroundPlaneNode().worldMatrix;
+ inverseGroundPlaneMatrix = new THREE.Matrix4();
+ realityEditor.gui.threejsScene.setMatrixFromArray(inverseGroundPlaneMatrix, groundPlaneMatrix);
+ inverseGroundPlaneMatrix.invert();
+ }
+ }, 1000);
+ }
+ setupEventListeners();
+ }
+
+ function setupEventListeners() {
+ realityEditor.network.addPostMessageHandler('measureAppSetClothPos', (msgData, fullMessageData) => {
+ if (!isOnDesktop) return; // todo Steve: for now, if on mobile, disable the cloth simulation function since it's both inaccurate & slows down the entire app
+ // todo Steve: besides bounding box info, we also need the volume object's uuid (b/c when finish calculating the cloth volume, we need to send & add the cloth & text under the corresponding bigParentObj
+ // we also need to have the original text label position, b/c we need to place the accurate cloth text label close by the original box volume label
+ if (msgData.uuid === undefined || msgData.boundingBoxMin === undefined || msgData.boundingBoxMax === undefined) return;
+ let min = msgData.boundingBoxMin;
+ let max = msgData.boundingBoxMax;
+ let x = max.x - min.x;
+ let y = max.y - min.y;
+ let z = max.z - min.z;
+ let center = new THREE.Vector3((max.x + min.x) / 2, (max.y + min.y) / 2, (max.z + min.z) / 2);
+ initCloth(x, y, z, center, null, msgData.uuid, fullMessageData.object, fullMessageData.frame);
+
+ balanceLoad();
+ })
+ }
+
+ function balanceLoad() {
+ clearInterval(CLOTH_INTERVAL_ID);
+ if (CLOTH_COUNT === 0) return;
+ CLOTH_INTERVAL_ID = setInterval(() => {
+ update();
+ }, CLOTH_INTERVAL_MULTIPLIER * CLOTH_COUNT);
+ }
+
+ const MASS = 0.1;
+ const DAMPING = 0.03;
+ const DRAG = 1 - DAMPING;
+
+ class Particle {
+ constructor(indices, pos, bufferGeoIndex) {
+ this.indices = {
+ x: indices.x,
+ y: indices.y,
+ z: indices.z
+ };
+ this.position = new THREE.Vector3().copy(pos);
+ this.previous = new THREE.Vector3().copy(pos);
+ this.original = new THREE.Vector3().copy(pos); // forgot why this is useful. need to look at original cloth sim code again
+ this.normal = new THREE.Vector3();
+ this.bufferGeoIndex = bufferGeoIndex;
+
+ this.hasCollided = false;
+ this.collided = false;
+
+ this.a = new THREE.Vector3(0, 0, 0); // acceleration
+ this._mass = MASS;
+ this.invMass = 1 / MASS;
+ this.tmp = new THREE.Vector3();
+ this.tmp2 = new THREE.Vector3();
+ this.tmp3 = new THREE.Vector3();
+ }
+
+ getIndex() {
+ return this.bufferGeoIndex;
+ }
+
+ _getIndices() {
+ return this.indices;
+ }
+
+ addForce(force) {
+ this.a.add(this.tmp2.copy(force).multiplyScalar(this.invMass));
+ }
+
+ integrate(timesq) {
+ if (this.hasCollided) return; // todo Steve: completely immobilize the collided particle. Attempt to make the system stable
+ let newPos = this.tmp.subVectors(this.position, this.previous);
+ newPos.multiplyScalar(DRAG).add(this.position);
+ newPos.add(this.a.multiplyScalar(timesq));
+
+ // add an upper limit to the difference between the 2 positions, cannot exceed the collision threshold or the satisfy constraint threshold, to avoid overshooting past & not collide with the mesh
+ this.tmp3.subVectors(newPos, this.position);
+ let length = this.tmp3.length();
+ if (length > COLLIDE_THRESHOLD) { // todo Steve: besides inside satisfyConstraints we should make sure each particle doesn't move past collision distance & their own constraint,
+ // todo Steve: we should also add here to make sure that they don't move past their own constraint
+ // maybe we should implement a new "integrate" function that adds up all the moves during this time step, and use a Math.min function to limit the overall transformation
+ newPos = this.position.clone().add(this.tmp3.normalize().multiplyScalar(COLLIDE_THRESHOLD));
+ }
+
+ this.tmp = this.previous;
+ this.previous = this.position;
+ this.position = newPos;
+
+ this.a.set(0, 0, 0);
+ }
+
+ collide(pos) { // particle collide with mesh, cannot move further. Should later change this to include bounding off force
+ this.hasCollided = true;
+ this.collided = true;
+
+ this.previous.copy(pos);
+ this.position.copy(pos);
+
+ this.a.set(0, 0, 0);
+ }
+ }
+
+ function xyzIndexToParticleKey(x, y, z) {
+ return `(${x},${y},${z})`;
+ }
+
+ class Particles {
+ constructor() {
+ this.map = new Map();
+ }
+
+ // Insert an object with x, y, z
+ push(x, y, z, particle) {
+ const key = xyzIndexToParticleKey(x, y, z);
+ this.map.set(key, particle);
+ }
+
+ // Get an object with x, y, z
+ get(x, y, z) {
+ const key = xyzIndexToParticleKey(x, y, z);
+ return this.map.get(key);
+ }
+
+ // Check if an object with x, y, z exists
+ has(x, y, z) {
+ const key = xyzIndexToParticleKey(x, y, z);
+ return this.map.has(key);
+ }
+
+ // Remove an object with x, y, z
+ remove(x, y, z) {
+ const key = xyzIndexToParticleKey(x, y, z);
+ this.map.delete(key);
+ }
+ }
+
+ let particles = new Particles();
+ let particlesPosArr = []; // particles position array for building buffer geometry
+ // todo Steve: particles UV array for building buffer geometry, currently not able to make uv's (to make a wireframe-w/o-diagonal custom shader) due to the way we use the same particle vertex position for multiple faces / indices.
+ // also cannot make everything BoxGeometry & get the points from the BoxGeometry points, b/c this way each vertex would have 3 different normals, which doesn't make sense which normal to use when raycasting to mesh bvh objects. Figure out a way to solve this problem.
+ let particlesIndexArr = [];
+ let restDistance = null;
+ let xLength = null, yLength = null, zLength = null;
+ let xSegs = null, ySegs = null, zSegs = null;
+ let center = null;
+
+ function makeParticles(x, y, z, meshCenter, dist) {
+ xLength = x;
+ yLength = y;
+ zLength = z;
+ restDistance = dist;
+ COLLIDE_THRESHOLD = Math.min(COLLIDE_THRESHOLD, restDistance / 2);
+ center = meshCenter;
+ xSegs = Math.ceil(xLength / restDistance);
+ ySegs = Math.ceil(yLength / restDistance);
+ zSegs = Math.ceil(zLength / restDistance);
+
+ let bufferGeoIndex = 0;
+ const indices = {x: null, y: null, z: null};
+ const pos = new THREE.Vector3();
+
+ particles = new Particles();
+ particlesPosArr = [];
+
+ const makeParticleIndices = (xIndex, yIndex, zIndex) => {
+ indices.x = xIndex;
+ indices.y = yIndex;
+ indices.z = zIndex;
+ };
+ const makeParticlePosition = (xIndex, yIndex, zIndex) => {
+ pos.set(xIndex * restDistance - xLength / 2 + center.x, yIndex * restDistance - yLength / 2 + center.y, zIndex * restDistance - zLength / 2 + center.z);
+ particlesPosArr.push(pos.x, pos.y, pos.z);
+ };
+ const makeParticleInfo = (xIndex, yIndex, zIndex) => {
+ makeParticleIndices(xIndex, yIndex, zIndex);
+ makeParticlePosition(xIndex, yIndex, zIndex);
+ }
+
+ // another method for generating the particles, 10 x 10 x 10 instead of 11 x 11 x 11
+ // for (let zIndex = 0; zIndex < zSegs; zIndex++) {
+ // for (let xIndex = 0; xIndex < xSegs; xIndex++) {
+ // pos.set((xIndex+0.5) * restDistance - xLength / 2, (0+0.5) * restDistance - yLength / 2, (zIndex+0.5) * restDistance - zLength / 2);
+ // }
+ // }
+ // bottom layer, iterate 11 x 11 times, y === 0
+ for (let z = 0; z <= zSegs; z++) {
+ for (let x = 0; x <= xSegs; x++) {
+ makeParticleInfo(x, 0, z);
+ particles.push(x, 0, z, new Particle(indices, pos, bufferGeoIndex++));
+ }
+ }
+ // front face, iterate 10 x 9 times, z === zSegs
+ for (let y = 1; y < ySegs; y++) {
+ for (let x = 0; x < xSegs; x++) {
+ makeParticleInfo(x, y, zSegs);
+ particles.push(x, y, zSegs, new Particle(indices, pos, bufferGeoIndex++));
+ }
+ }
+ // right face, x === xSegs
+ for (let y = 1; y < ySegs; y++) {
+ for (let z = zSegs; z > 0; z--) {
+ makeParticleInfo(xSegs, y, z);
+ particles.push(xSegs, y, z, new Particle(indices, pos, bufferGeoIndex++));
+ }
+ }
+ // back face, z === 0
+ for (let y = 1; y < ySegs; y++) {
+ for (let x = xSegs; x > 0; x--) {
+ makeParticleInfo(x, y, 0);
+ particles.push(x, y, 0, new Particle(indices, pos, bufferGeoIndex++));
+ }
+ }
+ // left face, x === 0
+ for (let y = 1; y < ySegs; y++) {
+ for (let z = 0; z < zSegs; z++) {
+ makeParticleInfo(0, y, z);
+ particles.push(0, y, z, new Particle(indices, pos, bufferGeoIndex++));
+ }
+ }
+ // top layer, y === ySegs
+ for (let z = 0; z <= zSegs; z++) {
+ for (let x = 0; x <= xSegs; x++) {
+ makeParticleInfo(x, ySegs, z);
+ particles.push(x, ySegs, z, new Particle(indices, pos, bufferGeoIndex++));
+ }
+ }
+ return particles;
+ }
+
+ function _addSphere(pos) {
+ let sphere = new THREE.Mesh(sphereGeo, sphereMatRed);
+ sphere.position.copy(pos);
+ realityEditor.gui.threejsScene.addToScene(sphere, {layers: 1});
+ }
+
+ let constraints = [];
+ let lineGeo;
+ const lineMatYellow = new THREE.LineBasicMaterial({color: 0xffff00});
+
+ function makeConstraints(isVisualize = false) {
+ constraints = [];
+ let particle1 = null, particle2 = null, particle3 = null;
+ // bottom layer constraints, y === 0, iterate 9 x 9 + 9 + 9 times
+ for (let z = 1; z < zSegs; z++) {
+ for (let x = 1; x < xSegs; x++) {
+ particle1 = particles.get(x, 0, z);
+ particle2 = particles.get(x + 1, 0, z);
+ particle3 = particles.get(x, 0, z + 1);
+ constraints.push([particle1, particle2, restDistance]);
+ constraints.push([particle1, particle3, restDistance]);
+
+ if (isVisualize) {
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle2.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle3.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ }
+ }
+ }
+ for (let z = 1; z < zSegs; z++) {
+ particle1 = particles.get(0, 0, z);
+ particle2 = particles.get(1, 0, z);
+ constraints.push([particle1, particle2, restDistance]);
+
+ if (isVisualize) {
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle2.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ }
+ }
+ for (let x = 1; x < xSegs; x++) {
+ particle1 = particles.get(x, 0, 0);
+ particle2 = particles.get(x, 0, 1);
+ constraints.push([particle1, particle2, restDistance]);
+
+ if (isVisualize) {
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle2.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ }
+ }
+ // front face constraints, z === zSegs, iterate 10 x 10 times
+ for (let y = 0; y < ySegs; y++) {
+ for (let x = 0; x < xSegs; x++) {
+ particle1 = particles.get(x, y, zSegs);
+ particle2 = particles.get(x + 1, y, zSegs);
+ particle3 = particles.get(x, y + 1, zSegs);
+ constraints.push([particle1, particle2, restDistance]);
+ constraints.push([particle1, particle3, restDistance]);
+
+ if (isVisualize) {
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle2.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle3.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ }
+ }
+ }
+ // right face constraints, x === xSegs, iterate 10 x 10 times
+ for (let y = 0; y < ySegs; y++) {
+ for (let z = zSegs; z > 0; z--) {
+ particle1 = particles.get(xSegs, y, z);
+ particle2 = particles.get(xSegs, y + 1, z);
+ particle3 = particles.get(xSegs, y, z - 1);
+ constraints.push([particle1, particle2, restDistance]);
+ constraints.push([particle1, particle3, restDistance]);
+
+ if (isVisualize) {
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle2.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle3.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ }
+ }
+ }
+ // back face indices, z === 0, iterate 10 x 10 times
+ for (let y = 0; y < ySegs; y++) {
+ for (let x = xSegs; x > 0; x--) {
+ particle1 = particles.get(x, y, 0);
+ particle2 = particles.get(x - 1, y, 0);
+ particle3 = particles.get(x, y + 1, 0);
+ constraints.push([particle1, particle2, restDistance]);
+ constraints.push([particle1, particle3, restDistance]);
+
+ if (isVisualize) {
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle2.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle3.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ }
+ }
+ }
+ // left face indices, x === 0, iterate 10 x 10 times
+ for (let y = 0; y < ySegs; y++) {
+ for (let z = 0; z < zSegs; z++) {
+ particle1 = particles.get(0, y, z);
+ particle2 = particles.get(0, y + 1, z);
+ particle3 = particles.get(0, y, z + 1);
+ constraints.push([particle1, particle2, restDistance]);
+ constraints.push([particle1, particle3, restDistance]);
+
+ if (isVisualize) {
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle2.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle3.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ }
+ }
+ }
+ // top layer constraints, y === ySegs, iterate 10 x 10 + 10 + 10 times, normal facing upwards
+ for (let z = 0; z < zSegs; z++) {
+ for (let x = 0; x < xSegs; x++) {
+ particle1 = particles.get(x, ySegs, z);
+ particle2 = particles.get(x + 1, ySegs, z);
+ particle3 = particles.get(x, ySegs, z + 1);
+ constraints.push([particle1, particle2, restDistance]);
+ constraints.push([particle1, particle3, restDistance]);
+
+ if (isVisualize) {
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle2.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle3.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ }
+ }
+ }
+ for (let z = 0; z < zSegs; z++) {
+ particle1 = particles.get(xSegs, ySegs, z);
+ particle2 = particles.get(xSegs, ySegs, z + 1);
+ constraints.push([particle1, particle2, restDistance]);
+
+ if (isVisualize) {
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle2.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ }
+ }
+ for (let x = 0; x < xSegs; x++) {
+ particle1 = particles.get(x, ySegs, zSegs);
+ particle2 = particles.get(x + 1, ySegs, zSegs);
+ constraints.push([particle1, particle2, restDistance]);
+
+ if (isVisualize) {
+ lineGeo = new THREE.BufferGeometry().setFromPoints([particle1.original, particle2.original]);
+ realityEditor.gui.threejsScene.addToScene(new THREE.Line(lineGeo, lineMatYellow), {layers: 1});
+ }
+ }
+ return constraints;
+ }
+
+ let pins = [];
+
+ function makePins(_isVisualize) {
+ pins = [];
+ function _makePin(x, y, z) {
+ let pinParticle = particles.get(x, y, z);
+ // if (_isVisualize) _addSphere(pinParticle.original);
+ pins.push(pinParticle)
+ }
+
+ // top 4 corners pinned down, cannot move
+ // _makePin(0, ySegs, 0);
+ // _makePin(xSegs, ySegs, 0);
+ // _makePin(xSegs, ySegs, zSegs);
+ // _makePin(0, ySegs, zSegs);
+
+ // bottom 4 corners pinned down
+ // _makePin(0, 0, 0);
+ // _makePin(xSegs, 0, 0);
+ // _makePin(xSegs, 0, zSegs);
+ // _makePin(0, 0, zSegs);
+
+ // all outer edges pinned down
+ // for (let x = 0; x <= xSegs; x++) {
+ // _makePin(x, 0, 0);
+ // _makePin(x, ySegs, 0);
+ // _makePin(x, 0, zSegs);
+ // _makePin(x, ySegs, zSegs);
+ // }
+ // for (let y = 0; y <= ySegs; y++) {
+ // _makePin(0, y, 0);
+ // _makePin(xSegs, y, 0);
+ // _makePin(0, y, zSegs);
+ // _makePin(xSegs, y, zSegs);
+ // }
+ // for (let z = 0; z <= zSegs; z++) {
+ // _makePin(0, 0, z);
+ // _makePin(xSegs, 0, z);
+ // _makePin(0, ySegs, z);
+ // _makePin(xSegs, ySegs, z);
+ // }
+
+
+ // top center point pinned down
+ // _makePin(Math.ceil(xSegs / 2), ySegs, Math.ceil(zSegs / 2));
+
+ // entire top face pinned down
+ // for (let z = 0; z <= zSegs; z++) {
+ // for (let x = 0; x <= xSegs; x++) {
+ // _makePin(x, ySegs, z);
+ // }
+ // }
+
+ // todo Steve: can try to pin down the 8 corners of the Box while simulating in the AreaTarget mesh
+
+ // entire bottom face pinned down
+ for (let z = 0; z <= zSegs; z++) {
+ for (let x = 0; x <= xSegs; x++) {
+ _makePin(x, 0, z);
+ }
+ }
+ return pins;
+ }
+
+ function makeBufferGeometryIndexArr() {
+ particlesIndexArr = [];
+ let idx0 = null, idx1 = null, idx2 = null, idx3 = null;
+ // bottom layer indices, y === 0, iterate 10 x 10 times, normal facing downwards
+ for (let z = 0; z < zSegs; z++) {
+ for (let x = 0; x < xSegs; x++) {
+ idx0 = particles.get(x, 0, z).getIndex();
+ idx1 = particles.get(x + 1, 0, z).getIndex();
+ idx2 = particles.get(x + 1, 0, z + 1).getIndex();
+ idx3 = particles.get(x, 0, z + 1).getIndex();
+ particlesIndexArr.push(idx0, idx1, idx2, idx0, idx2, idx3);
+ }
+ }
+ // front face indices, z === zSegs, iterate 10 x 10 times
+ for (let y = 0; y < ySegs; y++) {
+ for (let x = 0; x < xSegs; x++) {
+ idx0 = particles.get(x, y, zSegs).getIndex();
+ idx1 = particles.get(x + 1, y, zSegs).getIndex();
+ idx2 = particles.get(x + 1, y + 1, zSegs).getIndex();
+ idx3 = particles.get(x, y + 1, zSegs).getIndex();
+ particlesIndexArr.push(idx0, idx1, idx2, idx0, idx2, idx3);
+ }
+ }
+ // right face indices, x === xSegs, iterate 10 x 10 times
+ for (let y = 0; y < ySegs; y++) {
+ for (let z = zSegs; z > 0; z--) {
+ idx0 = particles.get(xSegs, y, z).getIndex();
+ idx1 = particles.get(xSegs, y, z - 1).getIndex();
+ idx2 = particles.get(xSegs, y + 1, z - 1).getIndex();
+ idx3 = particles.get(xSegs, y + 1, z).getIndex();
+ particlesIndexArr.push(idx0, idx1, idx2, idx0, idx2, idx3);
+ }
+ }
+ // back face indices, z === 0, iterate 10 x 10 times
+ for (let y = 0; y < ySegs; y++) {
+ for (let x = xSegs; x > 0; x--) {
+ idx0 = particles.get(x, y, 0).getIndex();
+ idx1 = particles.get(x - 1, y, 0).getIndex();
+ idx2 = particles.get(x - 1, y + 1, 0).getIndex();
+ idx3 = particles.get(x, y + 1, 0).getIndex();
+ particlesIndexArr.push(idx0, idx1, idx2, idx0, idx2, idx3);
+ }
+ }
+ // left face indices, x === 0, iterate 10 x 10 times
+ for (let y = 0; y < ySegs; y++) {
+ for (let z = 0; z < zSegs; z++) {
+ idx0 = particles.get(0, y, z).getIndex();
+ idx1 = particles.get(0, y, z + 1).getIndex();
+ idx2 = particles.get(0, y + 1, z + 1).getIndex();
+ idx3 = particles.get(0, y + 1, z).getIndex();
+ particlesIndexArr.push(idx0, idx1, idx2, idx0, idx2, idx3);
+ }
+ }
+ // top layer indices, y === ySegs, iterate 10 x 10 times, normal facing upwards
+ for (let z = 0; z < zSegs; z++) {
+ for (let x = 0; x < xSegs; x++) {
+ idx0 = particles.get(x, ySegs, z).getIndex();
+ idx1 = particles.get(x + 1, ySegs, z).getIndex();
+ idx2 = particles.get(x + 1, ySegs, z + 1).getIndex();
+ idx3 = particles.get(x, ySegs, z + 1).getIndex();
+ particlesIndexArr.push(idx0, idx3, idx1, idx1, idx3, idx2);
+ }
+ }
+ }
+
+ let clothGeometry = null, clothMesh = null;
+ let normalAttri = null;
+ const RED = new THREE.Color(0xff0000);
+
+ // helper sphere
+ const sphereGeo = new THREE.SphereGeometry(5, 8, 4);
+ const sphereMatRed = new THREE.MeshBasicMaterial({color: RED});
+
+ function makeBufferGeometry() {
+ clothGeometry = new THREE.BufferGeometry();
+
+ makeBufferGeometryIndexArr();
+ clothGeometry.setIndex(particlesIndexArr);
+
+ let posAttri = new THREE.BufferAttribute(new Float32Array(particlesPosArr), 3);
+
+ clothGeometry.setAttribute('position', posAttri);
+
+ // initialize particle.normal field
+ clothGeometry.computeVertexNormals();
+ normalAttri = clothGeometry.attributes.normal;
+ particles.map.forEach((particle) => {
+ particle.normal.fromBufferAttribute(normalAttri, particle.getIndex()).negate();
+ })
+
+ let material = new THREE.MeshStandardMaterial({
+ color: 0x888888,
+ // transparent: true,
+ // opacity: 0.5,
+ wireframe: true
+ });
+ clothMesh = new THREE.Mesh(clothGeometry, material);
+ // clothMesh.visible = false;
+ realityEditor.gui.threejsScene.addToScene(clothMesh, {layers: 1});
+
+ // const edges = new THREE.EdgesGeometry( geometry, 0 );
+ // const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial( { color: 0xffffff } ) );
+ // realityEditor.gui.threejsScene.addToScene( line, {layers: 1});
+ return clothMesh;
+ }
+
+ /**********************************************************************************************************************
+ ************************* init a cloth & add all the properties into a global object *********************************
+ **********************************************************************************************************************/
+ const CLOTH_INFO = {};
+ function initCloth(xLength, yLength, zLength, center, restDistance = null, uuid, objectKey, frameKey) {
+ if (restDistance === null) {
+ restDistance = Math.max(xLength, Math.max(yLength, zLength)) / 25;
+ }
+ // console.log(`Rest distance: ${restDistance} m`);
+
+ let _particles = makeParticles(xLength, yLength, zLength, center, restDistance);
+ let _constraints = makeConstraints(false);
+ let _pins = makePins(true);
+ let _clothMesh = makeBufferGeometry();
+ let _winds = makeWind();
+ let _time = Date.now();
+ let _tmpTextLabelPos = new THREE.Vector3().addVectors(center, tmpTextLabelOffset);
+
+ CLOTH_INFO[`${_time}`] = {
+ objectKey: objectKey, // which measure tool object initiated the cloth simulation
+ frameKey: frameKey, // which measure tool frame initiated the cloth simulation
+ uuid: uuid, // inside that measure tool, which bigParentObj should the cloth be added to once finished
+ particles: _particles,
+ constraints: _constraints,
+ pins: _pins,
+ clothMesh: _clothMesh,
+ winds: _winds,
+ startTime: _time,
+ initVolume: null,
+ volume: 0,
+ tmpTextLabelPos: _tmpTextLabelPos,
+ tmpTextLabelObj: null, // todo Steve: a temporary text label, created under the parent userInterface, but will get deleted after finish computing volume & send info down to tool
+ };
+ CLOTH_COUNT++;
+ }
+
+ // simulation & render code
+
+ let diff = new THREE.Vector3();
+
+ function satisfyConstraints(constraint, initVolume, volume) {
+ let p1 = constraint[0];
+ let p2 = constraint[1];
+ let distance = constraint[2];
+
+ diff.subVectors(p2.position, p1.position);
+ let currentDist = diff.length();
+ // currentDist = Math.min(Math.min(currentDist, restDistance), constraint[2]); // todo Steve: a huge visual difference between this method line and below. Find out why.
+ // currentDist = Math.min(currentDist, restDistance);
+ currentDist = Math.min(currentDist, constraint[2] * 1.05);
+ if (currentDist === 0) return; // prevents division by 0
+ let correction = diff.multiplyScalar(1 - distance / currentDist);
+ let correctionHalf = correction.multiplyScalar(0.5);
+
+ if (p1.collided) {
+ p2.position.sub(correction);
+ } else if (p2.collided) {
+ p1.position.add(correction);
+ } else if (!p1.collided && !p2.collided) {
+ p1.position.add(correctionHalf);
+ p2.position.sub(correctionHalf);
+ } else {
+ return;
+ }
+
+ if (initVolume === null) return;
+ let p = Math.max(0, volume / initVolume);
+ constraint[2] = restDistance * p;
+ }
+
+ const TIMESTEP = 5 / 1000; // step size 5 / 10 seems like some good choices. Note that the step size also affects COLLIDE_THRESHOLD and GRAVITY. The bigger the step size, the bigger COLLIDE_THRESHOLD & the smaller GRAVITY needs to be, to avoid skipping some collisions
+ const TIMESTEP_SQ = TIMESTEP * TIMESTEP;
+
+ const GRAVITY = 9.8 * 100;
+ const _gravity = new THREE.Vector3(0, -GRAVITY, 0).multiplyScalar(MASS);
+
+ const WIND_STRENGTH = 1000;
+ const WIND_DISTANCE_OFFSET = 1 * 1000;
+ let winds = [];
+
+ class Wind {
+ constructor(position, force) {
+ this.position = position;
+ this.force = force;
+
+ // this.arrowHelper = new THREE.ArrowHelper(this.force.clone().normalize(), this.position, this.force.length() * 0.5, 0x00ff00);
+ // realityEditor.gui.threejsScene.addToScene(this.arrowHelper, {layers: 1});
+ }
+ }
+
+ function makeWind() {
+ winds = [];
+ winds.push(new Wind(new THREE.Vector3(0, -(yLength / 2 + WIND_DISTANCE_OFFSET), 0).add(center), new THREE.Vector3(0, 1, 0).multiplyScalar(WIND_STRENGTH))); // bottom
+ winds.push(new Wind(new THREE.Vector3(0, 0, (zLength / 2 + WIND_DISTANCE_OFFSET)).add(center), new THREE.Vector3(0, 0, -1).multiplyScalar(WIND_STRENGTH))); // front
+ winds.push(new Wind(new THREE.Vector3((xLength / 2 + WIND_DISTANCE_OFFSET), 0, 0).add(center), new THREE.Vector3(-1, 0, 0).multiplyScalar(WIND_STRENGTH))); // right
+ winds.push(new Wind(new THREE.Vector3(0, 0, -(zLength / 2 + WIND_DISTANCE_OFFSET)).add(center), new THREE.Vector3(0, 0, 1).multiplyScalar(WIND_STRENGTH))); // back
+ winds.push(new Wind(new THREE.Vector3(-(xLength / 2 + WIND_DISTANCE_OFFSET), 0, 0).add(center), new THREE.Vector3(1, 0, 0).multiplyScalar(WIND_STRENGTH))); // left
+ winds.push(new Wind(new THREE.Vector3(0, (yLength / 2 + WIND_DISTANCE_OFFSET), 0).add(center), new THREE.Vector3(0, -1, 0).multiplyScalar(WIND_STRENGTH))); // top
+
+ return winds;
+ }
+
+ // when porting to userInterface repo, don't have to include the mesh bvh and raycaster (?). Or maybe instantiate a specific raycaster just for raycasting for cloth simulation, everytime we instantiate a new cloth (?)
+ const raycaster = new THREE.Raycaster();
+ raycaster.layers.enable(0);
+ raycaster.layers.enable(1);
+ raycaster.layers.enable(2);
+ let COLLIDE_THRESHOLD = 50; // 0.05 seems like a perfect threshold: too big then it skips some collision; too small it causes some particle jittering
+
+ function simulateCloth(particles, constraints, pins, winds, initVolume, volume) {
+ if (cachedOcclusionObject === null || inverseGroundPlaneMatrix === null) {
+ return;
+ }
+
+ // // apply gravity force
+ // particles.map.forEach((particle) => {
+ // particle.addForce(_gravity);
+ // })
+
+ // apply wind force
+ let tmp = new THREE.Vector3(), distance = null;
+ particles.map.forEach((particle) => {
+ winds.forEach((wind) => {
+ if (particle.hasCollided) return;
+
+ distance = tmp.subVectors(particle.position, wind.position).length();
+ particle.addForce(wind.force.clone().divideScalar(distance).multiplyScalar(1000));
+ })
+ })
+
+ // apply particle inward force
+ particles.map.forEach((particle) => {
+ if (particle.hasCollided) return;
+
+ particle.addForce(particle.normal.clone().multiplyScalar(0.6).multiplyScalar(1000));
+ })
+
+ // collision with mesh
+ let particlePos = null, particleDir = null, result = null, tmpPos = new THREE.Vector3();
+ particles.map.forEach((particle) => {
+ particlePos = particle.position;
+ particleDir = particle.normal;
+ tmpPos.copy(particlePos);
+ tmpPos.y -= raycastPosOffset;
+ raycaster.set(tmpPos, particleDir);
+ raycaster.firstHitOnly = true;
+ result = raycaster.intersectObjects([cachedOcclusionObject], true);
+
+ if (result.length !== 0) {
+ result[0].point.applyMatrix4(inverseGroundPlaneMatrix);
+ }
+
+ if (result.length === 0 || result[0].distance > COLLIDE_THRESHOLD) { // not collided
+ particle.collided = false;
+ return;
+ }
+
+ let diff = particle.position.clone().sub(result[0].point);
+ particle.collide(result[0].point.add(diff.normalize().multiplyScalar(COLLIDE_THRESHOLD)));
+ })
+
+ // verlet integration
+ particles.map.forEach((particle) => {
+ particle.integrate(TIMESTEP_SQ);
+ })
+
+ // relax constraints
+ // for (let i = 0; i < constraints.length; i++) {
+ // satisfyConstraints(constraints[i], initVolume, volume);
+ // }
+ let rand = Math.floor(Math.random() * constraints.length);
+ for (let i = rand; i < constraints.length; i++) {
+ satisfyConstraints(constraints[i], initVolume, volume);
+ }
+ for (let i = rand - 1; i >= 0; i--) {
+ satisfyConstraints(constraints[i], initVolume, volume);
+ }
+
+ // pin constraints
+ let pinParticle = null;
+ for (let i = 0; i < pins.length; i++) {
+ pinParticle = pins[i];
+ pinParticle.position.copy(pinParticle.original);
+ pinParticle.previous.copy(pinParticle.original);
+ pinParticle.a.set(0, 0, 0);
+ }
+ }
+
+ function renderCloth(particles, clothMesh) {
+ if (cachedOcclusionObject === null || inverseGroundPlaneMatrix === null) {
+ return;
+ }
+ clothGeometry = clothMesh.geometry;
+ normalAttri = clothGeometry.attributes.normal;
+ // change cloth buffer geometry mesh render
+ particles.map.forEach((particle) => {
+ let p = particle.position;
+ let bufferGeoIndex = particle.getIndex();
+ clothGeometry.attributes.position.setXYZ(bufferGeoIndex, p.x, p.y, p.z);
+ })
+ clothGeometry.attributes.position.needsUpdate = true;
+
+ // update particle.normal field
+ clothGeometry.computeVertexNormals();
+ particles.map.forEach((particle) => {
+ particle.normal.fromBufferAttribute(normalAttri, particle.getIndex()).negate();
+ })
+ }
+
+ const tmpTextLabelOffset = new THREE.Vector3(200, -200, 200);
+ function getVolume(key, geometry, divObj, divObjPos) {
+ if (!geometry.isBufferGeometry) {
+ console.log("'geometry' must be an indexed or non-indexed buffer geometry");
+ return 0;
+ }
+ let isIndexed = geometry.index !== null;
+ let position = geometry.attributes.position;
+ let sum = 0;
+ let p1 = new THREE.Vector3(),
+ p2 = new THREE.Vector3(),
+ p3 = new THREE.Vector3();
+ if (!isIndexed) {
+ let faces = position.count / 3;
+ for (let i = 0; i < faces; i++) {
+ p1.fromBufferAttribute(position, i * 3 + 0);
+ p2.fromBufferAttribute(position, i * 3 + 1);
+ p3.fromBufferAttribute(position, i * 3 + 2);
+ sum += signedVolumeOfTriangle(p1, p2, p3);
+ }
+ } else {
+ let index = geometry.index;
+ let faces = index.count / 3;
+ for (let i = 0; i < faces; i++) {
+ p1.fromBufferAttribute(position, index.array[i * 3 + 0]);
+ p2.fromBufferAttribute(position, index.array[i * 3 + 1]);
+ p3.fromBufferAttribute(position, index.array[i * 3 + 2]);
+ sum += signedVolumeOfTriangle(p1, p2, p3);
+ }
+ }
+
+
+ if (divObj === null) {
+ let div1 = document.createElement('div');
+ div1.classList.add('cloth-text');
+ div1.style.background = 'rgb(20,20,20)';
+ div1.innerHTML = `≈ ${(sum * (UNIT_SCALE * UNIT_SCALE * UNIT_SCALE) / (1000 * 1000 * 1000)).toFixed(3)} m3 `;
+ let divObj1 = new CSS2DObject(div1);
+ divObj1.position.copy(divObjPos);
+ realityEditor.gui.threejsScene.addToScene(divObj1);
+ CLOTH_INFO[`${key}`].tmpTextLabelObj = divObj1;
+ } else {
+ divObj.element.innerHTML = `≈ ${(sum * (UNIT_SCALE * UNIT_SCALE * UNIT_SCALE) / (1000 * 1000 * 1000)).toFixed(3)} m3 `;
+ }
+
+ return sum;
+ }
+
+ function signedVolumeOfTriangle(p1, p2, p3) {
+ return p1.dot(p2.cross(p3)) / 6.0;
+ }
+
+ function update() {
+ if (Object.keys(CLOTH_INFO).length === 0) return;
+ for (const key of Object.keys(CLOTH_INFO)) {
+ const value = CLOTH_INFO[`${key}`];
+ simulateCloth(value.particles, value.constraints, value.pins, value.winds, value.initVolume, value.volume);
+ renderCloth(value.particles, value.clothMesh);
+
+ let new_volume = getVolume(key, value.clothMesh.geometry, value.tmpTextLabelObj, value.tmpTextLabelPos);
+ if (value.volume === 0) { // when first started, the volume is set to 0
+ value.volume = new_volume;
+ value.initVolume = new_volume;
+ return;
+ }
+
+ if ( new_volume < 0 || Date.now() - value.startTime > 5000 && (Math.abs((value.volume - new_volume) / value.volume) < 0.00001) || Date.now() - value.startTime > 30000 ) { // if: (1) new volume < 0; (2) after running 5 seconds && change of volume < 0.001%; (3) after running 30 seconds, then count as finished
+ console.log(`The final computed volume is ${new_volume}`);
+ console.log(Math.abs((value.volume - new_volume) / value.volume));
+ delete CLOTH_INFO[`${key}`];
+ CLOTH_COUNT--;
+ balanceLoad();
+ // todo Steve: delete the cloth & volume text labels, and send corresponding info back to the tools
+ sendClothInfoToMeasureTool(value.objectKey, value.frameKey, value.uuid, value.clothMesh, value.volume, value.tmpTextLabelPos);
+ // todo Steve: delete the cloth & volume text labels
+ // also, add a listener in setupEventListeners, s.t. if heard a tool delete corresponding volume for that uuid, stop the corresponding simulation & delete the cloth mesh
+ value.clothMesh.geometry.dispose();
+ value.clothMesh.material.dispose();
+ value.clothMesh.parent.remove(value.clothMesh);
+ value.tmpTextLabelObj.parent.remove(value.tmpTextLabelObj);
+ } else {
+ value.volume = new_volume;
+ }
+ }
+ }
+
+ function sendClothInfoToMeasureTool(objectKey, frameKey, uuid, clothMesh, volume, labelPos) {
+ let frame = realityEditor.getFrame(objectKey, frameKey)
+ if (frame === null || frame.src !== 'spatialMeasure') return;
+ let iframe = document.getElementById('iframe' + frameKey);
+ iframe.contentWindow.postMessage(JSON.stringify({
+ uuid: uuid,
+ clothMesh: clothMesh.clone().toJSON(),
+ volume: volume,
+ labelPos: labelPos,
+ }), '*');
+ }
+
+ exports.initService = initService;
+
+}(realityEditor.measure.clothSimulation));
diff --git a/src/measure/mapShaderSettingsUI.js b/src/measure/mapShaderSettingsUI.js
new file mode 100644
index 000000000..c17190657
--- /dev/null
+++ b/src/measure/mapShaderSettingsUI.js
@@ -0,0 +1,298 @@
+export class MapShaderSettingsUI {
+ constructor() {
+
+ this.root = document.createElement('div');
+ this.root.id = 'hpa-settings';
+
+ // Styled via css/humanPoseAnalyzerSettingsUi.css
+ this.root.innerHTML = `
+
+
+
+
Map Settings
+
+
+
Select Maps
+
+ Colored Map
+ Height Map
+ Steepness Map
+
+
+
+
Highlight walkable area
+
+
+
+
+
+
+ `;
+
+ this.addDoubleSlider();
+ this.setUpEventListeners();
+ this.enableDrag();
+ document.body.appendChild(this.root);
+ this.setInitialPosition();
+ this.hide(); // It is important to set the menu's position before hiding it, otherwise its width will be calculated as 0
+ }
+
+ /**
+ * Sets the initial position of the settings UI to be in the top right corner of the screen, under the navbar and menu button
+ */
+ setInitialPosition() {
+ const navbar = document.querySelector('.desktopMenuBar');
+ const navbarHeight = navbar ? navbar.offsetHeight : 0;
+ // const sessionMenuContainer = document.querySelector('#sessionMenuContainer');
+ // const sessionMenuLeft = sessionMenuContainer ? sessionMenuContainer.offsetLeft : 0;
+ // if (sessionMenuContainer) { // Avoid the top right menu
+ // this.root.style.top = `calc(${navbarHeight}px + 2em)`;
+ // this.root.style.left = `calc(${sessionMenuLeft - this.root.offsetWidth}px - 6em)`;
+ // return;
+ // }
+ this.root.style.top = `calc(${navbarHeight}px + 2em)`;
+ this.root.style.left = `calc(${window.innerWidth - this.root.offsetWidth}px - 2em)`;
+ this.snapToFitScreen();
+ }
+
+ addDoubleSlider() {
+ const sliderMinRange = this.root.querySelector('#sliderMinRange');
+ const sliderMinNumber = this.root.querySelector('#sliderMinNumber');
+ const sliderMaxRange = this.root.querySelector('#sliderMaxRange');
+ const sliderMaxNumber = this.root.querySelector('#sliderMaxNumber');
+
+ let minAngle = sliderMinRange.value;
+ let maxAngle = sliderMaxRange.value;
+
+ sliderMinRange.addEventListener('input', function() {
+ const val = parseFloat(this.value);
+ if (val > maxAngle) {
+ sliderMaxRange.value = val;
+ sliderMaxNumber.value = val;
+ maxAngle = val;
+ }
+ sliderMinNumber.value = val;
+ minAngle = val;
+ realityEditor.gui.threejsScene.updateGradientMapThreshold(minAngle, maxAngle);
+ realityEditor.app.pathfinding.updateSteepnessRange(minAngle, maxAngle);
+ });
+
+ sliderMinNumber.addEventListener('input', function() {
+ const val = parseFloat(this.value);
+ if (val > maxAngle) {
+ sliderMaxRange.value = val;
+ sliderMaxNumber.value = val;
+ maxAngle = val;
+ }
+ sliderMinRange.value = val;
+ minAngle = val;
+ realityEditor.gui.threejsScene.updateGradientMapThreshold(minAngle, maxAngle);
+ realityEditor.app.pathfinding.updateSteepnessRange(minAngle, maxAngle);
+ });
+
+ sliderMaxRange.addEventListener('input', function() {
+ const val = parseFloat(this.value);
+ if (val < minAngle) {
+ sliderMinRange.value = val;
+ sliderMinNumber.value = val;
+ minAngle = val;
+ }
+ sliderMaxNumber.value = val;
+ maxAngle = val;
+ realityEditor.gui.threejsScene.updateGradientMapThreshold(minAngle, maxAngle);
+ realityEditor.app.pathfinding.updateSteepnessRange(minAngle, maxAngle);
+ });
+
+ sliderMaxNumber.addEventListener('input', function() {
+ const val = parseFloat(this.value);
+ if (val < minAngle) {
+ sliderMinRange.value = val;
+ sliderMinNumber.value = val;
+ minAngle = val;
+ }
+ sliderMaxRange.value = val;
+ maxAngle = val;
+ realityEditor.gui.threejsScene.updateGradientMapThreshold(minAngle, maxAngle);
+ realityEditor.app.pathfinding.updateSteepnessRange(minAngle, maxAngle);
+ });
+ }
+
+ setUpEventListeners() {
+ // todo Steve: add event listeners for turning / toggling the UI on and off
+ realityEditor.network.addPostMessageHandler('measureAppTurnMapUI', (boolean) => {
+ if (boolean) this.show();
+ else this.hide();
+ });
+ realityEditor.network.addPostMessageHandler('measureAppToggleMapUI', () => {
+ this.toggle();
+ });
+ // Toggle menu minimization when clicking on the header, but only if not dragging
+ this.root.querySelector('.hpa-settings-header').addEventListener('mousedown', event => {
+ event.stopPropagation();
+ let mouseDownX = event.clientX;
+ let mouseDownY = event.clientY;
+ const mouseUpListener = event => {
+ const mouseUpX = event.clientX;
+ const mouseUpY = event.clientY;
+ if (mouseDownX === mouseUpX && mouseDownY === mouseUpY) {
+ this.toggleMinimized();
+ }
+ this.root.querySelector('.hpa-settings-header').removeEventListener('mouseup', mouseUpListener);
+ };
+ this.root.querySelector('.hpa-settings-header').addEventListener('mouseup', mouseUpListener);
+ });
+
+ this.root.querySelector('#measure-app-select-map-shader').addEventListener('change', (event) => {
+ realityEditor.gui.threejsScene.changeMeasureMapType(event.target.value);
+ });
+
+ realityEditor.device.registerCallback('vehicleDeleted', this.onVehicleDeleted.bind(this)); // deleted using userinterface
+ realityEditor.network.registerCallback('vehicleDeleted', this.onVehicleDeleted.bind(this)); // deleted using server
+
+ this.root.querySelector('#measure-app-highlight-walkable-area').addEventListener('change', (event) => {
+ realityEditor.gui.threejsScene.highlightWalkableArea(event.target.checked);
+ });
+
+ // Add listeners to aid with clicking checkboxes
+ this.root.querySelectorAll('.hpa-settings-section-row-checkbox').forEach((checkbox) => {
+ const checkboxContainer = checkbox.parentElement;
+ checkboxContainer.addEventListener('click', () => {
+ checkbox.checked = !checkbox.checked;
+ checkbox.dispatchEvent(new Event('change'));
+ });
+ checkbox.addEventListener('click', (event) => {
+ event.stopPropagation(); // Prevent double-counting clicks
+ });
+ });
+
+ // Add click listeners to selects to stop propagation to rest of app
+ this.root.querySelectorAll('.hpa-settings-section-row-select').forEach((select) => {
+ select.addEventListener('click', (event) => {
+ event.stopPropagation();
+ });
+ });
+ }
+
+ onVehicleDeleted(event) {
+ if (!event.objectKey || !event.frameKey || event.nodeKey) {
+ return;
+ }
+ if (realityEditor.envelopeManager.getFrameTypeFromKey(event.objectKey, event.frameKey) === 'spatialMeasure') {
+ this.root.querySelector('#measure-app-select-map-shader').value = 'color';
+ realityEditor.gui.threejsScene.changeMeasureMapType('color');
+ this.hide();
+
+ let iframe = document.getElementById('iframe' + event.frameKey);
+ iframe.contentWindow.postMessage(JSON.stringify({
+ isAppClosed: true,
+ }), '*');
+ }
+ }
+
+ enableDrag() {
+ let dragStartX = 0;
+ let dragStartY = 0;
+ let dragStartLeft = 0;
+ let dragStartTop = 0;
+
+ this.root.querySelector('.hpa-settings-header').addEventListener('mousedown', (event) => {
+ event.stopPropagation();
+ dragStartX = event.clientX;
+ dragStartY = event.clientY;
+ dragStartLeft = this.root.offsetLeft;
+ dragStartTop = this.root.offsetTop;
+
+ const mouseMoveListener = (event) => {
+ event.stopPropagation();
+ this.root.style.left = `${dragStartLeft + event.clientX - dragStartX}px`;
+ this.root.style.top = `${dragStartTop + event.clientY - dragStartY}px`;
+ this.snapToFitScreen();
+ }
+ const mouseUpListener = () => {
+ document.removeEventListener('mousemove', mouseMoveListener);
+ document.removeEventListener('mouseup', mouseUpListener);
+ }
+ document.addEventListener('mousemove', mouseMoveListener);
+ document.addEventListener('mouseup', mouseUpListener);
+ });
+ }
+
+ /**
+ * If the settings menu is out of bounds, snap it back into the screen
+ */
+ snapToFitScreen() {
+ const navbar = document.querySelector('.desktopMenuBar');
+ const navbarHeight = navbar ? navbar.offsetHeight : 0;
+ if (this.root.offsetTop < navbarHeight) {
+ this.root.style.top = `${navbarHeight}px`;
+ }
+ if (this.root.offsetLeft < 0) {
+ this.root.style.left = '0px';
+ }
+ if (this.root.offsetLeft + this.root.offsetWidth > window.innerWidth) {
+ this.root.style.left = `${window.innerWidth - this.root.offsetWidth}px`;
+ }
+ // Keep the header visible on the screen off the bottom
+ if (this.root.offsetTop + this.root.querySelector('.hpa-settings-header').offsetHeight > window.innerHeight) {
+ this.root.style.top = `${window.innerHeight - this.root.querySelector('.hpa-settings-header').offsetHeight}px`;
+ }
+ }
+
+ show() {
+ this.root.classList.remove('hidden');
+ }
+
+ hide() {
+ this.root.classList.add('hidden');
+ }
+
+ toggle() {
+ if (this.root.classList.contains('hidden')) {
+ this.show();
+ } else {
+ this.hide();
+ }
+ }
+
+ minimize() {
+ if (this.root.classList.contains('hidden')) {
+ return;
+ }
+ const previousWidth = this.root.offsetWidth;
+ this.root.classList.add('hpa-settings-minimized');
+ this.root.style.width = `${previousWidth}px`;
+ this.root.querySelector('.hpa-settings-header-icon').innerText = '+';
+ }
+
+ maximize() {
+ if (this.root.classList.contains('hidden')) {
+ return;
+ }
+ this.root.classList.remove('hpa-settings-minimized');
+ this.root.querySelector('.hpa-settings-header-icon').innerText = '_';
+ }
+
+ toggleMinimized() {
+ if (this.root.classList.contains('hpa-settings-minimized')) {
+ this.maximize();
+ } else {
+ this.minimize();
+ }
+ }
+}
diff --git a/src/moduleCallbacks.js b/src/moduleCallbacks.js
new file mode 100644
index 000000000..b06c601c5
--- /dev/null
+++ b/src/moduleCallbacks.js
@@ -0,0 +1,81 @@
+createNameSpace("realityEditor.moduleCallbacks");
+
+/**
+ * @fileOverview realityEditor.moduleCallbacks.js
+ * Creates a reusable class for handling the callbacks of a given module
+ *
+ * @example How to Use:
+ *
+ * If you want other modules A and B to be able to register callbacks on your module C:
+ *
+ * 1. Create a private CallbackHandler withing module C, with the name of module C:
+ * realityEditor.gui.pocket.callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('gui/pocket');
+ *
+ * 2. Add a public method called registerCallback to module C: @todo: I have another version that automatically adds this
+ * realityEditor.gui.pocket.registerCallback = function(functionName, callback) {
+ if (!this.callbackHandler) {
+ this.callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('gui/pocket'); // lazily instantiate it if not already
+ }
+ this.callbackHandler.registerCallback(functionName, callback); // register the callback within the private member
+ };
+ *
+ * 3. In module A or B, call registerCallback on module C with the name of the event you are listening for:
+ * realityEditor.gui.pocket.registerCallback('frameAdded', function(params) {
+ console.log(params.objectKey, params.frameKey);
+ });
+ *
+ * 4. In module C, when the event occurs, call callbackHandler.triggerCallbacks with the event name and any params:
+ *
+ * this.callbackHandler.triggerCallbacks('frameAdded', {objectKey: closestObjectKey, frameKey: frameID, frameType: frame.src});
+ */
+
+(function(exports) {
+
+ /**
+ * class to handle callback registration and triggering, which can be instantiated for each module that needs it
+ * @param {string} moduleName - currently just used for debugging purposes
+ * @constructor
+ */
+ function CallbackHandler(moduleName) {
+ this.moduleName = moduleName; // only stored for debugging purposes
+
+ /**
+ * A set of arrays of callbacks that other modules can register to be notified of actions.
+ * Contains a property for each method name in the module that can trigger events in other modules.
+ * The value of each property is an array containing pointers to the callback functions that should be
+ * triggered when that function is called.
+ * @type {Object.>}
+ */
+ this.callbacks = {}
+ }
+
+ /**
+ * Adds a callback function that will be invoked when the moduleName.[functionName] is called
+ * @param {string} functionName
+ * @param {function} callback
+ */
+ CallbackHandler.prototype.registerCallback = function(functionName, callback) {
+ if (typeof this.callbacks[functionName] === 'undefined') {
+ this.callbacks[functionName] = [];
+ }
+
+ this.callbacks[functionName].push(callback);
+ };
+
+ /**
+ * Utility for iterating calling all callbacks that other modules have registered for the given function
+ * @param {string} functionName
+ * @param {object|undefined} params
+ */
+ CallbackHandler.prototype.triggerCallbacks = function(functionName, params) {
+ if (typeof this.callbacks[functionName] === 'undefined') return;
+
+ // iterates over all registered callbacks to trigger events in various modules
+ this.callbacks[functionName].forEach(function(callback) {
+ callback(params);
+ });
+ };
+
+ exports.CallbackHandler = CallbackHandler;
+
+})(realityEditor.moduleCallbacks);
diff --git a/src/motionStudy/MotionStudyMobile.js b/src/motionStudy/MotionStudyMobile.js
new file mode 100644
index 000000000..383284af5
--- /dev/null
+++ b/src/motionStudy/MotionStudyMobile.js
@@ -0,0 +1,15 @@
+import {MotionStudy} from './motionStudy.js';
+
+export class MotionStudyMobile extends MotionStudy {
+ constructor(frame) {
+ super(frame);
+ }
+
+ show2D() {
+ // Only show timeline and other simple 2d ui
+ if (!this.container.parentElement) {
+ document.body.appendChild(this.container);
+ this.timelineContainer.style.display = 'none';
+ }
+ }
+}
diff --git a/src/motionStudy/ValueAddWasteTimeManager.js b/src/motionStudy/ValueAddWasteTimeManager.js
new file mode 100644
index 000000000..a790a4843
--- /dev/null
+++ b/src/motionStudy/ValueAddWasteTimeManager.js
@@ -0,0 +1,36 @@
+import {Timeline} from '../utilities/Timeline.js';
+
+export const ValueAddWasteTimeTypes = {
+ VALUE_ADD: "VALUE",
+ WASTE_TIME: "WASTE"
+}
+
+export class ValueAddWasteTimeManager extends Timeline {
+ constructor() {
+ super();
+ }
+
+ /**
+ * @param {number} startTime
+ * @param {number} endTime
+ */
+ markValueAdd(startTime, endTime) {
+ if (this.isRegionPresent(startTime, endTime, ValueAddWasteTimeTypes.VALUE_ADD)) {
+ this.clear(startTime, endTime); // Toggle off if already set to value add
+ return;
+ }
+ this.insert(startTime, endTime, ValueAddWasteTimeTypes.VALUE_ADD);
+ }
+
+ /**
+ * @param {number} startTime
+ * @param {number} endTime
+ */
+ markWasteTime(startTime, endTime) {
+ if (this.isRegionPresent(startTime, endTime, ValueAddWasteTimeTypes.WASTE_TIME)) {
+ this.clear(startTime, endTime); // Toggle off if already set to waste time
+ return;
+ }
+ this.insert(startTime, endTime, ValueAddWasteTimeTypes.WASTE_TIME);
+ }
+}
diff --git a/src/motionStudy/index.js b/src/motionStudy/index.js
new file mode 100644
index 000000000..07cc3da44
--- /dev/null
+++ b/src/motionStudy/index.js
@@ -0,0 +1,151 @@
+createNameSpace("realityEditor.motionStudy");
+
+import {MotionStudy} from './motionStudy.js'
+import {MotionStudyMobile} from './MotionStudyMobile.js'
+
+(function(exports) {
+ /**
+ * @param {string} frame - frame id associated with instance of
+ * motionStudy
+ */
+ function makeMotionStudy(frame) {
+ if (realityEditor.device.environment.isDesktop()) {
+ return new MotionStudy(frame);
+ } else {
+ return new MotionStudyMobile(frame);
+ }
+ }
+
+ const noneFrame = 'none';
+ let activeFrame = '';
+ let motionStudyByFrame = {};
+
+ function getDefaultMotionStudy() {
+ return motionStudyByFrame[noneFrame];
+ }
+ exports.getDefaultMotionStudy = getDefaultMotionStudy;
+
+ /**
+ * @return {MotionStudy}
+ */
+ function getActiveMotionStudy() {
+ return motionStudyByFrame[activeFrame];
+ }
+ exports.getActiveMotionStudy = getActiveMotionStudy;
+
+ function getMotionStudyByFrame(frame) {
+ return motionStudyByFrame[frame];
+ }
+ exports.getMotionStudyByFrame = getMotionStudyByFrame;
+
+ /**
+ * @return {HumanPoseAnalyzer}
+ */
+ function getActiveHumanPoseAnalyzer() {
+ let motionStudy = getActiveMotionStudy();
+ if (!motionStudy) {
+ return;
+ }
+ return motionStudy.humanPoseAnalyzer;
+ }
+ exports.getActiveHumanPoseAnalyzer = getActiveHumanPoseAnalyzer;
+
+ /**
+ * @return {Timeline}
+ */
+ function getActiveTimeline() {
+ let motionStudy = getActiveMotionStudy();
+ if (!motionStudy) {
+ return;
+ }
+ return motionStudy.timeline;
+ }
+ exports.getActiveTimeline = getActiveTimeline;
+
+ function onVehicleDeleted(event) {
+ if (!event.objectKey || !event.frameKey || event.nodeKey) {
+ return;
+ }
+ if (!motionStudyByFrame[event.frameKey]) {
+ return;
+ }
+ motionStudyByFrame[event.frameKey].close();
+ delete motionStudyByFrame[event.frameKey];
+ if (activeFrame === event.frameKey) {
+ activeFrame = noneFrame;
+ }
+ }
+
+ function initService() {
+ activeFrame = noneFrame;
+ motionStudyByFrame[noneFrame] = makeMotionStudy(noneFrame);
+ motionStudyByFrame[noneFrame].show3D();
+ const settingsUi = motionStudyByFrame[noneFrame].humanPoseAnalyzer.settingsUi;
+ if (settingsUi) {
+ settingsUi.markLive();
+ }
+
+ realityEditor.network.addPostMessageHandler('analyticsOpen', (msgData) => {
+ if (!motionStudyByFrame[msgData.frame]) {
+ motionStudyByFrame[msgData.frame] = makeMotionStudy(msgData.frame);
+ }
+ activeFrame = msgData.frame;
+ motionStudyByFrame[msgData.frame].open();
+ realityEditor.app.enableHumanTracking();
+ });
+
+ realityEditor.network.addPostMessageHandler('analyticsClose', (msgData) => {
+ if (!motionStudyByFrame[msgData.frame]) {
+ return;
+ }
+ motionStudyByFrame[msgData.frame].close();
+ if (activeFrame === msgData.frame) {
+ activeFrame = noneFrame;
+ }
+ });
+
+ realityEditor.network.addPostMessageHandler('analyticsFocus', (msgData) => {
+ if (!motionStudyByFrame[msgData.frame]) {
+ motionStudyByFrame[msgData.frame] = makeMotionStudy(msgData.frame);
+ }
+ if (activeFrame !== msgData.frame) {
+ const activeMotionStudy = getActiveMotionStudy();
+ if (activeMotionStudy !== realityEditor.motionStudy.getDefaultMotionStudy()) {
+ activeMotionStudy.blur(); // Default motionStudy should only lose 2D UI manually via menu bar
+ }
+ }
+ activeFrame = msgData.frame;
+ motionStudyByFrame[msgData.frame].focus();
+ });
+
+ realityEditor.network.addPostMessageHandler('analyticsBlur', (msgData) => {
+ if (!motionStudyByFrame[msgData.frame]) {
+ return;
+ }
+ motionStudyByFrame[msgData.frame].blur();
+ if (activeFrame === msgData.frame) {
+ activeFrame = noneFrame;
+ }
+ });
+
+ realityEditor.network.addPostMessageHandler('analyticsSetDisplayRegion', (msgData) => {
+ if (!motionStudyByFrame[msgData.frame]) {
+ return;
+ }
+ motionStudyByFrame[msgData.frame].setDisplayRegion(msgData.displayRegion);
+ });
+
+ realityEditor.network.addPostMessageHandler('analyticsHydrate', (msgData) => {
+ if (!motionStudyByFrame[msgData.frame]) {
+ return;
+ }
+ motionStudyByFrame[msgData.frame].hydrateMotionStudy(msgData.analyticsData);
+ });
+
+ realityEditor.device.registerCallback('vehicleDeleted', onVehicleDeleted); // deleted using userinterface
+ realityEditor.network.registerCallback('vehicleDeleted', onVehicleDeleted); // deleted using server
+ }
+ exports.initService = initService;
+}(realityEditor.motionStudy));
+
+export const initService = realityEditor.motionStudy.initService;
diff --git a/src/motionStudy/motionStudy.js b/src/motionStudy/motionStudy.js
new file mode 100644
index 000000000..b10a653fe
--- /dev/null
+++ b/src/motionStudy/motionStudy.js
@@ -0,0 +1,821 @@
+import * as THREE from '../../thirdPartyCode/three/three.module.js';
+
+import {Timeline} from './timeline.js';
+import {
+ RegionCard,
+ RegionCardState,
+} from './regionCard.js';
+import {HumanPoseAnalyzer} from '../humanPose/HumanPoseAnalyzer.js';
+import {
+ postPersistRequest,
+} from './utils.js';
+import {ValueAddWasteTimeManager} from "./ValueAddWasteTimeManager.js";
+import {makeTextInput} from '../utilities/makeTextInput.js';
+
+const RecordingState = {
+ empty: 'empty',
+ recording: 'recording',
+ done: 'done',
+};
+
+export class MotionStudy {
+ /**
+ * @param {string} frame - frame id associated with instance of
+ * motionStudy
+ */
+ constructor(frame) {
+ this.frame = frame;
+
+ this.container = document.createElement('div');
+ this.container.id = 'analytics-container';
+
+ this.timelineContainer = document.createElement('div');
+ this.timelineContainer.id = 'analytics-timeline-container';
+
+ this.patchFilter = this.patchFilter.bind(this);
+
+ this.container.appendChild(this.timelineContainer);
+ this.timeline = new Timeline(this, this.timelineContainer);
+
+ this.createStepLabelComponent();
+
+ this.onStepFileChange = this.onStepFileChange.bind(this);
+
+ this.threejsContainer = new THREE.Group();
+ this.humanPoseAnalyzer = new HumanPoseAnalyzer(this, this.threejsContainer);
+ this.opened = false;
+ this.loadingHistory = false;
+ this.lastHydratedData = null;
+ this.writeMSDataTimeout = null;
+ this.livePlayback = false;
+ this.lastDisplayRegion = null;
+ this.pinnedRegionCards = [];
+ this.activeRegionCard = null;
+ this.nextStepNumber = 1;
+ this.stepLabels = [];
+ this.pinnedRegionCardsContainer = null;
+ this.exportLinkContainer = null;
+ this.createNewPinnedRegionCardsContainer();
+ this.valueAddWasteTimeManager = new ValueAddWasteTimeManager();
+
+ this.videoPlayer = null;
+
+ this.draw = this.draw.bind(this);
+
+ requestAnimationFrame(this.draw);
+ }
+
+ createStepLabelComponent() {
+ this.stepLabelContainer = document.createElement('div');
+ this.stepLabelContainer.id = 'analytics-step-label-container';
+ // this.stepLabelContainer.style.display = '';
+
+ this.onStepFileChange = this.onStepFileChange.bind(this);
+ this.stepLabel = document.createElement('span');
+ this.stepLabel.classList.add('analytics-step');
+ this.stepLabel.textContent = 'Step 1';
+
+ this.stepLabelContainer.appendChild(this.stepLabel);
+
+ this.container.appendChild(this.stepLabelContainer);
+ }
+
+ createStepFileUploadComponent() {
+ this.stepFileUploadContainer = document.createElement('div');
+ this.stepFileUploadContainer.id = 'analytics-step-file-upload-container';
+ this.stepFileUploadContainer.classList.add('analytics-button-container');
+
+ this.stepFileInputLabel = document.createElement('label');
+ // this.stepFileInputLabel.classList.add('analytics-step');
+ this.stepFileInputLabel.setAttribute('for', 'analytics-step-file');
+ this.stepFileInputLabel.textContent = 'Import Step File';
+
+ this.stepFileInput = document.createElement('input');
+ this.stepFileInput.id = 'analytics-step-file';
+ this.stepFileInput.type = 'file';
+ this.stepFileInput.accept = '.xml,text/xml';
+ this.stepFileInput.addEventListener('change', this.onStepFileChange);
+
+ this.stepFileUploadContainer.appendChild(this.stepFileInputLabel);
+ this.stepFileUploadContainer.appendChild(this.stepFileInput);
+
+ this.pinnedRegionCardsContainer.appendChild(this.stepFileUploadContainer);
+ }
+
+ /**
+ * On envelope open
+ * add, load pinned region cards, load spaghetti, set timeline
+ */
+ open() {
+ this.show2D();
+ this.show3D();
+ }
+
+ /**
+ * Shows all 2D UI
+ */
+ show2D() {
+ if (!this.container.parentElement) {
+ document.body.appendChild(this.container);
+ }
+ if (this.humanPoseAnalyzer.settingsUi) {
+ this.humanPoseAnalyzer.settingsUi.show();
+ }
+ }
+
+ /**
+ * Hides all 2D UI
+ */
+ hide2D() {
+ if (this.container.parentElement) {
+ document.body.removeChild(this.container);
+ }
+ if (this.humanPoseAnalyzer.settingsUi) {
+ this.humanPoseAnalyzer.settingsUi.hide();
+ }
+ }
+
+ /**
+ * Shows all 3D UI (spaghetti and clones)
+ */
+ show3D() {
+ if (!this.threejsContainer.parent) {
+ realityEditor.gui.threejsScene.addToScene(this.threejsContainer);
+ }
+ }
+
+ /**
+ * Hides all 3D UI (spaghetti and clones)
+ */
+ hide3D() {
+ if (this.threejsContainer.parent) {
+ realityEditor.gui.threejsScene.removeFromScene(this.threejsContainer);
+ }
+ this.resetPatchVisibility();
+ // Reset the highlight region (and any animation)
+ this.setHighlightRegion(null);
+ }
+
+ /**
+ * On envelope close
+ * remove pinned region cards, remove timeline, remove spaghetti
+ */
+ close() {
+ this.hide2D();
+ this.hide3D();
+
+ // if memory limited then clearing all historical data makes sense
+ // this.humanPoseAnalyzer.clearHistoricalData();
+ }
+
+ /**
+ * On envelope focus (unblur)
+ */
+ focus() {
+ this.show2D();
+ this.show3D();
+ }
+
+ /**
+ * On envelope blur
+ * Remove all 2d ui
+ */
+ blur() {
+ this.hide2D();
+ }
+
+ /**
+ * Add a new container for pinned region cards, removing the old one if applicable
+ */
+ createNewPinnedRegionCardsContainer() {
+ if (this.pinnedRegionCardsContainer) {
+ this.container.removeChild(this.pinnedRegionCardsContainer);
+ }
+ const pinnedRegionCardsContainer = document.createElement('div');
+ pinnedRegionCardsContainer.classList.add('analytics-pinned-region-cards-container');
+ if (this.videoPlayer) {
+ pinnedRegionCardsContainer.classList.add('analytics-has-video');
+ }
+ // Prevent camera control from stealing attempts to scroll the container
+ pinnedRegionCardsContainer.addEventListener('wheel', (event) => {
+ event.stopPropagation();
+ });
+ this.container.appendChild(pinnedRegionCardsContainer);
+
+ this.pinnedRegionCardsContainer = pinnedRegionCardsContainer;
+ this.pinnedRegionCards = [];
+
+ this.createTitleInput();
+
+ this.exportLinkContainer = document.createElement('div');
+ this.exportLinkContainer.classList.add('analytics-button-container');
+ this.exportLinkContainer.classList.add('analytics-export-link-container');
+
+ this.exportLinkPinnedRegionCards = document.createElement('a');
+ this.exportLinkPinnedRegionCards.classList.add('analytics-export-link');
+ this.exportLinkPinnedRegionCards.setAttribute('download', 'spatial analytics timeline region cards.csv');
+ this.exportLinkPinnedRegionCards.textContent = 'Export Cards';
+
+ this.exportLinkPoseData = document.createElement('a');
+ this.exportLinkPoseData.classList.add('analytics-export-link');
+ this.exportLinkPoseData.setAttribute('download', 'spatial analytics pose data.json');
+ this.exportLinkPoseData.textContent = 'Export Poses';
+
+ this.exportLinkContainer.style.display = 'none';
+ this.exportLinkContainer.appendChild(this.exportLinkPinnedRegionCards);
+ this.exportLinkContainer.appendChild(this.exportLinkPoseData);
+ this.pinnedRegionCardsContainer.appendChild(this.exportLinkContainer);
+ this.createStepFileUploadComponent();
+ }
+
+ createTitleInput() {
+ this.titleInput = document.createElement('div');
+ this.titleInput.classList.add('analytics-button-container');
+ this.titleInput.classList.add('analytics-title');
+ this.titleInput.contentEditable = true;
+ this.titleInput.textContent = '';
+ makeTextInput(this.titleInput, () => {
+ this.writeMotionStudyData();
+ });
+ this.pinnedRegionCardsContainer.appendChild(this.titleInput);
+ }
+
+ draw() {
+ if (this.container.parentElement) {
+ this.timeline.draw();
+ }
+ requestAnimationFrame(this.draw);
+ }
+
+ /**
+ * @param {CameraVisPatch} patch
+ * @return {boolean}
+ */
+ patchFilter(patch) {
+ if (!this.lastDisplayRegion) {
+ return true;
+ }
+
+ if (this.lastDisplayRegion.startTime > 0 &&
+ patch.creationTime < this.lastDisplayRegion.startTime) {
+ return false;
+ }
+
+ if (this.lastDisplayRegion.endTime > 0 &&
+ patch.creationTime > this.lastDisplayRegion.endTime) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * We take control over CameraVis patch visibility for
+ * animation reasons so this restores them all
+ */
+ resetPatchVisibility() {
+ const desktopRenderer = realityEditor.gui.ar.desktopRenderer;
+ if (!desktopRenderer) {
+ return;
+ }
+
+ const patches = Object.values(desktopRenderer.getCameraVisPatches() || {}).filter(this.patchFilter);
+
+ for (const patch of patches) {
+ patch.show();
+ patch.resetShaderMode();
+ }
+ }
+
+ /**
+ * Processes the given historical poses and renders them efficiently
+ * @param {Pose[]} poses - the poses to render
+ */
+ bulkRenderHistoricalPoses(poses) {
+ if (realityEditor.humanPose.draw.is2DPoseRendered()) return;
+ this.humanPoseAnalyzer.bulkHistoricalPosesUpdated(poses);
+ }
+
+ /**
+ * @param {number} time - absolute time within timeline
+ * @param {boolean} fromSpaghetti - prevents infinite recursion from
+ * modifying human pose spaghetti which calls this function
+ */
+ setCursorTime(time, fromSpaghetti) {
+ this.timeline.setCursorTime(time);
+ this.humanPoseAnalyzer.setCursorTime(time, fromSpaghetti);
+
+ if (!this.humanPoseAnalyzer.isAnimationPlaying() && this.videoPlayer) {
+ this.videoPlayer.currentTime = (time - this.videoStartTime) / 1000;
+ }
+ }
+
+ /**
+ * @typedef {Object} TimeRegion
+ * @property {number} startTime - start of time interval in ms
+ * @property {number} endTime - end of time interval in ms
+ */
+
+ /**
+ * Sets the time interval to highlight
+ * @param {TimeRegion} highlightRegion
+ * @param {boolean} fromSpaghetti - prevents infinite recursion from
+ * modifying human pose spaghetti which calls this function
+ */
+ setHighlightRegion(highlightRegion, fromSpaghetti) {
+ if (!highlightRegion && this.activeRegionCard) {
+ // Unexpectedly deactivated from outside of region card logic
+ this.activeRegionCard.displayActive = false;
+ this.activeRegionCard.updateDisplayActive();
+ this.activeRegionCard = null;
+ }
+ this.timeline.setHighlightRegion(highlightRegion);
+ this.humanPoseAnalyzer.setHighlightRegion(highlightRegion, fromSpaghetti);
+ }
+
+ /**
+ * Sets the time interval to display. Syncs state across timeline and
+ * humanPoseAnalyzer
+ * @param {TimeRegion} region - the time interval to display
+ * @param {boolean} fromSpaghetti - prevents infinite recursion from
+ * modifying human pose spaghetti which calls this function
+ */
+ async setDisplayRegion(region, fromSpaghetti) {
+ if (region.recordingState) {
+ this.updateStepVisibility(region.recordingState);
+ }
+
+ if (this.lastDisplayRegion) {
+ if (Math.abs(this.lastDisplayRegion.startTime - region.startTime) < 1 &&
+ Math.abs(this.lastDisplayRegion.endTime - region.endTime) < 1) {
+ return;
+ }
+ }
+
+ this.lastDisplayRegion = region;
+
+ this.timeline.setDisplayRegion(region);
+ let livePlayback = region.startTime < 0 || region.endTime < 0;
+ if (this.livePlayback && !livePlayback) {
+ await postPersistRequest();
+ }
+ this.livePlayback = livePlayback;
+
+ this.loadingHistory = true;
+ this.humanPoseAnalyzer.resetLiveHistoryClones();
+ this.humanPoseAnalyzer.resetLiveHistoryLines();
+ if (region.startTime >= 0 && region.endTime >= 0) {
+ // Only load history if display region is unbounded, new tools set displayRegion to (Date.now(), -1)
+ await realityEditor.humanPose.loadHistory(region, this);
+ }
+ this.loadingHistory = false;
+ if (region && !fromSpaghetti) {
+ this.humanPoseAnalyzer.setDisplayRegion(region);
+ }
+ }
+
+ updateStepVisibility(recordingState) {
+ switch (recordingState) {
+ case RecordingState.empty:
+ case RecordingState.recording:
+ this.stepLabelContainer.style.display = '';
+ this.exportLinkContainer.style.display = 'none';
+ break;
+ case RecordingState.done:
+ default:
+ this.stepLabelContainer.style.display = 'none';
+ this.exportLinkContainer.style.display = '';
+ break;
+ }
+ if (recordingState !== RecordingState.empty) {
+ this.stepFileUploadContainer.style.display = 'none';
+ this.stepLabelContainer.classList.add('analytics-step-label-container-active');
+ } else {
+ this.stepFileUploadContainer.style.display = '';
+ }
+ this.updateStepLabel();
+ }
+
+ onStepFileChange() {
+ if (this.stepFileInput.files.length === 0) {
+ return;
+ }
+ const file = this.stepFileInput.files[0];
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ let parser = new DOMParser();
+ let doc = parser.parseFromString(e.target.result, 'text/xml');
+ let elts = doc.querySelectorAll('TextAS-KD');
+ this.stepLabels = Array.from(elts).map(elt => elt.textContent);
+ this.updateStepLabel();
+ };
+ reader.onerror = (e) => {
+ console.error(e);
+ };
+ reader.readAsText(file);
+ }
+
+ getStepLabel() {
+ const i = this.nextStepNumber;
+ let label = 'Step ' + i;
+ if (i <= this.stepLabels.length) {
+ label = this.stepLabels[i - 1];
+ }
+ return label;
+ }
+
+ getStepColor() {
+ let hue = (this.nextStepNumber * 37 + 180) % 360;
+ return `hsl(${hue} 100% 60%)`;
+ }
+
+ updateStepLabel() {
+ this.stepLabelContainer.style.borderColor = this.getStepColor();
+ this.stepLabel.textContent = this.getStepLabel();
+ }
+
+ /**
+ * @param {'reba'|'motion'} lens
+ */
+ setLens(lens) {
+ console.error('setLens unimplemented', lens);
+ }
+
+ /**
+ * @param {'bone'|'pose'} lensDetail
+ */
+ setLensDetail(lensDetail) {
+ console.error('setLensDetail unimplemented', lensDetail);
+ }
+
+ /**
+ * @param {string} spaghettiAttachPoint
+ */
+ setSpaghettiAttachPoint(spaghettiAttachPoint) {
+ console.error('setSpaghettiAttachPoint unimplemented', spaghettiAttachPoint);
+ }
+
+ /**
+ * @param {string} spaghettiVisible
+ */
+ setSpaghettiVisible(spaghettiVisible) {
+ console.error('setSpaghettiVisible unimplemented', spaghettiVisible);
+ }
+
+ /**
+ * @param {string} allClonesVisible
+ */
+ setAllClonesVisible(allClonesVisible) {
+ console.error('setAllClonesVisible unimplemented', allClonesVisible);
+ }
+
+ hydrateMotionStudy(data) {
+ if (this.loadingHistory) {
+ setTimeout(() => {
+ this.hydrateMotionStudy(data);
+ }, 100);
+ return;
+ }
+
+ this.lastHydratedData = data;
+
+ if (!this.videoPlayer && data.videoUrls) {
+ this.videoPlayer = new realityEditor.gui.ar.videoPlayback.VideoPlayer('video' + this.frame, data.videoUrls);
+ let matches = /\/rec(\d+)/.exec(data.videoUrls.color);
+ if (matches && matches[1]) {
+ this.videoStartTime = parseFloat(matches[1]);
+ }
+ this.videoPlayer.hide();
+ this.videoPlayer.colorVideo.controls = true;
+ this.videoPlayer.colorVideo.style.display = '';
+ this.videoPlayer.colorVideo.classList.add('analytics-video');
+ this.pinnedRegionCardsContainer.classList.add('analytics-has-video');
+
+ this.createVideoPlayerShowHideButton();
+
+ this.container.appendChild(this.videoPlayer.colorVideo);
+ }
+
+ if (data.valueAddWasteTime) {
+ this.valueAddWasteTimeManager.fromJSON(data.valueAddWasteTime);
+ this.humanPoseAnalyzer.reprocessLens(this.humanPoseAnalyzer.valueAddWasteTimeLens);
+ }
+
+ data.regionCards.sort((rcDescA, rcDescB) => {
+ return rcDescA.startTime - rcDescB.startTime;
+ });
+
+ if (data.title) {
+ this.titleInput.textContent = data.title;
+ }
+
+ for (let desc of data.regionCards) {
+ let poses = this.humanPoseAnalyzer.getPosesInTimeInterval(desc.startTime, desc.endTime);
+ if (poses.length === 0) {
+ let defaultMotionStudy = realityEditor.motionStudy.getDefaultMotionStudy();
+ poses = defaultMotionStudy.humanPoseAnalyzer.getPosesInTimeInterval(desc.startTime, desc.endTime);
+ }
+ let regionCard = new RegionCard(this, this.pinnedRegionCardsContainer, poses, desc);
+ regionCard.state = RegionCardState.Pinned;
+ if (desc.label) {
+ regionCard.setLabel(desc.label);
+ }
+ regionCard.removePinAnimation();
+ this.addRegionCard(regionCard);
+ }
+ }
+
+ createVideoPlayerShowHideButton() {
+ this.videoPlayerShowHideButton = document.createElement('div');
+ this.videoPlayerShowHideButton.classList.add('analytics-video-toggle');
+ this.videoPlayerShowHideButton.classList.add('analytics-button-container');
+ this.videoPlayerShowHideButton.textContent = 'Show Spatial Video';
+ this.videoPlayerShowHideButton.addEventListener('pointerup', () => {
+ if (this.videoPlayer.isShown()) {
+ this.videoPlayer.hide();
+ this.videoPlayerShowHideButton.textContent = 'Show Spatial Video';
+ } else {
+ this.videoPlayer.show();
+ this.videoPlayerShowHideButton.textContent = 'Hide Spatial Video';
+ }
+ });
+ this.container.appendChild(this.videoPlayerShowHideButton);
+ }
+
+ addRegionCard(regionCard) {
+ // Allow for a small amount of inaccuracy in timestamps, e.g. when
+ // switching from live to historical clone source
+ const tolerance = 500;
+ for (let pinnedRegionCard of this.pinnedRegionCards) {
+ if ((Math.abs(pinnedRegionCard.startTime - regionCard.startTime) < tolerance) &&
+ (Math.abs(pinnedRegionCard.endTime - regionCard.endTime) < tolerance)) {
+ // New region card already exists in the list
+ regionCard.remove();
+
+ // New region card may have an updated label
+ // TODO have better criteria than starting with Step
+ const newLabel = regionCard.labelElement.textContent;
+ if (newLabel && !newLabel.startsWith('Step ')) {
+ pinnedRegionCard.setLabel(newLabel);
+ }
+ return;
+ }
+ }
+ this.pinnedRegionCards.push(regionCard);
+ regionCard.updateValueAddWasteTimeUi(this.valueAddWasteTimeManager);
+
+ if (regionCard.getLabel().length === 0) {
+ regionCard.setLabel(this.getStepLabel());
+ if (this.stepLabels.length > 0) {
+ if (this.writeMSDataTimeout) {
+ clearTimeout(this.writeMSDataTimeout);
+ }
+ this.writeMSDataTimeout = setTimeout(() => {
+ this.writeMotionStudyData();
+ this.writeMSDataTimeout = null;
+ }, 1000);
+ }
+ }
+
+ regionCard.setAccentColor(this.getStepColor());
+
+ this.nextStepNumber += 1;
+
+ this.updateStepLabel();
+
+ this.updateExportLinks();
+
+ // wider tolerance for associating local cameravis patches with
+ // potentially remote region cards
+ const patchTolerance = 3000;
+ if (Math.abs(regionCard.endTime - Date.now()) > patchTolerance) {
+ return;
+ }
+
+ try {
+ const desktopRenderer = realityEditor.gui.ar.desktopRenderer;
+ if (!desktopRenderer) {
+ return;
+ }
+
+ const patches = desktopRenderer.cloneCameraVisPatches('HIDDEN');
+ if (!patches) {
+ return;
+ }
+ } catch (e) {
+ console.warn('Unable to clone patches', e);
+ }
+
+ // Hide cloned patches after brief delay to not clutter the space
+ // setTimeout(() => {
+ // for (const patch of Object.values(patches)) {
+ // patch.visible = false;
+ // }
+ // }, patchTolerance);
+ }
+
+ writeMotionStudyData() {
+ // Write region card descriptions to public data of currently active envelope
+ let openEnvelopes = realityEditor.envelopeManager.getOpenEnvelopes();
+ let allCards = this.pinnedRegionCards.map(regionCard => {
+ return {
+ startTime: regionCard.startTime,
+ endTime: regionCard.endTime,
+ label: regionCard.getLabel(),
+ };
+ });
+
+ allCards.sort((rcDescA, rcDescB) => {
+ return rcDescA.startTime - rcDescB.startTime;
+ });
+
+ for (let envelope of openEnvelopes) {
+ let objectKey = envelope.object;
+ let frameKey = envelope.frame;
+ if (frameKey !== this.frame) {
+ continue;
+ }
+ const motionStudyData = Object.assign(
+ {},
+ this.lastHydratedData || {},
+ {
+ regionCards: allCards,
+ valueAddWasteTime: this.valueAddWasteTimeManager.toJSON()
+ },
+ );
+ if (this.titleInput.textContent) {
+ motionStudyData.title = this.titleInput.textContent;
+ }
+ realityEditor.network.realtime.writePublicData(objectKey, frameKey, frameKey + 'storage', 'analyticsData', motionStudyData);
+ }
+ }
+
+ pinRegionCard(regionCard) {
+ regionCard.state = RegionCardState.Pinned;
+ if (regionCard.getLabel() === 'Step') {
+ regionCard.setLabel('Step ' + this.nextStepNumber);
+ this.nextStepNumber += 1;
+ }
+ setTimeout(() => {
+ regionCard.moveTo(35, 120 + 240 * this.pinnedRegionCards.length);
+ }, 10);
+
+ setTimeout(() => {
+ regionCard.removePinAnimation();
+
+ this.addRegionCard(regionCard);
+ this.writeMotionStudyData();
+
+ regionCard.switchContainer(this.pinnedRegionCardsContainer);
+ }, 750);
+ }
+
+ unpinRegionCard(regionCard) {
+ this.pinnedRegionCards = this.pinnedRegionCards.filter(prc => {
+ return prc !== regionCard;
+ });
+ this.updateExportLinks();
+ this.writeMotionStudyData();
+ }
+
+ updateExportLinks() {
+ this.updatePinnedRegionCardsExportLink();
+ this.updatePoseDataExportLink();
+ }
+
+ updatePinnedRegionCardsExportLink() {
+ let header = [
+ 'label',
+ 'start', 'end', 'duration seconds', 'distance meters',
+ 'reba avg', 'reba min', 'reba max',
+ 'accel avg', 'accel min', 'accel max',
+ ];
+ let lines = [header];
+ for (let regionCard of this.pinnedRegionCards) {
+ if (regionCard.poses.length === 0) {
+ continue;
+ }
+
+ lines.push([
+ regionCard.getLabel(),
+ new Date(regionCard.startTime).toISOString(),
+ new Date(regionCard.endTime).toISOString(),
+ regionCard.durationMs / 1000,
+ regionCard.distanceMm / 1000,
+ regionCard.graphSummaryValues['REBA'].average,
+ regionCard.graphSummaryValues['REBA'].minimum,
+ regionCard.graphSummaryValues['REBA'].maximum,
+ regionCard.graphSummaryValues['Accel'].average,
+ regionCard.graphSummaryValues['Accel'].minimum,
+ regionCard.graphSummaryValues['Accel'].maximum,
+ ]);
+ }
+ let dataUrl = 'data:text/plain;charset=UTF-8,' + encodeURIComponent(lines.map(line => {
+ return line.join(',');
+ }).join('\n'));
+
+ this.exportLinkPinnedRegionCards.href = dataUrl;
+ // window.open(dataUrl, '_blank');
+ }
+
+ updatePoseDataExportLink() {
+ const allPoses = this.humanPoseAnalyzer.getPosesInTimeInterval(0, Number.MAX_VALUE);
+
+ if (allPoses.length === 0) {
+ return;
+ }
+
+ // Create array manually since we can go over the JSON.stringify and string
+ // length limits
+ const poseStrings = ['['];
+
+ for (const pose of allPoses) {
+ let filteredPose = {
+ joints: {},
+ timestamp: pose.timestamp,
+ };
+
+ for (const jointKey of Object.keys(pose.joints)) {
+ const jointData = pose.joints[jointKey];
+ // Clone to modify pose data (filter out unnecessary info)
+ filteredPose.joints[jointKey] = Object.assign({}, jointData);
+ delete filteredPose.joints[jointKey].poseObjectId;
+ delete filteredPose.joints[jointKey].rebaColor;
+ delete filteredPose.joints[jointKey].rebaColorOverall;
+ delete filteredPose.joints[jointKey].overallRebaColor;
+ delete filteredPose.joints[jointKey].position;
+ delete filteredPose.joints[jointKey].acceleration;
+ delete filteredPose.joints[jointKey].velocity;
+ delete filteredPose.joints[jointKey].confidence;
+ }
+
+ poseStrings.push(JSON.stringify(filteredPose));
+ poseStrings.push(',');
+ }
+ poseStrings.pop();
+ poseStrings.push(']');
+
+ const blob = new Blob(poseStrings, {type: 'application/json'});
+ const url = URL.createObjectURL(blob);
+ this.exportLinkPoseData.href = url;
+ }
+
+ /**
+ * @param {RegionCard} activeRegionCard
+ */
+ setActiveRegionCard(activeRegionCard) {
+ if (this.activeRegionCard) {
+ this.activeRegionCard.displayActive = false;
+ this.activeRegionCard.updateDisplayActive();
+ }
+ this.activeRegionCard = activeRegionCard;
+ }
+
+ /**
+ * @param {RegionCard} timelineRegionCard
+ */
+ setTimelineRegionCard(timelineRegionCard) {
+ if (this.activeRegionCard) {
+ this.activeRegionCard.setPoses(timelineRegionCard.poses);
+ }
+ }
+
+ /**
+ * @param {number} startTime
+ * @param {number} endTime
+ */
+ markWasteTime(startTime, endTime) {
+ this.valueAddWasteTimeManager.markWasteTime(startTime, endTime);
+ this.humanPoseAnalyzer.reprocessLens(this.humanPoseAnalyzer.valueAddWasteTimeLens);
+ this.pinnedRegionCards.forEach(card => {
+ card.updateValueAddWasteTimeUi();
+ });
+ this.writeMotionStudyData();
+ }
+
+ /**
+ * @param {number} startTime
+ * @param {number} endTime
+ */
+ markValueAdd(startTime, endTime) {
+ this.valueAddWasteTimeManager.markValueAdd(startTime, endTime);
+ this.humanPoseAnalyzer.reprocessLens(this.humanPoseAnalyzer.valueAddWasteTimeLens);
+ this.pinnedRegionCards.forEach(card => {
+ card.updateValueAddWasteTimeUi();
+ });
+ this.writeMotionStudyData();
+ }
+
+ updateRegionCards() {
+ this.pinnedRegionCards.forEach(card => {
+ card.updateLensStatistics();
+ });
+ if (this.timeline.regionCard) {
+ this.timeline.regionCard.updateLensStatistics();
+ }
+
+ this.updateExportLinks();
+ //this.writeMotionStudyData();
+ }
+
+}
diff --git a/src/motionStudy/regionCard.js b/src/motionStudy/regionCard.js
new file mode 100644
index 000000000..3d58b1af6
--- /dev/null
+++ b/src/motionStudy/regionCard.js
@@ -0,0 +1,593 @@
+import {getMeasurementTextLabel} from '../humanPose/spaghetti.js';
+import {JOINTS} from '../humanPose/constants.js';
+import {MIN_ACCELERATION, MAX_ACCELERATION} from '../humanPose/AccelerationLens.js';
+import {MIN_REBA_SCORE, MAX_REBA_SCORE} from '../humanPose/OverallRebaLens.js';
+import {ValueAddWasteTimeTypes} from './ValueAddWasteTimeManager.js';
+import {makeTextInput} from '../utilities/makeTextInput.js';
+
+const cardWidth = 200;
+const rowHeight = 22;
+
+const svgNS = 'http://www.w3.org/2000/svg';
+
+export const RegionCardState = {
+ Tooltip: 'Tooltip', // an ephemeral tooltip on the timeline
+ Pinned: 'Pinned', // a regioncard displayed with statistics
+};
+
+/**
+ * A Region Card contains a full summary of a given [start time, end time]
+ * region on the timeline
+ *
+ * For example:
+ * 12/12/22, 12:10:58 - 12:11:20
+ * 5m traveled in 22s
+ * REBA
+ * Avg: 4 Low: 1
+ * MoEc
+ * Avg: 12 Low: 2
+ */
+export class RegionCard {
+ /**
+ * @param {MotionStudy} motionStudy - parent instance of MotionStudy
+ * @param {Element} container
+ * @param {Array} poses - the poses to process in this region card
+ * @param {{startTime: number, endTime: number}?} desc - If present, the
+ * dehydrated description of this card
+ */
+ constructor(motionStudy, container, poses, desc) {
+ this.motionStudy = motionStudy;
+ this.container = container;
+ this.poses = poses;
+ this.element = document.createElement('div');
+ this.dateTimeFormat = new Intl.DateTimeFormat('default', {
+ // dateStyle: 'short',
+ timeStyle: 'medium',
+ hour12: false,
+ });
+ this.state = RegionCardState.Tooltip;
+ this.accentColor = '';
+ // If a region card has control over the timeline's displayed points
+ this.displayActive = false;
+ this.onPointerOver = this.onPointerOver.bind(this);
+ this.onPointerDown = this.onPointerDown.bind(this);
+ this.onPointerMove = this.onPointerMove.bind(this);
+ this.onPointerOut = this.onPointerOut.bind(this);
+ this.onClickPin = this.onClickPin.bind(this);
+ this.onClickShow = this.onClickShow.bind(this);
+
+ this.createCard();
+ if (desc) {
+ this.startTime = desc.startTime;
+ this.endTime = desc.endTime;
+ }
+ this.setPoses(poses);
+ this.updateValueAddWasteTimeUi();
+
+ this.element.addEventListener('pointerover', this.onPointerOver);
+ this.element.addEventListener('pointerdown', this.onPointerDown);
+ this.element.addEventListener('pointermove', this.onPointerMove);
+ this.element.addEventListener('pointerout', this.onPointerOut);
+ this.container.appendChild(this.element);
+ }
+
+ onPointerOver() {
+ this.element.classList.remove('minimized');
+ // if (this.state === RegionCardState.Pinned) {
+ // this.motionStudy.setHighlightRegion({
+ // startTime: this.startTime,
+ // endTime: this.endTime,
+ // });
+ // }
+ }
+
+ onPointerOut() {
+ this.element.classList.add('minimized');
+ }
+
+ onPointerDown(e) {
+ e.stopPropagation();
+ }
+
+ onPointerMove(e) {
+ e.stopPropagation();
+ }
+
+ onClickPin() {
+ switch (this.state) {
+ case RegionCardState.Tooltip:
+ this.pin();
+ break;
+ case RegionCardState.Pinned:
+ this.unpin();
+ break;
+ }
+ event.stopPropagation();
+ }
+
+ onClickShow() {
+ switch (this.state) {
+ case RegionCardState.Tooltip:
+ this.pin();
+ break;
+ case RegionCardState.Pinned:
+ if (this.displayActive) {
+ this.motionStudy.setActiveRegionCard(null);
+ this.motionStudy.setHighlightRegion(null);
+ this.motionStudy.setCursorTime(-1);
+ this.displayActive = false;
+ } else {
+ this.motionStudy.setActiveRegionCard(this);
+ this.motionStudy.setHighlightRegion({
+ startTime: this.startTime,
+ endTime: this.endTime,
+ label: this.getLabel(),
+ });
+ this.displayActive = true;
+ }
+ break;
+ }
+ this.updateDisplayActive();
+ }
+
+ pin() {
+ this.state = RegionCardState.Pinned;
+ const rect = this.element.getBoundingClientRect();
+
+ this.switchContainer(document.body);
+
+ this.element.style.bottom = 'auto';
+ this.moveTo(rect.left, rect.top);
+ this.element.classList.add('pinAnimation', 'minimized');
+ this.updatePinButtonText();
+
+ this.motionStudy.pinRegionCard(this);
+ }
+
+ save() {
+ let addedTool = realityEditor.spatialCursor.addToolAtScreenCenter('spatialAnalytics');
+ const frameKey = addedTool.uuid;
+ const publicData = {
+ startTime: this.startTime,
+ endTime: this.endTime,
+ summary: this.element.outerHTML,
+ };
+ const write = () => {
+ realityEditor.network.realtime.writePublicData(addedTool.objectId, frameKey, frameKey + 'storage', 'status', publicData);
+ };
+ setTimeout(write, 1000);
+ setTimeout(write, 2000);
+ }
+
+ removePinAnimation() {
+ this.element.classList.remove('pinAnimation');
+ this.element.classList.add('pinned');
+ this.element.style.top = 'auto';
+ this.element.style.left = 'auto';
+ this.updatePinButtonText();
+ }
+
+ unpin() {
+ this.remove();
+ this.motionStudy.unpinRegionCard(this);
+ }
+
+ updatePinButtonText() {
+ let pinButton = this.element.querySelector('#analytics-region-card-step');
+ if (pinButton) {
+ pinButton.textContent = this.state === RegionCardState.Pinned ? 'Remove Step' : 'Mark Step';
+ }
+ this.updateDisplayActive();
+ }
+
+ updateDisplayActive() {
+ let showButton = this.element.querySelector('#analytics-region-card-show');
+ if (!showButton) {
+ console.warn('regioncard missing element');
+ return;
+ }
+
+ if (this.state === RegionCardState.Pinned) {
+ showButton.style.opacity = '1';
+ } else {
+ showButton.style.opacity = '0';
+ }
+
+ showButton.textContent = this.displayActive ? 'Hide' : 'Show';
+
+ if (this.displayActive) {
+ this.element.classList.add('displayActive');
+ } else {
+ this.element.classList.remove('displayActive');
+ }
+ }
+
+ createCard() {
+ this.element.classList.add('analytics-region-card');
+ this.element.classList.add('minimized');
+
+ const dateTimeTitle = document.createElement('div');
+ dateTimeTitle.classList.add(
+ 'analytics-region-card-title',
+ 'analytics-region-card-date-time'
+ );
+
+ const colorDot = document.createElement('div');
+ colorDot.classList.add(
+ 'analytics-region-card-dot'
+ );
+
+ const motionSummary = document.createElement('div');
+ motionSummary.classList.add(
+ 'analytics-region-card-subtitle',
+ 'analytics-region-card-motion-summary'
+ );
+
+ this.valueAddWasteTimeSummary = document.createElement('div');
+ this.valueAddWasteTimeSummary.classList.add('analytics-region-card-value-add-waste-time-summary');
+ this.valueAddWasteTimeSummary.setValues = (valuePercent, wastePercent) => {
+ this.valueAddWasteTimeSummary.innerHTML = `Value Add: ${valuePercent}%, Waste Time: ${wastePercent}%`;
+ }
+ this.valueAddWasteTimeSummary.setValues(0, 0);
+
+ this.element.appendChild(dateTimeTitle);
+ this.element.appendChild(colorDot);
+ this.element.appendChild(motionSummary);
+ this.element.appendChild(this.valueAddWasteTimeSummary);
+
+ this.labelElement = document.createElement('div');
+ this.labelElement.classList.add('analytics-region-card-label');
+ this.labelElement.setAttribute('contenteditable', true);
+ this.setLabel('');
+
+ makeTextInput(this.labelElement, () => {
+ this.motionStudy.writeMotionStudyData();
+ });
+
+ this.element.appendChild(this.labelElement);
+
+ this.graphSummaryValues = {};
+ this.createGraphSection('reba', 'REBA');
+ this.createGraphSection('accel', 'Accel');
+
+ const buttonContainer = document.createElement('div');
+ buttonContainer.classList.add('analytics-region-card-button-container');
+ this.element.appendChild(buttonContainer);
+
+ const pinButton = document.createElement('div');
+ pinButton.classList.add('analytics-region-card-button');
+ pinButton.id = 'analytics-region-card-step';
+ pinButton.textContent = this.state === RegionCardState.Pinned ? 'Remove Step' : 'Mark Step';
+ pinButton.addEventListener('click', this.onClickPin);
+ buttonContainer.appendChild(pinButton);
+
+ const showButton = document.createElement('div');
+ showButton.classList.add('analytics-region-card-button');
+ showButton.id = 'analytics-region-card-show';
+ showButton.addEventListener('click', this.onClickShow);
+ buttonContainer.appendChild(showButton);
+
+ const valueAddWasteTimeDiv = document.createElement('div');
+ valueAddWasteTimeDiv.classList.add('analytics-value-add-waste-time-container');
+ buttonContainer.appendChild(valueAddWasteTimeDiv);
+
+ const wasteTimeButton = document.createElement('div');
+ wasteTimeButton.classList.add('analytics-waste-time-item');
+ wasteTimeButton.textContent = 'Waste';
+ wasteTimeButton.addEventListener('click', () => {
+ if (this.state === RegionCardState.Pinned) {
+ this.motionStudy.markWasteTime(this.startTime, this.endTime);
+ } else {
+ const highlightRegion = this.motionStudy.timeline.highlightRegion;
+ this.motionStudy.markWasteTime(highlightRegion.startTime, highlightRegion.endTime);
+ this.updateValueAddWasteTimeUi(); // Needed for Tooltips, since the motionStudy session does not track or update them
+ }
+ });
+ this.wasteTimeButton = wasteTimeButton;
+ valueAddWasteTimeDiv.appendChild(wasteTimeButton);
+
+ const valueAddButton = document.createElement('div');
+ valueAddButton.classList.add('analytics-value-add-item');
+ valueAddButton.textContent = 'Value';
+ valueAddButton.addEventListener('click', () => {
+ if (this.state === RegionCardState.Pinned) {
+ this.motionStudy.markValueAdd(this.startTime, this.endTime);
+ } else {
+ const highlightRegion = this.motionStudy.timeline.highlightRegion;
+ this.motionStudy.markValueAdd(highlightRegion.startTime, highlightRegion.endTime);
+ this.updateValueAddWasteTimeUi(); // Needed for Tooltips, since the motionStudy session does not track or update them
+ }
+ });
+ this.valueAddButton = valueAddButton;
+ valueAddWasteTimeDiv.appendChild(valueAddButton);
+
+ this.updateDisplayActive();
+ }
+
+ setPoses(poses) {
+ this.poses = poses;
+
+ // Getting times from poses is more accurate to the local data
+ if (this.poses.length > 0) {
+ this.poses.sort((a, b) => {
+ return a.timestamp - b.timestamp;
+ });
+ let filteredPoses = [];
+ let lastTs = 0;
+ for (let pose of this.poses) {
+ if (pose.timestamp - lastTs < 50) {
+ continue;
+ }
+ lastTs = pose.timestamp;
+ filteredPoses.push(pose);
+ }
+ this.poses = filteredPoses;
+
+ this.startTime = this.poses[0].timestamp;
+ this.endTime = this.poses[this.poses.length - 1].timestamp;
+ }
+
+ try {
+ const dateTimeTitle = this.element.querySelector('.analytics-region-card-date-time');
+ dateTimeTitle.textContent = this.dateTimeFormat.formatRange(
+ new Date(this.startTime),
+ new Date(this.endTime),
+ );
+ } catch (_) {
+ // formatRange failed for some time-related reason
+ }
+
+ if (this.poses.length === 0) {
+ return;
+ }
+
+ const motionSummary = this.element.querySelector('.analytics-region-card-motion-summary');
+ motionSummary.textContent = this.getMotionSummaryText();
+
+ this.graphSummaryValues = {};
+ this.updateLensStatistics();
+ }
+
+ getMotionSummaryText() {
+ let distanceMm = 0;
+
+ this.poses.forEach((pose, index) => {
+ if (index === 0) return;
+ const previousPose = this.poses[index - 1];
+ const joint = pose.getJoint(JOINTS.HEAD);
+ const previousJoint = previousPose.getJoint(JOINTS.HEAD);
+ const dx = joint.position.x - previousJoint.position.x;
+ const dy = joint.position.y - previousJoint.position.y;
+ const dz = joint.position.z - previousJoint.position.z;
+ distanceMm += Math.sqrt(dx * dx + dy * dy + dz * dz);
+ });
+
+ this.distanceMm = distanceMm;
+ this.durationMs = this.endTime - this.startTime;
+
+ return getMeasurementTextLabel(distanceMm, this.endTime - this.startTime);
+ }
+
+ createGraphSection(id, titleText) {
+ let title = document.createElement('div');
+ title.classList.add('analytics-region-card-graph-section-title');
+ title.textContent = titleText;
+
+ let sparkLine = document.createElementNS(svgNS, 'svg');
+ sparkLine.classList.add('analytics-region-card-graph-section-sparkline');
+ sparkLine.setAttribute('width', cardWidth / 3);
+ sparkLine.setAttribute('height', rowHeight);
+ sparkLine.setAttribute('xmlns', svgNS);
+
+ let path = document.createElementNS(svgNS, 'path');
+ path.classList.add('analytics-region-card-graph-section-sparkline-path-' + id);
+ path.setAttribute('stroke-width', '1');
+
+ sparkLine.appendChild(path);
+
+ let average = document.createElement('div');
+ average.classList.add(
+ 'analytics-region-card-graph-section-value',
+ 'analytics-region-card-graph-section-average-' + id
+ );
+
+ let minimum = document.createElement('div');
+ minimum.classList.add(
+ 'analytics-region-card-graph-section-value',
+ 'analytics-region-card-graph-section-minimum-' + id
+ );
+
+ let maximum = document.createElement('div');
+ maximum.classList.add(
+ 'analytics-region-card-graph-section-value',
+ 'analytics-region-card-graph-section-maximum-' + id
+ );
+
+ this.element.appendChild(title);
+ this.element.appendChild(sparkLine);
+ this.element.appendChild(average);
+ this.element.appendChild(minimum);
+ this.element.appendChild(maximum);
+ }
+
+ updateGraphSection(id, titleText, poseValueFunction, minValue, maxValue) {
+ let summaryValues = this.getSummaryValues(poseValueFunction);
+ this.graphSummaryValues[titleText] = summaryValues;
+
+ let path = this.element.querySelector('.analytics-region-card-graph-section-sparkline-path-' + id);
+ path.setAttribute('d', this.getSparkLinePath(poseValueFunction, summaryValues));
+
+ let average = this.element.querySelector('.analytics-region-card-graph-section-average-' + id);
+ // average.innerHTML = '';
+ average.textContent = 'Avg: ';
+ average.appendChild(this.makeSummaryValue(summaryValues.average, minValue, maxValue));
+
+ let minimum = this.element.querySelector('.analytics-region-card-graph-section-minimum-' + id);
+ minimum.textContent = 'Min: ';
+ minimum.appendChild(this.makeSummaryValue(summaryValues.minimum, minValue, maxValue));
+
+ let maximum = this.element.querySelector('.analytics-region-card-graph-section-maximum-' + id);
+ maximum.textContent = 'Max: ';
+ maximum.appendChild(this.makeSummaryValue(summaryValues.maximum, minValue, maxValue));
+ }
+
+ updateLensStatistics() {
+ if (this.poses.length === 0) {
+ return;
+ }
+
+ this.updateGraphSection('reba', 'REBA', pose => pose.getJoint(JOINTS.HEAD).overallRebaScore, MIN_REBA_SCORE, MAX_REBA_SCORE);
+ this.updateGraphSection('accel', 'Accel', pose => {
+ let maxAcceleration = 0;
+ pose.forEachJoint(joint => {
+ maxAcceleration = Math.max(maxAcceleration, joint.accelerationMagnitude || 0);
+ });
+ return maxAcceleration;
+ }, MIN_ACCELERATION, MAX_ACCELERATION);
+ }
+
+ /**
+ * @param {number} val
+ * @param {number} min
+ * @param {number} max
+ * @return {Element} span containing text val with color based on val's position within min and max
+ */
+ makeSummaryValue(val, min, max) {
+ let span = document.createElement('span');
+ if (max < 1000) {
+ span.textContent = val.toFixed(1);
+ } else {
+ if (val < 1000) {
+ span.textContent = val.toFixed(0);
+ } else {
+ // limit to thousands, e.g. 1234 -> 1.2k
+ let valThousands = (val / 1000).toFixed(1);
+ if (val > 100000) {
+ valThousands = (val / 1000).toFixed(0);
+ }
+ span.textContent = `${valThousands}K`;
+ }
+ }
+ if (val > max) {
+ // Prevent overflowing scale
+ val = max;
+ }
+ let hue = (max - val) / (max - min) * 120;
+ span.style.color = `hsl(${hue}, 100%, 50%)`;
+ return span;
+ }
+
+ getSparkLinePath(poseValueFunction, summaryValues) {
+ let minX = this.startTime;
+ let maxX = this.endTime;
+ let minY = summaryValues.minimum - 0.5;
+ let maxY = summaryValues.maximum + 0.5;
+ let width = cardWidth / 3;
+ let height = rowHeight;
+ let path = 'M ';
+ for (let i = 0; i < this.poses.length; i++) {
+ const pose = this.poses[i];
+ const val = poseValueFunction(pose);
+ const x = Math.round((pose.timestamp - minX) / (maxX - minX) * width);
+ const y = Math.round((maxY - val) / (maxY - minY) * height);
+ path += x + ' ' + y;
+ if (i < this.poses.length - 1) {
+ let nextPose = this.poses[i + 1];
+ if (nextPose.timestamp - pose.timestamp < 500) {
+ path += ' L ';
+ } else {
+ path += ' M ';
+ }
+ }
+ }
+ return path;
+ }
+
+ getSummaryValues(poseValueFunction) {
+ let average = 0;
+ let minimum = 9001 * 9001;
+ let maximum = -minimum;
+ for (const pose of this.poses) {
+ const val = poseValueFunction(pose);
+ average += val;
+ minimum = Math.min(minimum, val);
+ maximum = Math.max(maximum, val);
+ }
+ average /= this.poses.length;
+ return {
+ average,
+ minimum,
+ maximum,
+ };
+ }
+
+ getLabel() {
+ return this.labelElement.textContent;
+ }
+
+ setLabel(label) {
+ this.labelElement.textContent = label;
+ }
+
+ setAccentColor(accentColor) {
+ this.accentColor = accentColor;
+ const colorDot = this.element.querySelector('.analytics-region-card-dot');
+ if (colorDot) {
+ colorDot.style.backgroundColor = this.accentColor;
+ }
+ }
+
+ moveTo(x, y) {
+ this.element.style.left = x + 'px';
+ if (this.state === RegionCardState.Pinned) {
+ this.element.style.top = y + 'px';
+ } else {
+ this.element.style.bottom = y + 'px';
+ }
+ }
+
+ remove() {
+ this.container.removeChild(this.element);
+ }
+
+ switchContainer(newContainer) {
+ this.remove();
+ this.container = newContainer;
+ this.container.appendChild(this.element);
+ }
+
+ updateValueAddWasteTimeUi() {
+ const regionValue = this.motionStudy.valueAddWasteTimeManager.getValueForRegion(this.startTime, this.endTime);
+
+ if (regionValue === ValueAddWasteTimeTypes.WASTE_TIME) {
+ this.wasteTimeButton.classList.add('selected');
+ this.valueAddButton.classList.remove('selected');
+ } else if (regionValue === ValueAddWasteTimeTypes.VALUE_ADD) {
+ this.valueAddButton.classList.add('selected');
+ this.wasteTimeButton.classList.remove('selected');
+ } else {
+ this.valueAddButton.classList.remove('selected');
+ this.wasteTimeButton.classList.remove('selected');
+ }
+
+ const subset = this.motionStudy.valueAddWasteTimeManager.subset(this.startTime, this.endTime);
+ let totalValueAdd = 0;
+ let totalWasteTime = 0;
+ const totalTime = this.endTime - this.startTime;
+ subset.regions.forEach(region => {
+ if (region.value === ValueAddWasteTimeTypes.VALUE_ADD) {
+ totalValueAdd += region.duration;
+ } else if (region.value === ValueAddWasteTimeTypes.WASTE_TIME) {
+ totalWasteTime += region.duration;
+ }
+ });
+ if (totalTime === 0) {
+ console.warn('Region Card has 0 duration, cannot set Value Add/Waste Time ui');
+ return;
+ }
+ const valuePercent = Math.round(totalValueAdd / totalTime * 100);
+ const wastePercent = Math.round(totalWasteTime / totalTime * 100);
+
+ this.valueAddWasteTimeSummary.setValues(valuePercent, wastePercent);
+ }
+}
diff --git a/src/motionStudy/timeline.js b/src/motionStudy/timeline.js
new file mode 100644
index 000000000..6489bd988
--- /dev/null
+++ b/src/motionStudy/timeline.js
@@ -0,0 +1,1026 @@
+import {RegionCard, RegionCardState} from './regionCard.js';
+import {
+ setAnimationMode,
+ AnimationMode, getPosesInTimeInterval,
+} from '../humanPose/draw.js';
+import {ValueAddWasteTimeTypes} from './ValueAddWasteTimeManager.js';
+
+const needleTopPad = 4;
+const needleTipWidth = 12;
+const needlePad = 12;
+const needleWidth = 3;
+const needleDragWidth = 12;
+
+const rowPad = 4;
+const rowHeight = 16;
+const boardHeight = 4 * (rowPad + rowHeight) + rowPad;
+const boardStart = needlePad + needleTopPad;
+const minimapHeight = rowHeight;
+const minimapStart = boardStart + boardHeight + minimapHeight;
+
+const labelPad = 4;
+
+const DEFAULT_MAX_WIDTH_MS = 1024 / 0.00004;
+const MIN_WIDTH_MS = 1024 / 0.12;
+
+const DragMode = {
+ NONE: 'none',
+ SELECT: 'select',
+ PAN: 'pan',
+};
+
+const DEFAULT_WIDTH_MS = 60 * 1000;
+
+export class Timeline {
+ /**
+ * @param {MotionStudy} motionStudy - parent MotionStudy instance of this timeline
+ * @param {Element} container - where to insert timeline
+ */
+ constructor(motionStudy, container) {
+ this.motionStudy = motionStudy;
+ this.container = container;
+
+ this.canvas = document.createElement('canvas');
+ this.canvas.classList.add('analytics-timeline');
+ this.gfx = this.canvas.getContext('2d');
+
+ this.pixelsPerMs = 0.01; // 1024 * 100 / (24 * 60 * 60 * 1000);
+ this.timeMin = Date.now() - DEFAULT_WIDTH_MS;
+ this.resetBounds();
+ this.widthMs = DEFAULT_WIDTH_MS;
+ this.scrolled = false;
+ container.appendChild(this.canvas);
+
+ this.width = -1;
+ this.displayRegion = null;
+ this.height = boardHeight + boardStart + needlePad + minimapHeight;
+ this.highlightRegion = null;
+ this.highlightStartTime = -1;
+ this.regionCard = null;
+ this.lastRegionCardCacheKey = '';
+
+ this.dragMode = DragMode.NONE;
+ this.mouseX = -1;
+ this.mouseY = -1;
+ this.cursorTime = -1;
+
+ this.lastDraw = Date.now();
+
+ this.controlsCanvas = document.createElement('canvas');
+ this.controlsCanvas.classList.add('analytics-timeline-controls');
+ this.controlsGfx = this.controlsCanvas.getContext('2d');
+ let dpr = window.devicePixelRatio;
+ this.controlsCanvas.width = (rowHeight + rowPad) * dpr;
+ this.controlsCanvas.height = this.height * dpr;
+ this.controlsCanvas.style.width = (rowHeight + rowPad) + 'px';
+ this.controlsCanvas.style.height = this.height + 'px';
+ container.appendChild(this.controlsCanvas);
+
+ this.iconPlay = document.createElement('img');
+ this.iconPlay.src = './png/playing.png';
+
+ this.iconPause = document.createElement('img');
+ this.iconPause.src = './png/paused.png';
+
+ this.boardLabelLeft = document.createElement('div');
+ this.boardLabelLeft.classList.add('timelineBoardLabel');
+ container.appendChild(this.boardLabelLeft);
+
+ this.boardLabelRight = document.createElement('div');
+ this.boardLabelRight.classList.add('timelineBoardLabel');
+ container.appendChild(this.boardLabelRight);
+
+ this.dateFormat = new Intl.DateTimeFormat('default', {
+ dateStyle: 'short',
+ timeStyle: 'medium',
+ hour12: false,
+ });
+
+ this.timeFormat = new Intl.DateTimeFormat('default', {
+ timeStyle: 'medium',
+ hour12: false,
+ });
+
+ this.onPointerDown = this.onPointerDown.bind(this);
+ this.onPointerMove = this.onPointerMove.bind(this);
+ this.onPointerUp = this.onPointerUp.bind(this);
+ this.onPointerOver = this.onPointerOver.bind(this);
+ this.onPointerOut = this.onPointerOut.bind(this);
+ this.onWheel = this.onWheel.bind(this);
+
+ this.onControlsPointerDown = this.onControlsPointerDown.bind(this);
+ this.onControlsPointerUp = this.onControlsPointerUp.bind(this);
+
+ this.canvas.addEventListener('pointerdown', this.onPointerDown);
+ this.canvas.addEventListener('pointermove', this.onPointerMove);
+ this.canvas.addEventListener('pointerup', this.onPointerUp);
+ this.canvas.addEventListener('pointerover', this.onPointerOver);
+ this.canvas.addEventListener('pointerout', this.onPointerOut);
+ this.canvas.addEventListener('wheel', this.onWheel);
+
+ this.controlsCanvas.addEventListener('pointerdown', this.onControlsPointerDown);
+ this.controlsCanvas.addEventListener('pointerup', this.onControlsPointerUp);
+
+ realityEditor.device.layout.onWindowResized(this.recomputeSize.bind(this));
+ }
+
+ reset() {
+ this.displayRegion = null;
+ this.highlightRegion = null;
+ this.highlightStartTime = -1;
+ this.timeMin = Date.now() - DEFAULT_WIDTH_MS;
+ this.widthMs = DEFAULT_WIDTH_MS;
+ this.scrolled = false;
+ this.lastRegionCardCacheKey = '';
+ this.resetBounds();
+ }
+
+ recomputeSize() {
+ let rect = this.canvas.getBoundingClientRect();
+ if (rect.width <= 0) {
+ return;
+ }
+
+ this.width = rect.width;
+ this.pixelsPerMs = rect.width / this.widthMs;
+
+ this.canvas.width = rect.width;
+ this.canvas.height = this.height;
+ this.gfx.width = rect.width;
+ this.gfx.height = this.height;
+ }
+
+ draw() {
+ let dt = Date.now() - this.lastDraw;
+ this.lastDraw += dt;
+
+ if (this.width < 0) {
+ this.recomputeSize();
+ }
+
+ if (this.timeMin > 0 && !this.scrolled) {
+ const newTimeMin = Date.now() - this.widthMs;
+ if (newTimeMin > this.timeMin) {
+ this.timeMin = newTimeMin;
+ if (this.timeMin + this.widthMs > this.maxTimeMax) {
+ this.timeMin = this.maxTimeMax - this.widthMs;
+ }
+ }
+ }
+
+ if (this.dragMode === DragMode.SELECT) {
+ // If mouse is far to either side of timeline during selection,
+ // scroll the timeline in that direction
+ const dragSpeedBase = 0.5;
+ const dragStart = 0.15;
+ if (this.mouseX < this.width * dragStart) {
+ let velX = this.width * (dragStart + 0.05) - this.mouseX;
+ let velTime = velX / this.pixelsPerMs * dragSpeedBase;
+ this.timeMin -= velTime * dt / 1000;
+ this.limitTimeMin();
+ } else if (this.mouseX > this.width * (1 - dragStart)) {
+ let velX = this.mouseX - this.width * (1 - dragStart - 0.05);
+ let velTime = velX / this.pixelsPerMs * dragSpeedBase;
+ this.timeMin += velTime * dt / 1000;
+ this.limitTimeMin();
+ }
+ }
+
+ this.gfx.clearRect(0, 0, this.width, this.height);
+
+ this.gfx.fillStyle = 'rgba(0, 0, 0, 0.1)';
+ this.gfx.fillRect(0, boardStart, this.width, boardHeight);
+
+ this.rowIndex = 0;
+ this.calculateAndDrawTicks();
+ this.drawPoses();
+ this.drawPinnedRegionCards();
+ this.drawPatches();
+ this.drawValueAddWasteTime();
+
+ this.drawHighlightRegion();
+
+ this.drawCursor();
+
+ this.drawMinimap();
+
+ this.drawControls();
+
+ this.updateBoardLabels();
+ this.updateRegionCard();
+ }
+
+ drawHighlightRegion() {
+ if (!this.highlightRegion) {
+ return;
+ }
+
+ let startX = this.timeToX(this.highlightRegion.startTime);
+ this.gfx.fillStyle = '#00ffff';
+ this.gfx.beginPath();
+ this.gfx.moveTo(startX + needleWidth / 2, 0);
+ this.gfx.lineTo(startX + needleWidth / 2, this.height);
+ this.gfx.lineTo(startX - needleWidth / 2, this.height);
+ this.gfx.lineTo(startX - needleWidth / 2, needleTipWidth);
+ this.gfx.lineTo(startX - needleWidth / 2 - needleTipWidth, 0);
+ this.gfx.closePath();
+ this.gfx.fill();
+
+ let endX = this.timeToX(this.highlightRegion.endTime);
+ this.gfx.beginPath();
+ this.gfx.moveTo(endX - needleWidth / 2, 0);
+ this.gfx.lineTo(endX - needleWidth / 2, this.height);
+ this.gfx.lineTo(endX + needleWidth / 2, this.height);
+ this.gfx.lineTo(endX + needleWidth / 2, needleTipWidth);
+ this.gfx.lineTo(endX + needleWidth / 2 + needleTipWidth, 0);
+ this.gfx.closePath();
+ this.gfx.fill();
+ }
+
+ drawCursor() {
+ if (this.cursorTime < this.timeMin || this.cursorTime > this.timeMin + this.widthMs) {
+ return;
+ }
+ let x = this.timeToX(this.cursorTime);
+ this.gfx.fillStyle = 'white';
+ this.gfx.fillRect(x - needleWidth / 2, 0, needleWidth, boardHeight + needlePad * 2);
+ }
+
+ formatRangeToLabels(dateTimeFormat, dateStart, dateEnd) {
+ const parts = dateTimeFormat.formatRangeToParts(dateStart, dateEnd);
+ let startLabel = '';
+ let endLabel = '';
+ let started = false;
+ for (const part of parts) {
+ switch (part.source) {
+ case 'shared':
+ if (!started) {
+ startLabel += part.value;
+ }
+ break;
+ case 'startRange':
+ startLabel += part.value;
+ started = true;
+ break;
+ case 'endRange':
+ endLabel += part.value;
+ break;
+ }
+ }
+ return {
+ startLabel,
+ endLabel,
+ };
+ }
+
+ updateBoardLabels() {
+ const {startLabel, endLabel} = this.formatRangeToLabels(
+ this.dateFormat,
+ new Date(this.timeMin),
+ new Date(this.timeMin + this.widthMs)
+ );
+ this.boardLabelLeft.textContent = startLabel;
+ this.boardLabelRight.textContent = endLabel;
+
+ this.boardLabelLeft.style.left = '0px';
+ this.boardLabelLeft.style.bottom = `${this.height + labelPad - boardStart}px`;
+ this.boardLabelRight.style.right = '0px';
+ this.boardLabelRight.style.bottom = `${this.height + labelPad - boardStart}px`;
+ }
+
+ updateRegionCard() {
+ if (!this.highlightRegion) {
+ if (this.regionCard) {
+ if (this.regionCard.state !== RegionCardState.Pinned) {
+ this.regionCard.remove();
+ }
+ this.regionCard = null;
+ }
+ return;
+ }
+
+ const leftTime = this.highlightRegion.startTime;
+ const rightTime = this.highlightRegion.endTime;
+ const midTime = (leftTime + rightTime) / 2;
+ const midX = this.timeToX(midTime);
+
+ let cacheKey = `${leftTime} ${rightTime} ${midX}`;
+ if (this.lastRegionCardCacheKey === cacheKey &&
+ this.regionCard && !this.regionCard.element.classList.contains('pinned')) {
+ return;
+ }
+ this.lastRegionCardCacheKey = cacheKey;
+
+ if (this.regionCard) {
+ if (this.regionCard.state !== RegionCardState.Pinned) {
+ this.regionCard.remove();
+ }
+ this.regionCard = null;
+ }
+ this.regionCard = new RegionCard(this.motionStudy, this.container, getPosesInTimeInterval(leftTime, rightTime));
+
+ this.regionCard.moveTo(midX, this.height + labelPad);
+
+ this.motionStudy.setTimelineRegionCard(this.regionCard);
+ }
+
+ timeToX(timeMs) {
+ return Math.round((timeMs - this.timeMin) * this.pixelsPerMs);
+ }
+
+ xToTime(x) {
+ return x / this.pixelsPerMs + this.timeMin;
+ }
+
+ /**
+ * @param {number} time
+ * @return {boolean}
+ */
+ isHighlight(time) {
+ if (!this.highlightRegion) {
+ return true;
+ }
+ return time >= this.highlightRegion.startTime &&
+ time <= this.highlightRegion.endTime;
+ }
+
+ rowIndexToRowY(index) {
+ return boardStart + rowPad + (rowHeight + rowPad) * index;
+ }
+
+ drawPoses() {
+ let hpa = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ for (let spaghetti of Object.values(hpa.historyLines[hpa.activeLens.name].all)) {
+ this.drawSpaghettiPoses(spaghetti.points);
+ }
+ this.rowIndex += 1;
+ }
+
+ /**
+ * Convert an rgba color array to a css color string
+ * @param {number[4]} rgba
+ * @return {string}
+ */
+ rgbaToString(rgba) {
+ return `rgba(${Math.round(rgba[0])}, ${Math.round(rgba[1])}, ${Math.round(rgba[2])}, ${Math.round(rgba[3])})`;
+ }
+
+ /**
+ * Approximate equality between two rgba color arrays
+ * @param {number[4]} rgba1
+ * @param {number[4]} rgba2
+ * @return boolean
+ */
+ rgbaEquals(rgba1, rgba2) {
+ for (let i = 0; i < 4; i++) {
+ if (Math.round(rgba1[i]) !== Math.round(rgba2[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Dim, brighten, or keep an rgba color array the same based on whether
+ * `time` is highlighted
+ * @param {number} time
+ * @param {number[4]} rgba
+ * @return {number[4]}
+ */
+ recolorPoseForHighlight(time, rgba) {
+ if (!this.highlightRegion) {
+ return rgba;
+ }
+ const dim = 0.6;
+ const bri = 1.3;
+ if (this.isHighlight(time)) {
+ return [
+ Math.min(rgba[0] * bri, 255),
+ Math.min(rgba[1] * bri, 255),
+ Math.min(rgba[2] * bri, 255),
+ Math.min(rgba[3] * bri, 255),
+ ];
+ }
+ return [
+ rgba[0] * dim,
+ rgba[1] * dim,
+ rgba[2] * dim,
+ rgba[3] * dim,
+ ];
+ }
+
+ drawSpaghettiPoses(poses) {
+ let lastPose = poses[0];
+ let lastPoseTime = lastPose.timestamp;
+ let startSectionTime = lastPoseTime;
+ const maxPoseDelayLenience = 500;
+
+ const rowY = this.rowIndexToRowY(this.rowIndex);
+
+ const timeMax = this.timeMin + this.widthMs;
+
+ for (const pose of poses) {
+ if (pose.timestamp < this.timeMin) {
+ startSectionTime = pose.timestamp;
+ lastPose = pose;
+ lastPoseTime = lastPose.timestamp;
+ continue;
+ }
+ if (pose.timestamp > this.timeMin + this.widthMs) {
+ break;
+ }
+ const isGap = pose.timestamp - lastPoseTime > maxPoseDelayLenience;
+ const poseColor = this.recolorPoseForHighlight(pose.timestamp, pose.originalColor);
+ const lastPoseColor = this.recolorPoseForHighlight(lastPose.timestamp, lastPose.originalColor);
+ const isColorSwap = !this.rgbaEquals(poseColor, lastPoseColor);
+ if (!isGap && !isColorSwap) {
+ lastPose = pose;
+ lastPoseTime = lastPose.timestamp;
+ continue;
+ }
+ this.gfx.fillStyle = this.rgbaToString(lastPoseColor);
+ // When swapping highlight allow the pose section to clip on the
+ // right side at the highlight region border
+ if (isColorSwap && !isGap && this.highlightRegion) {
+ // Swap point is either due to the start or the end, whichever
+ // is between the two poses
+ if (lastPoseTime < this.highlightRegion.startTime &&
+ this.highlightRegion.startTime < pose.timestamp) {
+ lastPoseTime = this.highlightRegion.startTime;
+ }
+ if (lastPoseTime < this.highlightRegion.endTime &&
+ this.highlightRegion.endTime < pose.timestamp) {
+ lastPoseTime = this.highlightRegion.endTime;
+ }
+ }
+
+ const startX = this.timeToX(startSectionTime);
+ const endX = this.timeToX(lastPoseTime);
+ this.gfx.fillRect(
+ startX,
+ rowY,
+ endX - startX,
+ rowHeight
+ );
+
+ if (isColorSwap && !isGap) {
+ // When swapping color extend the pose section
+ // leftwards down to the highlight region border
+ startSectionTime = lastPoseTime;
+ } else {
+ startSectionTime = pose.timestamp;
+ }
+ lastPose = pose;
+ lastPoseTime = pose.timestamp;
+ }
+
+ if (timeMax - lastPoseTime < maxPoseDelayLenience) {
+ lastPoseTime = timeMax;
+ }
+
+ const lastPoseColor = this.recolorPoseForHighlight(lastPose.timestamp, lastPose.originalColor);
+ this.gfx.fillStyle = this.rgbaToString(lastPoseColor);
+ const startX = this.timeToX(startSectionTime);
+ const endX = this.timeToX(lastPoseTime);
+ this.gfx.fillRect(
+ startX,
+ rowY,
+ endX - startX,
+ rowHeight
+ );
+ }
+
+ drawPinnedRegionCards() {
+ if (this.motionStudy.pinnedRegionCards.length === 0) {
+ return;
+ }
+
+ const rowY = this.rowIndexToRowY(this.rowIndex);
+ this.rowIndex += 1;
+
+ const timeMax = this.timeMin + this.widthMs;
+
+ for (const prc of this.motionStudy.pinnedRegionCards) {
+ if (!prc.accentColor) {
+ continue;
+ }
+
+ let timeStart = prc.startTime;
+ let timeEnd = prc.endTime;
+
+ if (timeEnd < this.timeMin || timeStart > timeMax) {
+ continue;
+ }
+
+ // Limit to timeline bounds
+ timeStart = Math.max(timeStart, this.timeMin);
+ timeEnd = Math.min(timeEnd, timeMax);
+
+ const startX = this.timeToX(timeStart);
+ const endX = this.timeToX(timeEnd);
+ this.gfx.fillStyle = prc.accentColor;
+ this.gfx.fillRect(
+ startX,
+ rowY,
+ endX - startX,
+ rowHeight
+ );
+ if (prc.displayActive) {
+ let offset = (Date.now() / 500) % 8;
+ let dashes = [4, 4];
+ this.gfx.lineDashOffset = offset;
+ this.gfx.setLineDash(dashes);
+ this.gfx.strokeStyle = 'white';
+ this.gfx.strokeRect(
+ startX,
+ rowY,
+ endX - startX,
+ rowHeight
+ );
+ this.gfx.setLineDash([]);
+ }
+ }
+ }
+
+ drawPatches() {
+ const desktopRenderer = realityEditor.gui.ar.desktopRenderer;
+ if (!desktopRenderer) {
+ return;
+ }
+
+ let patches = Object.values(desktopRenderer.getCameraVisPatches() || {})
+ .filter(this.motionStudy.patchFilter);
+
+ if (patches.length === 0) {
+ return;
+ }
+
+ const timeMax = this.timeMin + this.widthMs;
+
+ const rowY = this.rowIndexToRowY(this.rowIndex);
+ this.rowIndex += 1;
+
+ this.gfx.fillStyle = 'rgb(200, 200, 200)';
+
+ for (const patch of patches) {
+ let timeStart = patch.creationTime;
+ let timeEnd = patch.creationTime + 1000;
+
+ let patchVisible = this.cursorTime > timeStart && this.cursorTime < timeEnd;
+
+ if (patchVisible) {
+ patch.show();
+ } else {
+ patch.hide();
+ }
+
+ if (timeEnd < this.timeMin || timeStart > timeMax) {
+ continue;
+ }
+
+ // Limit to timeline bounds
+ timeStart = Math.max(timeStart, this.timeMin);
+ timeEnd = Math.min(timeEnd, timeMax);
+
+ const startX = this.timeToX(timeStart);
+ const endX = this.timeToX(timeEnd);
+ this.gfx.fillRect(
+ startX,
+ rowY,
+ endX - startX,
+ rowHeight
+ );
+ }
+ }
+
+ drawValueAddWasteTime() {
+ if (this.motionStudy.valueAddWasteTimeManager.regions.length === 0) {
+ return;
+ }
+
+ const rowY = this.rowIndexToRowY(this.rowIndex);
+ this.rowIndex += 1;
+
+ const timeMax = this.timeMin + this.widthMs;
+
+ this.motionStudy.valueAddWasteTimeManager.regions.forEach(region => {
+ if (region.endTime < this.timeMin || region.startTime > timeMax) {
+ return;
+ }
+ const timeStart = Math.max(region.startTime, this.timeMin);
+ const timeEnd = Math.min(region.endTime, timeMax);
+
+ const startX = this.timeToX(timeStart);
+ const endX = this.timeToX(timeEnd);
+
+ this.gfx.fillStyle = region.value === ValueAddWasteTimeTypes.WASTE_TIME ? "#880000" : "#008800";
+ this.gfx.fillRect(
+ startX,
+ rowY,
+ endX - startX,
+ rowHeight
+ );
+ });
+ }
+
+ calculateAndDrawTicks() {
+ const tickSpacings = [
+ 1000,
+ 10 * 1000,
+ 60 * 1000, // one minute
+ 120 * 1000,
+ 10 * 60 * 1000,
+ 60 * 60 * 1000, // one hour
+ 6 * 60 * 60 * 1000,
+ 12 * 60 * 60 * 1000,
+ 24 * 60 * 60 * 1000,
+ ];
+
+ let chosenTick = 1;
+ while (chosenTick < tickSpacings.length) {
+ if (this.widthMs < tickSpacings[chosenTick] * 12) {
+ break;
+ }
+ chosenTick += 1;
+ }
+
+ if (chosenTick >= tickSpacings.length) {
+ return;
+ }
+
+ let minorTick = tickSpacings[chosenTick - 1];
+ if (chosenTick > 4) {
+ minorTick = tickSpacings[chosenTick - 2];
+ }
+ let majorTick = tickSpacings[chosenTick];
+
+ this.gfx.fillStyle = 'rgba(128, 128, 128, 0.3)';
+ this.fillTicks(minorTick);
+ this.gfx.fillStyle = 'rgba(128, 128, 128, 0.7)';
+ this.fillTicks(majorTick);
+ }
+
+ fillTicks(tickAmountMs) {
+ let tickMs = Math.floor(this.timeMin / tickAmountMs) * tickAmountMs;
+
+ while (tickMs < this.timeMin + this.widthMs) {
+ let tickX = this.timeToX(tickMs);
+ tickMs += tickAmountMs;
+
+ this.gfx.fillRect(tickX - 1, boardStart, 1, boardHeight);
+ }
+ }
+
+ drawMinimap() {
+ this.gfx.fillStyle = 'rgba(0, 0, 0, 0.1)';
+ this.gfx.fillRect(0, minimapStart, this.width, minimapHeight);
+
+ this.gfx.fillStyle = 'rgba(255, 255, 255, 0.7)';
+ let min = this.minTimeMin;
+ let max = this.maxTimeMax;
+ let fullTimeWidth = max - min;
+ let startX = (this.timeMin - this.minTimeMin) / fullTimeWidth * this.width;
+ let width = this.widthMs / fullTimeWidth * this.width;
+ this.gfx.fillRect(startX, minimapStart, width, minimapHeight);
+ }
+
+ drawControls() {
+ this.controlsGfx.fillStyle = 'rgba(0, 0, 0, 0.3)';
+ let dpr = window.devicePixelRatio;
+ this.controlsGfx.scale(dpr, dpr);
+ this.controlsGfx.clearRect(0, boardStart + rowPad, rowHeight, rowHeight);
+ this.controlsGfx.fillRect(0, boardStart + rowPad, rowHeight, rowHeight);
+
+ let hpa = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (hpa && hpa.animationMode === AnimationMode.region) {
+ let icon = this.iconPlay;
+ if (hpa.isAnimationPlaying()) {
+ icon = this.iconPause;
+ }
+ const iconSize = rowHeight - 2 * rowPad;
+ this.controlsGfx.drawImage(icon, rowPad, boardStart + 2 * rowPad, iconSize, iconSize);
+ }
+ this.controlsGfx.resetTransform(dpr, dpr);
+ }
+
+ onControlsPointerDown(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) return;
+
+ if (event.offsetY < boardStart + rowPad) {
+ return;
+ }
+ if (event.offsetY > boardStart + rowPad + rowHeight) {
+ return;
+ }
+
+ event.stopPropagation();
+ }
+
+ /**
+ * Handles pointer up (click end, presumably) events for the controls
+ * sidebar. e.g. pause/play pose playback
+ * @param {PointerEvent} event
+ */
+ onControlsPointerUp(event) {
+ // Currently just the play/pause icon
+ if (realityEditor.device.isMouseEventCameraControl(event)) return;
+
+ if (event.offsetY < boardStart + rowPad) {
+ return;
+ }
+ if (event.offsetY > boardStart + rowPad + rowHeight) {
+ return;
+ }
+
+ let hpa = realityEditor.motionStudy.getActiveHumanPoseAnalyzer();
+ if (hpa && hpa.animation) {
+ hpa.animation.playing = !hpa.animation.playing;
+ }
+
+ event.stopPropagation();
+ }
+
+ updatePointer(event) {
+ this.mouseX = event.offsetX;
+ this.mouseY = event.offsetY;
+ }
+
+ isPointerOnRow() {
+ return this.mouseY > boardStart &&
+ this.mouseY < this.rowIndexToRowY(this.rowIndex) - rowPad;
+ }
+
+ isPointerOnBoard() {
+ return this.mouseY > boardStart &&
+ this.mouseY < boardStart + boardHeight;
+ }
+
+ isPointerOnNeedle() {
+ return this.isPointerOnStartNeedle() ||
+ this.isPointerOnEndNeedle();
+ }
+
+ isPointerOnStartNeedle() {
+ if (!this.highlightRegion) {
+ return false;
+ }
+ let startX = this.timeToX(this.highlightRegion.startTime);
+ let width = needleDragWidth;
+ if (this.mouseY < boardStart) {
+ width = needleTipWidth + needlePad * 2;
+ startX -= width / 2;
+ }
+
+ return Math.abs(this.mouseX - startX) < width / 2;
+ }
+
+ isPointerOnEndNeedle() {
+ if (!this.highlightRegion) {
+ return false;
+ }
+ let endX = this.timeToX(this.highlightRegion.endTime);
+ let width = needleDragWidth;
+ if (this.mouseY < boardStart) {
+ width = needleTipWidth + needlePad * 2;
+ endX += width / 2;
+ }
+
+ return Math.abs(this.mouseX - endX) < width / 2;
+ }
+
+ onPointerDown(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) return;
+
+ this.updatePointer(event);
+
+ if (this.isPointerOnRow() || this.isPointerOnNeedle()) {
+ this.dragMode = DragMode.SELECT;
+ if (this.isPointerOnStartNeedle()) {
+ this.highlightStartTime = this.highlightRegion.endTime;
+ } else if (this.isPointerOnEndNeedle()) {
+ this.highlightStartTime = this.highlightRegion.startTime;
+ } else {
+ this.highlightStartTime = this.xToTime(event.offsetX);
+ }
+ setAnimationMode(AnimationMode.regionAll);
+ } else {
+ this.dragMode = DragMode.PAN;
+ }
+ this.motionStudy.setCursorTime(-1);
+ event.stopPropagation();
+ }
+
+ onPointerMove(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) return;
+
+ this.updatePointer(event);
+
+ switch (this.dragMode) {
+ case DragMode.NONE:
+ this.onPointerMoveDragModeNone(event);
+ break;
+ case DragMode.SELECT:
+ this.onPointerMoveDragModeSelect(event);
+ break;
+ case DragMode.PAN:
+ this.onPointerMoveDragModePan(event);
+ break;
+ }
+ event.stopPropagation();
+ }
+
+ onPointerMoveDragModeNone(_event) {
+ let cursor = 'default';
+ if (this.isPointerOnNeedle()) {
+ cursor = 'grab';
+ } else if (this.isPointerOnRow()) {
+ cursor = 'col-resize';
+ } else if (this.isPointerOnBoard()) {
+ cursor = 'move';
+ }
+ this.canvas.style.cursor = cursor;
+
+ this.motionStudy.setCursorTime(this.xToTime(this.mouseX));
+ }
+
+ onPointerMoveDragModeSelect(_event) {
+ this.canvas.style.cursor = 'col-resize';
+ let highlightEndTime = this.xToTime(this.mouseX);
+
+ let startTime = Math.min(this.highlightStartTime, highlightEndTime);
+ let endTime = Math.max(this.highlightStartTime, highlightEndTime);
+ this.motionStudy.setHighlightRegion({
+ startTime,
+ endTime,
+ });
+ setAnimationMode(AnimationMode.regionAll);
+ }
+
+ onPointerMoveDragModePan(event) {
+ this.canvas.style.cursor = 'move';
+ let dTime = event.movementX / this.pixelsPerMs;
+ this.timeMin -= dTime;
+ this.limitTimeMin();
+
+ this.scrolled = true;
+ }
+
+ /**
+ * Restricts timeMin based on current zoom level, minTimeMin, and
+ * maxTimeMax.
+ */
+ limitTimeMin() {
+ if (this.timeMin < this.minTimeMin) {
+ this.timeMin = this.minTimeMin;
+ return;
+ }
+ if (this.timeMin + this.widthMs > this.maxTimeMax) {
+ this.timeMin = this.maxTimeMax - this.widthMs;
+ return;
+ }
+ }
+
+ setCursorTime(cursorTime) {
+ this.cursorTime = cursorTime;
+ }
+
+ setHighlightRegion(highlightRegion) {
+ this.highlightRegion = highlightRegion;
+ if (!this.highlightRegion) {
+ return;
+ }
+
+ if (this.highlightRegion.endTime < this.timeMin ||
+ this.highlightRegion.startTime > this.timeMin + this.widthMs) {
+ // Center on new highlight region
+ this.timeMin = (this.highlightRegion.startTime + this.highlightRegion.endTime) / 2 - this.widthMs / 2;
+ }
+ }
+
+ /**
+ * @param {TimeRegion} displayRegion
+ */
+ setDisplayRegion(displayRegion) {
+ this.displayRegion = Object.assign({}, displayRegion);
+ if (!this.displayRegion) {
+ this.resetBounds();
+ return;
+ }
+
+ let {startTime, endTime} = this.displayRegion;
+ let unbounded = endTime <= 0;
+
+ if (!unbounded) {
+ // Pin timeline to the bounds being set
+ this.scrolled = true;
+ }
+
+ if (startTime <= 0) {
+ startTime = Date.now();
+ this.displayRegion.startTime = startTime;
+ }
+ if (endTime <= 0) {
+ endTime = startTime + DEFAULT_WIDTH_MS;
+ this.displayRegion.endTime = endTime;
+ }
+
+ // Snap zoom to equal entire displayRegion
+ let newWidthMs = endTime - startTime;
+ this.timeMin = startTime;
+ this.widthMs = Math.max(newWidthMs, MIN_WIDTH_MS);
+ this.minTimeMin = this.timeMin;
+ if (this.width > 0) {
+ this.pixelsPerMs = this.width / this.widthMs;
+ } else {
+ this.pixelsPerMs = -1;
+ }
+
+ if (unbounded) {
+ this.maxTimeMax = Number.MAX_VALUE;
+ this.maxWidthMs = DEFAULT_MAX_WIDTH_MS;
+ } else {
+ this.maxTimeMax = this.timeMin + this.widthMs;
+ // Set maximum to be fully encompassing board
+ this.maxWidthMs = this.widthMs;
+ }
+ }
+
+ resetBounds() {
+ this.maxWidthMs = DEFAULT_MAX_WIDTH_MS;
+ this.minTimeMin = 0;
+ this.maxTimeMax = Number.MAX_VALUE;
+ }
+
+ onPointerUp(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) return;
+
+ this.updatePointer(event);
+
+ if (this.dragMode === DragMode.SELECT &&
+ Math.abs(this.timeToX(this.highlightStartTime) - this.mouseX) < 3) {
+ this.motionStudy.setHighlightRegion(null);
+ } else {
+ setAnimationMode(AnimationMode.region);
+ }
+
+ this.dragMode = DragMode.NONE;
+ this.motionStudy.setCursorTime(-1);
+
+ event.stopPropagation();
+ }
+
+ onPointerOver(event) {
+ if (realityEditor.device.isMouseEventCameraControl(event)) return;
+
+ this.updatePointer(event);
+ }
+
+ onPointerOut(_event) {
+ this.motionStudy.setCursorTime(-1);
+ }
+
+ onWheel(event) {
+ this.updatePointer(event);
+
+ const timeBefore = this.xToTime(this.mouseX);
+
+ if (Math.abs(event.deltaY) * 1.3 > Math.abs(event.deltaX)) {
+ let factor = 1 + Math.abs(event.deltaY) * 0.01;
+ if (event.deltaY < 0) {
+ // Preserves same scrolling speed
+ factor = 1 / factor;
+ }
+ this.widthMs *= factor;
+ if (this.widthMs > this.maxWidthMs) {
+ this.widthMs = this.maxWidthMs;
+ if (this.maxWidthMs !== DEFAULT_MAX_WIDTH_MS) {
+ this.timeMin = this.minTimeMin;
+ }
+ }
+ if (this.widthMs < MIN_WIDTH_MS) {
+ this.widthMs = MIN_WIDTH_MS;
+ }
+
+ // let timeCenter = this.timeMin + this.widthMs / 2;
+ this.pixelsPerMs = this.width / this.widthMs;
+ // this.timeMin = timeCenter - this.widthMs / 2;
+
+ // Do some math to keep timeBefore at the same x value
+ let newTimeMin = timeBefore - this.mouseX / this.pixelsPerMs;
+ if (newTimeMin >= this.minTimeMin && newTimeMin <= this.maxTimeMax - this.widthMs) {
+ this.timeMin = newTimeMin;
+ }
+ } else {
+ let dTime = event.deltaX / this.pixelsPerMs;
+ this.timeMin -= dTime;
+ }
+
+ this.limitTimeMin();
+
+ this.scrolled = true;
+
+ event.preventDefault();
+ event.stopPropagation();
+ }
+}
+
diff --git a/src/motionStudy/utils.js b/src/motionStudy/utils.js
new file mode 100644
index 000000000..00337cafc
--- /dev/null
+++ b/src/motionStudy/utils.js
@@ -0,0 +1,22 @@
+/**
+ * Make a request to the world object (in charge of history logging) to
+ * save its log just in case something bad happens
+ */
+export async function postPersistRequest() {
+ const worldObject = realityEditor.worldObjects.getBestWorldObject();
+ if (!worldObject) {
+ console.warn('postPersistRequest unable to find worldObject');
+ return;
+ }
+ const historyLogsUrl = realityEditor.network.getURL(worldObject.ip, realityEditor.network.getPort(worldObject), '/history/persist');
+ try {
+ const res = await fetch(historyLogsUrl, {
+ method: 'POST',
+ });
+
+ const body = await res.json();
+ console.log('postPersistRequest logName', body);
+ } catch (e) {
+ console.log('postPersistRequest failed', e);
+ }
+}
diff --git a/src/network/availableFrames.js b/src/network/availableFrames.js
new file mode 100644
index 000000000..176f7e73c
--- /dev/null
+++ b/src/network/availableFrames.js
@@ -0,0 +1,370 @@
+createNameSpace("realityEditor.network.availableFrames");
+
+/**
+ * @fileOverview realityEditor.network.availableFrames.js
+ * Provides a central interface for loading available frames from each server into the pocket.
+ * Keeps track of which frames are supported by each server, and provides the correct metadata, icons, and html files
+ * for the frames based on which object/server you are closest to at any given time.
+ */
+
+(function(exports) {
+
+ /**
+ * @typedef {Object} FrameInfo
+ * @property {Image} icon - preloaded image with src path for pocket icon image
+ * @property {Object.<{name: string, nodes: Array, showInPocket: boolean, tags: Array}>} properties - flexible set of metadata about the frame
+ */
+
+ /**
+ * Maps each serverIP to a structure of FrameInfo for each frame type that the server hosts/supports
+ * @type {Object.}
+ */
+ var framesPerServer = {};
+
+ /**
+ * Public init method sets up module by registering callbacks when important events happen in other modules
+ */
+ function initService() {
+ // immediately triggers for each server already in the system, and then triggers again every time a new server is detected
+ realityEditor.network.onNewServerDetected(onNewServerDetected);
+
+ // if frames get enabled or disabled on the server, refresh the set of availableFrames
+ realityEditor.network.addUDPMessageHandler('action', function(message) {
+ if (typeof message.action.reloadAvailableFrames !== 'undefined') {
+ // download all pocket assets from the serverIP and rebuild the pocket
+ // TODO: this could be greatly optimized by only downloading/changing the reloadAvailableFrames.frameName
+ onNewServerDetected(message.action.reloadAvailableFrames.serverIP);
+ }
+ });
+ onNewServerDetected('localhost');
+ }
+
+ /**
+ * Downloads the metadata (including pocket icons) for all available frames on a new server that is detected.
+ * Stores the results in the framesPerServer data structure
+ * @param {string} serverIP
+ */
+ function onNewServerDetected(serverIP) {
+ var urlEndpoint = realityEditor.network.getURL(serverIP, realityEditor.network.getPortByIp(serverIP), '/availableFrames/');
+ realityEditor.network.getData(null, null, null, urlEndpoint, function (_nullObj, _nullFrame, _nullNode, response) {
+ framesPerServer[serverIP] = response;
+ if (!realityEditor.device.environment.variables.overrideMenusAndButtons) {
+ setTimeout(() => {
+ downloadFramePocketAssets(serverIP); // preload the icons
+ }, 5000);
+ triggerServerFramesInfoUpdatedCallbacks(); // this can be detected to update the pocket if it is already open
+ }
+ });
+ }
+
+ /**
+ * Preload the icon image for each frame on the given server, and store in the framesPerServer data structure
+ * @param {string} serverIP
+ */
+ function downloadFramePocketAssets(serverIP) {
+ var frames = framesPerServer[serverIP];
+ if (frames) {
+ Object.values(frames).forEach(function(frameInfo) {
+ if (typeof frameInfo.icon === "undefined") {
+ var preloadedImage = new Image(); // download / preload the icon.gif for each frame
+ preloadedImage.src = getFrameIconSrcByIP(serverIP, frameInfo.properties.name);
+ frameInfo.icon = preloadedImage;
+ }
+ });
+ }
+ }
+
+ var DEBUG_TEST_POCKET = false; // turn this on to test conditional pocket functionality on local server
+
+ /**
+ * Gets the set of servers with currently visible objects/worlds, sorted by which has the closest visible object,
+ * and for each server, gets its IP address, and the IP address of the server hosting its frames
+ * (itself, for up-to-date servers, or localhost, if that server is too old of a version to have its own frames)
+ * and gets the set of all frames that objects on that server can support (framesPerServer[proxyIP])
+ * @param {Array.} visibleObjectKeys
+ * @return {Array.<{actualIP: string, proxyIP: string, frames: Object.}>}
+ */
+ function getFramesForAllVisibleObjects(visibleObjectKeys) {
+
+ var sortedByDistance = sortByDistance(visibleObjectKeys);
+
+ // sort by order of closest
+ var sortedVisibleServerIPs = sortedByDistance.map(function(objectInfo) {
+ var actualIP = realityEditor.getObject(objectInfo.objectKey).ip;
+ var proxyIP = getServerIPForObjectFrames(objectInfo.objectKey);
+ return {
+ actualIP: actualIP,
+ proxyIP: proxyIP // TODO: could only include proxyIP if it isn't identical to actualIP?
+ };
+ });
+
+ // filter out duplicates
+ var uniqueServerIPs = [];
+
+ sortedVisibleServerIPs.forEach(function(item) {
+ // if uniqueServerIPs doesn't already have an item with all identical properties, add this one
+ // note: this is an N^2 solution. fine for now because N is usually very small, but may need to be optimized in the future
+ var isAlreadyContained = false;
+ uniqueServerIPs.forEach(function(uniqueItem) {
+ if (isAlreadyContained) { return; }
+ if (uniqueItem.actualIP === item.actualIP && uniqueItem.proxyIP === item.proxyIP) {
+ isAlreadyContained = true;
+ }
+ });
+
+ if (!isAlreadyContained) {
+ uniqueServerIPs.push(item);
+ }
+ });
+
+ var allFrames = [];
+
+ uniqueServerIPs.forEach(function(serverInfo) {
+ var knownFrames = framesPerServer[serverInfo.proxyIP] || {};
+ var framesCopy = JSON.parse(JSON.stringify(knownFrames)); // load from the proxy
+ // Object.keys(framesCopy).forEach(function(frameName) {
+ // if (!framesCopy[frameName].properties.showInPocket) {
+ // delete framesCopy[frameName];
+ // }
+ // });
+
+ allFrames.push({
+ actualIP: serverInfo.actualIP,
+ proxyIP: serverInfo.proxyIP,
+ frames: framesCopy // TODO: if proxyIP !== actualIP, maybe don't include duplicate frames, just detect and retrieve them from the proxyIP's data structure instead
+ });
+ return framesCopy;
+ });
+
+ return allFrames;
+ }
+
+ /**
+ * Helper function to sort a list of object keys by the distance of that object to the camera, closest to furthest
+ * Returns the sorted list with some additional metadata for each entry
+ * @param {Array.} objectKeys
+ * @return {Array.<{objectKey: string, distance: number, isWorldObject: boolean, timestamp: number}>}
+ * @todo: should be moved to gui.ar or gui.ar.utilities
+ */
+ function sortByDistance(objectKeys) {
+ var validObjectKeys = objectKeys.filter(function(objectKey) {
+ return realityEditor.getObject(objectKey); // only use objectKeys that correspond to valid objects
+ });
+
+ return validObjectKeys.map( function(objectKey) {
+ var distance = realityEditor.sceneGraph.getDistanceToCamera(objectKey);
+ var isWorldObject = false;
+ var object = realityEditor.getObject(objectKey);
+ if (object && object.isWorldObject) {
+ isWorldObject = true;
+ if (objectKey === realityEditor.worldObjects.getLocalWorldId()) {
+ // WORLD_local is "infinitely" far away, so that it is prioritized last
+ distance = Number.MAX_SAFE_INTEGER;
+ } else {
+ // world objects are essentially infinitely far away (10 million meters) compared to regular objects
+ // so that regular objects are prioritized over them
+ distance = realityEditor.gui.ar.MAX_DISTANCE + distance;
+ }
+ }
+ return {
+ objectKey: objectKey,
+ distance: distance,
+ isWorldObject: isWorldObject,
+ timestamp: object.timestamp || 0
+ };
+ }).sort(function (a, b) {
+ return (a.distance - b.distance);
+ });
+ }
+
+ /**
+ * Given the frame name (type), finds the closest object that supports that type of frame.
+ * Works with objects and world objects, prioritizing non-world objects according to the implementation of getClosestObject.
+ * @param frameName - the type of the frame (e.g. graphUI, slider, switch)
+ * @return {string|null}
+ */
+ function getBestObjectInfoForFrame(frameName) {
+ let possibleObjectKeys = getPossibleObjectsForFrame(frameName);
+
+ if (possibleObjectKeys.length === 0) return null;
+
+ // this works now that world objects have a sense of distance just like regular objects
+ return realityEditor.gui.ar.getClosestObject(function(objectKey) {
+ return possibleObjectKeys.indexOf(objectKey) > -1;
+ })[0]; // getClosestObject returns [objectKey, frameKey, nodeKey], so result[0] is the objectKey
+ }
+
+ /**
+ * Out of the current visible objects, figures out which subset of them could support having this type of frame attached.
+ * @param {string} frameName - the type of the frame (e.g. graphUI, slider, switch)
+ * @param {boolean?} useAttachesTo - if true, filter down possible object based on frame's attachesTo property
+ * @return {Array.} - list of compatible objectKeys
+ */
+ function getPossibleObjectsForFrame(frameName, useAttachesTo) {
+ // search framesPerServer for this frameName to see which server this can go on
+
+ var compatibleServerIPs = [];
+
+ for (var serverIP in framesPerServer) {
+ var serverFrames = framesPerServer[serverIP];
+ if (typeof serverFrames[frameName] !== 'undefined') {
+ compatibleServerIPs.push(serverIP);
+ }
+ }
+
+ // filter down visible objects if their IP (or proxyIP) is compatible
+
+ var compatibleObjects = [];
+
+ Object.keys(realityEditor.gui.ar.draw.visibleObjects).filter(function(objectKey) {
+ if (typeof objects[objectKey] === 'undefined') {
+ return false;
+ }
+ if (objects[objectKey].type === 'human') {
+ return false;
+ }
+ return true;
+ }).forEach(function(objectKey) {
+ var proxyIP = getServerIPForObjectFrames(objectKey);
+ if (compatibleServerIPs.indexOf(proxyIP) > -1) {
+ compatibleObjects.push(objectKey);
+ }
+ });
+
+ // filter down objects even more if this frame type includes an attachesTo property
+ // if the frame doesn't specify attachesTo, ignores this extra round of filtering
+ if (useAttachesTo) {
+ let attachesTo = realityEditor.gui.pocket.getAttachesTo(frameName);
+ if (typeof attachesTo !== 'undefined') {
+ let incompatibleObjects = [];
+ // filter out objects based on "object" and "world" tags in the attachesTo list
+ compatibleObjects.forEach(function(objectKey) {
+ let shouldInclude = false;
+ let object = realityEditor.getObject(objectKey);
+ if (attachesTo.includes('object')) {
+ shouldInclude = true;
+ }
+ if (attachesTo.includes('world')) {
+ if (object.isWorldObject) {
+ shouldInclude = true;
+ }
+ }
+ if (!shouldInclude) {
+ incompatibleObjects.push(objectKey);
+ }
+ });
+ incompatibleObjects.forEach(function(objectKey) {
+ compatibleObjects.splice(compatibleObjects.indexOf(objectKey), 1);
+ });
+ }
+ }
+
+ return compatibleObjects;
+ }
+
+ /**
+ * Gets the framesPerServer metadata for the server where the closest object is hosted.
+ * If the server is an old version that doesn't host its own frames, load from the phone's localhost server instead
+ * @param {string} closestObjectKey
+ * @return {Object.}
+ */
+ function getFramesForPocket(closestObjectKey) {
+ var serverIP = getServerIPForObjectFrames(closestObjectKey);
+ var framesCopy = JSON.parse(JSON.stringify(framesPerServer[serverIP]));
+ Object.keys(framesCopy).forEach(function(frameName) {
+ // if (!framesCopy[frameName].properties.showInPocket) {
+ // delete framesCopy[frameName];
+ // }
+ if (DEBUG_TEST_POCKET) {
+ if (realityEditor.getObject(closestObjectKey).isWorldObject) {
+ // if (frameName === 'buttonOff' || frameName === 'buttonOn') {
+ if (frameName.indexOf('b') > -1 || frameName.indexOf('a') > -1) {
+ delete framesCopy[frameName];
+ }
+ }
+ }
+ });
+ return framesCopy;
+ }
+
+ /**
+ * Helper function that returns which IP to load the frames from for the provided object
+ * Usually just the .ip property of that object, but defaults to localhost if that server is an old version that doesn't support hosting its own frames
+ * @param {string} objectKey
+ * @return {string} - IP address
+ */
+ function getServerIPForObjectFrames(objectKey) {
+ var serverIP = realityEditor.getObject(objectKey).ip;
+ if (typeof framesPerServer[serverIP] === 'undefined') {
+ serverIP = 'localhost';
+ }
+ return serverIP;
+ }
+
+ /**
+ * Given a closest object and a frame name, returns the src path for the pocket icon (loaded from correct server)
+ * @param {string} objectKey
+ * @param {string} frameName
+ * @return {string} - image src path
+ */
+ function getFrameIconSrc(objectKey, frameName) {
+ var serverIP = getServerIPForObjectFrames(objectKey);
+ return getFrameIconSrcByIP(serverIP, frameName);
+ }
+
+ /**
+ * Given a server IP address and a frame name, returns the path to that frame's pocket icon on that server
+ * @param {string} serverIP
+ * @param {string} frameName
+ * @return {string} - image src path
+ */
+ function getFrameIconSrcByIP(serverIP, frameName) {
+ return realityEditor.network.getURL( serverIP, realityEditor.network.getPortByIp(serverIP), '/frames/' + frameName + '/icon.gif');
+ }
+
+ /**
+ * Given a closest object and a frame name, returns the path to the html for that iframe on the correct server
+ * @param {string} objectKey
+ * @param {string} frameName
+ * @return {string} - html src path
+ */
+ function getFrameSrc(objectKey, frameName) {
+ var serverIP = getServerIPForObjectFrames(objectKey);
+ return realityEditor.network.getURL(serverIP, realityEditor.network.getPort(objects[objectKey]), '/frames/' + frameName + '/index.html');
+ }
+
+ var serverFrameInfoUpdatedCallbacks = [];
+
+ /**
+ * Use this to notify other services that we have discovered available frame info for a new server,
+ * or we have received updated frame info from a previously discovered server
+ * @param {function} callback
+ */
+ function onServerFramesInfoUpdated(callback) {
+ serverFrameInfoUpdatedCallbacks.push(callback);
+ }
+
+ /**
+ * Calls the callbacks for anything that subscribed to onServerFramesInfoUpdated
+ */
+ function triggerServerFramesInfoUpdatedCallbacks() {
+ serverFrameInfoUpdatedCallbacks.forEach(function(callback) {
+ callback();
+ });
+ }
+
+ exports.initService = initService;
+ exports.getFramesForPocket = getFramesForPocket;
+
+ exports.getFrameSrc = getFrameSrc;
+ exports.getFrameIconSrc = getFrameIconSrc;
+
+ exports.getPossibleObjectsForFrame = getPossibleObjectsForFrame;
+ exports.getBestObjectInfoForFrame = getBestObjectInfoForFrame;
+
+ exports.onServerFramesInfoUpdated = onServerFramesInfoUpdated;
+
+ exports.getFramesForAllVisibleObjects = getFramesForAllVisibleObjects;
+ exports.sortByDistance = sortByDistance;
+
+})(realityEditor.network.availableFrames);
diff --git a/src/network/discovery.js b/src/network/discovery.js
new file mode 100644
index 000000000..68dcd4f10
--- /dev/null
+++ b/src/network/discovery.js
@@ -0,0 +1,214 @@
+createNameSpace("realityEditor.network.discovery");
+
+(function(exports) {
+
+ // discoveryMap[serverIp][objectId] = { heartbeat: { id, ip, port, vn, tcs }, metadata: { name, type } }
+ let discoveryMap = {};
+ let serverServices = {};
+
+ // Allows us to pause object discovery from the time the app loads until we have finished scanning
+ let exceptions = []; // when scanning a world object, we add its name to the exceptions so we can still load it
+ let queuedHeartbeats = []; // heartbeats received while paused will be processed after resuming
+ let heartbeatsPaused = false;
+ let isSystemInitializing = true; // pause heartbeats for the first instant while everything is still initializing
+
+ let primaryWorld = null; // if set, we will ignore processing all world heartbeats except for the primary world
+
+ let callbacks = {
+ onServerDetected: [],
+ onObjectDetected: []
+ };
+
+ function initService() {
+ realityEditor.network.registerCallback('objectDeleted', (params) => {
+ deleteFromDiscoveryMap(params.objectIP, params.objectID);
+ });
+
+ setTimeout(() => {
+ isSystemInitializing = false;
+ processNextQueuedHeartbeat();
+ }, 1000);
+ // 1 second is very generous... could be replaced in future by a more robust
+ // way to tell when all of the addons have finished initializing
+ }
+
+ function processNextQueuedHeartbeat() {
+ if (queuedHeartbeats.length === 0) { return; }
+ let message = queuedHeartbeats.shift();
+ processHeartbeat(message);
+ setTimeout(processNextQueuedHeartbeat, 100); // process async to avoid overwhelming all at once
+ }
+
+ function deleteFromDiscoveryMap(ip, id) {
+ if (typeof discoveryMap[ip] === 'undefined') { return; }
+ if (typeof discoveryMap[ip][id] === 'undefined') { return; }
+ delete discoveryMap[ip][id];
+ // todo: trigger any callbacks? depends if other modules are subscribed to the full discoveryMap or not
+ // todo: can we detect when a server turns off so we can delete it from our map?
+ }
+
+ function updateDiscoveryMap(message) {
+ if (typeof discoveryMap[message.ip] === 'undefined') {
+ discoveryMap[message.ip] = {};
+ callbacks.onServerDetected.forEach(cb => cb(message.ip));
+ }
+ if (typeof discoveryMap[message.ip][message.id] === 'undefined') {
+ discoveryMap[message.ip][message.id] = {
+ heartbeat: message,
+ metadata: null
+ };
+ processNewObjectDiscovery(message.ip, realityEditor.network.getPort(message), message.id);
+ }
+ // TODO: should this module concern itself with the heartbeat checksum? probably not, we are only concerned about presence
+ }
+
+ // independently from adding the json to the objects data structure, we query the server for some important metadata about this heartbeat
+ function processNewObjectDiscovery(ip, port, id) {
+ let url = realityEditor.network.getURL(ip, port, '/object/' + id);
+ realityEditor.network.getData(id, null, null, url, function (objectKey, frameKey, nodeKey, msg) {
+ if (!msg) return;
+ if (typeof discoveryMap[ip][id] !== 'undefined') {
+ discoveryMap[ip][id].metadata = {
+ name: msg.name,
+ type: msg.type
+ }
+ callbacks.onObjectDetected.forEach(cb => cb(discoveryMap[ip][id]));
+ }
+ });
+ }
+
+ // This should be directly triggered by whatever is listening for UDP messages
+ // These are the per-object heartbeats
+ function processHeartbeat(message) {
+ // upon a new object discovery message, add the object and download its target files
+ if (typeof message.id === 'undefined' || typeof message.ip === 'undefined') {
+ return;
+ }
+
+ updateDiscoveryMap(message);
+
+ let ignoreFromPause = false;
+ if (heartbeatsPaused) {
+ ignoreFromPause = !exceptions.some(name => message.id.includes(name));
+ }
+
+ if (realityEditor.device.environment.variables.suppressObjectDetections || ignoreFromPause || isSystemInitializing) {
+ // only add it if we don't already have the same one pending
+ const alreadyInArray = queuedHeartbeats.some(existingMessage => {
+ return existingMessage.id === message.id &&
+ existingMessage.ip === message.ip &&
+ existingMessage.port === message.port &&
+ existingMessage.vn === message.vn &&
+ existingMessage.pr === message.pr &&
+ existingMessage.tcs === message.tcs;
+ });
+ if (!alreadyInArray) {
+ queuedHeartbeats.push(message);
+ }
+ } else {
+ if (typeof message.zone !== 'undefined' && message.zone !== '') {
+ if (realityEditor.gui.settings.toggleStates.zoneState && realityEditor.gui.settings.toggleStates.zoneStateText === message.zone) {
+ realityEditor.network.addHeartbeatObject(message);
+ }
+ } else if (!realityEditor.gui.settings.toggleStates.zoneState) {
+ realityEditor.network.addHeartbeatObject(message);
+ }
+ }
+ }
+
+ // These are the per-server heartbeats
+ // They include a list of services, and get sent even if no objects exist yet on that server
+ function processServerBeat(message) {
+ if (typeof message.ip === 'undefined') {
+ return;
+ }
+
+ if (typeof discoveryMap[message.ip] === 'undefined') {
+ discoveryMap[message.ip] = {};
+ callbacks.onServerDetected.forEach(cb => cb(message.ip));
+ }
+
+ if (typeof message.services !== 'undefined') {
+ serverServices[message.ip] = message.services;
+ }
+ }
+
+ exports.setPrimaryWorld = (ip, id) => {
+ primaryWorld = {
+ ip: ip,
+ id: id
+ };
+ }
+
+ exports.getPrimaryWorldInfo = () => {
+ return primaryWorld;
+ }
+
+ exports.pauseObjectDetections = () => {
+ heartbeatsPaused = true;
+ }
+
+ exports.resumeObjectDetections = () => {
+ heartbeatsPaused = false;
+ processNextQueuedHeartbeat();
+ }
+
+ exports.addExceptionToPausedObjectDetections = (objectName) => {
+ exceptions.push(objectName);
+ }
+
+ exports.deleteObject = (ip, id) => {
+ deleteFromDiscoveryMap(ip, id);
+
+ queuedHeartbeats = queuedHeartbeats.filter(message => {
+ return message.id !== id && message.ip !== ip;
+ });
+ }
+
+ exports.onServerDetected = (callback) => {
+ callbacks.onServerDetected.push(callback);
+ }
+
+ exports.onObjectDetected = (callback) => {
+ callbacks.onObjectDetected.push(callback);
+ }
+
+ exports.getDetectedServerIPs = ({limitToWorldService = false} = {}) => {
+ if (!limitToWorldService) return Object.keys(discoveryMap);
+
+ // if limitToWorldService, and at least one server demands services=world, only return servers with the demand
+ let serversWithWorldService = Object.keys(discoveryMap).filter(serverIp => {
+ return serverServices[serverIp] && serverServices[serverIp].includes('world');
+ });
+
+ if (serversWithWorldService.length > 0) {
+ return serversWithWorldService;
+ }
+
+ // if no servers demand the world, return all servers
+ return Object.keys(discoveryMap);
+ }
+
+ exports.getDetectedObjectIDs = () => {
+ return Object.values(discoveryMap).map(serverContents => Object.keys(serverContents)).flat();
+ }
+
+ exports.getDetectedObjectsOfType = (type) => {
+ let serverContents = Object.values(discoveryMap); // array of [{id1: info}, { id2: info, id3: info }]
+ let matchingObjects = [];
+ serverContents.forEach(serverInfo => {
+ Object.keys(serverInfo).forEach(objectId => {
+ let objectInfo = serverInfo[objectId];
+ if (objectInfo.metadata && objectInfo.metadata.type === type) {
+ matchingObjects.push(objectInfo);
+ }
+ });
+ })
+ return matchingObjects;
+ }
+
+ exports.initService = initService;
+ exports.processHeartbeat = processHeartbeat;
+ exports.processServerBeat = processServerBeat;
+
+})(realityEditor.network.discovery);
diff --git a/src/network/frameContentAPI.js b/src/network/frameContentAPI.js
new file mode 100644
index 000000000..c80ee6c98
--- /dev/null
+++ b/src/network/frameContentAPI.js
@@ -0,0 +1,243 @@
+createNameSpace("realityEditor.network.frameContentAPI");
+
+/**
+ * @fileOverview realityEditor.network.frameContentAPI.js
+ * Provides a central interface for transmitting data to the Frames and Nodes
+ * @todo: finish moving other functionality here
+ */
+
+(function(exports) {
+
+ let lastSentMatrices = {};
+
+ /**
+ * Public init method sets up module by registering callbacks when important events happen in other modules
+ */
+ function initService() {
+ realityEditor.device.keyboardEvents.registerCallback('keyUpHandler', keyUpHandler);
+ realityEditor.device.keyboardEvents.registerCallback('keyboardHidden', onKeyboardHidden);
+
+ realityEditor.gui.pocket.registerCallback('frameAdded', onFrameAdded);
+
+ realityEditor.device.registerCallback('vehicleDeleted', onVehicleDeleted);
+ realityEditor.network.registerCallback('vehicleDeleted', onVehicleDeleted);
+
+ realityEditor.gui.ar.draw.registerCallback('fullScreenEjected', onFullScreenEjected);
+
+ realityEditor.sceneGraph.network.onObjectLocalized(worldIdUpdated);
+
+ setupInternalPostMessageListeners();
+ }
+
+ function setupInternalPostMessageListeners() {
+ realityEditor.network.addPostMessageHandler('sendCoordinateSystems', (msgContent, fullMessage) => {
+ let frame = realityEditor.getFrame(fullMessage.object, fullMessage.frame);
+ if (!frame) return;
+ frame.sendCoordinateSystems = msgContent;
+ console.log('frame was told to send coordinate systems', frame.sendCoordinateSystems);
+ });
+ }
+
+ function sendCoordinateSystemsToIFrame(objectKey, frameKey) {
+ let frame = realityEditor.getFrame(objectKey, frameKey);
+ if (!frame) return;
+ if (!frame.sendCoordinateSystems) return;
+
+ if (typeof lastSentMatrices[frameKey] === 'undefined') {
+ lastSentMatrices[frameKey] = {};
+ }
+
+ let coordinateSystems = {};
+
+ if (frame.sendCoordinateSystems.camera) {
+ coordinateSystems.camera = realityEditor.sceneGraph.getCameraNode().worldMatrix;
+ }
+ if (frame.sendCoordinateSystems.projectionMatrix) {
+ coordinateSystems.projectionMatrix = globalStates.realProjectionMatrix;
+ }
+ if (frame.sendCoordinateSystems.toolOrigin) {
+ coordinateSystems.toolOrigin = realityEditor.sceneGraph.getSceneNodeById(frameKey).worldMatrix;
+ }
+ if (frame.sendCoordinateSystems.groundPlaneOrigin) {
+ coordinateSystems.groundPlaneOrigin = realityEditor.sceneGraph.getGroundPlaneNode().worldMatrix;
+ }
+ if (frame.sendCoordinateSystems.worldOrigin) {
+ coordinateSystems.worldOrigin = realityEditor.sceneGraph.getSceneNodeById(realityEditor.sceneGraph.getWorldId()).worldMatrix;
+ }
+
+ // only calculate the more complex ones if the tool origin has also changed, otherwise skip the computation
+ // because they can't have changed without the tool origin also changing
+ if (frame.sendCoordinateSystems.toolGroundPlaneShadow || frame.sendCoordinateSystems.toolSurfaceShadow) {
+ let toolOriginChecksum = matrixChecksum(realityEditor.sceneGraph.getSceneNodeById(frameKey).worldMatrix);
+ if (!lastSentMatrices[frameKey].toolOrigin || lastSentMatrices[frameKey].toolOrigin !== toolOriginChecksum) {
+ if (frame.sendCoordinateSystems.toolGroundPlaneShadow) {
+ coordinateSystems.toolGroundPlaneShadow = realityEditor.gui.threejsScene.getToolGroundPlaneShadowMatrix(objectKey, frameKey);
+ }
+ if (frame.sendCoordinateSystems.toolSurfaceShadow) {
+ coordinateSystems.toolSurfaceShadow = realityEditor.gui.threejsScene.getToolSurfaceShadowMatrix(objectKey, frameKey);
+ }
+ }
+ }
+
+ let keysThatDidntChange = [];
+ Object.keys(coordinateSystems).forEach(coordSystem => {
+ let checksum = matrixChecksum(coordinateSystems[coordSystem]);
+ if (lastSentMatrices[frameKey][coordSystem] && lastSentMatrices[frameKey][coordSystem] === checksum) {
+ keysThatDidntChange.push(coordSystem);
+ }
+ });
+
+ keysThatDidntChange.forEach(key => {
+ delete coordinateSystems[key];
+ });
+
+ if (Object.keys(coordinateSystems).length === 0) return;
+
+ globalDOMCache["iframe" + frameKey].contentWindow.postMessage(JSON.stringify({
+ coordinateSystems: coordinateSystems
+ }), '*');
+
+ Object.keys(coordinateSystems).forEach(coordSystem => {
+ lastSentMatrices[frameKey][coordSystem] = matrixChecksum(coordinateSystems[coordSystem]);
+ });
+
+ // if using toolGroundPlaneShadow or toolSurfaceShadow, but not toolOrigin, store the tool origin checksum to help with the above shortcut
+ if ((frame.sendCoordinateSystems.toolSurfaceShadow || frame.sendCoordinateSystems.toolGroundPlaneShadow) && !frame.sendCoordinateSystems.toolOrigin) {
+ lastSentMatrices[frameKey].toolOrigin = matrixChecksum(realityEditor.sceneGraph.getSceneNodeById(frameKey).worldMatrix);
+ }
+ }
+
+ // Quick and dirty checksum should efficiently and correctly identify changes *almost* all of the time
+ // There is a chance that the sum of a matrix elements could stay the same when the matrix changes,
+ // but in practice this is unlikely to happen due to the many digits of precision we're working with.
+ function matrixChecksum(matrix) {
+ return matrix.reduce((acc, val) => acc + val, 0);
+ }
+
+ /**
+ * Sends a frameCreatedEvent into all visible frames, which they can listen for via the object.js API
+ * @param {{objectKey: string, frameKey: string, frameType: string}} params
+ */
+ function onFrameAdded(params) {
+ sendMessageToAllVisibleFrames({
+ frameCreatedEvent: {
+ objectId: params.objectKey,
+ frameId: params.frameKey,
+ frameType: params.frameType
+ }
+ });
+ }
+
+ /**
+ * If this comes from a frame, not a node, sends a frameDeletedEvent into all visible frames, which they can listen for via the object.js API
+ * @param {{objectKey: string, frameKey: string, additionalInfo: {frameType: string|undefined}}} params
+ */
+ function onVehicleDeleted(params) {
+ if (params.objectKey && params.frameKey && !params.nodeKey) { // only send message about frames, not nodes
+ sendMessageToAllVisibleFrames({
+ frameDeletedEvent: {
+ objectId: params.objectKey,
+ frameId: params.frameKey,
+ frameType: params.additionalInfo.frameType
+ }
+ });
+ }
+ }
+
+ /**
+ * Gets triggered when a fullscreen frame, which had requested exclusive fullscreen access, was kicked out by a new exclusive fullscreen frame
+ * Sends a fullScreenEjectedEvent message to the frame that got kicked out, so it can update its UI in response
+ * @param {{objectKey: string, frameKey: string}} params
+ */
+ function onFullScreenEjected(params) {
+ realityEditor.network.postMessageIntoFrame(params.frameKey, {
+ fullScreenEjectedEvent: {
+ objectId: params.objectKey,
+ frameId: params.frameKey
+ }
+ });
+ }
+
+ /**
+ * Helper function to post a message into all iframes on visible objects
+ * @param {*} msgContent
+ */
+ function sendMessageToAllVisibleFrames(msgContent) {
+ for (var visibleObjectKey in realityEditor.gui.ar.draw.visibleObjects) {
+ sendMessageToAllFramesOnObject(visibleObjectKey, msgContent);
+ }
+ }
+
+ /**
+ * Helper function to post a message into all iframes on visible objects
+ * @param {string} objectKey
+ * @param {*} msgContent
+ */
+ function sendMessageToAllFramesOnObject(objectKey, msgContent) {
+ realityEditor.forEachFrameInObject(objectKey, function(objectKey, frameKey) {
+ realityEditor.network.postMessageIntoFrame(frameKey, msgContent);
+ });
+ }
+
+ /**
+ * Receives key up events from the keyboardEvents module, and forwards them to active frames
+ * @param {{event: KeyboardEvent}} params
+ */
+ function keyUpHandler(params) {
+ var acyclicEventObject = getMutablePointerEventCopy(params.event); // can't stringify a cyclic object, which the event might be
+ sendMessageToAllVisibleFrames({keyboardUpEvent: acyclicEventObject});
+ }
+
+ function onKeyboardHidden() {
+ sendMessageToAllVisibleFrames({keyboardHiddenEvent: true});
+ }
+
+ /**
+ * Reusable function to strip out the cyclic properties of a PointerEvent (or other event) and clone it so the result can be modified or stringified
+ * @param {PointerEvent|*} event
+ * @return {*} - a shallow copy of the event, without ('currentTarget', 'srcElement', 'target', 'view', or 'path')
+ */
+ function getMutablePointerEventCopy(event) {
+ // we need to strip out the referenced DOM elements in order to JSON.stringify it
+ var keysToExclude = ['currentTarget', 'srcElement', 'target', 'view', 'path'];
+ var acyclicEventObject = copyObject(event, keysToExclude);
+ return acyclicEventObject;
+ }
+
+ /**
+ * Creates a shallow clone of a JSON object (key-by-key), with the option to exclude certain keys from the new copy.
+ * Useful for creating an acyclic version of the original so that it can be JSON.stringified
+ * @param {object} jsonObject
+ * @param {Array.|undefined} keysToExclude
+ * @return {object}
+ * @todo: move to a more reusable utility collection
+ */
+ function copyObject(jsonObject, keysToExclude) {
+ var newObject = {};
+ for (var key in jsonObject) {
+ if (typeof keysToExclude === 'undefined' || keysToExclude.indexOf(key) === -1) { // copy over all the keys that don't match the excluded ones
+ newObject[key] = jsonObject[key];
+ }
+ }
+ return newObject;
+ }
+
+ /**
+ * Gets triggered whenever an object's worldId get loaded or changed
+ * @param objectId
+ * @param worldId
+ */
+ function worldIdUpdated(objectId, worldId) {
+ sendMessageToAllFramesOnObject(objectId, {
+ updateWorldId: {
+ objectId: objectId,
+ worldId: worldId
+ }
+ });
+ }
+
+ exports.initService = initService;
+ exports.getMutablePointerEventCopy = getMutablePointerEventCopy;
+ exports.sendCoordinateSystemsToIFrame = sendCoordinateSystemsToIFrame;
+
+})(realityEditor.network.frameContentAPI);
diff --git a/src/network/index.js b/src/network/index.js
new file mode 100644
index 000000000..7cd89196d
--- /dev/null
+++ b/src/network/index.js
@@ -0,0 +1,3575 @@
+/**
+ *
+ *
+ * .,,,;;,'''..
+ * .'','... ..',,,.
+ * .,,,,,,',,',;;:;,. .,l,
+ * .,',. ... ,;, :l.
+ * ':;. .'.:do;;. .c ol;'.
+ * ';;' ;.; ', .dkl';, .c :; .'.',::,,'''.
+ * ',,;;;,. ; .,' .'''. .'. .d;''.''''.
+ * .oxddl;::,,. ', .'''. .... .'. ,:;..
+ * .'cOX0OOkdoc. .,'. .. ..... 'lc.
+ * .:;,,::co0XOko' ....''..'.'''''''.
+ * .dxk0KKdc:cdOXKl............. .. ..,c....
+ * .',lxOOxl:'':xkl,',......'.... ,'.
+ * .';:oo:... .
+ * .cd, โโโโโฌโโฌโโฌโโโโโฌโโ .
+ * .l; โโฃ โโโ โ โ โโโฌโ '
+ * 'l. โโโโโดโโด โด โโโโดโโ '.
+ * .o. ...
+ * .''''','.;:''.........
+ * .' .l
+ * .:. l'
+ * .:. .l.
+ * .x: :k;,.
+ * cxlc; cdc,,;;.
+ * 'l :.. .c ,
+ * o.
+ * .,
+ *
+ * โฆโโโโโโโโโฌ โฌโโฌโโฌ โฌ โโโโโฌโโฌโโฌโโโโโฌโโ โโโโฌโโโโโ โฌโโโโโโโโฌโ
+ * โ โฆโโโค โโโคโ โ โ โโฌโ โโฃ โโโ โ โ โโโฌโ โ โโโโฌโโ โ โโโค โ โ
+ * โฉโโโโโโด โดโดโโโด โด โด โโโโโดโโด โด โโโโดโโ โฉ โดโโโโโโโโโโโโโ โด
+ *
+ *
+ * Created by Valentin on 10/22/14.
+ *
+ * Copyright (c) 2015 Valentin Heun
+ * Modified by Valentin Heun 2014, 2015, 2016, 2017
+ * Modified by Benjamin Reynholds 2016, 2017
+ * Modified by James Hobin 2016, 2017
+ *
+ * All ascii characters above must be included in any redistribution.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+createNameSpace("realityEditor.network");
+
+realityEditor.network.state = {
+ proxyProtocol : null,
+ proxyUrl : null,
+ proxyHost : null,
+ proxyHostname: null,
+ proxyPort : null,
+ proxyNetwork : null,
+ proxySecret : null
+}
+
+realityEditor.network.desktopURLSchema = new ToolSocket.Schema([
+ new ToolSocket.Schema.StringValidator('n', {minLength: 1, maxLength: 25, pattern: /^[A-Za-z0-9_]*$/, required: true, expected: true}),
+ new ToolSocket.Schema.StringValidator('i', {minLength: 1, maxLength: 25, pattern: /^[A-Za-z0-9_]*$/}),
+ new ToolSocket.Schema.GroupValidator('s', [
+ new ToolSocket.Schema.StringValidator('s', {minLength: 0, maxLength: 45, pattern: /^[A-Za-z0-9_]*$/, required: true, expected: true}),
+ new ToolSocket.Schema.NullValidator('s'),
+ new ToolSocket.Schema.UndefinedValidator('s')
+ ], {expected: true}),
+ new ToolSocket.Schema.StringValidator('server', {minLength: 0, maxLength: 2000, pattern: /^[A-Za-z0-9~!@$%^&*()-_=+|;:,.]/}),
+ new ToolSocket.Schema.StringValidator('protocol', {minLength: 1, maxLength: 20, enum: ["spatialtoolbox", "ws", "wss", "http", "https"]}),
+]);
+
+// realityEditor.network.desktopURLSchema = {
+// "type": "object",
+// "items": {
+// "properties": {
+// "n": {"type": "string", "minLength": 1, "maxLength": 25, "pattern": "^[A-Za-z0-9_]*$"},
+// "i": {"type": "string", "minLength": 1, "maxLength": 25, "pattern": "^[A-Za-z0-9_]*$"},
+// "s": {"type": ["string", "null", "undefined"], "minLength": 0, "maxLength": 45, "pattern": "^[A-Za-z0-9_]*$"},
+// "server" : {"type": "string", "minLength": 0, "maxLength": 2000, "pattern": "^[A-Za-z0-9~!@$%^&*()-_=+|;:,.]"},
+// "protocol" : {"type": "string", "minLength": 1, "maxLength": 20, "enum": ["spatialtoolbox", "ws", "wss", "http", "https"]}
+// },
+// "required": ["n"],
+// "expected": ["n", "s"],
+// }
+// }
+//
+// realityEditor.network.urlSchema = {
+// "type": "object",
+// "items": {
+// "properties": {
+// "n": {"type": "string", "minLength": 1, "maxLength": 25, "pattern": "^[A-Za-z0-9_]*$"},
+// "i": {"type": "string", "minLength": 1, "maxLength": 25, "pattern": "^[A-Za-z0-9_]*$"},
+// "s": {"type": ["string", "null", "undefined"], "minLength": 0, "maxLength": 45, "pattern": "^[A-Za-z0-9_]*$"},
+// "server" : {"type": "string", "minLength": 0, "maxLength": 2000, "pattern": "^[A-Za-z0-9~!@$%^&*()-_=+|;:,.]"},
+// "protocol" : {"type": "string", "minLength": 1, "maxLength": 20, "enum": ["spatialtoolbox", "ws", "wss", "http", "https"]}
+// },
+// "required": ["n", "i"],
+// "expected": ["n", "i", "s"],
+// }
+// }
+//
+// realityEditor.network.qrSchema = {
+// "type": "object",
+// "items": {
+// "properties": {
+// "n": {"type": "string", "minLength": 1, "maxLength": 25, "pattern": "^[A-Za-z0-9_]*$"},
+// "s": {"type": ["string", "null", "undefined"], "minLength": 0, "maxLength": 45, "pattern": "^[A-Za-z0-9_]*$"},
+// "server" : {"type": "string", "minLength": 0, "maxLength": 2000, "pattern": "^[A-Za-z0-9~!@$%^&*()-_=+|;:,.]"},
+// "protocol" : {"type": "string", "minLength": 1, "maxLength": 20, "enum": ["spatialtoolbox", "ws", "wss", "http", "https"]}
+// },
+// "required": ["n", "server","protocol"],
+// "expected": ["n", "server", "protocol", "s"],
+// }
+// }
+
+/**
+ * if the main site is opened with https, we will assume that the main server is running https
+ */
+realityEditor.network.useHTTPS = location.protocol === "https:";
+
+/**
+ * @type {Array.<{messageName: string, callback: function}>}
+ */
+realityEditor.network.postMessageHandlers = [];
+
+/**
+ * Creates an extendable method for other modules to register callbacks that will be triggered
+ * from onInternalPostMessage events, without creating circular dependencies
+ * @param {string} messageName
+ * @param {function} callback
+ */
+realityEditor.network.addPostMessageHandler = function(messageName, callback) {
+ this.postMessageHandlers.push({
+ messageName: messageName,
+ callback: callback
+ });
+};
+
+realityEditor.network.nodeAddedCallbacks = {};
+
+realityEditor.network.getURL = function(server, identifier, route){
+ let protocol = null;
+ let host = null;
+ let network = null;
+ let destinationIdentifier = null;
+ let secret = null;
+
+ if (parseInt(Number(identifier))) {
+ protocol = realityEditor.network.useHTTPS ? "https": "http";
+ host = `${server}:${identifier}`;
+ } else {
+ let s = realityEditor.network.state;
+
+ if(s.proxyProtocol && s.proxyHost) {
+ protocol = s.proxyProtocol;
+ host = s.proxyHost;
+ }
+
+ if(s.proxyNetwork) network = s.proxyNetwork;
+ if(s.proxySecret) secret = s.proxySecret;
+ if(identifier) destinationIdentifier = identifier;
+ }
+
+ // concatenate URL
+ let returnUrl = protocol + '://' + host;
+ if(network) returnUrl += '/n/' + network;
+ if(destinationIdentifier) returnUrl += '/i/' + destinationIdentifier;
+ if(secret) returnUrl += '/s/' + secret;
+ if(route) returnUrl += route;
+ return returnUrl;
+}
+
+realityEditor.network.getIoTitle = function (identifier, title){
+ if (parseInt(Number(identifier))) {
+ return title;
+ } else {
+ let network = null;
+ let destinationIdentifier = null;
+ let secret = null;
+ let s = realityEditor.network.state;
+ if(s.proxyNetwork) network = s.proxyNetwork;
+ if(s.proxySecret) secret = s.proxySecret;
+ if(identifier) destinationIdentifier = identifier;
+
+ let returnUrl = "";
+ if(network) returnUrl += '/n/' + network;
+ if(destinationIdentifier) returnUrl += '/i/' + destinationIdentifier;
+ if(secret) returnUrl += '/s/' + secret;
+ if(title.charAt(0) !== '/') returnUrl += '/';
+ if(title) returnUrl += title;
+ return returnUrl;
+ }
+}
+
+realityEditor.network.getPort = function(object) {
+ if (typeof object === 'string') {
+ console.warn('DEPRECATED getPort', new Error().stack);
+ return objects[object].port;
+ }
+ return object.port;
+};
+realityEditor.network.getPortByIp = function(ip) {
+ if ((ip === '127.0.0.1' || ip === 'localhost') && globalStates.device) {
+ return '49369';
+ }
+
+ let serverPort = defaultHttpPort;
+
+ for (let key in objects) {
+ if (ip === objects[key].ip) {
+ serverPort = objects[key].port;
+ break;
+ }
+ }
+ return serverPort;
+};
+
+/**
+ * @type {Array.<{messageName: string, callback: function}>}
+ */
+realityEditor.network.udpMessageHandlers = [];
+
+/**
+ * Creates an extendable method for other modules to register callbacks that will be triggered
+ * when the interface receives any UDP message, without creating circular dependencies
+ * @param {string} messageName
+ * @param {function} callback
+ */
+realityEditor.network.addUDPMessageHandler = function(messageName, callback) {
+ this.udpMessageHandlers.push({
+ messageName: messageName,
+ callback: callback
+ });
+};
+
+/**
+ * @type {Array.}
+ */
+realityEditor.network.objectDiscoveredCallbacks = [];
+
+/**
+ * Allow other modules to be notified when a new object is discovered and added to the system.
+ * @param {function} callback
+ */
+realityEditor.network.addObjectDiscoveredCallback = function(callback) {
+ this.objectDiscoveredCallbacks.push(callback);
+
+ // trigger the callback for existing objects, if added too late
+ for (let [objectKey, object] of Object.entries(objects)) {
+ callback(object, objectKey);
+ }
+};
+
+/**
+ * Lists of renderMode callback functions, organized by objectId
+ * @type {Object.>}
+ */
+realityEditor.network.renderModeUpdateCallbacks = {};
+
+/**
+ * Allow other modules to be notified when a specific object's renderMode changes. Also triggers once when added.
+ * @param {string} objectId
+ * @param {function} callback
+ */
+realityEditor.network.addRenderModeUpdateCallback = function(objectId, callback) {
+ if (typeof this.renderModeUpdateCallbacks[objectId] === 'undefined') {
+ this.renderModeUpdateCallbacks[objectId] = [];
+ }
+ this.renderModeUpdateCallbacks[objectId].push(callback);
+ let existingObject = realityEditor.getObject(objectId);
+ if (!existingObject) return;
+ callback(existingObject.renderMode);
+};
+
+/**
+ * @type {CallbackHandler}
+ */
+realityEditor.network.callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('network/index');
+
+/**
+ * Adds a callback function that will be invoked when the specified function is called
+ * @param {string} functionName
+ * @param {function} callback
+ */
+realityEditor.network.registerCallback = function(functionName, callback) {
+ if (!this.callbackHandler) {
+ this.callbackHandler = new realityEditor.moduleCallbacks.CallbackHandler('network/index');
+ }
+ this.callbackHandler.registerCallback(functionName, callback);
+};
+
+realityEditor.network.pendingNodeAdjustments = {};
+
+realityEditor.network.addPendingNodeAdjustment = function(objectKey, frameKey, nodeName, msgContent) {
+ let pendings = this.pendingNodeAdjustments;
+ if (typeof pendings[objectKey] === 'undefined') { pendings[objectKey] = {}; }
+ if (typeof pendings[objectKey][frameKey] === 'undefined') { pendings[objectKey][frameKey] = {}; }
+ if (typeof pendings[objectKey][frameKey][nodeName] === 'undefined') { pendings[objectKey][frameKey][nodeName] = []; }
+
+ pendings[objectKey][frameKey][nodeName].push(msgContent);
+}
+
+realityEditor.network.processPendingNodeAdjustments = function(objectKey, frameKey, nodeName, callback) {
+ let pendings = this.pendingNodeAdjustments;
+ if (typeof pendings[objectKey] === 'undefined') { return; }
+ if (typeof pendings[objectKey][frameKey] === 'undefined') { return; }
+ if (typeof pendings[objectKey][frameKey][nodeName] === 'undefined') { return; }
+
+ pendings[objectKey][frameKey][nodeName].forEach(function(msgContent) {
+ callback(objectKey, frameKey, nodeName, JSON.parse(JSON.stringify(msgContent)));
+ });
+ delete pendings[objectKey][frameKey][nodeName];
+}
+
+/**
+ * Converts an object with version < 1.7.0 to the new format:
+ * Objects now have frames, which can have nodes, but in the old version there were no frames
+ * and the nodes just existed on the object itself
+ * @param {Object} thisObject
+ * @param {string} objectKey
+ * @param {string} frameKey
+ */
+realityEditor.network.oldFormatToNew = function (thisObject, objectKey, frameKey) {
+ if (typeof frameKey === "undefined") {
+ frameKey = objectKey;
+ }
+ var _this = this;
+
+ if (thisObject.integerVersion < 170) {
+
+ _this.utilities.rename(thisObject, "folder", "name");
+ _this.utilities.rename(thisObject, "objectValues", "nodes");
+ _this.utilities.rename(thisObject, "objectLinks", "links");
+ delete thisObject["matrix3dMemory"];
+
+ if (!thisObject.frames) thisObject.frames = {};
+
+ thisObject.frames[frameKey].name = thisObject.name;
+ thisObject.frames[frameKey].nodes = thisObject.nodes;
+ thisObject.frames[frameKey].links = thisObject.links;
+
+ for (let linkKey in objects[objectKey].frames[frameKey].links) {
+ thisObject = objects[objectKey].frames[frameKey].links[linkKey];
+
+ _this.utilities.rename(thisObject, "ObjectA", "objectA");
+ _this.utilities.rename(thisObject, "locationInA", "nodeA");
+ if (!thisObject.frameA) thisObject.frameA = thisObject.objectA;
+ _this.utilities.rename(thisObject, "ObjectNameA", "nameA");
+
+ _this.utilities.rename(thisObject, "ObjectB", "objectB");
+ _this.utilities.rename(thisObject, "locationInB", "nodeB");
+ if (!thisObject.frameB) thisObject.frameB = thisObject.objectB;
+ _this.utilities.rename(thisObject, "ObjectNameB", "nameB");
+ _this.utilities.rename(thisObject, "endlessLoop", "loop");
+ _this.utilities.rename(thisObject, "countLinkExistance", "health");
+ }
+
+ /*for (var nodeKey in objects[objectKey].nodes) {
+ _this.utilities.rename(objects[objectKey].nodes, nodeKey, objectKey + nodeKey);
+ }*/
+ for (let nodeKey in objects[objectKey].frames[frameKey].nodes) {
+ thisObject = objects[objectKey].frames[frameKey].nodes[nodeKey];
+ _this.utilities.rename(thisObject, "plugin", "type");
+ _this.utilities.rename(thisObject, "appearance", "type");
+
+ if (thisObject.type === "default") {
+ thisObject.type = "node";
+ }
+
+
+ thisObject.data = {
+ value: thisObject.value,
+ mode: thisObject.mode,
+ unit: "",
+ unitMin: 0,
+ unitMax: 1
+ };
+ delete thisObject.value;
+ delete thisObject.mode;
+
+ }
+
+ }
+
+ objects[objectKey].uuid = objectKey;
+ objects[objectKey].frames[frameKey].uuid = frameKey;
+
+ for (let nodeKey in objects[objectKey].frames[frameKey].nodes) {
+ objects[objectKey].frames[frameKey].nodes[nodeKey].uuid = nodeKey;
+ }
+
+ for (let linkKey in objects[objectKey].frames[frameKey].links) {
+ objects[objectKey].frames[frameKey].links[linkKey].uuid = linkKey;
+ }
+
+};
+
+/**
+ * Properly initialize all the temporary, editor-only state for an object when it first gets added
+ * @param {string} objectKey
+ */
+realityEditor.network.onNewObjectAdded = function(objectKey) {
+ realityEditor.app.tap();
+
+ var thisObject = realityEditor.getObject(objectKey);
+ // this is a work around to set the state of an objects to not being visible.
+ realityEditor.gui.ar.draw.setObjectVisible(thisObject, false);
+ thisObject.screenZ = 1000;
+ thisObject.fullScreen = false;
+ thisObject.sendMatrix = false;
+ thisObject.sendMatrices = {
+ model: false,
+ view: false,
+ modelView : false,
+ devicePose : false,
+ groundPlane : false,
+ anchoredModelView: false,
+ allObjects : false
+ };
+ thisObject.sendScreenPosition = false;
+ thisObject.sendAcceleration = false;
+ thisObject.integerVersion = parseInt(objects[objectKey].version.replace(/\./g, ""));
+
+ if (typeof thisObject.matrix === 'undefined') {
+ thisObject.matrix = [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+ }
+
+ let isImageTarget = !(thisObject.isWorldObject || thisObject.type === 'world') &&
+ !realityEditor.gui.ar.anchors.isAnchorObject(objectKey) &&
+ !realityEditor.avatar.utils.isAvatarObject(thisObject) &&
+ !realityEditor.humanPose.utils.isHumanPoseObject(thisObject);
+
+ realityEditor.sceneGraph.addObject(objectKey, thisObject.matrix, isImageTarget);
+
+ // thisObject.unpinnedFrameKeys = {};
+ // thisObject.visibleUnpinnedFrames = {};
+
+ for (let frameKey in objects[objectKey].frames) {
+ var thisFrame = realityEditor.getFrame(objectKey, frameKey);
+ realityEditor.network.initializeDownloadedFrame(objectKey, frameKey, thisFrame);
+ }
+
+ // Object.keys(thisObject.unpinnedFrameKeys).forEach(function(frameKey) {
+ // console.log('deleted unpinned frame (for now): ' + frameKey);
+ // delete thisObject.frames[frameKey];
+ // });
+
+ if (!thisObject.protocol) {
+ thisObject.protocol = "R0";
+ }
+
+ objects[objectKey].uuid = objectKey;
+
+ for (let frameKey in objects[objectKey].frames) {
+ objects[objectKey].frames[frameKey].uuid = frameKey;
+ for (let nodeKey in objects[objectKey].frames[frameKey].nodes) {
+ objects[objectKey].frames[frameKey].nodes[nodeKey].uuid = nodeKey;
+ }
+
+ for (let linkKey in objects[objectKey].frames[frameKey].links) {
+ objects[objectKey].frames[frameKey].links[linkKey].uuid = linkKey;
+ }
+ }
+
+ realityEditor.gui.ar.utilities.setAverageScale(objects[objectKey]);
+
+ this.cout(JSON.stringify(objects[objectKey]));
+
+ // todo this needs to be looked at
+ realityEditor.gui.memory.addObjectMemory(objects[objectKey]);
+
+ // notify subscribed modules that a new object was added
+ realityEditor.network.objectDiscoveredCallbacks.forEach(function(callback) {
+ callback(objects[objectKey], objectKey);
+ });
+};
+
+realityEditor.network.initializeDownloadedFrame = function(objectKey, frameKey, thisFrame) {
+ // thisFrame.objectVisible = false; // gets set to false in draw.setObjectVisible function
+ thisFrame.screenZ = 1000;
+ thisFrame.fullScreen = false;
+ thisFrame.sendMatrix = false;
+ thisFrame.sendMatrices = {
+ model: false,
+ view: false,
+ modelView : false,
+ devicePose : false,
+ groundPlane : false,
+ anchoredModelView: false,
+ allObjects : false
+ };
+ thisFrame.sendScreenPosition = false;
+ thisFrame.sendAcceleration = false;
+ thisFrame.integerVersion = parseInt(objects[objectKey].version.replace(/\./g, "")) || 300;
+ thisFrame.visible = false;
+ thisFrame.objectId = objectKey;
+
+ if (typeof thisFrame.developer === 'undefined') {
+ thisFrame.developer = true;
+ }
+
+ var positionData = realityEditor.gui.ar.positioning.getPositionData(thisFrame);
+
+ if (positionData.matrix === null || typeof positionData.matrix !== "object") {
+ positionData.matrix = [];
+ }
+
+ realityEditor.sceneGraph.addFrame(objectKey, frameKey, thisFrame, positionData.matrix);
+ realityEditor.gui.ar.groundPlaneAnchors.sceneNodeAdded(objectKey, frameKey, thisFrame, positionData.matrix);
+
+ for (let nodeKey in thisFrame.nodes) {
+ var thisNode = thisFrame.nodes[nodeKey];
+ realityEditor.network.initializeDownloadedNode(objectKey, frameKey, nodeKey, thisNode);
+ }
+
+ // TODO: invert dependency
+ realityEditor.gui.ar.grouping.reconstructGroupStruct(frameKey, thisFrame);
+};
+
+realityEditor.network.initializeDownloadedNode = function(objectKey, frameKey, nodeKey, thisNode) {
+ if (thisNode.matrix === null || typeof thisNode.matrix !== "object") {
+ thisNode.matrix = [];
+ }
+
+ thisNode.objectId = objectKey;
+ thisNode.frameId = frameKey;
+ thisNode.loaded = false;
+ thisNode.visible = false;
+
+ if (typeof thisNode.publicData !== 'undefined') {
+ if (!publicDataCache.hasOwnProperty(frameKey)) {
+ publicDataCache[frameKey] = {};
+ }
+ publicDataCache[frameKey][thisNode.name] = thisNode.publicData;
+ }
+
+ if (thisNode.type === "logic") {
+ thisNode.guiState = new LogicGUIState();
+ let container = document.getElementById('craftingBoard');
+ thisNode.grid = new realityEditor.gui.crafting.grid.Grid(container.clientWidth - realityEditor.gui.crafting.menuBarWidth, container.clientHeight, CRAFTING_GRID_WIDTH, CRAFTING_GRID_HEIGHT, nodeKey);
+ //_this.realityEditor.gui.crafting.utilities.convertLinksFromServer(thisObject);
+ }
+
+ realityEditor.sceneGraph.addNode(objectKey, frameKey, nodeKey, thisNode, thisNode.matrix);
+};
+
+/**
+ * Looks at an object heartbeat, and if the object hasn't been added yet, downloads it and initializes all appropriate state
+ * @param {{id: string, ip: string, vn: number, tcs: string, zone: string}} beat - object heartbeat received via UDP
+ */
+realityEditor.network.addHeartbeatObject = function (beat) {
+ if (!realityEditor.device.loaded) {
+ // addHeartbeatObject called before init done
+ setTimeout(() => {
+ realityEditor.network.addHeartbeatObject(beat);
+ }, 500);
+ return;
+ }
+
+ if (beat && beat.id) {
+ if (!objects[beat.id]) {
+
+ // ignore this object if it's a world object and the primaryWorld is set but not equal to this one
+ // we make sure to ignore it before triggering the GET request, otherwise we might overload the network
+ let primaryWorldInfo = realityEditor.network.discovery.getPrimaryWorldInfo();
+ let isLocalWorld = beat.id === realityEditor.worldObjects.getLocalWorldId();
+ let isWorldBeat = realityEditor.worldObjects.isWorldObjectKey(beat.id);
+ if (primaryWorldInfo && isWorldBeat && !isLocalWorld) {
+ let hasIpInfo = primaryWorldInfo.ip;
+ if (beat.id !== primaryWorldInfo.id || (hasIpInfo && beat.ip !== primaryWorldInfo.ip)) {
+ // console.warn('ignoring adding world object ' + beat.id + ' because it doesnt match primary world ' + primaryWorldInfo.id);
+ return;
+ }
+ }
+
+ // download the object data from its server
+ let baseUrl = realityEditor.network.getURL(beat.ip, realityEditor.network.getPort(beat), '/object/' + beat.id);
+ let queryParams = '?excludeUnpinned=true';
+ this.getData(beat.id, null, null, baseUrl+queryParams, function (objectKey, frameKey, nodeKey, msg) {
+ if (msg && objectKey && !objects[objectKey]) {
+ // add the object
+ objects[objectKey] = msg;
+ objects[objectKey].ip = beat.ip;
+ if(beat.network) objects[objectKey].network = beat.network;
+ if(beat.port) objects[objectKey].port = beat.port;
+ // initialize temporary state and notify other modules
+ realityEditor.network.onNewObjectAdded(objectKey);
+
+ var doesDeviceSupportJPGTargets = true; // TODO: verify this somehow instead of always true
+ if (doesDeviceSupportJPGTargets) {
+ // this tries DAT first, then resorts to JPG if DAT not found
+ realityEditor.app.targetDownloader.downloadAvailableTargetFiles(beat);
+ } else {
+ // download XML, DAT, and initialize tracker
+ realityEditor.app.targetDownloader.downloadTargetFilesForDiscoveredObject(beat);
+ }
+
+ // check if onNewServerDetected callbacks should be triggered
+ realityEditor.network.checkIfNewServer(beat.ip);//, objectKey);
+ }
+ });
+ } else {
+ // if we receive a heartbeat of an object that has been created but it still needs targets
+ // try to re-download its target data if possible/necessary
+ var isInitialized = realityEditor.app.targetDownloader.isObjectTargetInitialized(beat.id) || // either target downloaded
+ beat.id === realityEditor.worldObjects.getLocalWorldId(); // or it's the _WORLD_local
+
+ if (!isInitialized && realityEditor.app.targetDownloader.isObjectReadyToRetryDownload(beat.id, beat.tcs)) {
+ setTimeout(function() {
+ realityEditor.app.targetDownloader.downloadAvailableTargetFiles(beat);
+ }, 1000);
+ }
+ }
+ }
+};
+
+realityEditor.network.knownServers = []; // todo: make private to module
+realityEditor.network.newServerDetectedCallbacks = [];
+
+/**
+ * Register a callback that will trigger for each serverIP currently known to the system and each new one as it is detected
+ * @todo: use this method more consistently across the codebase instead of several modules implementing similar behavior
+ * @param {function} callback
+ */
+realityEditor.network.onNewServerDetected = function(callback) {
+ // register callback for future detections
+ this.newServerDetectedCallbacks.push(callback);
+
+ // immediate trigger for already known servers
+ this.knownServers.forEach(function(serverIP) {
+ callback(serverIP);
+ });
+};
+
+/**
+ * Checks if a server has already been detected, and if not, detect it and trigger callbacks
+ * @param {string} serverIP
+ */
+realityEditor.network.checkIfNewServer = function (serverIP) {
+ var foundExistingMatch = this.knownServers.indexOf(serverIP) > -1; // TODO: make robust against different formatting of "same" IP
+
+ if (!foundExistingMatch) {
+ this.knownServers.push(serverIP);
+
+ // trigger callbacks
+ this.newServerDetectedCallbacks.forEach(function(callback) {
+ callback(serverIP);
+ });
+ }
+};
+
+/**
+ * Updates an entire object, including all of its frames and nodes, to be in sync with the remote version on the server
+ * @param {Objects} origin - the local copy of the Object
+ * @param {Objects} remote - the copy of the Object downloaded from the server
+ * @param {string} objectKey
+ * @param {string} avatarName
+ */
+realityEditor.network.updateObject = function (origin, remote, objectKey, avatarName) {
+ origin.x = remote.x;
+ origin.y = remote.y;
+ origin.scale = remote.scale;
+
+ if (remote.matrix) {
+ origin.matrix = remote.matrix;
+ }
+
+ // triggers any renderModeUpdateCallbacks if the object's renderMode has changed
+ if (origin.renderMode !== remote.renderMode) {
+ origin.renderMode = remote.renderMode;
+
+ if (typeof this.renderModeUpdateCallbacks[objectKey] !== 'undefined') {
+ this.renderModeUpdateCallbacks[objectKey].forEach(callback => {
+ callback(origin.renderMode);
+ });
+ }
+ }
+
+ // update each frame in the object // TODO: create an updateFrame function, the same way we have an updateNode function
+ for (let frameKey in remote.frames) {
+ let prevVisualization = origin.frames[frameKey] ? origin.frames[frameKey].visualization : null;
+ let newVisualization = remote.frames[frameKey] ? remote.frames[frameKey].visualization : null;
+
+ if (!remote.frames.hasOwnProperty(frameKey)) continue;
+ if (!origin.frames[frameKey]) {
+ origin.frames[frameKey] = remote.frames[frameKey];
+
+ origin.frames[frameKey].width = remote.frames[frameKey].width || 300;
+ origin.frames[frameKey].height = remote.frames[frameKey].height || 300;
+
+ origin.frames[frameKey].uuid = frameKey;
+
+ realityEditor.network.initializeDownloadedFrame(objectKey, frameKey, origin.frames[frameKey]);
+ // todo Steve: added a new frame
+ realityEditor.network.callbackHandler.triggerCallbacks('frameAdded', {objectKey: objectKey, frameKey: frameKey, frameType: origin.frames[frameKey].src, nodeKey: null, additionalInfo: {avatarName: avatarName}});
+
+ } else {
+ origin.frames[frameKey].visualization = remote.frames[frameKey].visualization;
+ origin.frames[frameKey].ar = remote.frames[frameKey].ar;
+ origin.frames[frameKey].screen = remote.frames[frameKey].screen;
+ origin.frames[frameKey].name = remote.frames[frameKey].name;
+
+ // now update each node in the frame
+ var remoteNodes = remote.frames[frameKey].nodes;
+ var originNodes = origin.frames[frameKey].nodes;
+
+ for (let nodeKey in remoteNodes) {
+ if (!remoteNodes.hasOwnProperty(nodeKey)) continue;
+
+ var originNode = originNodes[nodeKey];
+ var remoteNode = remoteNodes[nodeKey];
+ realityEditor.network.updateNode(originNode, remoteNode, objectKey, frameKey, nodeKey);
+ }
+
+ // remove extra nodes from origin that don't exist in remote
+ for (let nodeKey in originNodes) {
+ if (originNodes.hasOwnProperty(nodeKey) && !remoteNodes.hasOwnProperty(nodeKey)) {
+ realityEditor.gui.ar.draw.deleteNode(objectKey, frameKey, nodeKey);
+ realityEditor.network.callbackHandler.triggerCallbacks('vehicleDeleted', {objectKey: objectKey, frameKey: frameKey, nodeKey: nodeKey, additionalInfo: {}});
+ }
+ }
+
+ }
+
+ origin.frames[frameKey].links = JSON.parse(JSON.stringify(remote.frames[frameKey].links));
+
+ // TODO: invert dependency
+ realityEditor.gui.ar.grouping.reconstructGroupStruct(frameKey, origin.frames[frameKey]);
+
+ // this makes the tools load properly when pulling out of screens, pushing into screens
+ let visualizationChanged = prevVisualization && newVisualization && prevVisualization !== newVisualization;
+ if (globalDOMCache["iframe" + frameKey] && visualizationChanged) {
+ if (globalDOMCache["iframe" + frameKey].getAttribute('loaded')) {
+ realityEditor.network.onElementLoad(objectKey, frameKey, null);
+ }
+ }
+ }
+
+ // remove extra frames from origin that don't exist in remote
+ for (let frameKey in origin.frames) {
+ if (origin.frames.hasOwnProperty(frameKey) && !remote.frames.hasOwnProperty(frameKey)) {
+ // delete origin.frames[frameKey];
+ let frameType = origin.frames[frameKey].src;
+ realityEditor.gui.ar.draw.deleteFrame(objectKey, frameKey);
+ realityEditor.network.callbackHandler.triggerCallbacks('vehicleDeleted', {objectKey: objectKey, frameKey: frameKey, nodeKey: null, additionalInfo: {frameType: frameType, avatarName: avatarName}});
+ }
+ }
+};
+
+/**
+ * Updates a node (works for logic nodes too) to be in sync with the remote version on the server
+ * @param {Node|Logic} origin - the local copy
+ * @param {Node|Logic} remote - the copy downloaded from the server
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ */
+realityEditor.network.updateNode = function (origin, remote, objectKey, frameKey, nodeKey) {
+
+ var isRemoteNodeDeleted = (Object.keys(remote).length === 0 && remote.constructor === Object);
+
+ // delete local node if it exists locally but not on the server
+ if (origin && isRemoteNodeDeleted) {
+
+ realityEditor.gui.ar.draw.deleteNode(objectKey, frameKey, nodeKey);
+
+ var thisNode = realityEditor.getNode(objectKey, frameKey, nodeKey);
+
+ if (thisNode) {
+ delete objects[objectKey].frames[frameKey].nodes[nodeKey];
+ }
+ return;
+ }
+
+ // create the local node if it exists on the server but not locally
+ if (!origin) {
+
+ origin = remote;
+
+ if (origin.type === "logic") {
+ if (!origin.guiState) {
+ origin.guiState = new LogicGUIState();
+ }
+
+ if (!origin.grid) {
+ let container = document.getElementById('craftingBoard');
+ origin.grid = new realityEditor.gui.crafting.grid.Grid(container.clientWidth - realityEditor.gui.crafting.menuBarWidth, container.clientHeight, CRAFTING_GRID_WIDTH, CRAFTING_GRID_HEIGHT, origin.uuid);
+ }
+
+ }
+
+ objects[objectKey].frames[frameKey].nodes[nodeKey] = origin;
+
+ let positionData = realityEditor.gui.ar.positioning.getPositionData(origin);
+ realityEditor.sceneGraph.addNode(objectKey, frameKey, nodeKey, origin, positionData.matrix);
+
+ } else {
+ // update the local node's properties to match the one on the server if they both exists
+
+ origin.x = remote.x;
+ origin.y = remote.y;
+ origin.scale = remote.scale;
+ origin.name = remote.name;
+ origin.frameId = frameKey;
+ origin.objectId = objectKey;
+
+ if (remote.text) {
+ origin.text = remote.text;
+ }
+ if (remote.matrix) {
+ origin.matrix = remote.matrix;
+ }
+ origin.lockPassword = remote.lockPassword;
+ origin.lockType = remote.lockType;
+ origin.publicData = remote.publicData;
+ // console.log("update node: lockPassword = " + remote.lockPassword + ", lockType = " + remote.lockType);
+
+ // set up the crafting board for the local node if it's a logic node
+ if (origin.type === "logic") {
+ if (!origin.guiState) {
+ origin.guiState = new LogicGUIState();
+ }
+
+ if (!origin.grid) {
+ let container = document.getElementById('craftingBoard');
+ origin.grid = new realityEditor.gui.crafting.grid.Grid(container.clientWidth - realityEditor.gui.crafting.menuBarWidth, container.clientHeight, CRAFTING_GRID_WIDTH, CRAFTING_GRID_HEIGHT, origin.uuid);
+ }
+
+ }
+
+ }
+
+ // if it's a logic node, update its logic blocks and block links to match the remote, and re-render them if the board is open
+ if (remote.blocks) {
+ this.utilities.syncBlocksWithRemote(origin, remote.blocks);
+ }
+
+ if (remote.links) {
+ this.utilities.syncLinksWithRemote(origin, remote.links);
+ }
+
+ if (globalStates.currentLogic) {
+
+ if (globalStates.currentLogic.uuid === nodeKey) {
+
+ if (remote.type === 'logic') {
+ realityEditor.gui.crafting.updateGrid(objects[objectKey].frames[frameKey].nodes[nodeKey].grid);
+ }
+
+ realityEditor.gui.crafting.forceRedraw(globalStates.currentLogic);
+
+ }
+
+ } else {
+ if (globalDOMCache["iframe" + nodeKey]) {
+ if (globalDOMCache["iframe" + nodeKey].getAttribute('loaded')) {
+ realityEditor.network.onElementLoad(objectKey, frameKey, nodeKey);
+ }
+ }
+ }
+
+};
+
+/**
+ * When we receive any UDP message, this function triggers so that subscribed modules can react to specific messages
+ * @param {string|object} message
+ */
+realityEditor.network.onUDPMessage = function(message) {
+ if (typeof message === "string") {
+ try {
+ message = JSON.parse(message);
+ } catch (error) {
+ // error parsing JSON
+ }
+ }
+
+ this.udpMessageHandlers.forEach(function(messageHandler) {
+ if (typeof message[messageHandler.messageName] !== "undefined") {
+ messageHandler.callback(message);
+ }
+ });
+};
+
+/**
+ * When the app receives a UDP message with a field called "action", this gets triggered with the action contents.
+ * Actions listened for include reload(Object|Frame|Node|Link), advertiseConnection, load(Memory|LogicIcon) and addFrame
+ * @param {object|string} action
+ */
+realityEditor.network.onAction = function (action) {
+ var _this = this;
+ var thisAction;
+ if (typeof action === "object") {
+ thisAction = action;
+ } else {
+ while (action.charAt(0) === '"') action = action.substr(1);
+ while (action.charAt(action.length - 1) === ' ') action = action.substring(0, action.length - 1);
+ while (action.charAt(action.length - 1) === '"') action = action.substring(0, action.length - 1);
+
+ thisAction = {
+ action: action
+ };
+ }
+
+ if (thisAction.lastEditor === globalStates.tempUuid) {
+ return;
+ }
+
+ // reload links for a specific object.
+
+ if (typeof thisAction.reloadLink !== "undefined") {
+ // compatibility with old version where object was ID
+ if (thisAction.reloadLink.id) {
+ thisAction.reloadLink.object = thisAction.reloadLink.id;
+ // TODO: BEN set thisAction.reloadFrame
+ }
+
+ if (thisAction.reloadLink.object in objects) {
+ let urlEndpoint = realityEditor.network.getURL(objects[thisAction.reloadLink.object].ip, realityEditor.network.getPort(objects[thisAction.reloadLink.object]), '/object/' + thisAction.reloadLink.object + '/frame/' +thisAction.reloadLink.frame);
+ this.getData(thisAction.reloadLink.object, thisAction.reloadLink.frame, null, urlEndpoint, function (objectKey, frameKey, nodeKey, res) {
+
+ // });
+ // this.getData((realityEditor.network.useHTTPS ? 'https' : 'http') + '://' + objects[thisAction.reloadLink.object].ip + ':' + httpPort + '/object/' + thisAction.reloadLink.object + '/frame/' +thisAction.reloadLink.frame, thisAction.reloadLink.object, function (req, thisKey, frameKey) {
+
+ var thisFrame = realityEditor.getFrame(objectKey, frameKey);
+ if (objects[objectKey].integerVersion < 170) {
+
+ realityEditor.network.oldFormatToNew(objects[objectKey], objectKey, frameKey);
+ /*
+ objects[thisKey].links = req.links;
+ for (var linkKey in objects[thisKey].links) {
+ var thisObject = objects[thisKey].links[linkKey];
+
+ _this.utilities.rename(thisObject, "ObjectA", "objectA");
+ _this.utilities.rename(thisObject, "locationInA", "nodeA");
+ _this.utilities.rename(thisObject, "ObjectNameA", "nameA");
+
+ _this.utilities.rename(thisObject, "ObjectB", "objectB");
+ _this.utilities.rename(thisObject, "locationInB", "nodeB");
+ _this.utilities.rename(thisObject, "ObjectNameB", "nameB");
+ _this.utilities.rename(thisObject, "endlessLoop", "loop");
+ _this.utilities.rename(thisObject, "countLinkExistance", "health");
+ }
+ */
+ }
+ else {
+ thisFrame.links = res.links;
+ }
+
+ objects[objectKey].uuid = objectKey;
+ thisFrame.uuid = frameKey;
+
+ for (let nodeKey in thisFrame.nodes) {
+ thisFrame.nodes[nodeKey].uuid = nodeKey;
+ }
+
+ for (let linkKey in thisFrame.links) {
+ thisFrame.links[linkKey].uuid = linkKey;
+ }
+
+ // cout(objects[thisKey]);
+
+ _this.cout("got links");
+ });
+ }
+ }
+
+ if (typeof thisAction.reloadObject !== "undefined") {
+
+ if (thisAction.reloadObject.object in objects) {
+
+ let objectIP = objects[thisAction.reloadObject.object].ip;
+ let urlEndpoint = realityEditor.network.getURL(objectIP, realityEditor.network.getPort(objects[thisAction.reloadObject.object]), '/object/' + thisAction.reloadObject.object);
+
+ this.getData(thisAction.reloadObject.object, thisAction.reloadObject.frame, null, urlEndpoint, function (objectKey, frameKey, nodeKey, res) {
+
+ if (!res) {
+ delete objects[objectKey];
+ realityEditor.network.callbackHandler.triggerCallbacks('objectDeleted', { objectKey: objectKey, objectIP: objectIP });
+ return;
+ }
+
+ if (objects[objectKey].integerVersion < 170) {
+ if (typeof res.objectValues !== "undefined") {
+ res.nodes = res.objectValues;
+ }
+ }
+
+ let avatarId = realityEditor.avatar.getAvatarObjectKeyFromSessionId(thisAction.lastEditor);
+ realityEditor.network.updateObject(objects[objectKey], res, objectKey, avatarId);
+
+ _this.cout("got object");
+
+ }, { bypassCache: true });
+ }
+ }
+
+ if (typeof thisAction.reloadFrame !== "undefined") {
+ let thisFrame = realityEditor.getFrame(thisAction.reloadFrame.object, thisAction.reloadFrame.frame);
+
+ // only reload the frame if it already exists โ if it doesn't, it needs to be added with reloadObject in order to intialize properly
+ if (thisFrame) {
+ realityEditor.network.reloadFrame(thisAction.reloadFrame.object, thisAction.reloadFrame.frame, thisAction);
+ } else {
+ setTimeout(() => {
+ realityEditor.network.reloadFrame(thisAction.reloadFrame.object, thisAction.reloadFrame.frame, thisAction);
+ }, 500);
+ }
+ }
+
+ if (typeof thisAction.reloadNode !== "undefined") {
+ let thisFrame = realityEditor.getFrame(thisAction.reloadNode.object, thisAction.reloadNode.frame);
+
+ if (thisFrame !== null) {
+ // TODO: getData webServer.get('/object/*/') ... instead of /object/node
+
+ let urlEndpoint = realityEditor.network.getURL(objects[thisAction.reloadNode.object].ip, realityEditor.network.getPort(objects[thisAction.reloadNode.object]), '/object/' + thisAction.reloadNode.object + '/frame/' + thisAction.reloadNode.frame + '/node/' + thisAction.reloadNode.node + '/');
+ this.getData(thisAction.reloadObject.object, thisAction.reloadObject.frame, thisAction.reloadObject.node, urlEndpoint, function (objectKey, frameKey, nodeKey, res) {
+
+ // this.getData(
+ // (realityEditor.network.useHTTPS ? 'https' : 'http') + '://' + objects[thisAction.reloadNode.object].ip + ':' + httpPort + '/object/' + thisAction.reloadNode.object + "/node/" + thisAction.reloadNode.node + "/", thisAction.reloadNode.object, function (req, objectKey, frameKey, nodeKey) {
+
+ var thisFrame = realityEditor.getFrame(objectKey, frameKey);
+
+ if (!thisFrame.nodes[nodeKey]) {
+ thisFrame.nodes[nodeKey] = res;
+ } else {
+ realityEditor.network.updateNode(thisFrame.nodes[nodeKey], res, objectKey, frameKey, nodeKey);
+ }
+
+ _this.cout("got object");
+
+ }, thisAction.reloadNode.node);
+ }
+ }
+
+ if (thisAction.loadMemory) {
+ var id = thisAction.loadMemory.object;
+ let urlEndpoint = realityEditor.network.getURL(thisAction.loadMemory.ip, realityEditor.network.getPort(objects[id]), '/object/' + id);
+ this.getData(id, null, null, urlEndpoint, function (objectKey, frameKey, nodeKey, res) {
+
+ // this.getData(url, id, function (req, thisKey) {
+ _this.cout('received memory', res.memory);
+ objects[objectKey].memory = res.memory;
+ objects[objectKey].memoryCameraMatrix = res.memoryCameraMatrix;
+ objects[objectKey].memoryProjectionMatrix = res.memoryProjectionMatrix;
+
+ // _this.realityEditor.gui.memory.addObjectMemory(objects[objectKey]);
+ });
+ }
+
+ if (thisAction.loadLogicIcon) {
+ this.loadLogicIcon(thisAction.loadLogicIcon);
+ }
+
+ // Set states to locate object in space
+ if (thisAction.spatial) {
+
+ if(thisAction.spatial.locator){
+ let spatial = globalStates.spatial;
+ let action = thisAction.spatial.locator;
+ if(!thisAction.spatial.ip) return;
+ if(action.whereIs){
+ spatial.whereIs[thisAction.spatial.ip] = JSON.parse(JSON.stringify(action.whereIs));
+ }
+
+ if(action.whereWas){
+ spatial.whereWas[thisAction.spatial.ip] = JSON.parse(JSON.stringify(action.whereWas));
+ }
+
+ if(action.howFarIs){
+ spatial.howFarIs[thisAction.spatial.ip] = JSON.parse(JSON.stringify(action.howFarIs));
+ }
+
+ if(action.velocityOf){
+ spatial.velocityOf[thisAction.spatial.ip] = JSON.parse(JSON.stringify(action.velocityOf));
+ }
+
+ }
+ realityEditor.gui.spatial.checkState()
+ }
+
+
+ if (thisAction.addFrame) {
+ let thisObject = realityEditor.getObject(thisAction.addFrame.objectID);
+
+ if (thisObject) {
+
+ var frame = new Frame();
+
+ frame.objectId = thisAction.addFrame.objectID;
+ frame.name = thisAction.addFrame.name;
+
+ var frameID = frame.objectId + frame.name;
+ frame.uuid = frameID;
+
+ frame.ar.x = thisAction.addFrame.x;
+ frame.ar.y = thisAction.addFrame.y;
+ frame.ar.scale = thisAction.addFrame.scale;
+ frame.frameSizeX = thisAction.addFrame.frameSizeX;
+ frame.frameSizeY = thisAction.addFrame.frameSizeY;
+
+ frame.location = thisAction.addFrame.location;
+ frame.src = thisAction.addFrame.src;
+
+ // set other properties
+
+ frame.animationScale = 0;
+ frame.begin = realityEditor.gui.ar.utilities.newIdentityMatrix();
+ frame.width = frame.frameSizeX;
+ frame.height = frame.frameSizeY;
+ frame.loaded = false;
+ // frame.objectVisible = true;
+ frame.screen = {
+ x: frame.ar.x,
+ y: frame.ar.y,
+ scale: frame.ar.scale,
+ matrix: frame.ar.matrix
+ };
+ // frame.screenX = 0;
+ // frame.screenY = 0;
+ frame.screenZ = 1000;
+ frame.temp = realityEditor.gui.ar.utilities.newIdentityMatrix();
+
+ // thisFrame.objectVisible = false; // gets set to false in draw.setObjectVisible function
+ frame.fullScreen = false;
+ frame.sendMatrix = false;
+ frame.sendMatrices = {
+ model: false,
+ view: false,
+ modelView : false,
+ devicePose : false,
+ groundPlane : false,
+ anchoredModelView: false,
+ allObjects : false
+ };
+ frame.sendScreenPosition = false;
+ frame.sendAcceleration = false;
+ frame.integerVersion = 300; //parseInt(objects[objectKey].version.replace(/\./g, ""));
+ // thisFrame.visible = false;
+
+ var nodeNames = thisAction.addFrame.nodeNames;
+ nodeNames.forEach(function(nodeName) {
+ var nodeUuid = frameID + nodeName;
+ frame.nodes[nodeUuid] = new Node();
+ var addedNode = frame.nodes[nodeUuid];
+ addedNode.objectId = thisAction.addFrame.objectID;
+ addedNode.frameId = frameID;
+ addedNode.name = nodeName;
+ addedNode.text = undefined;
+ addedNode.type = 'node';
+ addedNode.x = 0; //realityEditor.utilities.randomIntInc(0, 200) - 100;
+ addedNode.y = 0; //realityEditor.utilities.randomIntInc(0, 200) - 100;
+ addedNode.frameSizeX = 100;
+ addedNode.frameSizeY = 100;
+
+ });
+
+ thisObject.frames[frameID] = frame;
+
+ }
+
+
+ // if (objects) {
+ // var thisObject = objects[thisAction.addFrame.objectID];
+ //
+ //
+ //
+ // var urlEndpoint = (realityEditor.network.useHTTPS ? 'https' : 'http') + '://' + objects[thisAction.reloadObject.object].ip + ':' + httpPort + '/object/' + thisAction.reloadObject.object;
+ // this.getData(thisAction.reloadObject.object, thisAction.reloadObject.frame, null, urlEndpoint, function (objectKey, frameKey, nodeKey, res) {
+ //
+ // // }
+ // // this.getData((realityEditor.network.useHTTPS ? 'https' : 'http') + '://' + objects[thisAction.reloadObject.object].ip + ':' + httpPort + '/object/' + thisAction.reloadObject.object, thisAction.reloadObject.object, function (req, thisKey) {
+ //
+ // if (objects[objectKey].integerVersion < 170) {
+ // if (typeof res.objectValues !== "undefined") {
+ // res.nodes = res.objectValues;
+ // }
+ // }
+ //
+ // console.log("updateObject", objects[objectKey], res, objectKey, frameKey);
+ //
+ //
+ // _this.cout("got object");
+ //
+ // });
+ // }
+ }
+
+ for (let key in thisAction) {
+ this.cout("found action: " + JSON.stringify(key));
+ }
+};
+
+realityEditor.network.reloadFrame = function(objectKey, frameKey, fullActionMessage) {
+ let thisObject = realityEditor.getObject(objectKey);
+ let thisFrame = realityEditor.getFrame(objectKey, frameKey);
+ if (!thisObject || !thisFrame) return;
+
+ let urlEndpoint = realityEditor.network.getURL(thisObject.ip, realityEditor.network.getPort(thisObject), '/object/' + objectKey + '/frame/' + frameKey);
+ this.getData(objectKey, frameKey, null, urlEndpoint, (objectKey, frameKey, nodeKey, res) => {
+
+ let propertiesToIgnore = fullActionMessage.reloadFrame.propertiesToIgnore;
+ let wasTriggeredFromEditor = fullActionMessage.reloadFrame.wasTriggeredFromEditor;
+
+ for (let thisKey in res) {
+ if (!res.hasOwnProperty(thisKey)) continue;
+ if (!thisFrame.hasOwnProperty(thisKey)) continue;
+ if (propertiesToIgnore) {
+ if (propertiesToIgnore.indexOf(thisKey) > -1) continue;
+
+ if (thisFrame.ar.x !== res.ar.x || thisFrame.ar.y !== res.ar.y || !realityEditor.gui.ar.utilities.isEqualStrict(thisFrame.ar.matrix, res.ar.matrix)) {
+ // todo Steve: find a way to store & compare the original & updated positions of the frame, and trigger framePosition event here
+ // realityEditor.network.callbackHandler.triggerCallbacks('frameRepositioned', {objectKey: thisFrame.objectId, frameKey: thisFrame.uuid, nodeKey: null, additionalInfo: {frameType: thisFrame.src, avatarName: realityEditor.avatar.getAvatarObjectKeyFromSessionId(thisAction.lastEditor)}});
+ }
+
+ // TODO: this is a temp fix to just ignore ar.x and ar.y but still send scale... find a more general way
+ if (thisKey === 'ar' &&
+ propertiesToIgnore.indexOf('ar.x') > -1 &&
+ propertiesToIgnore.indexOf('ar.y') > -1) {
+
+ // this wasn't scaled -> update the x and y but not the scale
+ if (thisFrame.ar.scale === res.ar.scale && !wasTriggeredFromEditor) {
+ thisFrame.ar.x = res.ar.x;
+ thisFrame.ar.y = res.ar.y;
+ } else {
+ // this was scaled -> update the scale but not the x and y
+ thisFrame.ar.scale = res.ar.scale;
+ }
+ continue;
+ }
+
+ // only rewrite existing properties of nodes, otherwise node.loaded gets removed and another element added
+ if (thisKey === 'nodes') {
+ for (let nodeKey in res.nodes) {
+ if (!thisFrame.nodes.hasOwnProperty(nodeKey)) {
+ thisFrame.nodes[nodeKey] = res.nodes[nodeKey];
+ } else {
+ for (let propertyKey in res.nodes[nodeKey]) {
+ if (propertyKey === 'loaded') { continue; }
+ thisFrame.nodes[nodeKey][propertyKey] = res.nodes[nodeKey][propertyKey];
+ }
+ }
+ }
+ continue;
+ }
+ }
+
+ thisFrame[thisKey] = res[thisKey];
+ }
+
+ realityEditor.gui.ar.grouping.reconstructGroupStruct(frameKey, thisFrame);
+ }, { bypassCache: true });
+}
+
+/**
+ * Gets triggered when an iframe makes a POST request to communicate with the Reality Editor via the object.js API
+ * Also gets triggered when the settings.html (or other menus) makes a POST request
+ * Modules can subscribe to these events by using realityEditor.network.addPostMessageHandler, in addition to the many
+ * events already hard-coded into this method (todo: better organize these and move/distribute to the related modules)
+ * @param {object|string} e - stringified or parsed event (works for either format)
+ */
+realityEditor.network.onInternalPostMessage = function (e) {
+ var msgContent = {};
+
+ // catch error when safari sends a misc event
+ if (typeof e === 'object' && typeof e.data === 'object') {
+ msgContent = e.data;
+
+ } else if (e.data && typeof e.data !== 'object') {
+ msgContent = JSON.parse(e.data);
+ } else {
+ msgContent = JSON.parse(e);
+ }
+
+ // iterates over all registered postMessageHandlers to trigger events in various modules
+ this.postMessageHandlers.forEach(function(messageHandler) {
+ if (typeof msgContent[messageHandler.messageName] !== 'undefined') {
+ messageHandler.callback(msgContent[messageHandler.messageName], msgContent);
+ }
+ });
+
+ if (typeof msgContent.settings !== "undefined") {
+ realityEditor.network.onSettingPostMessage(msgContent);
+ return;
+ }
+
+ if (typeof msgContent.foundObjectsButton !== 'undefined') {
+ realityEditor.network.onFoundObjectButtonMessage(msgContent);
+ return;
+ }
+
+ if (msgContent.resendOnElementLoad) {
+ var elt = document.getElementById('iframe' + msgContent.nodeKey);
+ if (elt) {
+ var data = elt.dataset;
+ realityEditor.network.onElementLoad(data.objectKey, data.frameKey, data.nodeKey);
+ }
+ }
+
+ var tempThisObject = {};
+ var thisVersionNumber = msgContent.version || 0; // defaults to 0 if no version included
+
+ if (thisVersionNumber >= 170) {
+ if ((!msgContent.object) || (!msgContent.object)) return; // TODO: is this a typo? checks identical condition twice
+ } else {
+ if ((!msgContent.obj) || (!msgContent.pos)) return;
+ msgContent.object = msgContent.obj;
+ msgContent.frame = msgContent.obj;
+ msgContent.node = msgContent.pos;
+ }
+
+ // var thisFrame = realityEditor.getFrame(msgContent.object, msgContent.frame);
+ // var thisNode = realityEditor.getNode(msgContent.node);
+ // var activeVehicle = thisNode || thisFrame;
+
+ // var activeKey = null;
+
+ if (msgContent.node) {
+ tempThisObject = realityEditor.getNode(msgContent.object, msgContent.frame, msgContent.node);
+ } else if (msgContent.frame) {
+ tempThisObject = realityEditor.getFrame(msgContent.object, msgContent.frame);
+ } else if (msgContent.object) {
+ tempThisObject = realityEditor.getObject(msgContent.object);
+ }
+
+ // make it work for pocket items too
+ if (!tempThisObject && msgContent.object && msgContent.object in pocketItem) {
+ if (msgContent.node && msgContent.frame) {
+ tempThisObject = pocketItem[msgContent.object].frames[msgContent.frame].nodes[msgContent.node];
+ } else if (msgContent.frame) {
+ tempThisObject = pocketItem[msgContent.object].frames[msgContent.frame];
+ } else {
+ tempThisObject = pocketItem[msgContent.object];
+ }
+ }
+
+ if (msgContent.frame && !tempThisObject) {
+ console.warn('The tool that sent this message doesn\'t exist - ignore the message', msgContent);
+ return;
+ }
+
+ tempThisObject = tempThisObject || {};
+
+ if (msgContent.width && msgContent.height) {
+ let activeKey = msgContent.node ? msgContent.node : msgContent.frame;
+
+ var overlay = document.getElementById(activeKey);
+ var iFrame = document.getElementById('iframe' + activeKey);
+ var svg = document.getElementById('svg' + activeKey);
+
+ var top = ((globalStates.width - msgContent.height) / 2);
+ var left = ((globalStates.height - msgContent.width) / 2);
+ overlay.style.width = msgContent.width;
+ overlay.style.height = msgContent.height;
+ overlay.style.top = top;
+ overlay.style.left = left;
+
+ iFrame.style.width = msgContent.width;
+ iFrame.style.height = msgContent.height;
+ iFrame.style.top = top;
+ iFrame.style.left = left;
+
+ let vehicle = realityEditor.getVehicle(msgContent.object, msgContent.frame, msgContent.node);
+ if (vehicle) {
+ vehicle.frameSizeX = msgContent.width;
+ vehicle.frameSizeY = msgContent.height;
+ vehicle.width = msgContent.width;
+ vehicle.height = msgContent.height;
+ }
+
+ if (svg) {
+ svg.style.width = msgContent.width;
+ svg.style.height = msgContent.height;
+
+ realityEditor.gui.ar.moveabilityOverlay.createSvg(svg);
+ }
+
+
+ if (globalStates.editingMode || realityEditor.device.getEditingVehicle() === tempThisObject) {
+ // svg.style.display = 'inline';
+ // svg.classList.add('visibleEditingSVG');
+
+ overlay.querySelector('.corners').style.visibility = 'visible';
+
+ } else {
+ // svg.style.display = 'none';
+ // svg.classList.remove('visibleEditingSVG');
+
+ overlay.querySelector('.corners').style.visibility = 'hidden';
+
+ }
+ }
+
+ // Forward the touch events from the nodes to the overall touch event collector
+
+ if (typeof msgContent.eventObject !== "undefined") {
+
+ if(msgContent.eventObject.type === "touchstart"){
+ realityEditor.device.touchInputs.screenTouchStart(msgContent.eventObject);
+ } else if(msgContent.eventObject.type === "touchend"){
+ realityEditor.device.touchInputs.screenTouchEnd(msgContent.eventObject);
+ } else if(msgContent.eventObject.type === "touchmove"){
+ realityEditor.device.touchInputs.screenTouchMove(msgContent.eventObject);
+ }
+ return;
+ }
+
+ if (typeof msgContent.screenObject !== "undefined") {
+ realityEditor.gui.screenExtension.receiveObject(msgContent.screenObject);
+ }
+
+ if (typeof msgContent.sendScreenObject !== "undefined") {
+ if(msgContent.sendScreenObject){
+ realityEditor.gui.screenExtension.registeredScreenObjects[msgContent.frame] = {
+ object : msgContent.object,
+ frame : msgContent.frame,
+ node: msgContent.node
+ };
+ realityEditor.gui.screenExtension.visibleScreenObjects[msgContent.frame] = {
+ object: msgContent.object,
+ frame: msgContent.frame,
+ node: msgContent.node,
+ x: 0,
+ y: 0
+ };
+ }
+ }
+
+ if (msgContent.sendMatrix === true) {
+ if (tempThisObject.integerVersion >= 32) {
+ tempThisObject.sendMatrix = true;
+ let activeKey = msgContent.node ? msgContent.node : msgContent.frame;
+ if (activeKey === msgContent.frame) { // only send these into frames, not nodes
+ // send the projection matrix into the iframe (e.g. for three.js to use)
+ globalDOMCache["iframe" + activeKey].contentWindow.postMessage(
+ '{"projectionMatrix":' + JSON.stringify(globalStates.realProjectionMatrix) + "}", '*');
+ }
+ }
+ }
+
+ if (typeof msgContent.sendMatrices !== "undefined") {
+ if (msgContent.sendMatrices.model === true || msgContent.sendMatrices.view === true) {
+ if (tempThisObject.integerVersion >= 32) {
+ if(!tempThisObject.sendMatrices) tempThisObject.sendMatrices = {};
+ tempThisObject.sendMatrices.model = msgContent.sendMatrices.model;
+ tempThisObject.sendMatrices.view = msgContent.sendMatrices.view;
+ let activeKey = msgContent.node ? msgContent.node : msgContent.frame;
+ if (activeKey === msgContent.frame) {
+ globalDOMCache["iframe" + activeKey].contentWindow.postMessage(
+ '{"projectionMatrix":' + JSON.stringify(globalStates.realProjectionMatrix) + "}", '*');
+ }
+ }
+ }
+ if (msgContent.sendMatrices.groundPlane === true) {
+ if (tempThisObject.integerVersion >= 32) {
+ if(!tempThisObject.sendMatrices) tempThisObject.sendMatrices = {};
+ tempThisObject.sendMatrices.groundPlane = true;
+ let activeKey = msgContent.node ? msgContent.node : msgContent.frame;
+ if (activeKey === msgContent.frame) {
+ globalDOMCache["iframe" + activeKey].contentWindow.postMessage(
+ '{"projectionMatrix":' + JSON.stringify(globalStates.realProjectionMatrix) + "}", '*');
+ }
+ }
+ }
+ if (msgContent.sendMatrices.anchoredModelView === true) {
+ if (tempThisObject.integerVersion >= 32) {
+ if(!tempThisObject.sendMatrices) tempThisObject.sendMatrices = {};
+ tempThisObject.sendMatrices.anchoredModelView = true;
+ let activeKey = msgContent.node ? msgContent.node : msgContent.frame;
+ if (activeKey === msgContent.frame) {
+ globalDOMCache["iframe" + activeKey].contentWindow.postMessage(
+ '{"projectionMatrix":' + JSON.stringify(globalStates.realProjectionMatrix) + "}", '*');
+ }
+ }
+ }
+ if (msgContent.sendMatrices.devicePose === true) {
+ if (tempThisObject.integerVersion >= 32) {
+ if(!tempThisObject.sendMatrices) tempThisObject.sendMatrices = {};
+ tempThisObject.sendMatrices.devicePose = true;
+ let activeKey = msgContent.node ? msgContent.node : msgContent.frame;
+ if (activeKey === msgContent.frame) {
+ // send the projection matrix into the iframe (e.g. for three.js to use)
+ globalDOMCache["iframe" + activeKey].contentWindow.postMessage(
+ '{"projectionMatrix":' + JSON.stringify(globalStates.realProjectionMatrix) + "}", '*');
+ }
+ }
+ }
+ if (msgContent.sendMatrices.allObjects === true) {
+ if (tempThisObject.integerVersion >= 32) {
+ if(!tempThisObject.sendMatrices) tempThisObject.sendMatrices = {};
+ tempThisObject.sendMatrices.allObjects = true;
+ let activeKey = msgContent.node ? msgContent.node : msgContent.frame;
+ if (activeKey === msgContent.frame) {
+ // send the projection matrix into the iframe (e.g. for three.js to use)
+ globalDOMCache["iframe" + activeKey].contentWindow.postMessage(
+ '{"projectionMatrix":' + JSON.stringify(globalStates.realProjectionMatrix) + "}", '*');
+ }
+ }
+ }
+
+ let isGroundPlaneVisualizerEnabled = realityEditor.gui.settings.toggleStates.visualizeGroundPlane;
+ globalStates.useGroundPlane = realityEditor.gui.ar.draw.doesAnythingUseGroundPlane() ||
+ isGroundPlaneVisualizerEnabled ||
+ realityEditor.gui.settings.toggleStates.repositionGroundAnchors;
+ realityEditor.app.callbacks.startGroundPlaneTrackerIfNeeded();
+ }
+
+ if (msgContent.sendScreenPosition === true) {
+ if (tempThisObject.integerVersion >= 32) {
+ tempThisObject.sendScreenPosition = true;
+ }
+ }
+
+ if (msgContent.sendDeviceDistance) {
+ tempThisObject.sendDeviceDistance = msgContent.sendDeviceDistance;
+ }
+
+ if (typeof msgContent.sendObjectPositions !== 'undefined') {
+ tempThisObject.sendObjectPositions = msgContent.sendObjectPositions;
+ }
+
+ if (msgContent.sendAcceleration === true) {
+
+ if (tempThisObject.integerVersion >= 32) {
+
+ tempThisObject.sendAcceleration = true;
+
+ if (globalStates.sendAcceleration === false) {
+ globalStates.sendAcceleration = true;
+ if (window.DeviceMotionEvent) {
+ window.addEventListener("deviceorientation", function () {
+
+ });
+
+ window.addEventListener("devicemotion", function (event) {
+
+ var thisState = globalStates.acceleration;
+
+ thisState.x = event.acceleration.x;
+ thisState.y = event.acceleration.y;
+ thisState.z = event.acceleration.z;
+
+ thisState.alpha = event.rotationRate.alpha;
+ thisState.beta = event.rotationRate.beta;
+ thisState.gamma = event.rotationRate.gamma;
+
+ // Manhattan Distance :-D
+ thisState.motion =
+ Math.abs(thisState.x) +
+ Math.abs(thisState.y) +
+ Math.abs(thisState.z) +
+ Math.abs(thisState.alpha) +
+ Math.abs(thisState.beta) +
+ Math.abs(thisState.gamma);
+
+ }, false);
+ }
+ }
+ }
+ }
+
+ if (msgContent.globalMessage) {
+ var iframes = document.getElementsByTagName('iframe');
+ for (let i = 0; i < iframes.length; i++) {
+
+ if (iframes[i].id !== "iframe" + msgContent.node && iframes[i].style.visibility !== "hidden") {
+ var objectKey = iframes[i].getAttribute("data-object-key");
+ if (objectKey) {
+ var receivingObject = (objectKey === 'pocket') ? (pocketItem[objectKey]) : objects[objectKey];
+ if (receivingObject.integerVersion >= 32) {
+ var msg = {};
+ if (receivingObject.integerVersion >= 170) {
+ msg = {globalMessage: msgContent.globalMessage};
+ } else {
+ msg = {ohGlobalMessage: msgContent.ohGlobalMessage};
+ }
+ iframes[i].contentWindow.postMessage(JSON.stringify(msg), "*");
+ }
+ }
+ }
+ }
+ }
+
+ if (msgContent.sendMessageToFrame) {
+
+ var iframe = globalDOMCache['iframe' + msgContent.sendMessageToFrame.destinationFrame];
+ if (iframe) {
+ iframe.contentWindow.postMessage(JSON.stringify(msgContent), '*');
+ }
+
+ // var iframes = document.getElementsByTagName('iframe');
+ // for (var i = 0; i < iframes.length; i++) {
+ //
+ // if (iframes[i].id !== "iframe" + msgContent.node && iframes[i].style.visibility !== "hidden") {
+ // var objectKey = iframes[i].getAttribute("data-object-key");
+ // if (objectKey) {
+ // var receivingObject = (objectKey === 'pocket') ? (pocketItem[objectKey]) : objects[objectKey];
+ // if (receivingObject.integerVersion >= 32) {
+ // var msg = {};
+ // if (receivingObject.integerVersion >= 170) {
+ // msg = {globalMessage: msgContent.globalMessage};
+ // } else {
+ // msg = {ohGlobalMessage: msgContent.ohGlobalMessage};
+ // }
+ // iframes[i].contentWindow.postMessage(JSON.stringify(msg), "*");
+ // }
+ // }
+ // }
+ // }
+ }
+
+ if (typeof msgContent.alwaysFaceCamera === 'boolean') {
+ tempThisObject.alwaysFaceCamera = msgContent.alwaysFaceCamera;
+ }
+
+ if (typeof msgContent.fullScreen === "boolean") {
+ if (msgContent.fullScreen === true) {
+
+ tempThisObject.fullScreen = true;
+
+ if (msgContent.fullscreenZPosition) {
+ tempThisObject.fullscreenZPosition = msgContent.fullscreenZPosition;
+ }
+
+ let zIndex = tempThisObject.fullscreenZPosition || globalStates.defaultFullscreenFrameZ; // defaults to background
+
+ document.getElementById("object" + msgContent.frame).style.transform =
+ 'matrix3d(1, 0, 0, 0,' +
+ '0, 1, 0, 0,' +
+ '0, 0, 1, 0,' +
+ '0, 0, ' + zIndex + ', 1)';
+
+ globalDOMCache[tempThisObject.uuid].dataset.leftBeforeFullscreen = globalDOMCache[tempThisObject.uuid].style.left;
+ globalDOMCache[tempThisObject.uuid].dataset.topBeforeFullscreen = globalDOMCache[tempThisObject.uuid].style.top;
+
+ globalDOMCache[tempThisObject.uuid].style.opacity = '0'; // svg overlay still exists so we can reposition, but invisible
+ globalDOMCache[tempThisObject.uuid].style.left = '0';
+ globalDOMCache[tempThisObject.uuid].style.top = '0';
+
+ globalDOMCache['iframe' + tempThisObject.uuid].dataset.leftBeforeFullscreen = globalDOMCache['iframe' + tempThisObject.uuid].style.left;
+ globalDOMCache['iframe' + tempThisObject.uuid].dataset.topBeforeFullscreen = globalDOMCache['iframe' + tempThisObject.uuid].style.top;
+
+ globalDOMCache['iframe' + tempThisObject.uuid].style.left = '0';
+ globalDOMCache['iframe' + tempThisObject.uuid].style.top = '0';
+ globalDOMCache['iframe' + tempThisObject.uuid].style.margin = '-2px';
+
+ globalDOMCache['iframe' + tempThisObject.uuid].classList.add('webGlFrame');
+
+ globalDOMCache['object' + tempThisObject.uuid].style.zIndex = zIndex;
+
+ if (realityEditor.device.editingState.frame === msgContent.frame) {
+ realityEditor.device.resetEditingState();
+ realityEditor.device.clearTouchTimer();
+ }
+
+ // check if this requiresExclusive, and there is already an exclusive one, then kick that out of fullscreen
+ if (tempThisObject.isFullScreenExclusive) {
+ realityEditor.gui.ar.draw.ensureOnlyCurrentFullscreen(msgContent.object, msgContent.frame);
+ }
+
+ }
+ if (msgContent.fullScreen === false) {
+ if (!msgContent.node) { // ignore messages from nodes of this frame
+ realityEditor.gui.ar.draw.removeFullscreenFromFrame(msgContent.object, msgContent.frame, msgContent.fullScreenAnimated);
+ realityEditor.envelopeManager.hideBlurredBackground(msgContent.frame);
+ }
+ }
+
+ // update containsStickyFrame property on object whenever this changes, so that we dont have to recompute every frame
+ let object = realityEditor.getObject(msgContent.object);
+ if (object) {
+ object.containsStickyFrame = realityEditor.gui.ar.draw.doesObjectContainStickyFrame(msgContent.object);
+ }
+
+ } else if(typeof msgContent.fullScreen === "string") {
+ if (msgContent.fullScreen === "sticky") {
+
+ tempThisObject.fullScreen = "sticky";
+
+ if (msgContent.fullscreenZPosition) {
+ tempThisObject.fullscreenZPosition = msgContent.fullscreenZPosition;
+ }
+
+ // z-index can be specified. if not, goes to background if not full2D, foreground if full2D
+ let zIndex = tempThisObject.fullscreenZPosition ||
+ (msgContent.fullScreenFull2D || tempThisObject.isFullScreenFull2D) ?
+ globalStates.defaultFullscreenFull2DFrameZ : globalStates.defaultFullscreenFrameZ;
+
+ if (typeof msgContent.fullScreenAnimated !== 'undefined') {
+
+ const parentDiv = globalDOMCache['object' + msgContent.frame];
+ let tempAnimDiv = document.createElement('div');
+ tempAnimDiv.classList.add('temp-anim-div');
+ tempAnimDiv.style.transform = globalDOMCache['object' + msgContent.frame].style.transform;
+ tempAnimDiv.style.width = globalDOMCache['object' + msgContent.frame].childNodes[0].style.width;
+ tempAnimDiv.style.height = globalDOMCache['object' + msgContent.frame].childNodes[0].style.height;
+ tempAnimDiv.style.top = globalDOMCache['object' + msgContent.frame].childNodes[0].style.top;
+ tempAnimDiv.style.left = globalDOMCache['object' + msgContent.frame].childNodes[0].style.left;
+ document.getElementById('GUI').appendChild(tempAnimDiv);
+ setTimeout(() => {
+ // To obtain this hard-coded matrix3d(), I added a tool, closed it to reveal the icon, and moved the camera towards the tool,
+ // so that it almost fills up the screen in the center. And then I get the matrix3d of the object that the tool is attached to.
+ // Very hacky, hope to make it procedural in the future
+ tempAnimDiv.style.transform = 'matrix3d(643.374, -0.373505, 0.000212662, 0.000212647, 0.372554, 643.38, 0.000554764, 0.000554727, -2.77404, 4.28636, 0.500033, 0.5, -1406.67, 2173.54, 34481.6, 253.541)';
+ tempAnimDiv.style.top = '0';
+ tempAnimDiv.style.left = '0';
+ tempAnimDiv.style.width = parentDiv.style.width;
+ tempAnimDiv.style.height = parentDiv.style.height;
+ tempAnimDiv.classList.add('temp-anim-div-anim');
+ setTimeout(() => {
+ tempAnimDiv.parentElement.removeChild(tempAnimDiv);
+ }, 500);
+ }, 10);
+ }
+
+ if (typeof msgContent.fullScreenFull2D !== 'undefined') {
+ if (msgContent.fullScreenFull2D) {
+ tempThisObject.isFullScreenFull2D = true; // if "sticky" fullscreen, gets called multiple times, so need to store in the frame
+ realityEditor.envelopeManager.showBlurredBackground(msgContent.frame);
+ } else {
+ tempThisObject.isFullScreenFull2D = false;
+ realityEditor.envelopeManager.hideBlurredBackground(msgContent.frame);
+ }
+ }
+
+ // make the div invisible while it switches to fullscreen mode, so we don't see a jump in content vs mode
+ document.getElementById("object" + msgContent.frame).classList.add('transitioningToFullscreen');
+ setTimeout(function() {
+ document.getElementById("object" + msgContent.frame).classList.remove('transitioningToFullscreen');
+ }, 200);
+
+ document.getElementById("object" + msgContent.frame).style.transform =
+ 'matrix3d(1, 0, 0, 0,' +
+ '0, 1, 0, 0,' +
+ '0, 0, 1, 0,' +
+ '0, 0, ' + zIndex + ', 1)';
+
+ globalDOMCache[tempThisObject.uuid].dataset.leftBeforeFullscreen = globalDOMCache[tempThisObject.uuid].style.left;
+ globalDOMCache[tempThisObject.uuid].dataset.topBeforeFullscreen = globalDOMCache[tempThisObject.uuid].style.top;
+
+ globalDOMCache[tempThisObject.uuid].style.opacity = '0';
+ globalDOMCache[tempThisObject.uuid].style.left = '0';
+ globalDOMCache[tempThisObject.uuid].style.top = '0';
+
+ globalDOMCache['iframe' + tempThisObject.uuid].dataset.leftBeforeFullscreen = globalDOMCache['iframe' + tempThisObject.uuid].style.left;
+ globalDOMCache['iframe' + tempThisObject.uuid].dataset.topBeforeFullscreen = globalDOMCache['iframe' + tempThisObject.uuid].style.top;
+
+ globalDOMCache['iframe' + tempThisObject.uuid].style.left = '0';
+ globalDOMCache['iframe' + tempThisObject.uuid].style.top = '0';
+ globalDOMCache['iframe' + tempThisObject.uuid].style.margin = '-2px';
+
+ globalDOMCache['iframe' + tempThisObject.uuid].classList.add('webGlFrame');
+
+ globalDOMCache['object' + tempThisObject.uuid].style.zIndex = zIndex;
+
+ // update containsStickyFrame property on object whenever this changes, so that we dont have to recompute every frame
+ let object = realityEditor.getObject(msgContent.object);
+ if (object) {
+ object.containsStickyFrame = true;
+ }
+
+ // check if this requiresExclusive, and there is already an exclusive one, then kick that out of fullscreen
+ if (tempThisObject.isFullScreenExclusive) {
+ realityEditor.gui.ar.draw.ensureOnlyCurrentFullscreen(msgContent.object, msgContent.frame);
+ }
+ }
+ }
+
+ if (typeof msgContent.full2D !== 'undefined') {
+ if (msgContent.full2D) {
+ // this is useful to make tools from external sites bigger, since we can't manually scale them while full2D is enabled
+ const UPDATE_SCALE_OF_FULL2D_TOOLS = true;
+ if (UPDATE_SCALE_OF_FULL2D_TOOLS) {
+ let activeVehicle = realityEditor.getFrame(msgContent.object, msgContent.frame);
+ realityEditor.gui.ar.positioning.setVehicleScale(activeVehicle, 3.0);
+ }
+ if (globalDOMCache[msgContent.frame]) {
+ globalDOMCache[msgContent.frame].classList.add('deactivatedIframeOverlay');
+ }
+ } else {
+ if (globalDOMCache[msgContent.frame]) {
+ globalDOMCache[msgContent.frame].classList.remove('deactivatedIframeOverlay');
+ }
+ }
+ }
+
+ if(typeof msgContent.stickiness === "boolean") {
+ tempThisObject.stickiness = msgContent.stickiness;
+ }
+
+ if (typeof msgContent.isFullScreenExclusive !== "undefined") {
+ tempThisObject.isFullScreenExclusive = msgContent.isFullScreenExclusive;
+
+ // check if this requiresExclusive, and there is already an exclusive one, then kick that out of fullscreen
+ if (tempThisObject.isFullScreenExclusive) {
+ realityEditor.gui.ar.draw.ensureOnlyCurrentFullscreen(msgContent.object, msgContent.frame);
+ }
+ }
+
+ if (typeof msgContent.getIsExclusiveFullScreenOccupied !== "undefined") {
+ if (globalDOMCache['iframe' + msgContent.frame]) {
+ globalDOMCache['iframe' + msgContent.frame].contentWindow.postMessage(JSON.stringify({
+ fullScreenOccupiedStatus: realityEditor.gui.ar.draw.getAllVisibleExclusiveFrames().length > 0
+ }), '*');
+ }
+ }
+
+ if (typeof msgContent.nodeIsFullScreen !== 'undefined') {
+ let nodeName = msgContent.nodeName;
+
+ let thisNodeKey = null;
+ Object.keys(tempThisObject.nodes).map(function(nodeKey) {
+ if (tempThisObject.nodes[nodeKey].name === nodeName) {
+ thisNodeKey = nodeKey;
+ }
+ });
+
+ if (thisNodeKey) {
+ this.setNodeFullScreen(tempThisObject.objectId, tempThisObject.uuid, nodeName, msgContent);
+ } else {
+ this.addPendingNodeAdjustment(tempThisObject.objectId, tempThisObject.uuid, nodeName, JSON.parse(JSON.stringify(msgContent)));
+ }
+ }
+
+ if (typeof msgContent.moveNode !== "undefined") {
+ let thisFrame = realityEditor.getFrame(msgContent.object, msgContent.frame);
+
+ // move each node within this frame with a matching name to the provided x,y coordinates
+ Object.keys(thisFrame.nodes).map(function(nodeKey) {
+ return thisFrame.nodes[nodeKey];
+ }).filter(function(node) {
+ return node.name === msgContent.moveNode.name;
+ }).forEach(function(node) {
+ node.x = (msgContent.moveNode.x) || 0;
+ node.y = (msgContent.moveNode.y) || 0;
+
+ var positionData = realityEditor.gui.ar.positioning.getPositionData(node);
+ var content = {};
+ content.x = positionData.x;
+ content.y = positionData.y;
+ content.scale = positionData.scale;
+
+ content.lastEditor = globalStates.tempUuid;
+ let urlEndpoint = realityEditor.network.getURL(objects[msgContent.object].ip, realityEditor.network.getPort(objects[msgContent.object]), '/object/' + msgContent.object + "/frame/" + msgContent.frame + "/node/" + node.uuid + "/nodeSize/");
+ realityEditor.network.postData(urlEndpoint, content);
+ });
+ }
+
+ if (typeof msgContent.resetNodes !== "undefined") {
+
+ realityEditor.forEachNodeInFrame(msgContent.object, msgContent.frame, function(thisObjectKey, thisFrameKey, thisNodeKey) {
+
+ // delete links to and from the node
+ realityEditor.forEachFrameInAllObjects(function(thatObjectKey, thatFrameKey) {
+ var thatFrame = realityEditor.getFrame(thatObjectKey, thatFrameKey);
+ Object.keys(thatFrame.links).forEach(function(linkKey) {
+ var thisLink = thatFrame.links[linkKey];
+ if (((thisLink.objectA === thisObjectKey) && (thisLink.frameA === thisFrameKey) && (thisLink.nodeA === thisNodeKey)) ||
+ ((thisLink.objectB === thisObjectKey) && (thisLink.frameB === thisFrameKey) && (thisLink.nodeB === thisNodeKey))) {
+ delete thatFrame.links[linkKey];
+ realityEditor.network.deleteLinkFromObject(objects[thatObjectKey].ip, thatObjectKey, thatFrameKey, linkKey);
+ }
+ });
+ });
+
+ // remove it from the DOM
+ realityEditor.gui.ar.draw.deleteNode(thisObjectKey, thisFrameKey, thisNodeKey);
+ // delete it from the server
+ realityEditor.network.deleteNodeFromObject(objects[thisObjectKey].ip, thisObjectKey, thisFrameKey, thisNodeKey);
+
+ });
+
+ }
+
+ if (typeof msgContent.beginTouchEditing !== "undefined") {
+ let activeKey = msgContent.node || msgContent.frame;
+ var element = document.getElementById(activeKey);
+ realityEditor.device.beginTouchEditing(element);
+ }
+
+ if (typeof msgContent.touchEvent !== "undefined") {
+ var event = msgContent.touchEvent;
+ var target = document.getElementById(msgContent.frame);
+ if (!target) {
+ return;
+ }
+ var fakeEvent = {
+ target: target,
+ currentTarget: target,
+ clientX: event.x,
+ clientY: event.y,
+ pageX: event.x,
+ pageY: event.y,
+ preventDefault: function () {
+ }
+ };
+ if (event.type === 'touchend') {
+ realityEditor.device.onDocumentPointerUp(fakeEvent);
+ realityEditor.device.onMultiTouchEnd(fakeEvent);
+ globalStates.tempEditingMode = false;
+ globalStates.unconstrainedSnapInitialPosition = null;
+ realityEditor.device.deactivateFrameMove(msgContent.frame);
+ let frame = globalDOMCache['iframe' + msgContent.frame];
+ if (frame && !msgContent.node) {
+ frame.contentWindow.postMessage(JSON.stringify({
+ stopTouchEditing: true
+ }), "*");
+ }
+ }
+ }
+
+ if (typeof msgContent.visibilityDistance !== "undefined") {
+ let activeVehicle = realityEditor.getFrame(msgContent.object, msgContent.frame);
+
+ activeVehicle.distanceScale = msgContent.visibilityDistance;
+ }
+
+ if (typeof msgContent.moveDelay !== "undefined") {
+ let activeVehicle = realityEditor.getFrame(msgContent.object, msgContent.frame);
+
+ if (activeVehicle) {
+ activeVehicle.moveDelay = msgContent.moveDelay;
+ }
+ }
+
+ if (msgContent.loadLogicIcon) {
+ this.loadLogicIcon(msgContent);
+ }
+
+ if (msgContent.loadLogicName) {
+ this.loadLogicName(msgContent);
+ }
+
+ if (typeof msgContent.publicData !== "undefined") {
+
+ let frame = realityEditor.getFrame(msgContent.object, msgContent.frame);
+ let node = realityEditor.getNode(msgContent.object, msgContent.frame, msgContent.node);
+
+ if (frame && node) {
+ if (!publicDataCache.hasOwnProperty(msgContent.frame)) {
+ publicDataCache[msgContent.frame] = {};
+ }
+ publicDataCache[msgContent.frame][node.name] = msgContent.publicData;
+ frame.publicData = msgContent.publicData;
+ node.publicData = JSON.parse(JSON.stringify(msgContent.publicData));
+
+ var TEMP_DISABLE_REALTIME_PUBLIC_DATA = true;
+
+ if (!TEMP_DISABLE_REALTIME_PUBLIC_DATA) {
+ var keys = realityEditor.getKeysFromVehicle(frame);
+ realityEditor.network.realtime.broadcastUpdate(keys.objectKey, keys.frameKey, keys.nodeKey, 'publicData', msgContent.publicData);
+ }
+ }
+
+ }
+
+ if (typeof msgContent.videoRecording !== "undefined") {
+ if (msgContent.videoRecording) {
+ realityEditor.device.videoRecording.startRecordingForFrame(msgContent.object, msgContent.frame);
+ } else {
+ realityEditor.device.videoRecording.stopRecordingForFrame(msgContent.object, msgContent.frame);
+ }
+ }
+
+ if (typeof msgContent.virtualizerRecording !== "undefined") {
+ if (msgContent.virtualizerRecording) {
+ realityEditor.device.videoRecording.startVirtualizerRecording();
+ } else {
+ realityEditor.device.videoRecording.stopVirtualizerRecording((baseUrl, recordingId, deviceId) => {
+ const thisMsg = {
+ virtualizerRecordingData: {
+ baseUrl,
+ recordingId,
+ deviceId
+ }
+ };
+ globalDOMCache["iframe" + msgContent.frame].contentWindow.postMessage(JSON.stringify(thisMsg), '*');
+ });
+ }
+ }
+
+ if (typeof msgContent.getScreenshotBase64 !== "undefined") {
+ realityEditor.network.frameIdForScreenshot = msgContent.frame;
+ realityEditor.app.getSnapshot("S", function(base64String) {
+ var thisMsg = {
+ getScreenshotBase64: base64String
+ // frameKey: realityEditor.network.frameIdForScreenshot
+ };
+ globalDOMCache["iframe" + realityEditor.network.frameIdForScreenshot].contentWindow.postMessage(JSON.stringify(thisMsg), '*');
+ });
+ }
+
+ if (typeof msgContent.openKeyboard !== "undefined") {
+ if (msgContent.openKeyboard) {
+ realityEditor.device.keyboardEvents.openKeyboard();
+ } else {
+ realityEditor.device.keyboardEvents.closeKeyboard();
+ }
+ }
+
+ if (typeof msgContent.ignoreAllTouches !== "undefined") {
+ let frame = realityEditor.getFrame(msgContent.object, msgContent.frame);
+ frame.ignoreAllTouches = msgContent.ignoreAllTouches;
+ }
+
+ if (typeof msgContent.getScreenDimensions !== "undefined") {
+ globalDOMCache["iframe" + msgContent.frame].contentWindow.postMessage(JSON.stringify({
+ screenDimensions: {
+ width: globalStates.height,
+ height: globalStates.width
+ }
+ }), '*');
+ }
+
+ // adjusts the iframe and touch overlay size based on a message from the iframe about the size of its contents changing
+ if (typeof msgContent.changeFrameSize !== 'undefined') {
+ let width = msgContent.changeFrameSize.width;
+ let height = msgContent.changeFrameSize.height;
+
+ let iFrame = document.getElementById('iframe' + msgContent.frame);
+ let overlay = document.getElementById(msgContent.frame);
+
+ iFrame.style.width = width + 'px';
+ iFrame.style.height = height + 'px';
+ overlay.style.width = width + 'px';
+ overlay.style.height = height + 'px';
+
+ let cornerPadding = 24;
+ overlay.querySelector('.corners').style.width = width + cornerPadding*2 + 'px';
+ overlay.querySelector('.corners').style.height = height + cornerPadding*2 + 'px';
+ }
+
+ // this is the API that frames can use to define which nodes they should have
+ if (typeof msgContent.initNode !== 'undefined') {
+ let nodeData = msgContent.initNode.nodeData;
+ let nodeKey = msgContent.frame + nodeData.name;
+ realityEditor.network.createNode(msgContent.object, msgContent.frame, nodeKey, nodeData);
+ }
+
+ // this is deprecated alias that can be used instead of initNode
+ if (typeof msgContent.createNode !== "undefined") {
+ let nodeData = msgContent.createNode;
+ let nodeKey = msgContent.frame + nodeData.name;
+ realityEditor.network.createNode(msgContent.object, msgContent.frame, nodeKey, nodeData);
+ }
+
+ // both renderers below are multual exclusive
+ if (typeof msgContent.useWebGlWorker !== 'undefined') {
+ realityEditor.gui.glRenderer.addWebGlProxy(msgContent.frame);
+ } else if (typeof msgContent.useToolRenderer !== 'undefined') {
+ const type = realityEditor.getFrame(msgContent.object, msgContent.frame).src;
+ realityEditor.gui.threejsScene.getInternals().addTool(msgContent.frame, type);
+ }
+
+ if (typeof msgContent.attachesTo !== 'undefined') {
+ let attachesTo = msgContent.attachesTo;
+
+ if (!attachesTo || !(attachesTo.length >= 1)) {
+ return;
+ }
+
+ let object = realityEditor.getObject(msgContent.object);
+
+ // check if this object is incompatible
+ let shouldInclude = false;
+ if (attachesTo.includes('object')) {
+ shouldInclude = true;
+ }
+ if (attachesTo.includes('world')) {
+ if (object.isWorldObject) {
+ shouldInclude = true;
+ }
+ }
+ if (shouldInclude) { return; } // compatible - no need to do anything
+
+ let loyaltyString = attachesTo.includes('object') ? 'object' : (attachesTo.includes('world') ? 'world' : null);
+ realityEditor.sceneGraph.setLoyalty(loyaltyString, msgContent.object, msgContent.frame, msgContent.node);
+ }
+
+ if (typeof msgContent.getWorldId !== 'undefined') {
+ // trigger the getWorldId callback
+ realityEditor.sceneGraph.network.triggerLocalizationCallbacks(msgContent.object);
+ }
+
+ if (typeof msgContent.sendPositionInWorld !== 'undefined') {
+ tempThisObject.sendPositionInWorld = true;
+ }
+
+ if (typeof msgContent.getPositionInWorld !== 'undefined') {
+ let response = {};
+
+ // check what it's best worldId should be
+ let worldObjectId = realityEditor.sceneGraph.getWorldId();
+
+ // only works if its localized against a world object
+ if (worldObjectId) {
+ let toolSceneNode = realityEditor.sceneGraph.getSceneNodeById(msgContent.frame);//.worldMatrix;
+ let worldSceneNode = realityEditor.sceneGraph.getSceneNodeById(worldObjectId);//.worldMatrix;
+ let relativeMatrix = toolSceneNode.getMatrixRelativeTo(worldSceneNode);
+
+ response.getPositionInWorld = {
+ objectId: msgContent.object,
+ worldId: worldObjectId,
+ worldMatrix: relativeMatrix
+ }
+ } else {
+ response.getPositionInWorld = {
+ objectId: msgContent.object,
+ worldId: null,
+ worldMatrix: null
+ }
+ }
+
+ if (globalDOMCache["iframe" + msgContent.frame]) {
+ globalDOMCache["iframe" + msgContent.frame].contentWindow.postMessage(JSON.stringify(response), '*');
+ }
+ }
+
+ if (typeof msgContent.errorNotification !== 'undefined') {
+ let errorMessageText = msgContent.errorNotification;
+ let messageTime = 5000;
+
+ // create UI if needed
+ let errorNotificationUI = document.getElementById('errorNotificationUI');
+ if (!errorNotificationUI) {
+ realityEditor.gui.modal.showBannerNotification(errorMessageText, 'errorNotificationUI', 'errorNotificationText', messageTime);
+ }
+ }
+
+ if (typeof msgContent.setPinned !== "undefined") {
+ realityEditor.network.setPinned(msgContent.object, msgContent.frame, msgContent.setPinned);
+ }
+};
+
+/**
+ * Call this when a tool uses initNode or sendCreateNode to add a new node to the tool
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {Object} nodeData
+ */
+realityEditor.network.createNode = function(objectKey, frameKey, nodeKey, nodeData) {
+ let frame = realityEditor.getFrame(objectKey, frameKey);
+ if (!frame) return;
+ if (typeof frame.nodes[nodeKey] !== 'undefined') return; // don't create the node if it already exists
+
+ let node = new Node();
+ frame.nodes[nodeKey] = node;
+
+ node.objectId = objectKey;
+ node.frameId = frameKey;
+ node.name = nodeData.name;
+
+ function getRandomPosition() {
+ return realityEditor.device.utilities.randomIntInc(0, 200) - 100;
+ }
+
+ // assign properties from the nodeData, but only if they exist
+ node.type = (typeof nodeData.type !== 'undefined' && nodeData.type) || node.type;
+ node.x = (typeof nodeData.x !== 'undefined' && nodeData.x) || getRandomPosition();
+ node.y = (typeof nodeData.y !== 'undefined' && nodeData.y) || getRandomPosition();
+ node.scale = (typeof nodeData.scaleFactor !== 'undefined' && nodeData.scaleFactor) || node.scale;
+ node.data.value = (typeof nodeData.defaultValue !== 'undefined' && nodeData.defaultValue) || node.data.value;
+
+ realityEditor.sceneGraph.addNode(objectKey, frameKey, nodeKey, node);
+
+ if (nodeData.attachToGroundPlane) {
+ realityEditor.sceneGraph.attachToGroundPlane(objectKey, frameKey, nodeKey);
+ }
+
+ // post node to server
+ let object = realityEditor.getObject(objectKey);
+ realityEditor.network.postNewNode(object.ip, objectKey, frameKey, nodeKey, node, (response) => {
+ if (!response.node) return;
+
+ let serverNode = (typeof response.node === 'string') ? JSON.parse(response.node) : response.node;
+ for (let key in serverNode) {
+ node[key] = serverNode[key]; // update local node to match server node
+ }
+
+ // trigger onNodeAddedToFrame callbacks
+ let nodeAddedCallbacks = realityEditor.network.nodeAddedCallbacks;
+ if (nodeAddedCallbacks[objectKey] && nodeAddedCallbacks[objectKey][frameKey]) {
+ nodeAddedCallbacks[objectKey][frameKey].forEach(callback => {
+ if (typeof callback !== 'function') return;
+ callback(nodeKey);
+ });
+ }
+ });
+}
+
+// allow modules to perform an action in response to the iframe loading and spatialInterface.initNode being processed
+// and the user interface posting the node to the server and the server responding with a success
+realityEditor.network.onNodeAddedToFrame = function(objectKey, frameKey, callback) {
+ let nodeAddedCallbacks = realityEditor.network.nodeAddedCallbacks;
+ if (typeof nodeAddedCallbacks[objectKey] === 'undefined') {
+ nodeAddedCallbacks[objectKey] = {};
+ }
+ if (typeof nodeAddedCallbacks[objectKey][frameKey] === 'undefined') {
+ nodeAddedCallbacks[objectKey][frameKey] = [];
+ }
+ nodeAddedCallbacks[objectKey][frameKey].push(callback);
+}
+
+realityEditor.network.setNodeFullScreen = function(objectKey, frameKey, nodeName, msgContent) {
+ let tempThisObject = realityEditor.getFrame(objectKey, frameKey);
+
+ let thisNodeKey = null;
+ Object.keys(tempThisObject.nodes).map(function(nodeKey) {
+ if (tempThisObject.nodes[nodeKey].name === nodeName) {
+ thisNodeKey = nodeKey;
+ }
+ });
+
+ let isFullscreen = msgContent.nodeIsFullScreen;
+
+ let thisNode = tempThisObject.nodes[thisNodeKey];
+ if (thisNode) {
+ thisNode.fullScreen = isFullscreen;
+
+ let element = globalDOMCache[thisNodeKey];
+ let iframeElement = globalDOMCache['iframe' + thisNodeKey];
+ let objectElement = globalDOMCache['object' + thisNodeKey];
+
+ if (isFullscreen) {
+ // don't need to set objectElement.style.transform here because that happens in gui.ar.draw
+ element.dataset.leftBeforeFullscreen = element.style.left;
+ element.dataset.topBeforeFullscreen = element.style.top;
+ element.style.opacity = '0'; // svg overlay still exists so we can reposition, but invisible
+ element.style.left = '0';
+ element.style.top = '0';
+
+ iframeElement.dataset.leftBeforeFullscreen = iframeElement.style.left;
+ iframeElement.dataset.topBeforeFullscreen = iframeElement.style.top;
+ iframeElement.style.left = '0';
+ iframeElement.style.top = '0';
+ iframeElement.style.margin = '-2px';
+
+ } else {
+ objectElement.style.zIndex = '';
+
+ element.style.opacity = '1';
+ if (element.dataset.leftBeforeFullscreen) {
+ // reset left/top offset when returns to non-fullscreen
+ element.style.left = element.dataset.leftBeforeFullscreen;
+ }
+ if (element.dataset.topBeforeFullscreen) {
+ element.style.top = element.dataset.topBeforeFullscreen;
+ }
+
+ if (iframeElement.dataset.leftBeforeFullscreen) {
+ iframeElement.style.left = iframeElement.dataset.leftBeforeFullscreen;
+ }
+ if (iframeElement.dataset.topBeforeFullscreen) {
+ iframeElement.style.top = iframeElement.dataset.topBeforeFullscreen;
+ }
+ }
+ }
+}
+
+realityEditor.network.setPinned = function(objectKey, frameKey, isPinned) {
+ let object = realityEditor.getObject(objectKey);
+ let frame = realityEditor.getFrame(objectKey, frameKey);
+
+ if (object && frame) {
+ if (isPinned !== frame.pinned) {
+ frame.pinned = isPinned;
+
+ let port = realityEditor.network.getPort(object);
+ var urlEndpoint = realityEditor.network.getURL(object.ip, port, '/object/' + objectKey + '/frame/' + frameKey + '/pinned/');
+ let content = {
+ isPinned: isPinned
+ };
+ this.postData(urlEndpoint, content, function(err, _response) {
+ if (err) {
+ console.warn('error posting setPinned to ' + urlEndpoint, err);
+ }
+ })
+ }
+ }
+};
+
+realityEditor.network.getNewObjectForFrame = function(objectKey, frameKey, attachesTo) {
+ let frame = realityEditor.getFrame(objectKey, frameKey);
+
+ var possibleObjectKeys = realityEditor.network.availableFrames.getPossibleObjectsForFrame(frame.src);
+
+ // get the closest object that is in possibleObjectKeys and attaches to
+ return realityEditor.gui.ar.getClosestObject(function(objectKey) {
+ if (possibleObjectKeys.indexOf(objectKey) > -1) {
+ let thatObject = realityEditor.getObject(objectKey);
+ let shouldIncludeThat = false;
+ if (attachesTo.includes('object')) {
+ shouldIncludeThat = true;
+ }
+ if (attachesTo.includes('world')) {
+ if (thatObject.isWorldObject) {
+ shouldIncludeThat = true;
+ }
+ }
+ if (shouldIncludeThat) {
+ return true;
+ }
+ }
+ return false;
+ })[0];
+};
+
+// TODO: this is a potentially incorrect way to implement this... figure out a more generalized way to pass closure variables into app.callbacks
+realityEditor.network.frameIdForScreenshot = null;
+
+/**
+ * Updates the icon of a logic node in response to UDP action message
+ * @param {{object: string, frame: string, node: string, loadLogicIcon: string}} data - loadLogicIcon is either "auto", "custom", or "null"
+ */
+realityEditor.network.loadLogicIcon = function(data) {
+ var iconImage = data.loadLogicIcon;
+ var logicNode = realityEditor.getNode(data.object, data.frame, data.node);
+ if (logicNode) {
+ logicNode.iconImage = iconImage;
+ if (typeof logicNode.nodeMemoryCustomIconSrc !== 'undefined') {
+ delete logicNode.nodeMemoryCustomIconSrc;
+ }
+ realityEditor.gui.ar.draw.updateLogicNodeIcon(logicNode);
+ }
+};
+
+/**
+ * Updates the name text of a logic node in response to UDP action message
+ * @param {{object: string, frame: string, node: string, loadLogicName: string}} data - loadLogicName is the new name
+ */
+realityEditor.network.loadLogicName = function(data) {
+ var logicNode = realityEditor.getNode(data.object, data.frame, data.node);
+ logicNode.name = data.loadLogicName;
+
+ // update node text label on AR view
+ globalDOMCache["iframe" + logicNode.uuid].contentWindow.postMessage(
+ JSON.stringify( { renameNode: logicNode.name }) , "*");
+
+ // // update model and view for pocket menu
+ // var savedIndex = realityEditor.gui.memory.nodeMemories.getIndexOfLogic(logicNode);
+ // if (savedIndex > -1) {
+ // realityEditor.gui.memory.nodeMemories.states.memories[savedIndex].name = logicNode.name;
+ // var nodeMemoryContainer = document.querySelector('.nodeMemoryBar').children[savedIndex];
+ // [].slice.call(nodeMemoryContainer.children).forEach(function(child) {
+ // if (!child.classList.contains('memoryNode') {
+ // child.innerHeight = logicNode.name;
+ // }
+ // });
+ // }
+
+ // upload name to server
+ var object = realityEditor.getObject(data.object);
+ this.postNewNodeName(object.ip, data.object, data.frame, data.node, logicNode.name);
+};
+
+/**
+ * POST /rename to the logic node to update it on the server
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {string} name
+ */
+realityEditor.network.postNewNodeName = function(ip, objectKey, frameKey, nodeKey, name) {
+ var contents = {
+ nodeName: name,
+ lastEditor: globalStates.tempUuid
+ };
+
+ this.postData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/node/" + nodeKey + "/rename/"), contents);
+};
+
+realityEditor.network.postAiApiKeys = function(endpoint, azureApiKey, isInit = false) {
+ if (endpoint === undefined || azureApiKey === undefined) return;
+ let worldId = realityEditor.worldObjects.getBestWorldObject();
+ let ip = worldId.ip;
+ let port = realityEditor.network.getPort(worldId);
+ let route = '/ai/init';
+
+ this.postData(realityEditor.network.getURL(ip, port, route),
+ {
+ endpoint: endpoint,
+ azureApiKey: azureApiKey,
+ },
+ function (err, res) {
+ if (err) {
+ console.warn('postNewNode error:', err);
+ } else {
+ if (res.answer === 'success') {
+ // change ai search text area to the actual search text area
+ realityEditor.ai.hideEndpointApiKeyAndShowSearchTextArea();
+ if (isInit) {
+ // todo Steve: broadcast this message to all avatars, and have them spin up their own Azure GPT-3.5 with the same API keys
+ // subsequently triggered avatars' postAiApiKeys have isInit set to false, thus not triggering infinite loop of calling other avatars to trigger the same function
+ // still need to consider the edge case where 2 avatars submit the same req at the same time, what's gon happen? Are they gon trigger an infinite loop of this function call?
+ // ALSO NEED TO CONSIDER: if someone in the session already logged in with ai, people who joined later how do they know how to join?
+
+ // todo Steve: currently, one edge case: when a user later join the session, before subscribing all the avatars & get the ai api keys,
+ // they input another ai api key. This way, even later users might get either api keys, maybe activating 2 different kinds of azure gpt instances
+ // solution: need to store this info in the session storage, and once set, don't update it. This way later user will get this info faster, and cannot modify it
+ // console.log(`Broadcast endpoint and apikey to other avatars: ${endpoint}, ${azureApiKey}`);
+ realityEditor.avatar.network.sendAiApiKeys(realityEditor.avatar.getMyAvatarNodeInfo(), {
+ endpoint: endpoint,
+ azureApiKey: azureApiKey,
+ });
+ }
+ }
+ }
+ });
+}
+
+realityEditor.network.postQuestionToAI = async function(conversation, extra) {
+ let route = '/ai/question';
+
+ const response = await fetch(window.location.origin + route, {
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ method: 'POST',
+ body: JSON.stringify({
+ conversation: conversation,
+ extra: extra,
+ })
+ });
+ const res = await response.json();
+ if (res.tools !== undefined) {
+ realityEditor.ai.getToolAnswer(res.category, res.tools);
+ } else {
+ realityEditor.ai.getAnswer(res.category, res.answer);
+ }
+}
+
+/**
+ * When the settings menu posts up its new state to the rest of the application, refresh/update all settings
+ * Also used for the settings menu to request data from the application, such as the list of Found Objects
+ * @param {object} msgContent
+ */
+realityEditor.network.onSettingPostMessage = function (msgContent) {
+
+ var self = document.getElementById("settingsIframe");
+
+ /**
+ * Get all the setting states
+ */
+
+ if (msgContent.settings.getSettings) {
+ self.contentWindow.postMessage(JSON.stringify({
+ getSettings: realityEditor.gui.settings.generateGetSettingsJsonMessage()
+ }), "*");
+ }
+
+ if (msgContent.settings.getMainDynamicSettings) {
+ self.contentWindow.postMessage(JSON.stringify({
+ getMainDynamicSettings: realityEditor.gui.settings.generateDynamicSettingsJsonMessage(realityEditor.gui.settings.MenuPages.MAIN)
+ }), "*");
+ }
+
+ if (msgContent.settings.getDevelopDynamicSettings) {
+ self.contentWindow.postMessage(JSON.stringify({
+ getDevelopDynamicSettings: realityEditor.gui.settings.generateDynamicSettingsJsonMessage(realityEditor.gui.settings.MenuPages.DEVELOP)
+ }), "*");
+ }
+
+ if (msgContent.settings.getEnvironmentVariables) {
+ self.contentWindow.postMessage(JSON.stringify({
+ getEnvironmentVariables: realityEditor.device.environment.variables
+ }), "*");
+ }
+
+ // this is used for the "Found Objects" settings menu, to request the list of all found objects to be posted back into the settings iframe
+ if (msgContent.settings.getObjects) {
+
+ var thisObjects = {};
+
+ for (let objectKey in realityEditor.objects) {
+ var thisObject = realityEditor.getObject(objectKey);
+
+ var isInitialized = realityEditor.app.targetDownloader.isObjectTargetInitialized(objectKey) || // either target downloaded
+ objectKey === realityEditor.worldObjects.getLocalWorldId(); // or it's the _WORLD_local
+
+ thisObjects[objectKey] = {
+ name: thisObject.name,
+ ip: thisObject.ip,
+ port: realityEditor.network.getPort(thisObject),
+ version: thisObject.version,
+ frames: {},
+ initialized: isInitialized,
+ isAnchor: realityEditor.gui.ar.anchors.isAnchorObject(objectKey),
+ isWorld: thisObject.isWorldObject,
+ isOcclusionActive: realityEditor.gui.threejsScene.isOcclusionActive(objectKey),
+ isNavigable: window.localStorage.getItem(`realityEditor.navmesh.${objectKey}`) != null
+ };
+
+ if (thisObject.isWorldObject) {
+ // getOrigin returns null if not seen yet, matrix if has been seen
+ thisObjects[objectKey].isLocalized = !!realityEditor.worldObjects.getOrigin(objectKey);
+ } else if (thisObject.isAnchor) {
+ // anchors are localized if their world object has been seen
+ thisObjects[objectKey].isLocalized = realityEditor.gui.ar.anchors.isAnchorObjectDetected(objectKey);
+ }
+
+ for (let frameKey in thisObject.frames) {
+ var thisFrame = realityEditor.getFrame(objectKey, frameKey);
+ if(thisFrame) {
+ thisObjects[objectKey].frames[frameKey] = {
+ name: thisFrame.name,
+ nodes: Object.keys(thisFrame.nodes).length,
+ links: Object.keys(thisFrame.links).length
+ }
+ }
+ }
+ }
+
+ self.contentWindow.postMessage(JSON.stringify({getObjects: thisObjects}), "*");
+ }
+
+ /**
+ * This is where all the setters are placed for the Settings menu
+ */
+
+ // iterates over all possible settings (extendedTracking, editingMode, zoneText, ...., etc) and updates local variables and triggers side effects based on new state values
+ if (msgContent.settings.setSettings) {
+
+ // sets property value for each dynamically-added toggle
+ realityEditor.gui.settings.addedToggles.forEach(function(toggle) {
+ if (typeof msgContent.settings.setSettings[toggle.propertyName] !== "undefined") {
+ realityEditor.gui.settings.toggleStates[toggle.propertyName] = msgContent.settings.setSettings[toggle.propertyName];
+ toggle.onToggleCallback(msgContent.settings.setSettings[toggle.propertyName]);
+ }
+
+ if (typeof msgContent.settings.setSettings[toggle.propertyName + 'Text'] !== "undefined") {
+ toggle.onTextCallback(msgContent.settings.setSettings[toggle.propertyName + 'Text']);
+ }
+ });
+
+ }
+
+ // can directly trigger native app APIs with message of correct format @todo: figure out if this is currently used?
+ if (msgContent.settings.functionName) {
+ realityEditor.app.appFunctionCall(msgContent.settings.functionName, msgContent.settings.messageBody, null);
+ }
+};
+
+/**
+ * function calls triggered by buttons in the settings' Found Objects menu
+ * @param {object} msgContent
+ */
+realityEditor.network.onFoundObjectButtonMessage = function(msgContent) {
+
+ if (msgContent.foundObjectsButton.hideSettings) {
+ realityEditor.gui.settings.hideSettings();
+ }
+
+ if (msgContent.foundObjectsButton.locateObjects) {
+ // split up objectKeys by ip to correctly format the whereIs information
+ globalStates.spatial.whereIs = {};
+ for (let objectKey in msgContent.foundObjectsButton.locateObjects) {
+ let object = realityEditor.getObject(objectKey);
+ if (object) {
+ let ip = object.ip;
+ if (typeof globalStates.spatial.whereIs[ip] === 'undefined') {
+ globalStates.spatial.whereIs[ip] = {};
+ }
+ globalStates.spatial.whereIs[ip][objectKey] = msgContent.foundObjectsButton.locateObjects[objectKey];
+ }
+ }
+ }
+
+ if (msgContent.foundObjectsButton.snapAnchorToScreen) {
+ let objectKey = msgContent.foundObjectsButton.snapAnchorToScreen;
+ realityEditor.gui.ar.anchors.snapAnchorToScreen(objectKey);
+ }
+};
+
+/**
+ * Ask a specific server to respond with which objects it has
+ * The server will respond with a list of json objects matching the format of discovery heartbeats
+ * Array.<{id: string, ip: string, vn: number, tcs: string, zone: string}>
+ * These heartbeats are processed like any other heartbeats
+ * @param {string} serverUrl - url for the reality server to download objects from, e.g. 10.10.10.20:8080
+ */
+realityEditor.network.discoverObjectsFromServer = function(serverUrl) {
+ var prefix = (serverUrl.indexOf('https://') === -1) ? ('https://') : ((serverUrl.indexOf('http://') === -1) ? ('http://') : (''));
+ var portSuffix = (/(:[0-9]+)$/.test(serverUrl)) ? ('') : (':' + defaultHttpPort);
+ var url = prefix + serverUrl + portSuffix + '/allObjects/';
+ realityEditor.network.getData(null, null, null, url, function(_nullObj, _nullFrame, _nullNode, msg) {
+ msg.forEach(function(heartbeat) {
+ realityEditor.network.addHeartbeatObject(heartbeat);
+ });
+ });
+};
+
+/**
+ * Helper function to perform a DELETE request on the server
+ * @param {string} url
+ * @param {object} content
+ */
+realityEditor.network.deleteData = function (url, content) {
+ var request = new XMLHttpRequest();
+ request.open('DELETE', url, true);
+ var _this = this;
+ request.onreadystatechange = function () {
+ if (request.readyState === 4) _this.cout("It deleted!");
+ };
+ request.setRequestHeader("Content-type", "application/json");
+ //request.setRequestHeader("Content-length", params.length);
+ // request.setRequestHeader("Connection", "close");
+ if (content) {
+ request.send(JSON.stringify(content));
+ } else {
+ request.send();
+ }
+ this.cout("deleteData");
+};
+
+/**
+ * Helper function to get the version number of the object. Defaults to 170.
+ * @param {string} objectKey
+ * @return {number}
+ */
+realityEditor.network.testVersion = function (objectKey) {
+ var thisObject = realityEditor.getObject(objectKey);
+ if (!thisObject) {
+ return 170;
+ } else {
+ return thisObject.integerVersion;
+ }
+};
+
+/**
+ * Makes a DELETE request to the server to remove a frame from an object. Only works for global frames, not local.
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ */
+realityEditor.network.deleteFrameFromObject = function(ip, objectKey, frameKey) {
+ this.cout("I am deleting a frame: " + ip);
+ var frameToDelete = realityEditor.getFrame(objectKey, frameKey);
+ if (frameToDelete) {
+ if (frameToDelete.location !== 'global') {
+ console.warn('WARNING: TRYING TO DELETE A LOCAL FRAME');
+ return;
+ }
+ } else {
+ console.warn('cant tell if local or global... frame has already been deleted on editor');
+ }
+ var contents = {lastEditor: globalStates.tempUuid};
+ this.deleteData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frames/" + frameKey), contents);
+};
+
+/**
+ * Makes a POST request to add a new frame to the object
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {Frame} contents
+ * @param {function} callback
+ */
+realityEditor.network.postNewFrame = function(ip, objectKey, contents, callback) {
+ contents.lastEditor = globalStates.tempUuid;
+ this.postData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/addFrame/"), contents, callback);
+};
+
+/**
+ * Duplicates a frame on the server (except gives it a new uuid). Used in response to pulling on staticCopy frames.
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {object|undefined} contents - currently doesn't need this, can exclude or pass in empty object {}
+ */
+realityEditor.network.createCopyOfFrame = function(ip, objectKey, frameKey, contents) {
+ contents = contents || {};
+ contents.lastEditor = globalStates.tempUuid;
+
+ var oldFrame = realityEditor.getFrame(objectKey, frameKey);
+
+ var cachedPositionData = {
+ x: oldFrame.ar.x,
+ y: oldFrame.ar.y,
+ scale: oldFrame.ar.scale,
+ matrix: oldFrame.ar.matrix
+ };
+
+ this.postData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frames/" + frameKey + "/copyFrame/"), contents, function(err, response) {
+ if (err) {
+ console.warn('unable to make copy of frame ' + frameKey);
+ } else {
+ var responseFrame = response.frame;
+ var newFrame = new Frame();
+ for (let propertyKey in responseFrame) {
+ if (!responseFrame.hasOwnProperty(propertyKey)) continue;
+ newFrame[propertyKey] = responseFrame[propertyKey];
+ }
+ var thisObject = realityEditor.getObject(objectKey);
+
+ // make this staticCopy so it replaces the old static copy
+ newFrame.staticCopy = true;
+
+ // copy position data directly from the old one in the editor so it is correctly placed to start (server version might have old data)
+ newFrame.ar = cachedPositionData;
+ thisObject.frames[response.frameId] = newFrame;
+ }
+ });
+};
+
+/**
+ * Makes a DELETE request to remove a link from the frame it is on (or object, for older versions)
+ * @todo: at this point, we can probably stop supporting the non-frame versions
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} linkKey
+ */
+realityEditor.network.deleteLinkFromObject = function (ip, objectKey, frameKey, linkKey) {
+ // generate action for all links to be reloaded after upload
+ this.cout("I am deleting a link: " + ip);
+
+ if (this.testVersion(objectKey) > 162) {
+ this.deleteData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/link/" + linkKey + "/editor/" + globalStates.tempUuid + "/deleteLink/"));
+ } else {
+ this.deleteData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/link/" + linkKey));
+ }
+};
+
+/**
+ * Makes a DELETE request to remove a node from the frame it is on
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ */
+realityEditor.network.deleteNodeFromObject = function (ip, objectKey, frameKey, nodeKey) {
+ // generate action for all links to be reloaded after upload
+ this.cout("I am deleting a node: " + ip);
+ this.deleteData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/node/" + nodeKey + "/editor/" + globalStates.tempUuid + "/deleteLogicNode/"));
+};
+
+/**
+ * Makes a DELETE request to remove a block from the logic node it is on
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {string} blockKey
+ */
+realityEditor.network.deleteBlockFromObject = function (ip, objectKey, frameKey, nodeKey, blockKey) {
+ // generate action for all links to be reloaded after upload
+ this.cout("I am deleting a block: " + ip);
+ // /logic/*/*/block/*/
+ this.deleteData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/node/" + nodeKey + "/block/" + blockKey + "/editor/" + globalStates.tempUuid + "/deleteBlock/"));
+};
+
+/**
+ *
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {string} linkKey
+ */
+realityEditor.network.deleteBlockLinkFromObject = function (ip, objectKey, frameKey, nodeKey, linkKey) {
+ // generate action for all links to be reloaded after upload
+ this.cout("I am deleting a block link: " + ip);
+ // /logic/*/*/link/*/
+ this.deleteData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/node/" + nodeKey + "/link/" + linkKey + "/editor/" + globalStates.tempUuid + "/deleteBlockLink/"));
+};
+
+/**
+ *
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ */
+realityEditor.network.updateNodeBlocksSettingsData = function(ip, objectKey, frameKey, nodeKey) {
+ var urlEndpoint = realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/node/" + nodeKey);
+ this.getData(objectKey, frameKey, nodeKey, urlEndpoint, function (objectKey, frameKey, nodeKey, res) {
+ for (var blockKey in res.blocks) {
+ if (!res.blocks.hasOwnProperty(blockKey)) continue;
+ if (res.blocks[blockKey].type === 'default') continue;
+ // TODO: refactor using getter functions
+ objects[objectKey].frames[frameKey].nodes[nodeKey].blocks[blockKey].publicData = res.blocks[blockKey].publicData;
+ objects[objectKey].frames[frameKey].nodes[nodeKey].blocks[blockKey].privateData = res.blocks[blockKey].privateData;
+ }
+ });
+};
+
+/**
+ * Helper function to make a GET request to the server.
+ * The objectKey, frameKey, and nodeKey are optional and will just be passed into the callback as additional arguments.
+ * @param {string|undefined} objectKey
+ * @param {string|undefined} frameKey
+ * @param {string|undefined} nodeKey
+ * @param {string} url
+ * @param {function} callback
+ * @param {*} options
+ */
+realityEditor.network.getData = function (objectKey, frameKey, nodeKey, url, callback, options = {bypassCache: false}) {
+ if (!nodeKey) nodeKey = null;
+ if (!frameKey) frameKey = null;
+ var req = new XMLHttpRequest();
+ let urlSuffix = options.bypassCache ? `?timestamp=${new Date().getTime()}` : '';
+ try {
+ req.open('GET', url + urlSuffix, true);
+ if (options.bypassCache) {
+ req.setRequestHeader('Cache-control', 'no-cache');
+ }
+ // Just like regular ol' XHR
+ req.onreadystatechange = function () {
+ if (req.readyState === 4) {
+ if (req.status >= 200 && req.status <= 299) {
+ // JSON.parse(req.responseText) etc.
+ if (req.responseText) {
+ callback(objectKey, frameKey, nodeKey, JSON.parse(req.responseText));
+ } else {
+ callback(objectKey, frameKey, nodeKey, req.responseText);
+ }
+ } else {
+ // Handle error case
+ console.warn("could not load content for GET:" + url);
+ }
+ }
+ };
+
+ req.onerror = (e) => {
+ console.error('realityEditor.network.getData xhr error', url, e);
+ };
+ req.ontimeout = (e) => {
+ console.error('realityEditor.network.getData xhr timeout', url, e);
+ };
+
+ req.send();
+
+ }
+ catch (e) {
+ this.cout("could not connect to" + url);
+ }
+};
+
+/**
+ * Helper function to POST data as json to url, calling callback with the JSON-encoded response data when finished
+ * @param {String} url
+ * @param {Object} body
+ * @param {Function} callback
+ */
+realityEditor.network.postData = function (url, body, callback) {
+ var request = new XMLHttpRequest();
+ var params = JSON.stringify(body);
+ request.open('POST', url, true);
+ request.onreadystatechange = function () {
+ if (request.readyState !== 4) {
+ return;
+ }
+ if (!callback) {
+ return;
+ }
+
+ if (request.status >= 200 && request.status <= 299) {
+ try {
+ // console.log(request);
+ callback(null, JSON.parse(request.responseText));
+ } catch (e) {
+ callback({status: request.status, error: e, failure: true}, null);
+ }
+ return;
+ }
+
+ callback({status: request.status, failure: true}, null);
+ };
+
+ request.setRequestHeader("Content-type", "application/json");
+ //request.setRequestHeader("Content-length", params.length);
+ // request.setRequestHeader("Connection", "close");
+ request.send(params);
+};
+
+/**
+ * Makes a POST request to add a new link from objectA, frameA, nodeA, to objectB, frameB, nodeB
+ * Only goes through with it after checking to make sure there is no network loop
+ * @param {Link} thisLink
+ * @param {string|undefined} existingLinkKey - include if you want server to use this as the link key. otherwise randomly generates it.
+ */
+realityEditor.network.postLinkToServer = function (thisLink, existingLinkKey) {
+
+ var thisObjectA = realityEditor.getObject(thisLink.objectA);
+ var thisFrameA = realityEditor.getFrame(thisLink.objectA, thisLink.frameA);
+ var thisNodeA = realityEditor.getNode(thisLink.objectA, thisLink.frameA, thisLink.nodeA);
+
+ var thisObjectB = realityEditor.getObject(thisLink.objectB);
+ var thisFrameB = realityEditor.getFrame(thisLink.objectB, thisLink.frameB);
+ var thisNodeB = realityEditor.getNode(thisLink.objectB, thisLink.frameB, thisLink.nodeB);
+
+ // if exactly one of objectA and objectB is the localWorldObject of the phone, prevent the link from being made
+ var localWorldObjectKey = realityEditor.worldObjects.getLocalWorldId();
+ var isBetweenLocalWorldAndOtherServer = (thisLink.objectA === localWorldObjectKey && thisLink.objectB !== localWorldObjectKey) ||
+ (thisLink.objectA !== localWorldObjectKey && thisLink.objectB === localWorldObjectKey);
+
+ var okForNewLink = this.checkForNetworkLoop(thisLink.objectA, thisLink.frameA, thisLink.nodeA, thisLink.logicA, thisLink.objectB, thisLink.frameB, thisLink.nodeB, thisLink.logicB) && !isBetweenLocalWorldAndOtherServer;
+
+ if (okForNewLink) {
+ var linkKey = this.realityEditor.device.utilities.uuidTimeShort();
+ if (existingLinkKey) {
+ linkKey = existingLinkKey;
+ }
+
+ var namesA, namesB;
+ var color = "";
+
+ if (thisLink.logicA !== false) {
+
+ if (thisLink.logicA === 0) color = "BLUE";
+ if (thisLink.logicA === 1) color = "GREEN";
+ if (thisLink.logicA === 2) color = "YELLOW";
+ if (thisLink.logicA === 3) color = "RED";
+
+ namesA = [thisObjectA.name, thisFrameA.name, thisNodeA.name + ":" + color];
+ } else {
+ namesA = [thisObjectA.name, thisFrameA.name, thisNodeA.name];
+ }
+
+ if (thisLink.logicB !== false) {
+
+ if (thisLink.logicB === 0) color = "BLUE";
+ if (thisLink.logicB === 1) color = "GREEN";
+ if (thisLink.logicB === 2) color = "YELLOW";
+ if (thisLink.logicB === 3) color = "RED";
+
+ namesB = [thisObjectB.name, thisFrameB.name, thisNodeB.name + ":" + color];
+ } else {
+ namesB = [thisObjectB.name, thisFrameB.name, thisNodeB.name];
+ }
+
+ // this is for backword compatibility
+ if (this.testVersion(thisLink.objectA) > 165) {
+
+ thisFrameA.links[linkKey] = {
+ objectA: thisLink.objectA,
+ frameA: thisLink.frameA,
+ nodeA: thisLink.nodeA,
+ logicA: thisLink.logicA,
+ namesA: namesA,
+ objectB: thisLink.objectB,
+ frameB: thisLink.frameB,
+ nodeB: thisLink.nodeB,
+ logicB: thisLink.logicB,
+ namesB: namesB
+ };
+
+ } else {
+ thisFrameA.links[linkKey] = {
+ ObjectA: thisLink.objectA,
+ ObjectB: thisLink.objectB,
+ locationInA: thisLink.nodeA,
+ locationInB: thisLink.nodeB,
+ ObjectNameA: namesA,
+ ObjectNameB: namesB
+ };
+
+ if (thisLink.logicA !== false || thisLink.logicB !== false) {
+ return;
+ }
+ }
+
+ // push new connection to objectA
+ //todo this is a work around to not crash the server. only temporarly for testing
+ // if(globalProgram.logicA === false && globalProgram.logicB === false) {
+ this.postNewLink(thisObjectA.ip, thisLink.objectA, thisLink.frameA, linkKey, thisFrameA.links[linkKey]);
+ // }
+ }
+};
+
+/**
+ * Subroutine that postLinkToServer calls after it has determined that there is no network loop, to actually perform the network request
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} linkKey
+ * @param {Link} thisLink
+ */
+realityEditor.network.postNewLink = function (ip, objectKey, frameKey, linkKey, thisLink) {
+ // generate action for all links to be reloaded after upload
+ thisLink.lastEditor = globalStates.tempUuid;
+ this.cout("sending Link");
+ this.postData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/link/" + linkKey + '/addLink/'), thisLink, function (_err, _response) {
+ // console.log(response);
+ });
+};
+
+/**
+ * Makes a POST request to add a new node to a frame
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {Node} thisNode
+ */
+realityEditor.network.postNewNode = function (ip, objectKey, frameKey, nodeKey, thisNode, callback) {
+ thisNode.lastEditor = globalStates.tempUuid;
+ this.postData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + '/frame/' + frameKey + '/node/' + nodeKey + '/addNode'), thisNode, function (err, response) {
+ if (err) {
+ console.warn('postNewNode error:', err);
+ } else if (callback) {
+ callback(response);
+ }
+ });
+
+};
+
+/**
+ * Makes a POST request to add a new crafting board link (logic block link) to the logic node
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {string} linkKey
+ * @param {BlockLink} thisLink
+ */
+realityEditor.network.postNewBlockLink = function (ip, objectKey, frameKey, nodeKey, linkKey, thisLink) {
+ this.cout("sending Block Link");
+ var linkMessage = this.realityEditor.gui.crafting.utilities.convertBlockLinkToServerFormat(thisLink);
+ linkMessage.lastEditor = globalStates.tempUuid;
+ // /logic/*/*/link/*/
+ this.postData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/node/" + nodeKey + "/link/" + linkKey + "/addBlockLink/"), linkMessage, function () {
+ });
+};
+
+/**
+ * Makes a POST request to add a new logic node to a frame
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {Logic} logic
+ */
+realityEditor.network.postNewLogicNode = function (ip, objectKey, frameKey, nodeKey, logic) {
+ this.cout("sending Logic Node");
+ // /logic/*/*/node/
+
+ var simpleLogic = this.realityEditor.gui.crafting.utilities.convertLogicToServerFormat(logic);
+ simpleLogic.lastEditor = globalStates.tempUuid;
+ this.postData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/node/" + nodeKey + "/addLogicNode/"), simpleLogic, function () {
+ });
+};
+
+/**
+ * Makes a POST request to move a logic block from one grid (x,y) position to another
+ * @todo: update to use a PUT request in all instances where we are modifying rather than creating
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} logicKey
+ * @param {string} blockKey
+ * @param {{x: number, y: number}} content
+ */
+realityEditor.network.postNewBlockPosition = function (ip, objectKey, frameKey, logicKey, blockKey, content) {
+ // generate action for all links to be reloaded after upload
+ this.cout("I am moving a block: " + ip);
+ // /logic/*/*/block/*/
+
+ content.lastEditor = globalStates.tempUuid;
+ if (typeof content.x === "number" && typeof content.y === "number") {
+ this.postData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/node/" + logicKey + "/block/" + blockKey + "/blockPosition/"), content, function () {
+ });
+ }
+};
+
+/**
+ * Makes a POST request to add a new logic block to a logic node
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {string} blockKey
+ * @param {Logic} block
+ */
+realityEditor.network.postNewBlock = function (ip, objectKey, frameKey, nodeKey, blockKey, block) {
+ this.cout("sending Block");
+ // /logic/*/*/block/*/
+ block.lastEditor = globalStates.tempUuid;
+
+ this.postData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]),'/object/' + objectKey + "/frame/" + frameKey + "/node/" + nodeKey + "/block/" + blockKey + "/addBlock/"), block, function () {
+ });
+};
+
+/**
+ * Recursively check if adding the specified link would introduce a cycle in the network topology
+ * @todo fully understand what's happening here and verify that this really works
+ * @todo make sure this works for logic block links too
+ * @param {string} objectAKey
+ * @param {string} frameAKey
+ * @param {string} nodeAKey
+ * @param {string} logicAKey
+ * @param {string} objectBKey
+ * @param {string} frameBKey
+ * @param {string} nodeBKey
+ * @param {string} logicBKey
+ * @return {boolean} - true if it's ok to add
+ */
+realityEditor.network.checkForNetworkLoop = function (objectAKey, frameAKey, nodeAKey, _logicAKey, objectBKey, frameBKey, nodeBKey, _logicBKey) {
+ var signalIsOk = true;
+ var thisFrame = realityEditor.getFrame(objectAKey, frameAKey);
+ var thisFrameLinks = thisFrame.links;
+
+ // check if connection is with it self
+ if (objectAKey === objectBKey && frameAKey === frameBKey && nodeAKey === nodeBKey) {
+ signalIsOk = false;
+ }
+
+ // todo check that objects are making these checks as well for not producing overlapeses.
+ // check if this connection already exists?
+ if (signalIsOk) {
+ for (var thisSubKey in thisFrameLinks) {
+ if (thisFrameLinks[thisSubKey].objectA === objectAKey &&
+ thisFrameLinks[thisSubKey].objectB === objectBKey &&
+ thisFrameLinks[thisSubKey].frameA === frameAKey &&
+ thisFrameLinks[thisSubKey].frameB === frameBKey &&
+ thisFrameLinks[thisSubKey].nodeA === nodeAKey &&
+ thisFrameLinks[thisSubKey].nodeB === nodeBKey) {
+ signalIsOk = false;
+ }
+ }
+ }
+
+ function searchL(objectA, frameA, nodeA, objectB, frameB, nodeB) {
+ var thisFrame = realityEditor.getFrame(objectB, frameB);
+ // TODO: make sure that these links dont get created in the first place - or that they get deleted / rerouted when destination frame changes
+ if (!thisFrame) return;
+
+ for (var key in thisFrame.links) { // this is within the frame
+ // this.cout(objectB);
+ var Bn = thisFrame.links[key]; // this is the link to work with
+ if (nodeB === Bn.nodeA) { // check if
+ if (nodeA === Bn.nodeB && objectA === Bn.objectB && frameA === Bn.frameB) {
+ signalIsOk = false;
+ break;
+ } else {
+ searchL(objectA, frameA, nodeA, Bn.objectB, Bn.frameB, Bn.nodeB);
+ }
+ }
+ }
+ }
+
+ // check that there is no endless loops through it self or any other connections
+ if (signalIsOk) {
+ searchL(objectAKey, frameAKey, nodeAKey, objectBKey, frameBKey, nodeBKey);
+ }
+
+ return signalIsOk;
+};
+
+/**
+ * Debug method to reset the position of a specified frame or node.
+ * Doesn't actually reset the position to origin, just refreshes the position, so you need to also manually set the position to 0,0,[] before calling this
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {string|undefined} type - "ui" if resetting a frame, null/undefined if resetting a node
+ */
+realityEditor.network.sendResetContent = function (objectKey, frameKey, nodeKey, type) {
+
+ var tempThisObject = {};
+ if (type !== "ui") {
+ tempThisObject = realityEditor.getNode(objectKey, frameKey, nodeKey);
+ } else {
+ tempThisObject = realityEditor.getFrame(objectKey, frameKey);
+ }
+
+ if (!tempThisObject) {
+ console.warn("Can't reset content of undefined object", objectKey, frameKey, nodeKey, type);
+ return;
+ }
+
+ var positionData = realityEditor.gui.ar.positioning.getPositionData(tempThisObject);
+
+ var content = {};
+ content.x = positionData.x;
+ content.y = positionData.y;
+ content.scale = positionData.scale;
+
+ if (typeof positionData.matrix === "object") {
+ content.matrix = positionData.matrix;
+ }
+
+ content.lastEditor = globalStates.tempUuid;
+
+ if (typeof content.x === "number" && typeof content.y === "number" && typeof content.scale === "number") {
+ realityEditor.gui.ar.utilities.setAverageScale(objects[objectKey]);
+ var urlEndpoint;
+ if (type !== 'ui') {
+ urlEndpoint = realityEditor.network.getURL(objects[objectKey].ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/node/" + nodeKey + "/nodeSize/");
+ } else {
+ urlEndpoint = realityEditor.network.getURL(objects[objectKey].ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/node/" + nodeKey + "/size/");
+ }
+ this.postData(urlEndpoint, content);
+ }
+
+};
+
+/**
+ * Makes a POST request to commit the state of the specified object to the server's git system, so that it can be reset to this point
+ * @param {string} objectKey
+ */
+realityEditor.network.sendSaveCommit = function (objectKey) {
+ var urlEndpoint = realityEditor.network.getURL(objects[objectKey].ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/saveCommit/");
+ var content = {};
+ this.postData(urlEndpoint, content, function(){});
+};
+
+/**
+ * Makes a POST request to reset the state of the object on the server to the last commit
+ * (eventually updates the local state too, after the server resets and pings the app with an update action message)
+ * @param {string} objectKey
+ */
+realityEditor.network.sendResetToLastCommit = function (objectKey) {
+ var urlEndpoint = realityEditor.network.getURL(objects[objectKey].ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/resetToLastCommit/");
+ var content = {};
+ this.postData(urlEndpoint, content, function(){});
+};
+
+realityEditor.network.toBeInitialized = {};
+realityEditor.network.isFirstInitialization = function(objectKey, frameKey, nodeKey) {
+ let activeKey = nodeKey || frameKey;
+ if (this.toBeInitialized[activeKey]) {
+ delete this.toBeInitialized[activeKey];
+ return true;
+ }
+ return false;
+};
+
+/**
+ * Gets set as the "onload" function of each frame/node iframe element.
+ * When the iframe contents finish loading, update some local state that depends on its size, and
+ * post a message into the frame with data including its object/frame/node keys, the GUI state, etc
+ * @param objectKey
+ * @param frameKey
+ * @param nodeKey
+ */
+realityEditor.network.onElementLoad = function (objectKey, frameKey, nodeKey) {
+
+ realityEditor.gui.ar.draw.notLoading = false;
+
+ if (nodeKey === "null") nodeKey = null;
+
+ var version = 170;
+ var object = realityEditor.getObject(objectKey);
+ if (object) {
+ version = object.integerVersion;
+ }
+ var frame = realityEditor.getFrame(objectKey, frameKey);
+ var nodes = frame ? frame.nodes : {};
+
+ var oldStyle = {
+ obj: objectKey,
+ pos: nodeKey,
+ objectValues: object ? object.nodes : {},
+ interface: globalStates.interface
+ };
+
+ var simpleNodes = this.utilities.getNodesJsonForIframes(nodes);
+
+ var newStyle = {
+ object: objectKey,
+ frame: frameKey,
+ objectData: {},
+ node: nodeKey,
+ nodes: simpleNodes,
+ port: realityEditor.network.getPort(object),
+ interface: globalStates.interface,
+ firstInitialization: realityEditor.network.isFirstInitialization(objectKey, frameKey, nodeKey),
+ parentLocation: window.location.href
+ };
+
+ if (version < 170 && objectKey === nodeKey) {
+ newStyle = oldStyle;
+ }
+
+ if (object && object.ip) {
+ newStyle.objectData = {
+ ip: object.ip,
+ port: realityEditor.network.getPort(object)
+ };
+ }
+ let activeKey = nodeKey || frameKey;
+
+ // if (globalDOMCache['svg' + activeKey]) {
+ // realityEditor.gui.ar.moveabilityOverlay.createSvg(globalDOMCache['svg' + activeKey]);
+ // }
+
+ globalDOMCache["iframe" + activeKey].setAttribute('loaded', true);
+ globalDOMCache["iframe" + activeKey].contentWindow.postMessage(JSON.stringify(newStyle), '*');
+
+ if (nodeKey) {
+ var node = realityEditor.getNode(objectKey, frameKey, nodeKey);
+ if (node.type === 'logic') {
+ realityEditor.gui.ar.draw.updateLogicNodeIcon(node);
+ }
+
+ this.processPendingNodeAdjustments(objectKey, frameKey, node.name, function(objectKey, frameKey, nodeName, msgContent) {
+ if (typeof msgContent.nodeIsFullScreen !== 'undefined') {
+ realityEditor.network.setNodeFullScreen(objectKey, frameKey, nodeName, msgContent); // TODO: actually do this after onElementLoad for the node
+ }
+ });
+ }
+
+ // adjust move-ability corner UI to match true width and height of frame contents
+ if (globalDOMCache['iframe' + activeKey].clientWidth > 0) { // get around a bug where corners would resize to 0 for new logic nodes
+ setTimeout(function() {
+ var trueSize = {
+ width: globalDOMCache['iframe' + activeKey].clientWidth,
+ height: globalDOMCache['iframe' + activeKey].clientHeight
+ };
+
+ var cornerPadding = 24;
+ globalDOMCache[activeKey].querySelector('.corners').style.width = trueSize.width + cornerPadding*2 + 'px';
+ globalDOMCache[activeKey].querySelector('.corners').style.height = trueSize.height + cornerPadding*2 + 'px';
+ }, 100); // resize corners after a slight delay to ensure that the frame has fully initialized with the correct size
+ }
+
+ // show the blue corners as soon as the frame loads
+ if (realityEditor.device.editingState.frame === frameKey && realityEditor.device.editingState.node === nodeKey) {
+ // document.getElementById('svg' + (nodeKey || frameKey)).classList.add('visibleEditingSVG');
+ globalDOMCache[(nodeKey || frameKey)].querySelector('.corners').style.visibility = 'visible';
+ }
+
+ if (globalDOMCache['iframe' + (nodeKey || frameKey)].dataset.isReloading) {
+ delete globalDOMCache['iframe' + (nodeKey || frameKey)].dataset.isReloading;
+ realityEditor.network.callbackHandler.triggerCallbacks('elementReloaded', {objectKey: objectKey, frameKey: frameKey, nodeKey: nodeKey});
+ } else {
+ realityEditor.network.callbackHandler.triggerCallbacks('elementLoaded', {objectKey: objectKey, frameKey: frameKey, nodeKey: nodeKey});
+ }
+
+ // this is used so we can render a placeholder until it loads
+ globalDOMCache['iframe' + (nodeKey || frameKey)].dataset.doneLoading = true;
+
+ this.cout("on_load");
+};
+
+/**
+ * Makes a POST request to add a lock to the specified node. Whether or not you are actually allowed to add the
+ * lock is determined within the server, based on the state of the node and the password and lock type you provide
+ * @todo: get locks working again, this time with real security (e.g. encryption)
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {{lockPassword: string, lockType: string}} content - lockType is "full" or "half" (see documentation in device/security.js)
+ */
+realityEditor.network.postNewLockToNode = function (ip, objectKey, frameKey, nodeKey, content) {
+ this.postData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/node/" + nodeKey + "/addLock/"), content, function () {
+ });
+};
+
+/**
+ * Makes a DELETE request to remove a lock from the specified node, given a password to use to unlock it
+ * @todo: encrypt / etc
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} nodeKey
+ * @param {string} password
+ */
+realityEditor.network.deleteLockFromNode = function (ip, objectKey, frameKey, nodeKey, password) {
+// generate action for all links to be reloaded after upload
+ this.deleteData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/node/" + nodeKey + "/password/" + password + "/deleteLock/"));
+};
+
+/**
+ * Makes a POST request to add a lock to the specified link.
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} linkKey
+ * @param {{lockPassword: string, lockType: string}} content
+ */
+realityEditor.network.postNewLockToLink = function (ip, objectKey, frameKey, linkKey, content) {
+// generate action for all links to be reloaded after upload
+ this.postData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/link/" + linkKey + "/addLock/"), content, function () {
+ });
+ // postData((realityEditor.network.useHTTPS ? 'https' : 'http') + '://' +ip+ ':' + httpPort+"/", content);
+ //console.log('post --- ' + (realityEditor.network.useHTTPS ? 'https' : 'http') + '://' + ip + ':' + httpPort + '/object/' + thisObjectKey + "/link/lock/" + thisLinkKey);
+
+};
+
+/**
+ * Makes a DELETE request to remove a lock from the specific link
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} linkKey
+ * @param {string} password
+ */
+realityEditor.network.deleteLockFromLink = function (ip, objectKey, frameKey, linkKey, password) {
+// generate action for all links to be reloaded after upload
+ this.deleteData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/link/" + linkKey + "/password/" + password + "/deleteLock/"));
+};
+
+/**
+ * Makes a POST request when a frame is pushed into a screen or pulled out into AR, to update state on server
+ * (updating on server causes the in-screen version of the frame to show/hide as a response)
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string} newVisualization - (either 'ar' or 'screen') the new visualization mode you want to change to
+ * @param {{x: number, y: number, scale: number, matrix: Array.}|null} oldVisualizationPositionData - optionally sync the other position data to the server before changing visualization modes. In practice, when we push into a screen we reset the AR frame's positionData to the origin
+ */
+realityEditor.network.updateFrameVisualization = function(ip, objectKey, frameKey, newVisualization, oldVisualizationPositionData) {
+
+ var urlEndpoint = realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/visualization/");
+ var content = {
+ visualization: newVisualization,
+ oldVisualizationPositionData: oldVisualizationPositionData
+ };
+ this.postData(urlEndpoint, content, function (_err, _response) {});
+};
+
+/**
+ * Makes a DELETE request to remove a frame's publicData from the server
+ * (used e.g. when a frame is moved from one object to another, the old copy of its public data needs to be deleted)
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ */
+realityEditor.network.deletePublicData = function(ip, objectKey, frameKey) {
+ this.deleteData(realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/publicData"));
+};
+
+/**
+ * Makes a POST request to upload a frame's publicData to the server
+ * (used e.g. when a frame is moved from one object to another, to upload public data to new object/server)
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param publicData
+ */
+realityEditor.network.postPublicData = function(ip, objectKey, frameKey, publicData) {
+
+ var urlEndpoint = realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/publicData");
+ var content = {
+ publicData: publicData,
+ lastEditor: globalStates.tempUuid
+ };
+
+ this.postData(urlEndpoint, content, function (_err, _response) {});
+};
+
+/**
+ * Helper function to locate the iframe element associated with a certain frame, and post a message into it
+ * @param {string} frameKey
+ * @param {object} message - JSON data to send into the frame
+ */
+realityEditor.network.postMessageIntoFrame = function(frameKey, message) {
+ var frame = document.getElementById('iframe' + frameKey);
+ if (frame) {
+ frame.contentWindow.postMessage(JSON.stringify(message), "*");
+ }
+};
+
+/**
+ * Makes a POST request to update groupIds on the server when a frame is added to or removed from a group
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} frameKey
+ * @param {string|null} newGroupID - either groupId or null for none
+ */
+realityEditor.network.updateGroupings = function(ip, objectKey, frameKey, newGroupID) {
+ var urlEndpoint = realityEditor.network.getURL(ip, realityEditor.network.getPort(objects[objectKey]), '/object/' + objectKey + "/frame/" + frameKey + "/group/");
+ var content = {
+ group: newGroupID,
+ lastEditor: globalStates.tempUuid
+ };
+ this.postData(urlEndpoint, content, function (_err, _response) {})
+};
+
+/**
+ * Makes a POST request to update the (x,y,scale,matrix) position data of a frame or node on the server
+ * @param {Frame|Node} activeVehicle
+ * @param {boolean} ignoreMatrix - include this if you only want to update (x,y,scale) not the transformation matrix
+ */
+realityEditor.network.postVehiclePosition = function(activeVehicle, ignoreMatrix = false) {
+ if (activeVehicle) {
+ var positionData = realityEditor.gui.ar.positioning.getPositionData(activeVehicle);
+ var content = {};
+ content.x = positionData.x;
+ content.y = positionData.y;
+ content.scale = positionData.scale;
+ if (!ignoreMatrix) {
+ content.matrix = positionData.matrix;
+ }
+ content.lastEditor = globalStates.tempUuid;
+
+ var endpointSuffix = realityEditor.isVehicleAFrame(activeVehicle) ? "/size/" : "/nodeSize/";
+ var keys = realityEditor.getKeysFromVehicle(activeVehicle);
+ var urlEndpoint = realityEditor.network.getURL(realityEditor.getObject(keys.objectKey).ip, realityEditor.network.getPort(realityEditor.getObject(keys.objectKey)), '/object/' + keys.objectKey + "/frame/" + keys.frameKey + "/node/" + keys.nodeKey + endpointSuffix);
+ realityEditor.network.postData(urlEndpoint, content);
+ }
+};
+
+/**
+ * Upload the current position of an object (via its transformation matrix) relative to the
+ * closest world object origin. Used for anchors.
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {Array.} matrix
+ * @param {string} worldId
+ */
+realityEditor.network.postObjectPosition = function(ip, objectKey, matrix, worldId) {
+ let port = realityEditor.network.getPort(objects[objectKey]);
+ var urlEndpoint = realityEditor.network.getURL(ip, port, '/object/' + objectKey + "/matrix");
+ let content = {
+ matrix: matrix,
+ worldId: worldId,
+ lastEditor: globalStates.tempUuid
+ };
+ this.postData(urlEndpoint, content, function(err, _response) {
+ if (err) {
+ console.warn('error posting object position to ' + urlEndpoint, err);
+ }
+ });
+};
+
+/**
+ * Update the renderMode of the object on the server and other clients
+ * @param {string} ip
+ * @param {string} objectKey
+ * @param {string} renderMode
+ * @returns {Promise