diff --git a/data/core.yaml b/data/core.yaml index 4fe523c6f..fb13e9b88 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -80,6 +80,7 @@ en: annotation: line: Squared the corners of a line. area: Squared the corners of an area. + square_enough: This can't be made more square than it already is. not_squarish: This can't be made square because it is not squarish. too_large: This can't be made square because not enough of it is currently visible. connected_to_hidden: This can't be made square because it is connected to a hidden feature. diff --git a/dist/locales/en.json b/dist/locales/en.json index 95390ba16..ee1767376 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -105,6 +105,7 @@ "line": "Squared the corners of a line.", "area": "Squared the corners of an area." }, + "square_enough": "This can't be made more square than it already is.", "not_squarish": "This can't be made square because it is not squarish.", "too_large": "This can't be made square because not enough of it is currently visible.", "connected_to_hidden": "This can't be made square because it is connected to a hidden feature." diff --git a/modules/actions/orthogonalize.js b/modules/actions/orthogonalize.js index b8410dfa7..b70401de8 100644 --- a/modules/actions/orthogonalize.js +++ b/modules/actions/orthogonalize.js @@ -1,13 +1,15 @@ import _clone from 'lodash-es/clone'; -import _uniq from 'lodash-es/uniq'; +import _cloneDeep from 'lodash-es/cloneDeep'; import { actionDeleteNode } from './delete_node'; import { geoVecAdd, + geoVecEqual, geoVecInterp, geoVecLength, geoVecNormalize, geoVecNormalizedDot, + geoVecProject, geoVecScale, geoVecSubtract } from '../geo'; @@ -17,7 +19,10 @@ import { * Based on https://github.com/openstreetmap/potlatch2/blob/master/net/systemeD/potlatch2/tools/Quadrilateralise.as */ export function actionOrthogonalize(wayID, projection) { - var threshold = 12; // degrees within right or straight to alter + var epsilon = 1e-4; + var threshold = 13; // degrees within right or straight to alter + + // We test normalized dot products so we can compare as cos(angle) var lowerThreshold = Math.cos((90 - threshold) * Math.PI / 180); var upperThreshold = Math.cos(threshold * Math.PI / 180); @@ -27,16 +32,32 @@ export function actionOrthogonalize(wayID, projection) { t = Math.min(Math.max(+t, 0), 1); var way = graph.entity(wayID); - var nodes = graph.childNodes(way); - var points = _uniq(nodes).map(function(n) { return projection(n.loc); }); - var corner = {i: 0, dotp: 1}; - var epsilon = 1e-4; - var node, loc, score, motions, i, j; + way = way.removeNode(''); // sanity check - remove any consecutive duplicates + graph = graph.replace(way); + + var isClosed = way.isClosed(); + var nodes = _clone(graph.childNodes(way)); + if (isClosed) nodes.pop(); + + // note: all geometry functions here use the unclosed node/point/coord list + + var nodeCount = {}; + var points = []; + var corner = { i: 0, dotp: 1 }; + var node, point, loc, score, motions, i, j; + + for (i = 0; i < nodes.length; i++) { + node = nodes[i]; + nodeCount[node.id] = (nodeCount[node.id] || 0) + 1; + points.push({ id: node.id, coord: projection(node.loc) }); + } + if (points.length === 3) { // move only one vertex for right triangle for (i = 0; i < 1000; i++) { motions = points.map(calcMotion); - points[corner.i] = geoVecAdd(points[corner.i], motions[corner.i]); + + points[corner.i].coord = geoVecAdd(points[corner.i].coord, motions[corner.i]); score = corner.dotp; if (score < epsilon) { break; @@ -44,22 +65,45 @@ export function actionOrthogonalize(wayID, projection) { } node = graph.entity(nodes[corner.i].id); - loc = projection.invert(points[corner.i]); + loc = projection.invert(points[corner.i].coord); graph = graph.replace(node.move(geoVecInterp(node.loc, loc, t))); } else { - var best; - var originalPoints = _clone(points); + var straights = []; + var simplified = []; + + // Remove points from nearly straight sections.. + // This produces a simplified shape to orthogonalize + for (i = 0; i < points.length; i++) { + point = points[i]; + var dotp = 0; + if (isClosed || (i > 0 && i < points.length - 1)) { + var a = points[(i - 1 + points.length) % points.length]; + var b = points[(i + 1) % points.length]; + dotp = Math.abs(normalizedDotProduct(a.coord, b.coord, point.coord)); + } + + if (dotp > upperThreshold) { + straights.push(point); + } else { + simplified.push(point); + } + } + + // Orthogonalize the simplified shape + var bestPoints = _cloneDeep(simplified); + var originalPoints = _cloneDeep(simplified); score = Infinity; for (i = 0; i < 1000; i++) { - motions = points.map(calcMotion); + motions = simplified.map(calcMotion); + for (j = 0; j < motions.length; j++) { - points[j] = geoVecAdd(points[j],motions[j]); + simplified[j].coord = geoVecAdd(simplified[j].coord, motions[j]); } - var newScore = squareness(points); + var newScore = calcScore(simplified, isClosed); if (newScore < score) { - best = _clone(points); + bestPoints = _cloneDeep(simplified); score = newScore; } if (score < epsilon) { @@ -67,30 +111,41 @@ export function actionOrthogonalize(wayID, projection) { } } - points = best; + var bestCoords = bestPoints.map(function(p) { return p.coord; }); + if (isClosed) bestCoords.push(bestCoords[0]); - for (i = 0; i < points.length; i++) { - // only move the points that actually moved - if (originalPoints[i][0] !== points[i][0] || originalPoints[i][1] !== points[i][1]) { - loc = projection.invert(points[i]); - node = graph.entity(nodes[i].id); + // move the nodes that should move + for (i = 0; i < bestPoints.length; i++) { + point = bestPoints[i]; + if (!geoVecEqual(originalPoints[i].coord, point.coord)) { + node = graph.entity(point.id); + loc = projection.invert(point.coord); graph = graph.replace(node.move(geoVecInterp(node.loc, loc, t))); } } - // remove empty nodes on straight sections - for (i = 0; t === 1 && i < points.length; i++) { - node = graph.entity(nodes[i].id); + // move the nodes along straight segments + for (i = 0; i < straights.length; i++) { + point = straights[i]; + if (nodeCount[point.id] > 1) continue; // skip self-intersections - if (graph.parentWays(node).length > 1 || - graph.parentRelations(node).length || - node.hasInterestingTags()) { - continue; - } + node = graph.entity(point.id); - var dotp = normalizedDotProduct(i, points); - if (dotp < -1 + epsilon) { + if (t === 1 && + graph.parentWays(node).length === 1 && + graph.parentRelations(node).length === 0 && + !node.hasInterestingTags() + ) { + // remove uninteresting points.. graph = actionDeleteNode(node.id)(graph); + + } else { + // move interesting points to the nearest edge.. + var choice = geoVecProject(point.coord, bestCoords); + if (choice) { + loc = projection.invert(choice.target); + graph = graph.replace(node.move(geoVecInterp(node.loc, loc, t))); + } } } } @@ -98,74 +153,121 @@ export function actionOrthogonalize(wayID, projection) { return graph; - function calcMotion(b, i, array) { - var a = array[(i - 1 + array.length) % array.length]; - var c = array[(i + 1) % array.length]; - var p = geoVecSubtract(a, b); - var q = geoVecSubtract(c, b); + function calcMotion(point, i, array) { + // don't try to move the endpoints of a non-closed way. + if (!isClosed && (i === 0 || i === array.length - 1)) return [0, 0]; + // don't try to move a node that appears more than once (self intersection) + if (nodeCount[array[i].id] > 1) return [0, 0]; + + var a = array[(i - 1 + array.length) % array.length].coord; + var origin = point.coord; + var b = array[(i + 1) % array.length].coord; + var p = geoVecSubtract(a, origin); + var q = geoVecSubtract(b, origin); var scale = 2 * Math.min(geoVecLength(p), geoVecLength(q)); p = geoVecNormalize(p); q = geoVecNormalize(q); - var dotp = filterDotProduct(p[0] * q[0] + p[1] * q[1]); + var dotp = (p[0] * q[0] + p[1] * q[1]); + var val = Math.abs(dotp); - // nasty hack to deal with almost-straight segments (angle is closer to 180 than to 90/270). - if (array.length > 3) { - if (dotp < -0.707106781186547) { - dotp += 1.0; - } - } else if (dotp && Math.abs(dotp) < corner.dotp) { + if (val < lowerThreshold) { // nearly orthogonal corner.i = i; - corner.dotp = Math.abs(dotp); + corner.dotp = val; + var vec = geoVecNormalize(geoVecAdd(p, q)); + return geoVecScale(vec, 0.1 * dotp * scale); } - var vec = geoVecNormalize(geoVecAdd(p, q)); - return geoVecScale(vec, 0.1 * dotp * scale); + return [0, 0]; // do nothing } }; - function squareness(points) { - return points.reduce(function(sum, val, i, array) { - var dotp = normalizedDotProduct(i, array); - dotp = filterDotProduct(dotp); - return sum + 2.0 * Math.min(Math.abs(dotp - 1.0), Math.min(Math.abs(dotp), Math.abs(dotp + 1))); - }, 0); - } - - - function normalizedDotProduct(i, points) { - var a = points[(i - 1 + points.length) % points.length]; - var origin = points[i]; - var b = points[(i + 1) % points.length]; + function normalizedDotProduct(a, b, origin) { + if (geoVecEqual(origin, a) || geoVecEqual(origin, b)) { + return 1; // coincident points, treat as straight and try to remove + } return geoVecNormalizedDot(a, b, origin); } function filterDotProduct(dotp) { - if (lowerThreshold > Math.abs(dotp) || Math.abs(dotp) > upperThreshold) { - return dotp; + var val = Math.abs(dotp); + if (val < epsilon) { + return 0; // already orthogonal + } else if (val < lowerThreshold || val > upperThreshold) { + return dotp; // can be adjusted + } else { + return null; // ignore vertex } - return 0; + } + + + function calcScore(points, isClosed) { + var score = 0; + var first = isClosed ? 0 : 1; + var last = isClosed ? points.length : points.length - 1; + var coords = points.map(function(p) { return p.coord; }); + + for (var i = first; i < last; i++) { + var a = coords[(i - 1 + coords.length) % coords.length]; + var origin = coords[i]; + var b = coords[(i + 1) % coords.length]; + + var dotp = filterDotProduct(normalizedDotProduct(a, b, origin)); + if (dotp === null) continue; // ignore vertex + score = score + 2.0 * Math.min(Math.abs(dotp - 1.0), Math.min(Math.abs(dotp), Math.abs(dotp + 1))); + } + + return score; + } + + + // similar to calcScore, but returns quickly if there is something to do + function canOrthogonalize(coords, isClosed) { + var score = null; + var first = isClosed ? 0 : 1; + var last = isClosed ? coords.length : coords.length - 1; + + for (var i = first; i < last; i++) { + var a = coords[(i - 1 + coords.length) % coords.length]; + var origin = coords[i]; + var b = coords[(i + 1) % coords.length]; + + var val = filterDotProduct(normalizedDotProduct(a, b, origin)); + if (val === null) continue; // ignore vertex + if (val > 0) return 1; // something to do + score = 0; // already square + } + + return score; } action.disabled = function(graph) { var way = graph.entity(wayID); - var nodes = graph.childNodes(way); - var points = _uniq(nodes).map(function(n) { return projection(n.loc); }); + way = way.removeNode(''); // sanity check - remove any consecutive duplicates + graph = graph.replace(way); - if (squareness(points)) { + var isClosed = way.isClosed(); + var nodes = _clone(graph.childNodes(way)); + if (isClosed) nodes.pop(); + + var coords = nodes.map(function(n) { return projection(n.loc); }); + var score = canOrthogonalize(coords, isClosed); + + if (score === null) { + return 'not_squarish'; + } else if (score === 0) { + return 'square_enough'; + } else { return false; } - - return 'not_squarish'; }; action.transitionable = true; - return action; } diff --git a/modules/geo/index.js b/modules/geo/index.js index 7023f31e3..4ef9e250d 100644 --- a/modules/geo/index.js +++ b/modules/geo/index.js @@ -38,5 +38,6 @@ export { geoVecInterp } from './vector.js'; export { geoVecLength } from './vector.js'; export { geoVecNormalize } from './vector.js'; export { geoVecNormalizedDot } from './vector.js'; +export { geoVecProject } from './vector.js'; export { geoVecSubtract } from './vector.js'; export { geoVecScale } from './vector.js'; diff --git a/modules/geo/vector.js b/modules/geo/vector.js index edf6cfa86..97318607d 100644 --- a/modules/geo/vector.js +++ b/modules/geo/vector.js @@ -84,3 +84,40 @@ export function geoVecCross(a, b, origin) { return (p[0]) * (q[1]) - (p[1]) * (q[0]); } + +// find closest orthogonal projection of point onto points array +export function geoVecProject(a, points) { + var min = Infinity; + var idx; + var target; + + for (var i = 0; i < points.length - 1; i++) { + var o = points[i]; + var s = geoVecSubtract(points[i + 1], o); + var v = geoVecSubtract(a, o); + var proj = geoVecDot(v, s) / geoVecDot(s, s); + var p; + + if (proj < 0) { + p = o; + } else if (proj > 1) { + p = points[i + 1]; + } else { + p = [o[0] + proj * s[0], o[1] + proj * s[1]]; + } + + var dist = geoVecLength(p, a); + if (dist < min) { + min = dist; + idx = i + 1; + target = p; + } + } + + if (idx !== undefined) { + return { index: idx, distance: min, target: target }; + } else { + return null; + } +} + diff --git a/modules/osm/way.js b/modules/osm/way.js index 82fdfd3c5..753f7acd4 100644 --- a/modules/osm/way.js +++ b/modules/osm/way.js @@ -195,7 +195,6 @@ _extend(osmWay.prototype, { // returns an object with the tag that implies this is an area, if any tagSuggestingArea: function() { - if (this.tags.area === 'yes') return { area: 'yes' }; if (this.tags.area === 'no') return null; @@ -230,7 +229,6 @@ _extend(osmWay.prototype, { }, isArea: function() { - if (this.tags.area === 'yes') return true; if (!this.isClosed() || this.tags.area === 'no')