Merge pull request #2194 from bhousel/bhousel-operations

Improvements to circularize action
This commit is contained in:
John Firebaugh
2014-04-23 19:11:40 -07:00
9 changed files with 479 additions and 14 deletions

View File

@@ -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 \

View File

@@ -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';

View File

@@ -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;

View File

@@ -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];

View File

@@ -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() {

125
js/lib/d3.v3.js vendored
View File

@@ -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; };

View File

@@ -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;
});
});

View File

@@ -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);

View File

@@ -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],