diff --git a/data/core.yaml b/data/core.yaml index a9b44ffb3..d4e3aed3b 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -1398,6 +1398,12 @@ en: message: "{feature} has no classification" tip: "Find roads that are missing a proper road classification" reference: "Roads without a specific type may not appear in maps or routing." + unsquare_way: + title: Unsquare Buildings + message: "{feature} isn't quite square" + tip: "Find buildings with nearly square corners" + buildings: + reference: "Buildings that are almost square should be squared." fix: connect_almost_junction: annotation: Connected very close features. @@ -1441,6 +1447,8 @@ en: title: Set as inner set_as_outer: title: Set as outer + square_feature: + title: Square this feature tag_as_disconnected: title: Tag as disconnected annotation: Tagged very close features as disconnected. diff --git a/dist/locales/en.json b/dist/locales/en.json index d36fd02fb..ee0177ffb 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -1729,6 +1729,14 @@ "tip": "Find roads that are missing a proper road classification", "reference": "Roads without a specific type may not appear in maps or routing." }, + "unsquare_way": { + "title": "Unsquare Buildings", + "message": "{feature} isn't quite square", + "tip": "Find buildings with nearly square corners", + "buildings": { + "reference": "Buildings that are almost square should be squared." + } + }, "fix": { "connect_almost_junction": { "annotation": "Connected very close features." @@ -1791,6 +1799,9 @@ "set_as_outer": { "title": "Set as outer" }, + "square_feature": { + "title": "Square this feature" + }, "tag_as_disconnected": { "title": "Tag as disconnected", "annotation": "Tagged very close features as disconnected." diff --git a/modules/actions/orthogonalize.js b/modules/actions/orthogonalize.js index 5bd3a505e..11ba1131d 100644 --- a/modules/actions/orthogonalize.js +++ b/modules/actions/orthogonalize.js @@ -1,7 +1,8 @@ import { actionDeleteNode } from './delete_node'; import { geoVecAdd, geoVecEqual, geoVecInterp, geoVecLength, geoVecNormalize, - geoVecNormalizedDot, geoVecProject, geoVecScale, geoVecSubtract + geoVecProject, geoVecScale, geoVecSubtract, + geoOrthoNormalizedDotProduct, geoOrthoCalcScore, geoOrthoCanOrthogonalize } from '../geo'; @@ -72,7 +73,7 @@ export function actionOrthogonalize(wayID, projection, vertexID) { 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)); + dotp = Math.abs(geoOrthoNormalizedDotProduct(a.coord, b.coord, point.coord)); } if (dotp > upperThreshold) { @@ -93,7 +94,7 @@ export function actionOrthogonalize(wayID, projection, vertexID) { for (j = 0; j < motions.length; j++) { simplified[j].coord = geoVecAdd(simplified[j].coord, motions[j]); } - var newScore = calcScore(simplified, isClosed); + var newScore = geoOrthoCalcScore(simplified, isClosed, epsilon, threshold); if (newScore < score) { bestPoints = clonePoints(simplified); score = newScore; @@ -183,67 +184,6 @@ export function actionOrthogonalize(wayID, projection, vertexID) { }; - 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) { - 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 - } - } - - - 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 dotp = filterDotProduct(normalizedDotProduct(a, b, origin)); - if (dotp === null) continue; // ignore vertex - if (Math.abs(dotp) > 0) return 1; // something to do - score = 0; // already square - } - - return score; - } - - // if we are only orthogonalizing one vertex, // get that vertex and the previous and next function nodeSubset(nodes, vertexID, isClosed) { @@ -273,13 +213,15 @@ export function actionOrthogonalize(wayID, projection, vertexID) { var nodes = graph.childNodes(way).slice(); // shallow copy if (isClosed) nodes.pop(); + var allowStraightAngles = false; if (vertexID !== undefined) { + allowStraightAngles = true; nodes = nodeSubset(nodes, vertexID, isClosed); if (nodes.length !== 3) return 'end_vertex'; } var coords = nodes.map(function(n) { return projection(n.loc); }); - var score = canOrthogonalize(coords, isClosed); + var score = geoOrthoCanOrthogonalize(coords, isClosed, epsilon, threshold, allowStraightAngles); if (score === null) { return 'not_squarish'; diff --git a/modules/core/history.js b/modules/core/history.js index ef633ae33..323200ff5 100644 --- a/modules/core/history.js +++ b/modules/core/history.js @@ -155,6 +155,8 @@ export function coreHistory(context) { }) .on('end interrupt', function() { _overwrite(origArguments, 1); + // run the completion handler, if any + if (action0.onCompletion) action0.onCompletion(); }); } else { diff --git a/modules/geo/index.js b/modules/geo/index.js index 4ef9e250d..90c0b99ae 100644 --- a/modules/geo/index.js +++ b/modules/geo/index.js @@ -41,3 +41,7 @@ export { geoVecNormalizedDot } from './vector.js'; export { geoVecProject } from './vector.js'; export { geoVecSubtract } from './vector.js'; export { geoVecScale } from './vector.js'; + +export { geoOrthoNormalizedDotProduct } from './ortho.js'; +export { geoOrthoCalcScore } from './ortho.js'; +export { geoOrthoCanOrthogonalize } from './ortho.js'; diff --git a/modules/geo/ortho.js b/modules/geo/ortho.js new file mode 100644 index 000000000..5f0d8c9cc --- /dev/null +++ b/modules/geo/ortho.js @@ -0,0 +1,72 @@ +import { + geoVecEqual, geoVecNormalizedDot +} from './vector'; + + +export function geoOrthoNormalizedDotProduct(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 geoOrthoFilterDotProduct(dotp, epsilon, lowerThreshold, upperThreshold, allowStraightAngles) { + var val = Math.abs(dotp); + if (val < epsilon) { + return 0; // already orthogonal + } else if (allowStraightAngles && Math.abs(val-1) < epsilon) { + return 0; // straight angle, which is okay in this case + } else if (val < lowerThreshold || val > upperThreshold) { + return dotp; // can be adjusted + } else { + return null; // ignore vertex + } +} + + +export function geoOrthoCalcScore(points, isClosed, epsilon, threshold) { + 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; }); + + var lowerThreshold = Math.cos((90 - threshold) * Math.PI / 180); + var upperThreshold = Math.cos(threshold * Math.PI / 180); + + 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 = geoOrthoFilterDotProduct(geoOrthoNormalizedDotProduct(a, b, origin), epsilon, lowerThreshold, upperThreshold); + 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 geoOrthoCalcScore, but returns quickly if there is something to do +export function geoOrthoCanOrthogonalize(coords, isClosed, epsilon, threshold, allowStraightAngles) { + var score = null; + var first = isClosed ? 0 : 1; + var last = isClosed ? coords.length : coords.length - 1; + + var lowerThreshold = Math.cos((90 - threshold) * Math.PI / 180); + var upperThreshold = Math.cos(threshold * Math.PI / 180); + + 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 = geoOrthoFilterDotProduct(geoOrthoNormalizedDotProduct(a, b, origin), epsilon, lowerThreshold, upperThreshold, allowStraightAngles); + if (dotp === null) continue; // ignore vertex + if (Math.abs(dotp) > 0) return 1; // something to do + score = 0; // already square + } + + return score; +} diff --git a/modules/operations/circularize.js b/modules/operations/circularize.js index 1b217dd58..bd58c4aba 100644 --- a/modules/operations/circularize.js +++ b/modules/operations/circularize.js @@ -10,6 +10,10 @@ export function operationCircularize(selectedIDs, context) { var extent = entity.extent(context.graph()); var geometry = context.geometry(entityID); var action = actionCircularize(entityID, context.projection); + action.onCompletion = function() { + // revalidate in case this is a building that's no longer squarable + context.validator().validate(); + }; var nodes = utilGetAllNodes(selectedIDs, context.graph()); var coords = nodes.map(function(n) { return n.loc; }); var _disabled; diff --git a/modules/operations/orthogonalize.js b/modules/operations/orthogonalize.js index 82eccf33a..08685885d 100644 --- a/modules/operations/orthogonalize.js +++ b/modules/operations/orthogonalize.js @@ -10,6 +10,13 @@ export function operationOrthogonalize(selectedIDs, context) { var _geometry; var _disabled; var action = chooseAction(); + if (action) { + action.onCompletion = function() { + // revalidate in case a building was squared + context.validator().validate(); + }; + } + var nodes = utilGetAllNodes(selectedIDs, context.graph()); var coords = nodes.map(function(n) { return n.loc; }); diff --git a/modules/validations/index.js b/modules/validations/index.js index 4be3fe7d0..6a481507e 100644 --- a/modules/validations/index.js +++ b/modules/validations/index.js @@ -11,3 +11,4 @@ export { validationOutdatedTags } from './outdated_tags'; export { validationPrivateData } from './private_data'; export { validationTagSuggestsArea } from './tag_suggests_area'; export { validationUnknownRoad } from './unknown_road'; +export { validationUnsquareWay } from './unsquare_way'; diff --git a/modules/validations/unsquare_way.js b/modules/validations/unsquare_way.js new file mode 100644 index 000000000..51606983a --- /dev/null +++ b/modules/validations/unsquare_way.js @@ -0,0 +1,64 @@ +import { t } from '../util/locale'; +import { operationOrthogonalize } from '../operations'; +import { geoOrthoCanOrthogonalize } from '../geo'; +import { utilDisplayLabel } from '../util'; +import { validationIssue, validationIssueFix } from '../core/validator'; + + +export function validationUnsquareWay() { + var type = 'unsquare_way'; + + var validation = function checkMissingRole(entity, context) { + + if (entity.type !== 'way' || entity.geometry(context.graph()) !== 'area') return []; + + if (!entity.tags.building || entity.tags.building === 'no') return []; + + var isClosed = entity.isClosed(); + var nodes = context.childNodes(entity).slice(); // shallow copy + if (isClosed) nodes.pop(); + + var locs = nodes.map(function(node) { + return context.projection(node.loc); + }); + + // use loose constraints compared to actionOrthogonalize + if (!geoOrthoCanOrthogonalize(locs, isClosed, 0.015, 7, true)) return []; + + return new validationIssue({ + type: type, + severity: 'warning', + message: t('issues.unsquare_way.message', { + feature: utilDisplayLabel(entity, context) + }), + reference: showReference, + entities: [entity], + fixes: [ + new validationIssueFix({ + icon: 'iD-operation-orthogonalize', + title: t('issues.fix.square_feature.title'), + onClick: function() { + var id = this.issue.entities[0].id; + var operation = operationOrthogonalize([id], context); + if (!operation.disabled()) { + operation(); + } + } + }) + ] + }); + + function showReference(selection) { + selection.selectAll('.issue-reference') + .data([0]) + .enter() + .append('div') + .attr('class', 'issue-reference') + .text(t('issues.unsquare_way.buildings.reference')); + } + }; + + validation.type = type; + + return validation; +}