diff --git a/Makefile b/Makefile index c5c4d5609..885df0a94 100644 --- a/Makefile +++ b/Makefile @@ -115,6 +115,7 @@ D3_FILES = \ node_modules/d3/src/geo/path.js \ node_modules/d3/src/geo/stream.js \ node_modules/d3/src/geom/polygon.js \ + node_modules/d3/src/geom/hull.js \ node_modules/d3/src/selection/index.js \ node_modules/d3/src/transition/index.js \ node_modules/d3/src/xhr/index.js \ diff --git a/js/id/actions/circularize.js b/js/id/actions/circularize.js index d9cbf82c8..6e9f44b0a 100644 --- a/js/id/actions/circularize.js +++ b/js/id/actions/circularize.js @@ -2,12 +2,17 @@ iD.actions.Circularize = function(wayId, projection, maxAngle) { maxAngle = (maxAngle || 20) * Math.PI / 180; var action = function(graph) { - var way = graph.entity(wayId), - nodes = _.uniq(graph.childNodes(way)), + var way = graph.entity(wayId); + + if (!way.isConvex(graph)) { + graph = action.makeConvex(graph); + } + + var nodes = _.uniq(graph.childNodes(way)), keyNodes = nodes.filter(function(n) { return graph.parentWays(n).length !== 1; }), points = nodes.map(function(n) { return projection(n.loc); }), keyPoints = keyNodes.map(function(n) { return projection(n.loc); }), - centroid = d3.geom.polygon(points).centroid(), + centroid = (points.length === 2) ? iD.geo.interp(points[0], points[1], 0.5) : d3.geom.polygon(points).centroid(), radius = d3.median(points, function(p) { return iD.geo.euclideanDistance(centroid, p); }), sign = d3.geom.polygon(points).area() > 0 ? 1 : -1, ids; @@ -28,16 +33,19 @@ iD.actions.Circularize = function(wayId, projection, maxAngle) { // key points and nodes are those connected to the ways, // they are projected onto the circle, inbetween nodes are moved - // to constant internals between key nodes, extra inbetween nodes are + // to constant intervals between key nodes, extra inbetween nodes are // added if necessary. for (var i = 0; i < keyPoints.length; i++) { var nextKeyNodeIndex = (i + 1) % keyNodes.length, - startNodeIndex = nodes.indexOf(keyNodes[i]), - endNodeIndex = nodes.indexOf(keyNodes[nextKeyNodeIndex]), + startNode = keyNodes[i], + endNode = keyNodes[nextKeyNodeIndex], + startNodeIndex = nodes.indexOf(startNode), + endNodeIndex = nodes.indexOf(endNode), numberNewPoints = -1, indexRange = endNodeIndex - startNodeIndex, distance, totalAngle, eachAngle, startAngle, endAngle, - angle, loc, node, j; + angle, loc, node, j, + inBetweenNodes = []; if (indexRange < 0) { indexRange += nodes.length; @@ -45,6 +53,7 @@ iD.actions.Circularize = function(wayId, projection, maxAngle) { // position this key node distance = iD.geo.euclideanDistance(centroid, keyPoints[i]); + if (distance === 0) { distance = 1e-4; } keyPoints[i] = [ centroid[0] + (keyPoints[i][0] - centroid[0]) / distance * radius, centroid[1] + (keyPoints[i][1] - centroid[1]) / distance * radius]; @@ -56,7 +65,7 @@ iD.actions.Circularize = function(wayId, projection, maxAngle) { totalAngle = endAngle - startAngle; // detects looping around -pi/pi - if (totalAngle*sign > 0) { + if (totalAngle * sign > 0) { totalAngle = -sign * (2 * Math.PI - Math.abs(totalAngle)); } @@ -87,7 +96,40 @@ iD.actions.Circularize = function(wayId, projection, maxAngle) { graph = graph.replace(node); nodes.splice(endNodeIndex + j, 0, node); + inBetweenNodes.push(node.id); } + + // Check for other ways that share these keyNodes.. + // If keyNodes are adjacent in both ways, + // we can add inBetween nodes to that shared way too.. + if (indexRange === 1 && inBetweenNodes.length) { + var startIndex1 = way.nodes.lastIndexOf(startNode.id), + endIndex1 = way.nodes.lastIndexOf(endNode.id), + wayDirection1 = (endIndex1 - startIndex1); + if (wayDirection1 < -1) { wayDirection1 = 1;} + + /*jshint -W083 */ + _.each(_.without(graph.parentWays(keyNodes[i]), way), function(sharedWay) { + if (sharedWay.areAdjacent(startNode.id, endNode.id)) { + var startIndex2 = sharedWay.nodes.lastIndexOf(startNode.id), + endIndex2 = sharedWay.nodes.lastIndexOf(endNode.id), + wayDirection2 = (endIndex2 - startIndex2), + insertAt = endIndex2; + if (wayDirection2 < -1) { wayDirection2 = 1;} + + if (wayDirection1 !== wayDirection2) { + inBetweenNodes.reverse(); + insertAt = startIndex2; + } + for (j = 0; j < inBetweenNodes.length; j++) { + sharedWay = sharedWay.addNode(inBetweenNodes[j], insertAt + j); + } + graph = graph.replace(sharedWay); + } + }); + /*jshint +W083 */ + } + } // update the way to have all the new nodes @@ -100,6 +142,38 @@ iD.actions.Circularize = function(wayId, projection, maxAngle) { return graph; }; + action.makeConvex = function(graph) { + var way = graph.entity(wayId), + nodes = _.uniq(graph.childNodes(way)), + points = nodes.map(function(n) { return projection(n.loc); }), + sign = d3.geom.polygon(points).area() > 0 ? 1 : -1, + hull = d3.geom.hull(points); + + // D3 convex hulls go counterclockwise.. + if (sign === -1) { + nodes.reverse(); + points.reverse(); + } + + for (var i = 0; i < hull.length - 1; i++) { + var startIndex = points.indexOf(hull[i]), + endIndex = points.indexOf(hull[i+1]), + indexRange = (endIndex - startIndex); + + if (indexRange < 0) { + indexRange += nodes.length; + } + + // move interior nodes to the surface of the convex hull.. + for (var j = 1; j < indexRange; j++) { + var point = iD.geo.interp(hull[i], hull[i+1], j / indexRange), + node = nodes[(j + startIndex) % nodes.length].move(projection.invert(point)); + graph = graph.replace(node); + } + } + return graph; + }; + action.disabled = function(graph) { if (!graph.entity(wayId).isClosed()) return 'not_closed'; diff --git a/js/id/core/way.js b/js/id/core/way.js index 4d254de40..ff7416dc4 100644 --- a/js/id/core/way.js +++ b/js/id/core/way.js @@ -55,6 +55,30 @@ _.extend(iD.Way.prototype, { return this.nodes.length > 0 && this.first() === this.last(); }, + isConvex: function(resolver) { + if (!this.isClosed() || this.isDegenerate()) return null; + + var nodes = _.uniq(resolver.childNodes(this)), + coords = _.pluck(nodes, 'loc'), + curr = 0, prev = 0; + + for (var i = 0; i < coords.length; i++) { + var o = coords[(i+1) % coords.length], + a = coords[i], + b = coords[(i+2) % coords.length], + res = iD.geo.cross(o, a, b); + + curr = (res > 0) ? 1 : (res < 0) ? -1 : 0; + if (curr === 0) { + continue; + } else if (prev && curr !== prev) { + return false; + } + prev = curr; + } + return true; + }, + isArea: function() { if (this.tags.area === 'yes') return true; diff --git a/js/id/geo.js b/js/id/geo.js index b09105594..2fa2e2ace 100644 --- a/js/id/geo.js +++ b/js/id/geo.js @@ -9,6 +9,13 @@ iD.geo.interp = function(p1, p2, t) { p1[1] + (p2[1] - p1[1]) * t]; }; +// 2D cross product of OA and OB vectors, i.e. z-component of their 3D cross product. +// Returns a positive value, if OAB makes a counter-clockwise turn, +// negative for clockwise turn, and zero if the points are collinear. +iD.geo.cross = function(o, a, b) { + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); +}; + // http://jsperf.com/id-dist-optimization iD.geo.euclideanDistance = function(a, b) { var x = a[0] - b[0], y = a[1] - b[1]; diff --git a/js/id/operations/circularize.js b/js/id/operations/circularize.js index 2e59b90e7..39926aff1 100644 --- a/js/id/operations/circularize.js +++ b/js/id/operations/circularize.js @@ -9,8 +9,10 @@ iD.operations.Circularize = function(selectedIDs, context) { }; operation.available = function() { + var entity = context.entity(entityId); return selectedIDs.length === 1 && - context.entity(entityId).type === 'way'; + entity.type === 'way' && + _.uniq(entity.nodes).length > 1; }; operation.disabled = function() { diff --git a/js/lib/d3.v3.js b/js/lib/d3.v3.js index 3dd2c8452..728a594e6 100644 --- a/js/lib/d3.v3.js +++ b/js/lib/d3.v3.js @@ -4393,6 +4393,131 @@ function d3_geom_polygonClosed(coordinates) { b = coordinates[coordinates.length - 1]; return !(a[0] - b[0] || a[1] - b[1]); } +function d3_geom_pointX(d) { + return d[0]; +} + +function d3_geom_pointY(d) { + return d[1]; +} + +/** + * Computes the 2D convex hull of a set of points using Graham's scanning + * algorithm. The algorithm has been implemented as described in Cormen, + * Leiserson, and Rivest's Introduction to Algorithms. The running time of + * this algorithm is O(n log n), where n is the number of input points. + * + * @param vertices [[x1, y1], [x2, y2], …] + * @returns polygon [[x1, y1], [x2, y2], …] + */ +d3.geom.hull = function(vertices) { + var x = d3_geom_pointX, + y = d3_geom_pointY; + + if (arguments.length) return hull(vertices); + + function hull(data) { + if (data.length < 3) return []; + + var fx = d3_functor(x), + fy = d3_functor(y), + n = data.length, + vertices, // TODO use parallel arrays + plen = n - 1, + points = [], + stack = [], + d, + i, j, h = 0, x1, y1, x2, y2, u, v, a, sp; + + if (fx === d3_geom_pointX && y === d3_geom_pointY) vertices = data; + else for (i = 0, vertices = []; i < n; ++i) { + vertices.push([+fx.call(this, d = data[i], i), +fy.call(this, d, i)]); + } + + // find the starting ref point: leftmost point with the minimum y coord + for (i = 1; i < n; ++i) { + if (vertices[i][1] < vertices[h][1] + || vertices[i][1] == vertices[h][1] + && vertices[i][0] < vertices[h][0]) h = i; + } + + // calculate polar angles from ref point and sort + for (i = 0; i < n; ++i) { + if (i === h) continue; + y1 = vertices[i][1] - vertices[h][1]; + x1 = vertices[i][0] - vertices[h][0]; + points.push({angle: Math.atan2(y1, x1), index: i}); + } + points.sort(function(a, b) { return a.angle - b.angle; }); + + // toss out duplicate angles + a = points[0].angle; + v = points[0].index; + u = 0; + for (i = 1; i < plen; ++i) { + j = points[i].index; + if (a == points[i].angle) { + // keep angle for point most distant from the reference + x1 = vertices[v][0] - vertices[h][0]; + y1 = vertices[v][1] - vertices[h][1]; + x2 = vertices[j][0] - vertices[h][0]; + y2 = vertices[j][1] - vertices[h][1]; + if (x1 * x1 + y1 * y1 >= x2 * x2 + y2 * y2) { + points[i].index = -1; + continue; + } else { + points[u].index = -1; + } + } + a = points[i].angle; + u = i; + v = j; + } + + // initialize the stack + stack.push(h); + for (i = 0, j = 0; i < 2; ++j) { + if (points[j].index > -1) { + stack.push(points[j].index); + i++; + } + } + sp = stack.length; + + // do graham's scan + for (; j < plen; ++j) { + if (points[j].index < 0) continue; // skip tossed out points + while (!d3_geom_hullCCW(stack[sp - 2], stack[sp - 1], points[j].index, vertices)) { + --sp; + } + stack[sp++] = points[j].index; + } + + // construct the hull + var poly = []; + for (i = sp - 1; i >= 0; --i) poly.push(data[stack[i]]); + return poly; + } + + hull.x = function(_) { + return arguments.length ? (x = _, hull) : x; + }; + + hull.y = function(_) { + return arguments.length ? (y = _, hull) : y; + }; + + return hull; +}; + +// are three points in counter-clockwise order? +function d3_geom_hullCCW(i1, i2, i3, v) { + var t, a, b, c, d, e, f; + t = v[i1]; a = t[0]; b = t[1]; + t = v[i2]; c = t[0]; d = t[1]; + t = v[i3]; e = t[0]; f = t[1]; + return (f - b) * (c - a) - (d - b) * (e - a) > 0; +} var d3_ease_default = function() { return d3_identity; }; diff --git a/test/spec/actions/circularize.js b/test/spec/actions/circularize.js index 559ec0c7b..d551fe86b 100644 --- a/test/spec/actions/circularize.js +++ b/test/spec/actions/circularize.js @@ -1,7 +1,21 @@ describe("iD.actions.Circularize", function () { var projection = d3.geo.mercator(); + function isCircular(id, graph) { + var points = _.pluck(graph.childNodes(graph.entity(id)), 'loc').map(projection), + centroid = d3.geom.polygon(points).centroid(), + radius = iD.geo.euclideanDistance(centroid, points[0]), + estArea = Math.PI * radius * radius, + trueArea = Math.abs(d3.geom.polygon(points).area()), + pctDiff = (estArea - trueArea) / estArea; + + return (pctDiff < 0.025); // within 2.5% of circular area.. + } + it("creates nodes if necessary", function () { + // d ---- c + // | | + // a ---- b var graph = iD.Graph([ iD.Node({id: 'a', loc: [0, 0]}), iD.Node({id: 'b', loc: [2, 0]}), @@ -12,10 +26,14 @@ describe("iD.actions.Circularize", function () { graph = iD.actions.Circularize('-', projection)(graph); + expect(isCircular('-', graph)).to.be.ok; expect(graph.entity('-').nodes).to.have.length(20); }); it("reuses existing nodes", function () { + // d,e -- c + // | | + // a ---- b var graph = iD.Graph([ iD.Node({id: 'a', loc: [0, 0]}), iD.Node({id: 'b', loc: [2, 0]}), @@ -28,15 +46,20 @@ describe("iD.actions.Circularize", function () { graph = iD.actions.Circularize('-', projection)(graph); + expect(isCircular('-', graph)).to.be.ok; + nodes = graph.entity('-').nodes; - expect(nodes.indexOf('a')).to.be.gte(0); - expect(nodes.indexOf('b')).to.be.gte(0); - expect(nodes.indexOf('c')).to.be.gte(0); - expect(nodes.indexOf('d')).to.be.gte(0); - expect(nodes.indexOf('e')).to.be.gte(0); + expect(nodes).to.contain('a'); + expect(nodes).to.contain('b'); + expect(nodes).to.contain('c'); + expect(nodes).to.contain('d'); + expect(nodes).to.contain('e'); }); it("limits movement of nodes that are members of other ways", function () { + // b ---- a + // | | + // c ---- d var graph = iD.Graph([ iD.Node({id: 'a', loc: [2, 2]}), iD.Node({id: 'b', loc: [-2, 2]}), @@ -48,6 +71,7 @@ describe("iD.actions.Circularize", function () { graph = iD.actions.Circularize('-', projection)(graph); + expect(isCircular('-', graph)).to.be.ok; expect(iD.geo.euclideanDistance(graph.entity('d').loc, [2, -2])).to.be.lt(0.5); }); @@ -66,6 +90,9 @@ describe("iD.actions.Circularize", function () { } it("creates circle respecting min-angle limit", function() { + // d ---- c + // | | + // a ---- b var graph = iD.Graph([ iD.Node({id: 'a', loc: [0, 0]}), iD.Node({id: 'b', loc: [2, 0]}), @@ -76,6 +103,8 @@ describe("iD.actions.Circularize", function () { centroid, points; graph = iD.actions.Circularize('-', projection, 20)(graph); + + expect(isCircular('-', graph)).to.be.ok; points = _.pluck(graph.childNodes(graph.entity('-')), 'loc').map(projection); centroid = d3.geom.polygon(points).centroid(); @@ -91,6 +120,9 @@ describe("iD.actions.Circularize", function () { } it("leaves clockwise ways clockwise", function () { + // d ---- c + // | | + // a ---- b var graph = iD.Graph([ iD.Node({id: 'a', loc: [0, 0]}), iD.Node({id: 'b', loc: [2, 0]}), @@ -103,10 +135,14 @@ describe("iD.actions.Circularize", function () { graph = iD.actions.Circularize('+', projection)(graph); + expect(isCircular('+', graph)).to.be.ok; expect(area('+', graph)).to.be.gt(0); }); it("leaves counter-clockwise ways counter-clockwise", function () { + // d ---- c + // | | + // a ---- b var graph = iD.Graph([ iD.Node({id: 'a', loc: [0, 0]}), iD.Node({id: 'b', loc: [2, 0]}), @@ -119,6 +155,120 @@ describe("iD.actions.Circularize", function () { graph = iD.actions.Circularize('-', projection)(graph); + expect(isCircular('-', graph)).to.be.ok; expect(area('-', graph)).to.be.lt(0); }); + + it("adds new nodes on shared way wound in opposite direction", function () { + // c ---- b ---- f + // | / | + // | a | + // | \ | + // d ---- e ---- g + // + // a-b-c-d-e-a is counterclockwise + // a-b-f-g-e-a is clockwise + // + var graph = iD.Graph([ + iD.Node({id: 'a', loc: [ 0, 0]}), + iD.Node({id: 'b', loc: [ 1, 2]}), + iD.Node({id: 'c', loc: [-2, 2]}), + iD.Node({id: 'd', loc: [-2, -2]}), + iD.Node({id: 'e', loc: [ 1, -2]}), + iD.Node({id: 'f', loc: [ 3, 2]}), + iD.Node({id: 'g', loc: [ 3, -2]}), + iD.Way({id: '-', nodes: ['a', 'b', 'c', 'd', 'e', 'a']}), + iD.Way({id: '=', nodes: ['a', 'b', 'f', 'g', 'e', 'a']}) + ]); + + expect(_.intersection(graph.entity('-').nodes, graph.entity('=').nodes).length).to.eql(3); + expect(graph.entity('-').isConvex(graph)).to.be.false; + expect(graph.entity('=').isConvex(graph)).to.be.true; + + graph = iD.actions.Circularize('-', projection)(graph); + + expect(isCircular('-', graph)).to.be.ok; + expect(_.intersection(graph.entity('-').nodes, graph.entity('=').nodes).length).to.be.gt(3); + expect(graph.entity('-').isConvex(graph)).to.be.true; + expect(graph.entity('=').isConvex(graph)).to.be.false; + }); + + it("adds new nodes on shared way wound in similar direction", function () { + // c ---- b ---- f + // | / | + // | a | + // | \ | + // d ---- e ---- g + // + // a-b-c-d-e-a is counterclockwise + // a-e-g-f-b-a is counterclockwise + // + var graph = iD.Graph([ + iD.Node({id: 'a', loc: [ 0, 0]}), + iD.Node({id: 'b', loc: [ 1, 2]}), + iD.Node({id: 'c', loc: [-2, 2]}), + iD.Node({id: 'd', loc: [-2, -2]}), + iD.Node({id: 'e', loc: [ 1, -2]}), + iD.Node({id: 'f', loc: [ 3, 2]}), + iD.Node({id: 'g', loc: [ 3, -2]}), + iD.Way({id: '-', nodes: ['a', 'b', 'c', 'd', 'e', 'a']}), + iD.Way({id: '=', nodes: ['a', 'e', 'g', 'f', 'b', 'a']}) + ]); + + expect(_.intersection(graph.entity('-').nodes, graph.entity('=').nodes).length).to.eql(3); + expect(graph.entity('-').isConvex(graph)).to.be.false; + expect(graph.entity('=').isConvex(graph)).to.be.true; + + graph = iD.actions.Circularize('-', projection)(graph); + + expect(isCircular('-', graph)).to.be.ok; + expect(_.intersection(graph.entity('-').nodes, graph.entity('=').nodes).length).to.be.gt(3); + expect(graph.entity('-').isConvex(graph)).to.be.true; + expect(graph.entity('=').isConvex(graph)).to.be.false; + }); + + it("circularizes extremely concave ways with a key node on the wrong side of the centroid", function () { + // c ------------ b -- f + // | ___--- | + // | a === | + // | ---___ | + // d ------------ e -- g + // + // a-b-c-d-e-a is extremely concave and 'a' is to the left of centoid.. + // + var graph = iD.Graph([ + iD.Node({id: 'a', loc: [ 0, 0]}), + iD.Node({id: 'b', loc: [10, 2]}), + iD.Node({id: 'c', loc: [-2, 2]}), + iD.Node({id: 'd', loc: [-2, -2]}), + iD.Node({id: 'e', loc: [10, -2]}), + iD.Node({id: 'f', loc: [15, 2]}), + iD.Node({id: 'g', loc: [15, -2]}), + iD.Way({id: '-', nodes: ['a', 'b', 'c', 'd', 'e', 'a']}), + iD.Way({id: '=', nodes: ['a', 'b', 'f', 'g', 'e', 'a']}) + ]); + + expect(graph.entity('-').isConvex(graph)).to.be.false; + + graph = iD.actions.Circularize('-', projection)(graph); + + expect(isCircular('-', graph)).to.be.ok; + expect(graph.entity('-').isConvex(graph)).to.be.true; + expect(graph.entity('-').nodes).to.have.length(20); + }); + + it("circularizes a closed single line way", function () { + var graph = iD.Graph([ + iD.Node({id: 'a', loc: [0, 0]}), + iD.Node({id: 'b', loc: [0, 2]}), + iD.Way({id: '-', nodes: ['a', 'b', 'a']}), + ]); + + expect(area('-', graph)).to.eql(0); + + graph = iD.actions.Circularize('-', projection)(graph); + + expect(isCircular('-', graph)).to.be.ok; + }); + }); diff --git a/test/spec/core/way.js b/test/spec/core/way.js index c8b338cbe..b253638c7 100644 --- a/test/spec/core/way.js +++ b/test/spec/core/way.js @@ -87,6 +87,67 @@ describe('iD.Way', function() { }); }); + describe('#isConvex', function() { + it('returns true for convex ways', function() { + // d -- e + // | \ + // | a + // | / + // c -- b + var graph = iD.Graph([ + iD.Node({id: 'a', loc: [ 0.0003, 0.0000]}), + iD.Node({id: 'b', loc: [ 0.0002, -0.0002]}), + iD.Node({id: 'c', loc: [-0.0002, -0.0002]}), + iD.Node({id: 'd', loc: [-0.0002, 0.0002]}), + iD.Node({id: 'e', loc: [ 0.0002, 0.0002]}), + iD.Way({id: 'w', nodes: ['a','b','c','d','e','a']}) + ]); + expect(graph.entity('w').isConvex(graph)).to.be.true; + }); + + it('returns false for concave ways', function() { + // d -- e + // | / + // | a + // | \ + // c -- b + var graph = iD.Graph([ + iD.Node({id: 'a', loc: [ 0.0000, 0.0000]}), + iD.Node({id: 'b', loc: [ 0.0002, -0.0002]}), + iD.Node({id: 'c', loc: [-0.0002, -0.0002]}), + iD.Node({id: 'd', loc: [-0.0002, 0.0002]}), + iD.Node({id: 'e', loc: [ 0.0002, 0.0002]}), + iD.Way({id: 'w', nodes: ['a','b','c','d','e','a']}) + ]); + expect(graph.entity('w').isConvex(graph)).to.be.false; + }); + + it('returns null for non-closed ways', function() { + // d -- e + // | + // | a + // | \ + // c -- b + var graph = iD.Graph([ + iD.Node({id: 'a', loc: [ 0.0000, 0.0000]}), + iD.Node({id: 'b', loc: [ 0.0002, -0.0002]}), + iD.Node({id: 'c', loc: [-0.0002, -0.0002]}), + iD.Node({id: 'd', loc: [-0.0002, 0.0002]}), + iD.Node({id: 'e', loc: [ 0.0002, 0.0002]}), + iD.Way({id: 'w', nodes: ['a','b','c','d','e']}) + ]); + expect(graph.entity('w').isConvex(graph)).to.be.null; + }); + + it('returns null for degenerate ways', function() { + var graph = iD.Graph([ + iD.Node({id: 'a', loc: [0.0000, 0.0000]}), + iD.Way({id: 'w', nodes: ['a','a']}) + ]); + expect(graph.entity('w').isConvex(graph)).to.be.null; + }); + }); + describe('#isOneWay', function() { it('returns false when the way has no tags', function() { expect(iD.Way().isOneWay()).to.eql(false); diff --git a/test/spec/geo.js b/test/spec/geo.js index 7592782b7..a6a86ecb2 100644 --- a/test/spec/geo.js +++ b/test/spec/geo.js @@ -18,6 +18,27 @@ describe('iD.geo', function() { }); }); + describe('.cross', function() { + it('cross product of right hand turn is positive', function() { + var o = [0, 0], + a = [2, 0], + b = [0, 2]; + expect(iD.geo.cross(o, a, b)).to.eql(4); + }); + it('cross product of left hand turn is negative', function() { + var o = [0, 0], + a = [2, 0], + b = [0, -2]; + expect(iD.geo.cross(o, a, b)).to.eql(-4); + }); + it('cross product of colinear points is zero', function() { + var o = [0, 0], + a = [-2, 0], + b = [2, 0]; + expect(iD.geo.cross(o, a, b)).to.eql(0); + }); + }); + describe('.euclideanDistance', function() { it('distance between two same points is zero', function() { var a = [0, 0],