gojs
Version:
Interactive diagrams, charts, and graphs, such as trees, flowcharts, orgcharts, UML, BPMN, or business diagrams
1,193 lines (1,045 loc) • 201 kB
text/typescript
/*
* Copyright (C) 1998-2020 by Northwoods Software Corporation
* All Rights Reserved.
*
* Floorplan Class
* A Floorplan is a Diagram with special rules
*/
import * as go from 'gojs';
import { NodeLabelDraggingTool } from './NodeLabelDraggingTool.js';
import { WallBuildingTool } from './WallBuildingTool.js';
import { WallReshapingTool } from './WallReshapingTool.js';
export class Floorplan extends go.Diagram {
private _palettes: Array<go.Palette>;
private _pointNodes: go.Set<go.Node>;
private _dimensionLinks: go.Set<go.Link>;
private _angleNodes: go.Set<go.Node>;
/**
* A Floorplan is a special kind of Diagram. It supports walls, rooms, and many other common features a Floorplan might have.
* @param div The HTML DIV element or DIV element id for the Floorplan to use
*/
constructor(div: HTMLDivElement | string) {
super(div);
/**
* Floor Plan Setup:
* Initialize Floor Plan, Floor Plan Listeners, Floor Plan Overview
*/
// When a FloorplanPalette instance is made, it is automatically added to a Floorplan's "palettes" property
this._palettes = [];
// Point Nodes, Dimension Links, Angle Nodes on the Floorplan (never in model data)
this._pointNodes = new go.Set<go.Node>();
this._dimensionLinks = new go.Set<go.Link>();
this._angleNodes = new go.Set<go.Node>();
const $ = go.GraphObject.make;
this.allowLink = false;
this.undoManager.isEnabled = true;
this.layout.isInitial = false;
this.layout.isOngoing = false;
this.model = $(go.GraphLinksModel, {
modelData: {
'units': 'meters',
'unitsAbbreviation': 'm',
'unitsConversionFactor': .02,
'gridSize': 10,
'wallThickness': 10,
'preferences': {
showWallGuidelines: true,
showWallLengths: true,
showWallAngles: true,
showOnlySmallWallAngles: true,
showGrid: true,
gridSnap: true
}
}
});
this.grid = $(go.Panel, 'Grid',
{ gridCellSize: new go.Size(this.model.modelData.gridSize, this.model.modelData.gridSize), visible: true },
$(go.Shape, 'LineH', { stroke: 'lightgray' }),
$(go.Shape, 'LineV', { stroke: 'lightgray' }));
this.contextMenu = makeContextMenu();
this.commandHandler.canGroupSelection = function() { return true; };
this.commandHandler.canUngroupSelection = function() { return true; };
this.commandHandler.archetypeGroupData = { isGroup: true };
// Listeners
// if a wall is copied, update its geometry
this.addDiagramListener('SelectionCopied', function(e) {
const fp: Floorplan = e.diagram as Floorplan;
fp.selection.iterator.each(function(part) {
if (part.category === 'WallGroup') {
const w: go.Group = part as go.Group;
fp.updateWall(w);
}
});
});
// If a node has been dropped onto the Floorplan from a Palette...
this.addDiagramListener('ExternalObjectsDropped', function(e) {
const garbage: Array<go.Part> = [];
const fp: Floorplan = e.diagram as Floorplan;
fp.selection.iterator.each(function(node) {
// if floor node dropped, try to make a room node here with that floor brush style
if (node.category === 'FloorNode') {
const floorNode = node;
const pt: go.Point = fp.lastInput.documentPoint;
// try to make a room here
fp.maybeAddRoomNode(pt, floorNode.data.floorImage);
garbage.push(floorNode);
}
});
for (const i in garbage) {
e.diagram.remove(garbage[i]);
}
});
// When a wall is copied / pasted, update the wall geometry, angle, etc
this.addDiagramListener('ClipboardPasted', function(e) {
const fp: Floorplan = e.diagram as Floorplan;
e.diagram.selection.iterator.each(function(node) {
if (node.category === 'WallGroup') {
const w: go.Group = node as go.Group;
fp.updateWall(w);
}
});
});
// Display different help depending on selection context
this.addDiagramListener('ChangedSelection', function(e) {
const floorplan: Floorplan = e.diagram as Floorplan;
floorplan.skipsUndoManager = true;
floorplan.startTransaction('remove dimension links and angle nodes');
floorplan.pointNodes.iterator.each(function(node) { e.diagram.remove(node); });
floorplan.dimensionLinks.iterator.each(function(link) { e.diagram.remove(link); });
const missedDimensionLinks: Array<go.Link> = []; // used only in undo situations
floorplan.links.iterator.each(function(link) { if (link.data.category === 'DimensionLink') missedDimensionLinks.push(link); });
for (let i: number = 0; i < missedDimensionLinks.length; i++) {
e.diagram.remove(missedDimensionLinks[i]);
}
floorplan.pointNodes.clear();
floorplan.dimensionLinks.clear();
floorplan.angleNodes.iterator.each(function(node) { e.diagram.remove(node); });
floorplan.angleNodes.clear();
floorplan.commitTransaction('remove dimension links and angle nodes');
floorplan.skipsUndoManager = false;
floorplan.updateWallDimensions();
floorplan.updateWallAngles();
});
// if user deletes a wall, update rooms
this.addDiagramListener('SelectionDeleted', function(e) {
const wrt: WallReshapingTool = e.diagram.toolManager.mouseDownTools.elt(3) as WallReshapingTool;
wrt.joinAllColinearWalls();
wrt.splitAllWalls();
wrt.performAllMitering();
// also update rooms
const deletedParts: go.Set<go.Part> = e.subject as go.Set<go.Part>;
// make sure to get all the walls that were just deleted, so updateAllRoomBoundaries knows about what change triggered it
const walls: go.Set<go.Group> = new go.Set<go.Group>();
deletedParts.iterator.each(function(p) {
if (p instanceof go.Group && p.data.category === 'WallGroup') {
const w: go.Group = p as go.Group;
walls.add(w);
}
});
const fp: Floorplan = e.diagram as Floorplan;
fp.updateAllRoomBoundaries(walls);
});
/*
* Node Templates
* Add Default Node, Multi-Purpose Node, Window Node, Palette Wall Node, and Door Node to the Node Template Map
*/
this.nodeTemplateMap.add('', makeDefaultNode()); // Default Node (furniture)
this.nodeTemplateMap.add('MultiPurposeNode', makeMultiPurposeNode()); // Multi-Purpose Node
this.nodeTemplateMap.add('WindowNode', makeWindowNode()); // Window Node
this.nodeTemplateMap.add('PaletteWallNode', makePaletteWallNode()); // Palette Wall Node
this.nodeTemplateMap.add('DoorNode', makeDoorNode()); // Door Node
this.nodeTemplateMap.add('RoomNode', makeRoomNode()); // Room Node
/*
* Group Templates
* Add Default Group, Wall Group to Group Template Map
*/
this.groupTemplateMap.add('', makeDefaultGroup()); // Default Group
this.groupTemplateMap.add('WallGroup', makeWallGroup()); // Wall Group
/*
* Install Custom Tools
* Wall Building Tool, Wall Reshaping Tool
*/
const wallBuildingTool = new WallBuildingTool();
this.toolManager.mouseDownTools.insertAt(0, wallBuildingTool);
const wallReshapingTool = new WallReshapingTool();
this.toolManager.mouseDownTools.insertAt(3, wallReshapingTool);
wallBuildingTool.isEnabled = false;
const nodeLabelDraggingTool = new NodeLabelDraggingTool();
this.toolManager.mouseMoveTools.insertAt(3, nodeLabelDraggingTool);
/*
* Tool Overrides
*/
// If a wall was dragged to intersect another wall, update angle displays
this.toolManager.draggingTool.doDeactivate = function() {
// go.DraggingTool.prototype.doMouseUp.call(this);
const fp: Floorplan = this.diagram as Floorplan;
const tool = this;
fp.updateWallAngles();
this.isGridSnapEnabled = this.diagram.model.modelData.preferences.gridSnap;
// maybe recalc rooms, if dragging a wall
let selectedWall: go.Group | null | undefined = null;
fp.selection.iterator.each(function(p: go.Part) {
if (p.category === 'WallGroup' && selectedWall == null) {
const w: go.Group = p as go.Group;
selectedWall = w;
} else if (p.category === 'WallGroup' && selectedWall !== null) {
// only worry about selectedWall if there is a single selected wall (cannot drag multiple walls at once)
selectedWall = undefined;
}
});
if (selectedWall) {
const selWallSet = new go.Set<go.Group>(); selWallSet.add(selectedWall);
fp.updateAllRoomBoundaries(selWallSet);
const wrt: WallReshapingTool = fp.toolManager.mouseDownTools.elt(3) as WallReshapingTool;
wrt.performMiteringOnWall(selectedWall);
fp.updateWall(selectedWall);
}
go.DraggingTool.prototype.doDeactivate.call(this);
};
// If user holds SHIFT while dragging, do not use grid snap
this.toolManager.draggingTool.doMouseMove = function() {
if (this.diagram.lastInput.shift) {
this.isGridSnapEnabled = false;
} else this.isGridSnapEnabled = this.diagram.model.modelData.preferences.gridSnap;
go.DraggingTool.prototype.doMouseMove.call(this);
};
// When resizing, constantly update the node info box with updated size info; constantly update Dimension Links
this.toolManager.resizingTool.doMouseMove = function() {
const floorplan: Floorplan = this.diagram as Floorplan;
floorplan.updateWallDimensions();
go.ResizingTool.prototype.doMouseMove.call(this);
};
// When resizing a wallPart, do not allow it to be resized past the nearest wallPart / wall endpoints
this.toolManager.resizingTool.computeMaxSize = function() {
const tool = this;
const adornedObject = tool.adornedObject;
if (adornedObject !== null) {
const obj = adornedObject.part;
let wall: go.Group | null = null;
if (obj !== null) {
wall = this.diagram.findPartForKey(obj.data.group) as go.Group;
}
if ((wall !== null && obj !== null && (obj.category === 'DoorNode' || obj.category === 'WindowNode'))) {
let stationaryPt: go.Point | null = null; let movingPt: go.Point | null = null;
let resizeAdornment: go.Adornment | null = null;
const oitr = obj.adornments.iterator;
while (oitr.next()) {
const adorn: go.Adornment = oitr.value;
if (adorn.name === 'WallPartResizeAdornment') {
resizeAdornment = adorn;
}
}
if (resizeAdornment !== null) {
const ritr = resizeAdornment.elements.iterator;
while (ritr.next()) {
const el: go.GraphObject = ritr.value;
const handle: go.GraphObject | null = tool.handle;
if (handle !== null) {
if (el instanceof go.Shape && el.alignment === handle.alignment) {
movingPt = el.getDocumentPoint(go.Spot.Center);
}
if (el instanceof go.Shape && el.alignment !== handle.alignment) {
stationaryPt = el.getDocumentPoint(go.Spot.Center);
}
}
}
}
// find the constrainingPt; that is, the endpoint (wallPart endpoint or wall endpoint)
// that is the one closest to movingPt but still farther from stationaryPt than movingPt
// this loop checks all other wallPart endpoints of the wall that the resizing wallPart is a part of
let constrainingPt; let closestDist = Number.MAX_VALUE;
wall.memberParts.iterator.each(function(part: go.Part) {
if (part.data.key !== obj.data.key) {
const endpoints = getWallPartEndpoints(part);
for (let i: number = 0; i < endpoints.length; i++) {
const point = endpoints[i];
const distanceToMovingPt = Math.sqrt(point.distanceSquaredPoint(movingPt));
if (distanceToMovingPt < closestDist) {
const distanceToStationaryPt = Math.sqrt(point.distanceSquaredPoint(stationaryPt));
if (distanceToStationaryPt > distanceToMovingPt) {
closestDist = distanceToMovingPt;
constrainingPt = point;
}
}
}
}
});
// if we're not constrained by a wallPart endpoint, the constraint will come from a wall endpoint; figure out which one
if (constrainingPt === undefined || constrainingPt === null) {
if (wall.data.startpoint.distanceSquaredPoint(movingPt) > wall.data.startpoint.distanceSquaredPoint(stationaryPt)) constrainingPt = wall.data.endpoint;
else constrainingPt = wall.data.startpoint;
}
// set the new max size of the wallPart according to the constrainingPt
let maxLength: number = 0;
if (stationaryPt !== null) {
maxLength = Math.sqrt(stationaryPt.distanceSquaredPoint(constrainingPt));
}
return new go.Size(maxLength, wall.data.thickness);
}
}
return go.ResizingTool.prototype.computeMaxSize.call(tool);
};
this.toolManager.draggingTool.isGridSnapEnabled = true;
} // end Floorplan constructor
// Get / set array of all Palettes associated with this Floorplans
get palettes(): Array<go.Palette> { return this._palettes; }
set palettes(value: Array<go.Palette>) { this._palettes = value; }
// Get / set pointNodes
get pointNodes(): go.Set<go.Node> { return this._pointNodes; }
set pointNodes(value: go.Set<go.Node>) { this._pointNodes = value; }
// Get / set dimensionLinks
get dimensionLinks(): go.Set<go.Link> { return this._dimensionLinks; }
set dimensionLinks(value: go.Set<go.Link>) { this._dimensionLinks = value; }
// Get / set angleNodes
get angleNodes(): go.Set<go.Node> { return this._angleNodes; }
set angleNodes(value: go.Set<go.Node>) { this._angleNodes = value; }
/**
* Convert num number of pixels (document units) to units, using the adjustable conversion factor stored in modeldata
* @param {number} num This is in document units (colloquially, if inaccurately, referred to as "pixels")
* @return {number}
*/
public convertPixelsToUnits(num: number): number {
const units: string = this.model.modelData.units;
const factor: number = this.model.modelData.unitsConversionFactor;
return num * factor;
/*if (units === 'meters') return (num / 100) * factor;
if (units === 'feet') return (num / 30.48) * factor;
if (units === 'inches') return (num / 2.54) * factor;
return num * factor; */
}
/**
* Take a number of units, convert to cm, then divide by 2, (1px = 2cm, change this if you want to use a different paradigm)
* @param {number} num This is in document units (colloquially, if inaccurately, referred to as "pixels")
* @return {number}
*/
public convertUnitsToPixels(num: number): number {
const units: string = this.model.modelData.units;
const factor: number = this.model.modelData.unitsConversionFactor;
return num / factor;
/*if (units === 'meters') return (num * 100) / factor;
if (units === 'feet') return (num * 30.48) / factor;
if (units === 'inches') return (num * 2.54) / factor;
return num / factor; */
}
/**
* @param units string
*/
protected getUnitsAbbreviation(units: string): string {
switch (units) {
case 'centimeters': {
return 'cm';
}
case 'meters': {
return 'm';
}
case 'inches': {
return 'in';
}
case 'feet': {
return 'ft';
}
}
return units;
}
/**
* Convert a number of oldUnits to newUnits
* @param {string} oldUnits cm | m | ft | in
* @param {string} newUnits cm | m | ft | in
* @param {number} num The number of old units to convert to new ones
* @return {number} The number of new units
*/
public convertUnits(oldUnits: string, newUnits: string, num: number): number {
const fp: Floorplan = this;
let newNum: number = num;
oldUnits = fp.getUnitsAbbreviation(oldUnits);
newUnits = fp.getUnitsAbbreviation(newUnits);
switch (oldUnits) {
case 'cm': {
switch (newUnits) {
case 'm': {
newNum *= .01;
break;
}
case 'ft': {
newNum *= 0.0328084;
break;
}
case 'in': {
newNum *= 0.393701;
break;
}
}
break;
} // end cm oldUnits case
case 'm': {
switch (newUnits) {
case 'cm': {
newNum *= 100;
break;
}
case 'ft': {
newNum *= 3.28084;
break;
}
case 'in': {
newNum *= 39.3701;
break;
}
}
break;
} // end m oldUnits case
case 'ft': {
switch (newUnits) {
case 'cm': {
newNum *= 30.48;
break;
}
case 'm': {
newNum *= 0.3048;
break;
}
case 'in': {
newNum *= 12;
break;
}
}
break;
} // end ft oldUnits case
case 'in': {
switch (newUnits) {
case 'cm': {
newNum *= 2.54;
break;
}
case 'm': {
newNum *= 0.0254;
break;
}
case 'ft': {
newNum *= 0.0833333;
break;
}
}
break;
} // end in oldUnitsCase
}
return newNum;
}
public makeDefaultFurniturePaletteNodeData(): Array<any> {
return FURNITURE_NODE_DATA_ARRAY;
}
public makeDefaultWallpartsPaletteNodeData(): Array<any> {
return WALLPARTS_NODE_DATA_ARRAY;
}
/**
* Turn on wall building tool, set WallBuildingTool.isBuildingDivider to false
*/
public enableWallBuilding(): void {
const fp: Floorplan = this;
const wallBuildingTool: WallBuildingTool = fp.toolManager.mouseDownTools.elt(0) as WallBuildingTool;
wallBuildingTool.isBuildingDivider = false;
const wallReshapingTool: WallReshapingTool = fp.toolManager.mouseDownTools.elt(3) as WallReshapingTool;
wallBuildingTool.isEnabled = true;
wallReshapingTool.isEnabled = false;
fp.currentCursor = 'crosshair';
// clear resize adornments on walls/windows, if there are any
fp.nodes.iterator.each(function(n) { n.clearAdornments(); });
fp.clearSelection();
}
/**
* Turn on wall building tool, set WallBuildingTool.isBuildingDivider to true
*/
public enableDividerBuilding(): void {
const fp: Floorplan = this;
const wallBuildingTool: WallBuildingTool = fp.toolManager.mouseDownTools.elt(0) as WallBuildingTool;
fp.enableWallBuilding();
wallBuildingTool.isBuildingDivider = true;
fp.currentCursor = 'crosshair';
}
/**
* Turn off wall building tool
*/
public disableWallBuilding(): void {
const fp: Floorplan = this;
const wallBuildingTool: WallBuildingTool = fp.toolManager.mouseDownTools.elt(0) as WallBuildingTool;
const wallReshapingTool: WallReshapingTool = fp.toolManager.mouseDownTools.elt(3) as WallReshapingTool;
wallBuildingTool.isEnabled = false;
wallReshapingTool.isEnabled = true;
wallBuildingTool.isBuildingDivider = false;
fp.currentCursor = '';
// clear resize adornments on walls/windows, if there are any
fp.nodes.iterator.each(function(n) { n.clearAdornments(); });
fp.clearSelection();
}
/**
* Called when a checkbox in the options window is changed.
* Perform the appropriate changes to model data.
* @param checkboxId The string id of the HTML checkbox element that's been changed
*/
public checkboxChanged(checkboxId: string): void {
const floorplan: Floorplan = this;
floorplan.skipsUndoManager = true;
floorplan.startTransaction('change preference');
const element: HTMLInputElement = document.getElementById(checkboxId) as HTMLInputElement;
switch (checkboxId) {
case 'showGridCheckbox': {
floorplan.grid.visible = element.checked;
floorplan.model.modelData.preferences.showGrid = element.checked;
break;
}
case 'gridSnapCheckbox': {
floorplan.toolManager.draggingTool.isGridSnapEnabled = element.checked;
floorplan.model.modelData.preferences.gridSnap = element.checked;
break;
}
case 'wallGuidelinesCheckbox': floorplan.model.modelData.preferences.showWallGuidelines = element.checked; break;
case 'wallLengthsCheckbox': floorplan.model.modelData.preferences.showWallLengths = element.checked; floorplan.updateWallDimensions(); break;
case 'wallAnglesCheckbox': floorplan.model.modelData.preferences.showWallAngles = element.checked; floorplan.updateWallAngles(); break;
case 'onlySmallWallAnglesCheckbox': {
floorplan.model.modelData.preferences.showOnlySmallWallAngles = element.checked;
floorplan.updateWallAngles();
break;
}
}
floorplan.commitTransaction('change preference');
floorplan.skipsUndoManager = false;
}
/**
* Change the units being used by the Floorplan
* @param {HTMLFormElement} form The form element containing the units radio buttons in the options menu
*/
public changeUnits(form: HTMLFormElement): void {
const fp: Floorplan = this;
const radios: HTMLCollection = form.getElementsByTagName('input');
const prevUnits: string = fp.model.modelData.units;
// find selected radio, set units in modelData to the proper units
for (let i: number = 0; i < radios.length; i++) {
const radio: HTMLInputElement = radios[i] as HTMLInputElement;
if (radio.checked) {
const unitsStr: string = radio.id;
fp.model.setDataProperty(fp.model.modelData, 'units', unitsStr);
// also set unitsAbbreviation
switch (radio.id) {
case 'centimeters': fp.model.setDataProperty(fp.model.modelData, 'unitsAbbreviation', 'cm'); break;
case 'meters': fp.model.setDataProperty(fp.model.modelData, 'unitsAbbreviation', 'm'); break;
case 'feet': fp.model.setDataProperty(fp.model.modelData, 'unitsAbbreviation', 'ft'); break;
case 'inches': fp.model.setDataProperty(fp.model.modelData, 'unitsAbbreviation', 'in'); break;
}
}
}
const unitsAbbreviation: string = fp.model.modelData.unitsAbbreviation;
const unitAbbrevInputs: HTMLCollection = document.getElementsByClassName('unitsBox');
for (let i: number = 0; i < unitAbbrevInputs.length; i++) {
const uaInput: HTMLInputElement = unitAbbrevInputs[i] as HTMLInputElement;
uaInput.value = unitsAbbreviation;
}
// explicitly set the units conversion factor by converting the old one to the new units equivalent
const unitsConversionFactorInput: HTMLInputElement = document.getElementById('unitsConversionFactorInput') as HTMLInputElement;
const oldUnitsConversionFactor: number = parseFloat(unitsConversionFactorInput.value);
const units: string = fp.model.modelData.units;
const newUnitsConverstionFactor: number = fp.convertUnits(prevUnits, units, oldUnitsConversionFactor);
fp.model.setDataProperty(fp.model.modelData, 'unitsConversionFactor', newUnitsConverstionFactor);
unitsConversionFactorInput.value = newUnitsConverstionFactor.toString();
const unitInputs: HTMLCollection = document.getElementsByClassName('unitsInput');
for (let i: number = 0; i < unitInputs.length; i++) {
const input: HTMLInputElement = unitInputs[i] as HTMLInputElement;
if (input.id !== 'unitsConversionFactorInput') {
let value: number = parseFloat(input.value);
value = parseFloat(fp.convertUnits(prevUnits, units, value).toFixed(4));
input.value = value.toString();
}
}
}
/**
* Change the units conversion factor for the Floorplan.
* @param {HTMLInputElement} unitsConversionFactorInput The input element that contains the units conversion factor for the Floorplan
* @param {HTMLInputElement} gridSizeInput Optional. If provided, the grid will be updated too
*/
public changeUnitsConversionFactor(unitsConversionFactorInput: HTMLInputElement, gridSizeInput?: HTMLInputElement): void {
const floorplan: Floorplan = this;
const val: number = parseFloat(unitsConversionFactorInput.value);
if (isNaN(val) || !val || val === undefined) return;
floorplan.skipsUndoManager = true;
floorplan.model.set(floorplan.model.modelData, 'unitsConversionFactor', val);
if (gridSizeInput) {
floorplan.changeGridSize(gridSizeInput);
}
floorplan.skipsUndoManager = false;
}
/**
* Change the grid size being used for the Floorplan.
* @param gridSizeInput The input that contains the grid size to use
*/
public changeGridSize(gridSizeInput: HTMLInputElement): void {
const fp: Floorplan = this;
fp.skipsUndoManager = true;
fp.startTransaction('change grid size');
let inputVal: number = 0;
if (!isNaN(parseFloat(gridSizeInput.value)) && gridSizeInput.value != null && gridSizeInput.value !== '' &&
gridSizeInput.value !== undefined && parseFloat(gridSizeInput.value) > 0) inputVal = parseFloat(gridSizeInput.value);
else {
gridSizeInput.value = fp.convertPixelsToUnits(10).toString(); // if bad input given, revert to 20cm (10px) or unit equivalent
inputVal = parseFloat(gridSizeInput.value);
}
inputVal = fp.convertUnitsToPixels(inputVal);
fp.grid.gridCellSize = new go.Size(inputVal, inputVal);
// fp.toolManager.draggingTool.gridCellSize = new go.Size(inputVal, inputVal);
fp.model.setDataProperty(fp.model.modelData, 'gridSize', inputVal);
fp.commitTransaction('change grid size');
fp.skipsUndoManager = false;
}
/**
* Get the side of a wall (1 or 2) to use as the room boundary
* @param {go.Group} w
* @param {go.Point} ip
* @return {number}
*/
private getCounterClockwiseWallSide(w: go.Group, ip: go.Point): number {
const fp: Floorplan = this;
const wrt: WallReshapingTool = fp.toolManager.mouseDownTools.elt(3) as WallReshapingTool;
// these are the mitering point data properties of the wall opposite from the intersection point
let prop1: string | null = null; let prop2: string | null = null;
// If intersection point (ip) is wall (w)'s data.endpoint, prop1 = smpt1, prop2 = smpt2
if (wrt.pointsApproximatelyEqual(w.data.endpoint, ip)) {
prop1 = 'smpt1'; prop2 = 'smpt2';
} else {
prop1 = 'empt1'; prop2 = 'empt2';
}
const A: go.Point = ip;
const B: go.Point = w.data[prop2];
const C: go.Point = w.data[prop1];
// A = intersection point, B = w.data[prop1], C = w.data.[prop2]
// if AC is counterclockwise of AB, return 2; else return 1
function isClockwise(a: go.Point, b: go.Point, c: go.Point) {
const bool: boolean = ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) > 0;
return bool;
}
if (!isClockwise(A, B, C)) {
return 1;
} else return 2;
}
/**
* Returns the intersection point between the two lines.
* Lines are implied by two endpoints each.
* @param {go.Point} a1 Point Endpoint 1 of line A
* @param {go.Point} a2 Point Endpoint 2 of line A
* @param {go.Point} b1 Point Endpoint 1 of line B
* @param {go.Point} b2 Point Endpoint 2 of line B
* @return {go.Point | null}
*/
public getLinesIntersection(a1: go.Point, a2: go.Point, b1: go.Point, b2: go.Point): go.Point | null {
const am: number = (a1.y - a2.y) / (a1.x - a2.x); // slope of line 1
const bm: number = (b1.y - b2.y) / (b1.x - b2.x); // slope of line 2
// Line A is vertical
if (am === Infinity || am === -Infinity) {
const ipx: number = a1.x;
// line B y-intercept
const bi: number = -1 * ((bm * b1.x) - b1.y);
// Solve for line B's y at x=ipx
const ipy: number = (bm * ipx) + bi;
return new go.Point(ipx, ipy);
}
// Line B is vertical
if (bm === Infinity || bm === -Infinity) {
const ipx: number = b1.x;
// line A y-intercept
const ai: number = -1 * ((am * a1.x) - a1.y);
// Solve for line A's y at x=ipx
const ipy: number = (am * ipx) + ai;
return new go.Point(ipx, ipy);
}
if (Math.abs(am - bm) < Math.pow(2, -52)) {
return null;
} else {
const ipx: number = (am * a1.x - bm * b1.x + b1.y - a1.y) / (am - bm);
const ipy: number = (am * bm * (b1.x - a1.x) + bm * a1.y - am * b1.y) / (bm - am);
const ip: go.Point = new go.Point(ipx, ipy);
return ip;
}
}
/**
* Update the geometries of all rooms in the floorplan. This is called when a wall is added or reshaped or deleted
* @param {go.Set<go.Group>} changedWalls This is the set of walls that was just added / updated / removed. Often this is a single element
*/
public updateAllRoomBoundaries(changedWalls: go.Set<go.Group>): void {
const fp: Floorplan = this;
const wrt: WallReshapingTool = fp.toolManager.mouseDownTools.elt(3) as WallReshapingTool;
const rooms: go.Iterator<go.Node> = fp.findNodesByExample({ category: 'RoomNode' });
// rooms to remove
const garbage: Array<go.Node> = [];
rooms.iterator.each(function(r: go.Node) {
// do this until you've tried all wall intersections for the room OR the room boundaries have been successfully updated
let boundsFound: boolean = false;
let triedAllIntersections: boolean = false;
const seenW1: go.Set<go.Group> = new go.Set<go.Group>(); // the walls that have been used as w1 (later)
while (!boundsFound && !triedAllIntersections) {
// find a point "pt" that will definitely be in the room implied by the r.boundaryWalls (if the area is still enclosed)
// to do so, find 2 boundaryWalls that are connected and get a point just outside their intersection (along the proper mitering side)
// Note: Neither of these 2 walls may be in "changedWalls" (the walls that were added / modified)
// The first of these walls must still be valid (i.e. it was not split or joined with another wall in the action that trieggered this function)
const bw: Array<any> = r.data.boundaryWalls;
let e1 = null; let e2 = null; // entries that represent wall / mitering side pairs to use to find pt
for (let i = 0; i < bw.length + 1; i++) {
const entry = bw[i % (bw.length)];
const wk: string = entry[0];
const ww: go.Group = fp.findNodeForKey(wk) as go.Group;
if (ww === null) continue;
if (!changedWalls.contains(ww) && !seenW1.contains(ww)) {
if (e1 === null) {
e1 = entry;
} else if (e1 !== null && e2 === null) {
e2 = entry;
}
} else if (e1 !== null && e2 === null) {
e2 = entry;
} else if (e1 === null) {
e1 = null;
e2 = null;
}
}
// with these 2 entries (walls / mitering sides), we now get a point that would definitely be in the room (if the area is still enclosed)
// first, get the segments implied by mitering sides of the walls
let w1: go.Group | null = null; let w2: go.Group | null = null; let s1: number | null = null; let s2: number | null = null;
if (e1 !== null && e2 !== null) {
w1 = fp.findNodeForKey(e1[0]) as go.Group; s1 = e1[1];
w2 = fp.findNodeForKey(e2[0]) as go.Group; s2 = e2[1];
} else {
triedAllIntersections = true;
continue;
}
if (e1 !== null && w1 !== null) {
seenW1.add(w1);
}
const w1s: go.Point = w1.data['smpt' + s1]; const w1e: go.Point = w1.data['empt' + s1];
const w2s: go.Point = w2.data['smpt' + s2]; const w2e: go.Point = w2.data['empt' + s2];
// at which point do these 2 wall sides intersect?
const ip: go.Point | null = fp.getSegmentsIntersection(w1s, w1e, w2s, w2e);
if (ip === null) {
continue;
}
// the prop name of the point on the other mitering side of ip. we'll use this to get the angle of the intersection
const w1os: number = (s1 === 1) ? 2 : 1;
// let prop: string = wrt.pointsApproximatelyEqual(ip, w1s) ? "smpt" + w1os : "empt" + w1os;
const distToS = ip.distanceSquaredPoint(w1.data['smpt' + w1os]);
const distToE = ip.distanceSquaredPoint(w1.data['empt' + w1os]);
// which other side pt is closer to ip? That's the oip
const oip: go.Point = distToS <= distToE ? w1.data.startpoint : w1.data.endpoint;
const ang: number = oip.directionPoint(ip);
const newPt: go.Point = wrt.translateAndRotatePoint(ip, ang - 90, 0.5);
// debug -- show calculated pts
/*
const $ = go.GraphObject.make;
fp.add(
$(go.Node, 'Spot', { locationSpot: go.Spot.Center, location: oip },
$(go.Shape, 'Circle', { desiredSize: new go.Size(5, 5), fill: 'red' })
)
);
fp.add(
$(go.Node, 'Spot', { locationSpot: go.Spot.Center, location: ip },
$(go.Shape, 'Circle', { desiredSize: new go.Size(5, 5), fill: 'green' })
)
);
fp.add(
$(go.Node, 'Spot', { locationSpot: go.Spot.Center, location: newPt },
$(go.Shape, 'Circle', { desiredSize: new go.Size(5, 5), fill: 'cyan' })
)
);*/
boundsFound = fp.maybeAddRoomNode(newPt, r.data.floorImage, r);
}
// if the room boundaries are never found, this room must be removed
if (!boundsFound) {
garbage.push(r);
}
});
for (let i = 0; i < garbage.length; i++) {
fp.remove(garbage[i]);
}
// ensure proper room position by updating target bindings
fp.updateAllTargetBindings();
}
/**
* Tries to add a Room Node from a given point.
* The point must be enclosed by walls.
* @param {go.Point} pt
* @param {string} floorImage The image relative path to use as the Pattern brush for the room's flooring
* @param {go.Node} roomToUpdate Optional. If this is provided, the walls found for the area will be assigned to this room node
* @return {boolean} Whether or not the pt is enclosed by room boundaries
*/
public maybeAddRoomNode(pt: go.Point, floorImage: string, roomToUpdate?: go.Node | null): boolean {
if (roomToUpdate === undefined || roomToUpdate === null) {
roomToUpdate = null;
}
const fp: Floorplan = this;
// if the pt is on a Room or Wall, do nothing
const walls: go.Iterator<go.Group> = fp.findNodesByExample({ category: 'WallGroup' }) as go.Iterator<go.Group>;
let isPtOnRoomOrWall: boolean = false;
// make sure "pt" is not inside a wall or room node. If it is, do not run this function
walls.iterator.each(function(w: go.Group) {
if (fp.isPointInWall(w, pt)) {
isPtOnRoomOrWall = true;
}
});
const rooms: go.Iterator<go.Node> = fp.findNodesByExample({ category: 'RoomNode' }) as go.Iterator<go.Node>;
rooms.iterator.each(function(r: go.Node) {
if (roomToUpdate === null || (roomToUpdate !== null && roomToUpdate !== undefined && roomToUpdate.data.key !== r.data.key)) {
const isInRoom: boolean = fp.isPointInRoom(r, pt);
if (isInRoom) {
// Edge: it's possible we're within the polygon created by the rooms boundary walls, but over one of its holes
// if so, we may still be able to make new room here
let isPtInHole: boolean = false;
for (let i: number = 0; i < r.data.holes.length; i++) {
const hole: Array<any> = r.data.holes[i];
const polygon: go.List<go.Point> | null = fp.makePolygonFromRoomBoundaries(hole);
if (polygon !== null) {
if (fp.isPointInPolygon(polygon.toArray(), pt)) {
isPtInHole = true;
}
}
}
if (!isPtInHole) {
isPtOnRoomOrWall = true;
}
}
}
});
if (isPtOnRoomOrWall) {
return false;
}
// get thr boundary walls for the room
const boundaryWalls = fp.getRoomWalls(pt);
if (boundaryWalls === null) {
return false;
}
// also include holes in room
const holes: Array<Array<any>> = fp.findRoomHoles(boundaryWalls, pt);
// check if this is an update or add op
if (roomToUpdate !== null) {
fp.startTransaction('update room boundaryWalls and holes');
fp.model.setDataProperty(roomToUpdate.data, 'boundaryWalls', boundaryWalls);
fp.model.setDataProperty(roomToUpdate.data, 'holes', holes);
fp.commitTransaction('update room boundaryWalls and holes');
} else {
if (floorImage === null || floorImage === undefined) {
floorImage = 'images/textures/floor1.jpg';
}
const roomData = {
key: 'Room', category: 'RoomNode', name: 'Room Name',
boundaryWalls: boundaryWalls, holes: holes, floorImage: floorImage, showLabel: true, showFlooringOptions: true
};
fp.model.addNodeData(roomData);
roomToUpdate = fp.findPartForData(roomData) as go.Node;
}
fp.updateRoom(roomToUpdate);
return true;
}
/**
* Returns a specially formatted array that represents the boundaries of a room.
* These boundaries are the walls enclosing the room, which must include the given point
* The array is formatted with entries [wall, mitering side]
* @param {go.Point} pt
* @return {Array<any>}
*/
public getRoomWalls(pt: go.Point): Array<any> | null {
const fp: Floorplan = this;
// get the all the walls, in order from closest to farthest, the line from pt upwards would hit
const walls: go.Iterator<go.Group> = fp.findNodesByExample({ category: 'WallGroup' }) as go.Iterator<go.Group>;
const oPt: go.Point = new go.Point(pt.x, pt.y - 10000);
const wallsDistArr: Array<any> = []; // array of wall/dist pairs [[wallA, 15], [wallB, 30]] -- this makes sorting easier than if we were using a Map
walls.iterator.each(function(w) {
const ip: go.Point | null = fp.getSegmentsIntersection(pt, oPt, w.data.startpoint, w.data.endpoint);
if (ip !== null) {
const dist: number = Math.sqrt(ip.distanceSquaredPoint(pt));
wallsDistArr.push([w, dist]);
}
});
// sort all walls the line from pt to oPt intersects, in order of proximity to pt
wallsDistArr.sort(function(a, b) {
const distA: number = a[1];
const distB: number = b[1];
if (distA === distB) return 0;
else if (distA < distB) return -1;
else return 1;
});
// helper function -- copies a "path" (list of walls) up to a certain wall node
function selectivelyCopyPath(path: Array<any>, nodeToStopAt: go.Node) {
const p = [];
let copyNoMore: boolean = false;
for (let i: number = 0; i < path.length; i++) {
const entry = path[i];
const wk: string = entry[0];
const w: go.Group = fp.findNodeForKey(wk) as go.Group;
const side: number = entry[1];
if (!copyNoMore) {
p.push([w.data.key, side]);
if (w.data.key === nodeToStopAt.data.key) {
copyNoMore = true;
}
}
}
return p;
}
/**
* Recursively walk counter-clockwise along all walls connected to firstWall until you get back to first wall
* @param {go.Group | null} wall
* @param {Array<any>} path
* @param {go.Set<Array<any>>} possiblePaths
* @param {go.Set<go.Group> | null} seenWalls
* @param {go.Group} origWall
* @param {go.Point | null} prevPt
* @return {go.Set<Array<any>>}
*/
function recursivelyFindPaths(wall: go.Group, path: Array<any> | null, possiblePaths: go.Set<Array<any>>,
seenWalls: go.Set<go.Group> | null, origWall: go.Group, prevPt: go.Point | null): go.Set<Array<any>> | null {
if (wall === null) {
return null;
}
if (seenWalls === undefined || seenWalls === null) {
seenWalls = new go.Set<go.Group>();
}
seenWalls.add(wall);
// find which wall endpoint has angle between 180 and 270 from the right
const wrt: WallReshapingTool = fp.toolManager.mouseDownTools.elt(3) as WallReshapingTool;
const sPt: go.Point = wall.data.startpoint;
const ePt: go.Point = wall.data.endpoint;
const mpt: go.Point = new go.Point((sPt.x + ePt.x) / 2, (sPt.y + ePt.y) / 2);
const sa: number = mpt.directionPoint(sPt); // angle from mpt to spt
let ip: go.Point; // intersection point
let op: go.Point; // other point
if (prevPt === undefined || prevPt === null) {
ip = (sa >= 90 && sa <= 270) ? sPt : ePt; // cc point
op = (sa >= 90 && sa <= 270) ? ePt : sPt;
} else {
ip = (wrt.pointsApproximatelyEqual(sPt, prevPt)) ? ePt : sPt;
op = (wrt.pointsApproximatelyEqual(sPt, prevPt)) ? sPt : ePt;
}
// get all walls at ip
const ccWalls: go.List<go.Group> = wrt.getAllWallsAtIntersection(ip, true);
// sort these walls based on their clockwise angle, relative to wall
// sort all involved walls in any clockwise order
ccWalls.sort(function(a: go.Group, b: go.Group) {
// fp.sortWallsClockwise(a,b);
const B: go.Point | null = fp.getWallsIntersection(a, b);
if (B === null) return 0;
const as: go.Point = a.data.startpoint;
const ae: go.Point = a.data.endpoint;
const bs: go.Point = b.data.startpoint;
const be: go.Point = b.data.endpoint;
const A: go.Point = wrt.pointsApproximatelyEqual(ip, as) ? ae : as;
const C: go.Point = wrt.pointsApproximatelyEqual(ip, bs) ? be : bs;
const angA: number = B.directionPoint(A);
const angB: number = B.directionPoint(C);
if (angA > angB) return 1;
else if (angA < angB) return -1;
else return 0;
});
// offset the intersection walls (maintain relative order) s.t. wall "wall" is first
const intersectionWalls: Array<go.Group> = ccWalls.toArray();
const intersectionWallsReordered: Array<go.Group> = [];
let j: number = intersectionWalls.indexOf(wall);
for (let i: number = 0; i < intersectionWalls.length; i++) {
const w: go.Group = intersectionWalls[j];
intersectionWallsReordered.push(w);
j = (j + 1) % intersectionWalls.length;
}
ccWalls.clear();
for (let i: number = 0; i < intersectionWallsReordered.length; i++) {
const w: go.Group = intersectionWallsReordered[i];
ccWalls.add(w);
}
ccWalls.iterator.each(function(w: go.Group) {
// failsafe
if (w === undefined || w === null) {
possiblePaths = new go.Set<any>(); return possiblePaths;
}
// Base case : if we've found our way back to originalWall (add path to possiblePaths)
if ((w.data.key === origWall.data.key || ccWalls.contains(origWall)) && wall.data.key !== origWall.data.key) {
if (path !== null) {
possiblePaths.add(path);
}
} else if (seenWalls !== null && !seenWalls.contains(w)) {
// define path as all walls that came up until this wall
if (path === undefined || path === null) {
path = [];
// First wall is special case; just find out which mitering side is closer to the original point used for this room construction
// get intersection point from pt-oPt each of walls's mitering sides
// It's possible pt-oPt does not intersect one or both of the actual segments making up the mitering sides of wall,
// so use the lines implied by the mitering points, not just finite segments
const ip1: go.Point | null = fp.getLinesIntersection(pt, oPt, wall.data.smpt1, wall.data.empt1);
const ip2: go.Point | null = fp.getLinesIntersection(pt, oPt, wall.data.smpt2, wall.data.empt2);
if (ip1 !== null && ip2 !== null) {
// whichever mitering side pt-oPt strikes first (which intersection point is closer to pt) is the one to start with
const dist1: number = Math.sqrt(ip1.distanceSquaredPoint(pt));
const dist2: number = Math.sqrt(ip2.distanceSquaredPoint(pt));
const side1: number = (dist1 < dist2) ? 1 : 2;
path.push([wall.data.key, side1]);
}
} else {
path = selectivelyCopyPath(path, wall);
}
// get the "side" of the wall to use for the room boundary (side 1 or side 2, as defined by the mitering points in data)
const side: number = fp.getCounterClockwiseWallSide(w, ip);
// add w to path
path.push([w.data.key, side]);
recursivelyFindPaths(w, path, possiblePaths, seenWalls, origWall, ip);
}
});
return possiblePaths;
} // end recursivelyFindPaths
// iterate over these ordered walls until one allows for us to identify the room boundaries
// if none of these walls allow for that, "pt" is not enclosed by walls, so there is no room
let roomOuterBoundaryPts = null;
let roomOuterBoundaryPath = null; // an array with entries [[wall, side], [wall, side]...]
for (let i: number = 0; i < wallsDistArr.length; i++) {
const entry = wallsDistArr[i];
const w: go.Group = entry[0];
// I'm pretty sure the first possbilePath is always the right one
// This is an ordered path of all the walls that make up this room. It's Map, where keys are walls and values are the wall sides used for room boundaries (1 or 2)
let path = [];
let possiblePaths: go.Set<any> | null = new go.Set<any>();
possiblePaths = recursivelyFindPaths(w, null, possiblePaths, null, w, null);
if (possiblePaths === null || possiblePaths.count === 0) continue; // no path
path = possiblePaths.first();
// construct a polygon (Points) from this path
const polygon: go.List<go.Point> | null = fp.makePolygonFromRoomBoundaries(path);
if (polygon !== null) {
// make sure none of the walls in "path" have an endpoint inside the resulting polygon (this means an internal wall is included, those are dealt with later)
let pathIncludesInternalWall: boolean = false;
for (let j: number = 0; j < path.length; j++) {
const e = path[j];
const wwk: string = e[0];
const ww: go.Group = fp.findNodeForKey(wwk) as go.Group;
const ept: go.Point = ww.data.endpoint; const spt: go.Point = ww.data.startpoint;
if (fp.isPointInPolygon(polygon.toArray(), ept) || fp.isPointInPolygon(polygon.toArray(), spt)) {
pathIncludesInternalWall = true;
}
}
// make sure "pt" is enclosed by the polygon -- if so, these are the outer room bounds
if (fp.isPointInPolygon(polygon.toArray(), pt) /*&& !pathIncludesInternalWall*/) {
roomOuterBoundaryPts = polygon;
roomOuterBoundaryPath = path;
break;
}
}
}
// if we've found outer room boundary pts, we now need to account for some edge cases
// 1) Be sure to include "internal" walls in room boundaries (walls inside the room that connect to an outer boundary wall)
// 2) "Holes" -- walls / rooms inside these outer boundaries that do not connect to an outer boundary wall
if (roomOuterBoundaryPts !== null) {
// check if there are any walls with an endpoint in the room's outer boundaries polygon.
// If so, update room's boundaryWalls data and geometry to add those internal wall(s)
const newRoomBoundaryWalls: Array<any> = fp.addInternalWallsToRoom(roomOuterBoundaryPts, roomOuterBoundaryPath);
// let newRoomBoundaryWalls = roomOuterBoundaryPath;
return newRoomBoundaryWalls;
} else {
return null;
}
}
/**
* Returns an ordered List of Points that represents the polygon of a room, given a room's boundaryWalls array
* @param {Array<any>} path -- a specially formatted array, where entries are 2 entry arrays [wall, mitering side]
* This type of structure is used for a room's "boundaryWalls" data property
* @return {go.List<go.Point> | null}
*/
public makePolygonFromRoomBoundaries(path: Array<any>): go.List<go.Point> | null {
const fp: Floorplan = this;
const polygon: go.List<go.Poi