Home Reference Source

scripts/model/floorplan.js

import {EVENT_UPDATED, EVENT_LOADED, EVENT_NEW, EVENT_DELETED, EVENT_ROOM_NAME_CHANGED} from '../core/events.js';
import {EventDispatcher, Vector2, Vector3} from 'three';
import {Utils} from '../core/utils.js';
import {Dimensioning} from '../core/dimensioning.js';

import {HalfEdge} from './half_edge.js';
import {Corner} from './corner.js';
import {Wall} from './wall.js';
import {Room} from './room.js';

/** */
export const defaultFloorPlanTolerance = 10.0;

/**
 * A Floorplan represents a number of Walls, Corners and Rooms. This is an
 * abstract that keeps the 2d and 3d in sync
 */
export class Floorplan extends EventDispatcher
{
	/** Constructs a floorplan. */
	constructor()
	{
		super();
		/**
			* List of elements of Wall instance
			* @property {Wall[]} walls Array of walls
			* @type {Wall[]}
		**/
		this.walls = [];
		/**
			* List of elements of Corner instance
			* @property {Corner[]} corners array of corners
			* @type {Corner[]}
		**/
		this.corners = [];

		/**
		* List of elements of Room instance
		* @property {Room[]} walls Array of walls
		* @type {Room[]}
		**/
		this.rooms = [];

		/**
			* An {@link Object} that stores the metadata of rooms like name
			* @property {Object} metaroomsdata  stores the metadata of rooms like name
			* @type {Object}
		**/
		this.metaroomsdata = {};
		// List with reference to callback on a new wall insert event
		/**
			@deprecated
		**/
		this.new_wall_callbacks = [];
		// List with reference to callbacks on a new corner insert event
		/**
			@deprecated
		**/
		this.new_corner_callbacks = [];
		// List with reference to callbacks on redraw event
		/**
			@deprecated
		**/
		this.redraw_callbacks = [];
		// List with reference to callbacks for updated_rooms event
		/**
			@deprecated
		**/
		this.updated_rooms = [];
		// List with reference to callbacks for roomLoaded event
		/**
			@deprecated
		**/
		this.roomLoadedCallbacks = [];

		this.floorTextures = {};
		/**
			* The {@link CarbonSheet} that handles the background image to show in the 2D view
			* @property {CarbonSheet} _carbonSheet  The carbonsheet instance
			* @type {Object}
		**/
		this._carbonSheet = null;
	}

	/**
		*	@param {CarbonSheet} val
	**/
	set carbonSheet(val)
	{
		this._carbonSheet = val;
	}

	/**
		*	@return {CarbonSheet} _carbonSheet reference to the instance of {@link CarbonSheet}
	**/
	get carbonSheet()
	{
		return this._carbonSheet;
	}

	/**
		*	@return {HalfEdge[]} edges The array of {@link HalfEdge} 
	**/
	wallEdges()
	{
		var edges = [];
		this.walls.forEach((wall) => {
			if (wall.frontEdge)
			{
				edges.push(wall.frontEdge);
			}
			if (wall.backEdge)
			{
				edges.push(wall.backEdge);
			}
		});
		return edges;
	}

	/**
		* Returns the roof planes in the floorplan for intersection testing
		*	@return {Mesh[]} planes @see <https://threejs.org/docs/#api/en/objects/Mesh>
	**/
	roofPlanes()
	{
		var planes = [];
		this.rooms.forEach((room) => {
			planes.push(room.roofPlane);
		});
		return planes;
	}

	/**
		*	Returns all the planes for intersection for the walls
		* @return {Mesh[]} planes @see <https://threejs.org/docs/#api/en/objects/Mesh>
	**/
	wallEdgePlanes()
	{
		var planes = [];
		this.walls.forEach((wall) => {
			if (wall.frontEdge)
			{
				planes.push(wall.frontEdge.plane);
			}
			if (wall.backEdge)
			{
				planes.push(wall.backEdge.plane);
			}
		});
		return planes;
	}

	/**
		*	Returns all the planes for intersection of the floors in all room
		* @return {Mesh[]} planes @see <https://threejs.org/docs/#api/en/objects/Mesh>
	**/
	floorPlanes()
	{
		return Utils.map(this.rooms, (room) => {
			return room.floorPlane;
		});
	}

	fireOnNewWall(callback)
	{
		this.new_wall_callbacks.add(callback);
	}

	fireOnNewCorner(callback)
	{
		this.new_corner_callbacks.add(callback);
	}

	fireOnRedraw(callback)
	{
		this.redraw_callbacks.add(callback);
	}

	fireOnUpdatedRooms(callback)
	{
		this.updated_rooms.add(callback);
	}

	// This method needs to be called from the 2d floorplan whenever
	// the other method newWall is called.
	// This is to ensure that there are no floating walls going across
	// other walls. If two walls are intersecting then the intersection point
	// has to create a new wall.
	/**
	 *  Checks existing walls for any intersections they would make. If there are intersections then introduce new corners and new walls as required at places
	 *  @param {Corner} start
	 *  @param {Corner} end
	 *  @return {boolean} intersects 
	**/

	newWallsForIntersections(start, end)
	{
		var intersections = false;
		// This is a bug in the logic
		// When creating a new wall with a start and end
		// it needs to be checked if it is cutting other walls
		// If it cuts then all those walls have to removed and introduced as
		// new walls along with this new wall
		var cStart = new Vector2(start.getX(), start.getY());
		var cEnd = new Vector2(end.getX(), end.getY());
		var newCorners = [];

		for (var i=0;i<this.walls.length;i++)
		{
			var twall = this.walls[i];
			var bstart = {x:twall.getStartX(), y:twall.getStartY()};
			var bend = {x:twall.getEndX(), y:twall.getEndY()};
			var iPoint = Utils.lineLineIntersectPoint(cStart, cEnd, bstart, bend);
			if(iPoint)
			{
				var nCorner = this.newCorner(iPoint.x, iPoint.y);
				newCorners.push(nCorner);
				intersections = true;
			}
		}
		for( i=0;i<this.corners.length;i++)
		{
			var aCorner = this.corners[i];
			if(aCorner)
			{
				aCorner.relativeMove(0, 0);
				aCorner.snapToAxis(25);
			}
		}
		this.update();
		for( i=0;i<this.corners.length;i++)
		{
			aCorner = this.corners[i];
			if(aCorner)
			{
				aCorner.relativeMove(0, 0);
				aCorner.snapToAxis(25);
			}
		}

		this.update();
		return intersections;
	}

	/**
	 * Creates a new wall.
	 *
	 * @param {Corner} start The start corner.
	 * @param {Corner} end The end corner.
	 * @returns {Wall} The new wall.
	 */
	newWall(start, end)
	{
		var wall = new Wall(start, end);
    this.walls.push(wall);
		var scope = this;
		wall.addEventListener(EVENT_DELETED, function(o){scope.removeWall(o.item);});
		this.dispatchEvent({type: EVENT_NEW, item: this, newItem: wall});
		this.update();
		return wall;
	}



	/**
	 * Creates a new corner.
	 *
	 * @param {Number} x The x coordinate.
	 * @param {Number} y The y coordinate.
	 * @param {String} id An optional id. If unspecified, the id will be created internally.
	 * @returns {Corner} The new corner.
	 */
	newCorner(x, y, id)
	{
		var corner = new Corner(this, x, y, id);
		for (var i=0;i<this.corners.length;i++)
		{
				var existingCorner = this.corners[i];
				if(existingCorner.distanceFromCorner(corner) < 50)
				{
          return existingCorner;
				}
		}

		var scope = this;
		this.corners.push(corner);
		corner.addEventListener(EVENT_DELETED, function(o){scope.removeCorner(o.item);});
		this.dispatchEvent({type: EVENT_NEW, item: this, newItem: corner});

		// This code has been added by #0K. There should be an update whenever a
		// new corner is inserted
		this.update();

		return corner;
	}

	/**
	 * Removes a wall.
	 *
	 * @param {Wall} wall The wall to be removed.
	 */
	removeWall(wall)
	{
		Utils.removeValue(this.walls, wall);
		this.update();
	}

	/**
	 * Removes a corner.
	 *
	 * @param {Corner} corner The corner to be removed.
	 */
	removeCorner(corner)
	{
		Utils.removeValue(this.corners, corner);
	}

	/** Gets the walls.
		* @return {Wall[]}
	 **/
	getWalls()
	{
		return this.walls;
	}

	/** Gets the corners.
		* @return {Corner[]}
	**/
	getCorners()
	{
		return this.corners;
	}

	/** Gets the rooms.
		* @return {Room[]}
	**/
	getRooms()
	{
		return this.rooms;
	}

	/** Gets the room overlapping the location x, y.
		* @param {Number} mx
		* @param {Number} my
		* @return {Room}
	**/
	overlappedRoom(mx, my)
	{
			for (var i=0;i<this.rooms.length;i++)
			{
					var room = this.rooms[i];
					var flag = room.pointInRoom(new Vector2(mx, my));
					if(flag)
					{
						return room;
					}
			}

			return null;
	}

	/** Gets the Corner overlapping the location x, y at a tolerance.
		* @param {Number} x
		* @param {Number} y
		* @param {Number} tolerance
		* @return {Corner}
	**/
	overlappedCorner(x, y, tolerance)
	{
		tolerance = tolerance || defaultFloorPlanTolerance;
		for (var i = 0; i < this.corners.length; i++)
		{
			if (this.corners[i].distanceFrom(new Vector2(x, y)) < tolerance)
			{
				return this.corners[i];
			}
		}
		return null;
	}

	/** Gets the Wall overlapping the location x, y at a tolerance.
		* @param {Number} x
		* @param {Number} y
		* @param {Number} tolerance
		* @return {Wall}
	**/
	overlappedWall(x, y, tolerance)
	{
		tolerance = tolerance || defaultFloorPlanTolerance;
		for (var i = 0; i < this.walls.length; i++)
		{
			if (this.walls[i].distanceFrom(new Vector2(x, y)) < tolerance)
			{
				return this.walls[i];
			}
		}
		return null;
	}

	/** The metadata object with information about the rooms.
		* @return {Object} metaroomdata an object with room corner ids as key and names as values
	**/
	getMetaRoomData()
	{
		  var metaRoomData = {};
			this.rooms.forEach((room)=>{
				var metaroom = {};
				// var cornerids = [];
				// room.corners.forEach((corner)=>{
				// 		cornerids.push(corner.id);
				// });
				// var ids = cornerids.join(',');
				var ids = room.roomByCornersId;
				metaroom['name'] = room.name;
				metaRoomData[ids] = metaroom;
			});
			return metaRoomData;
	}

	// Save the floorplan as a json object file
	/**
		*	@return {void}
	**/
	saveFloorplan()
	{
		var floorplans = {corners: {}, walls: [], rooms: {}, wallTextures: [], floorTextures: {}, newFloorTextures: {}, carbonSheet:{}};
		var cornerIds = [];
// writing all the corners based on the corners array
// is having a bug. This is because some walls have corners
// that aren't part of the corners array anymore. This is a quick fix
// by adding the corners to the json file based on the corners in the walls
// this.corners.forEach((corner) => {
// floorplans.corners[corner.id] = {'x': corner.x,'y': corner.y};
// });

		this.walls.forEach((wall) => {
			if(wall.getStart() && wall.getEnd())
			{
				floorplans.walls.push({
					'corner1': wall.getStart().id,
					'corner2': wall.getEnd().id,
					'frontTexture': wall.frontTexture,
					'backTexture': wall.backTexture
				});
				cornerIds.push(wall.getStart());
				cornerIds.push(wall.getEnd());
			}
		});

		cornerIds.forEach((corner)=>{
			floorplans.corners[corner.id] = {'x': corner.x,'y': corner.y, 'elevation': Dimensioning.cmToMeasureRaw(corner.elevation)};
		});

		this.rooms.forEach((room)=>{
			var metaroom = {};
			var cornerids = [];
			room.corners.forEach((corner)=>{
					cornerids.push(corner.id);
			});
			var ids = cornerids.join(',');
			metaroom['name'] = room.name;
			floorplans.rooms[ids] = metaroom;
		});
		// floorplans.rooms = this.getMetaRoomData();

		if(this.carbonSheet)
		{
			floorplans.carbonSheet['url'] = this.carbonSheet.url;
			floorplans.carbonSheet['transparency'] = this.carbonSheet.transparency;
			floorplans.carbonSheet['x'] = this.carbonSheet.x;
			floorplans.carbonSheet['y'] = this.carbonSheet.y;
			floorplans.carbonSheet['anchorX'] = this.carbonSheet.anchorX;
			floorplans.carbonSheet['anchorY'] = this.carbonSheet.anchorY;
			floorplans.carbonSheet['width'] = this.carbonSheet.width;
			floorplans.carbonSheet['height'] = this.carbonSheet.height;
		}

		floorplans.newFloorTextures = this.floorTextures;
		return floorplans;
	}

	//Load the floorplan from a previously saved json object file
	/**
		*	@param {JSON} floorplan
		*	@return {void}
		*	@emits {EVENT_LOADED}
	**/
	loadFloorplan(floorplan)
	{
		this.reset();

		var corners = {};
		if (floorplan == null || !('corners' in floorplan) || !('walls' in floorplan))
		{
			return;
		}
		for (var id in floorplan.corners)
		{
			var corner = floorplan.corners[id];
			corners[id] = this.newCorner(corner.x, corner.y, id);
			if(corner.elevation)
			{
					corners[id].elevation = corner.elevation;
			}
		}
		var scope = this;
		floorplan.walls.forEach((wall) => {
			var newWall = scope.newWall(corners[wall.corner1], corners[wall.corner2]);
			if (wall.frontTexture)
			{
				newWall.frontTexture = wall.frontTexture;
			}
			if (wall.backTexture)
			{
				newWall.backTexture = wall.backTexture;
			}
		});

		if ('newFloorTextures' in floorplan)
		{
			this.floorTextures = floorplan.newFloorTextures;
		}
		this.metaroomsdata = floorplan.rooms;

		this.update();

		if('carbonSheet' in floorplan)
		{
			this.carbonSheet.clear();
			this.carbonSheet.maintainProportion = false;
			this.carbonSheet.x = floorplan.carbonSheet['x'];
			this.carbonSheet.y = floorplan.carbonSheet['y'];
			this.carbonSheet.transparency = floorplan.carbonSheet['transparency'];
			this.carbonSheet.anchorX = floorplan.carbonSheet['anchorX'];
			this.carbonSheet.anchorY = floorplan.carbonSheet['anchorY'];
			this.carbonSheet.width = floorplan.carbonSheet['width'];
			this.carbonSheet.height = floorplan.carbonSheet['height'];
			this.carbonSheet.url = floorplan.carbonSheet['url'];
			this.carbonSheet.maintainProportion = true;
		}
		this.dispatchEvent({type: EVENT_LOADED, item: this});
// this.roomLoadedCallbacks.fire();
	}

	/**
		* @deprecated
	**/
	getFloorTexture(uuid)
	{
		if (uuid in this.floorTextures)
		{
			return this.floorTextures[uuid];
		}
		return null;
	}

	/**
		* @deprecated
	**/
	setFloorTexture(uuid, url, scale)
	{
		this.floorTextures[uuid] = {url: url,scale: scale};
	}

	/** clear out obsolete floor textures */
	/**
		* @deprecated
	**/
	updateFloorTextures()
	{
		var uuids = Utils.map(this.rooms, function (room){return room.getUuid();});
		for (var uuid in this.floorTextures)
		{
			if (!Utils.hasValue(uuids, uuid))
			{
				delete this.floorTextures[uuid];
			}
		}
	}

	/**
		* Resets the floorplan data to empty
		* @return {void}
	**/
	reset()
	{
		var tmpCorners = this.corners.slice(0);
		var tmpWalls = this.walls.slice(0);
		tmpCorners.forEach((corner) => {
			corner.remove();
		});
		tmpWalls.forEach((wall) => {
			wall.remove();
		});
		this.corners = [];
		this.walls = [];
	}

	/**
		* @param {Object} event
		* @listens {EVENT_ROOM_NAME_CHANGED} When a room name is changed and updates to metaroomdata
	**/
	roomNameChanged(e)
	{
			if(this.metaroomsdata)
			{
					this.metaroomsdata[e.item.roomByCornersId] = e.newname;
			}
	}

	/**
	 *	Update the floorplan with new rooms, remove old rooms etc.
	 */
	update()
	{
		var scope = this;
		this.walls.forEach((wall) => {
			wall.resetFrontBack();
		});

		// this.rooms.forEach((room)=>{room.removeEventListener(EVENT_ROOM_NAME_CHANGED, scope.roomNameChanged)});

		var roomCorners = this.findRooms(this.corners);
		this.rooms = [];


		this.corners.forEach((corner)=>{
			corner.clearAttachedRooms();
		});

		roomCorners.forEach((corners) =>
		{
			var room = new Room(scope, corners);
			room.updateArea();
			scope.rooms.push(room);
			room.addEventListener(EVENT_ROOM_NAME_CHANGED, (e)=>{scope.roomNameChanged(e);});
			if(scope.metaroomsdata)
			{
				// var allids = Object.keys(scope.metaroomsdata);
				if(scope.metaroomsdata[room.roomByCornersId])
				{
					room.name = scope.metaroomsdata[room.roomByCornersId];
				}
				// for (var i=0;i<allids.length;i++)
				// {
				// 		var keyName = allids[i];
				// 		var ids = keyName.split(',');
				// 		var isThisRoom = room.hasAllCornersById(ids);
				// 		if(isThisRoom)
				// 		{
				// 				room.name = scope.metaroomsdata[keyName]['name'];
				// 		}
				// }
			}
		});

		// this.metaroomsdata = this.getMetaRoomData();
		this.assignOrphanEdges();
		this.updateFloorTextures();
		this.dispatchEvent({type: EVENT_UPDATED, item: this});
// this.updated_rooms.fire();
	}

	/**
	 * Returns the center of the floorplan in the y plane
	 * @return {Vector2} center
	 *	@see https://threejs.org/docs/#api/en/math/Vector2
	 */
	getCenter()
	{
		return this.getDimensions(true);
	}

	/**
	 * Returns the bounding volume of the full floorplan
	 * @return {Vector3} size
	 *	@see https://threejs.org/docs/#api/en/math/Vector3
	 */
	getSize()
	{
		return this.getDimensions(false);
	}

	/**
	 * Returns the bounding size or the center location of the full floorplan
	 *	@param {boolean} center If true return the center else the size
	 * @return {Vector3} size
	 *	@see https://threejs.org/docs/#api/en/math/Vector3
	 */
	getDimensions(center)
	{
		center = center || false; // otherwise, get size

		var xMin = Infinity;
		var xMax = -Infinity;
		var zMin = Infinity;
		var zMax = -Infinity;
		this.corners.forEach((corner) => {
			if (corner.x < xMin) xMin = corner.x;
			if (corner.x > xMax) xMax = corner.x;
			if (corner.y < zMin) zMin = corner.y;
			if (corner.y > zMax) zMax = corner.y;
		});
		var ret;
		if (xMin == Infinity || xMax == -Infinity || zMin == Infinity || zMax == -Infinity)
		{
			ret = new Vector3();
		}
		else
		{
			if (center)
			{
				// center
				ret = new Vector3((xMin + xMax) * 0.5, 0, (zMin + zMax) * 0.5);
			}
			else
			{
				// size
				ret = new Vector3((xMax - xMin), 0, (zMax - zMin));
			}
		}
		return ret;
	}

	/**
	 * An internal cleanup method
	 */
	assignOrphanEdges()
	{
		// kinda hacky
		// find orphaned wall segments (i.e. not part of rooms) and
		// give them edges
		var orphanWalls = [];
		this.walls.forEach((wall) => {
			if (!wall.backEdge && !wall.frontEdge)
			{
				wall.orphan = true;
				var back = new HalfEdge(null, wall, false);
				var front = new HalfEdge(null, wall, true);
				back.generatePlane();
				front.generatePlane();
				orphanWalls.push(wall);
			}
		});
	}

	/**
	 * Find the "rooms" in our planar straight-line graph. Rooms are set of the
	 * smallest (by area) possible cycles in this graph. @param corners The
	 * corners of the floorplan. @returns The rooms, each room as an array of
	 * corners.
	 *	@param {Corners[]} corners
	 * @return {Corners[][]} loops
	 **/
	findRooms(corners)
	{

		function _calculateTheta(previousCorner, currentCorner, nextCorner)
		{
			var theta = Utils.angle2pi(new Vector2(previousCorner.x - currentCorner.x, previousCorner.y - currentCorner.y), new Vector2(nextCorner.x - currentCorner.x, nextCorner.y - currentCorner.y));
			return theta;
		}

		function _removeDuplicateRooms(roomArray)
		{
			var results = [];
			var lookup = {};
			var hashFunc = function (corner)
			{
				return corner.id;
			};
			var sep = '-';
			for (var i = 0; i < roomArray.length; i++)
			{
				// rooms are cycles, shift it around to check uniqueness
				var add = true;
				var room = roomArray[i];
				for (var j = 0; j < room.length; j++)
				{
					var roomShift = Utils.cycle(room, j);
					var str = Utils.map(roomShift, hashFunc).join(sep);
					if (lookup.hasOwnProperty(str))
					{
						add = false;
					}
				}
				if (add)
				{
					results.push(roomArray[i]);
					lookup[str] = true;
				}
			}
			return results;
		}

		/**
		 * An internal method to find rooms based on corners and their connectivities
		 */
		function _findTightestCycle(firstCorner, secondCorner)
		{
			var stack = [];
			var next = {corner: secondCorner,previousCorners: [firstCorner]};
			var visited = {};
			visited[firstCorner.id] = true;

			while (next)
			{
				// update previous corners, current corner, and visited corners
				var currentCorner = next.corner;
				visited[currentCorner.id] = true;

				// did we make it back to the startCorner?
				if (next.corner === firstCorner && currentCorner !== secondCorner)
				{
					return next.previousCorners;
				}

				var addToStack = [];
				var adjacentCorners = next.corner.adjacentCorners();
				for (var i = 0; i < adjacentCorners.length; i++)
				{
					var nextCorner = adjacentCorners[i];

					// is this where we came from?
					// give an exception if its the first corner and we aren't
					// at the second corner
					if (nextCorner.id in visited && !(nextCorner === firstCorner && currentCorner !== secondCorner))
					{
						continue;
					}

					// nope, throw it on the queue
					addToStack.push(nextCorner);
				}

				var previousCorners = next.previousCorners.slice(0);
				previousCorners.push(currentCorner);
				if (addToStack.length > 1)
				{
					// visit the ones with smallest theta first
					var previousCorner = next.previousCorners[next.previousCorners.length - 1];
					addToStack.sort(function (a, b){return (_calculateTheta(previousCorner, currentCorner, b) - _calculateTheta(previousCorner, currentCorner, a));});
				}

				if (addToStack.length > 0)
				{
					// add to the stack
					addToStack.forEach((corner) => {
						stack.push({ corner: corner, previousCorners: previousCorners});
					});
				}

				// pop off the next one
				next = stack.pop();
			}
			return [];
		}

		// find tightest loops, for each corner, for each adjacent
		// TODO: optimize this, only check corners with > 2 adjacents, or
		// isolated cycles
		var loops = [];

		corners.forEach((firstCorner) => {
			firstCorner.adjacentCorners().forEach((secondCorner) => {
				loops.push(_findTightestCycle(firstCorner, secondCorner));
			});
		});

		// remove duplicates
		var uniqueLoops = _removeDuplicateRooms(loops);
		// remove CW loops
		var uniqueCCWLoops = Utils.removeIf(uniqueLoops, Utils.isClockwise);
		return uniqueCCWLoops;
	}
}