From fab6bd1d33a0c903c4f33b6ce07948222a64261a Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 1 Mar 2019 23:20:50 -0500 Subject: [PATCH] Support orthogonalizing a single vertex, add tests (closes #2205) --- data/core.yaml | 3 + dist/locales/en.json | 3 + modules/actions/orthogonalize.js | 35 ++++- modules/operations/orthogonalize.js | 53 ++++++-- test/spec/actions/orthogonalize.js | 201 ++++++++++++++++++++++++---- 5 files changed, 252 insertions(+), 43 deletions(-) diff --git a/data/core.yaml b/data/core.yaml index e60a697b6..22a6477b1 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -74,12 +74,15 @@ en: orthogonalize: title: Square description: + vertex: Square this corner. line: Square the corners of this line. area: Square the corners of this area. key: Q annotation: + vertex: Squared a single corner. line: Squared the corners of a line. area: Squared the corners of an area. + end_vertex: This can't be squared because it is an end node. 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. diff --git a/dist/locales/en.json b/dist/locales/en.json index dab117e7e..d0b53d3e6 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -97,14 +97,17 @@ "orthogonalize": { "title": "Square", "description": { + "vertex": "Square this corner.", "line": "Square the corners of this line.", "area": "Square the corners of this area." }, "key": "Q", "annotation": { + "vertex": "Squared a single corner.", "line": "Squared the corners of a line.", "area": "Squared the corners of an area." }, + "end_vertex": "This can't be squared because it is an end node.", "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.", diff --git a/modules/actions/orthogonalize.js b/modules/actions/orthogonalize.js index debf0f915..15f904a83 100644 --- a/modules/actions/orthogonalize.js +++ b/modules/actions/orthogonalize.js @@ -15,10 +15,7 @@ import { } from '../geo'; -/* - * Based on https://github.com/openstreetmap/potlatch2/blob/master/net/systemeD/potlatch2/tools/Quadrilateralise.as - */ -export function actionOrthogonalize(wayID, projection) { +export function actionOrthogonalize(wayID, projection, vertexID) { var epsilon = 1e-4; var threshold = 13; // degrees within right or straight to alter @@ -39,6 +36,11 @@ export function actionOrthogonalize(wayID, projection) { var nodes = _clone(graph.childNodes(way)); if (isClosed) nodes.pop(); + if (vertexID !== undefined) { + nodes = nodeSubset(nodes, vertexID, isClosed); + if (nodes.length !== 3) return graph; + } + // note: all geometry functions here use the unclosed node/point/coord list var nodeCount = {}; @@ -245,6 +247,26 @@ export function actionOrthogonalize(wayID, projection) { } + // if we are only orthogonalizing one vertex, + // get that vertex and the previous and next + function nodeSubset(nodes, vertexID, isClosed) { + var first = isClosed ? 0 : 1; + var last = isClosed ? nodes.length : nodes.length - 1; + + for (var i = first; i < last; i++) { + if (nodes[i].id === vertexID) { + return [ + nodes[(i - 1 + nodes.length) % nodes.length], + nodes[i], + nodes[(i + 1) % nodes.length] + ]; + } + } + + return []; + } + + action.disabled = function(graph) { var way = graph.entity(wayID); way = way.removeNode(''); // sanity check - remove any consecutive duplicates @@ -254,6 +276,11 @@ export function actionOrthogonalize(wayID, projection) { var nodes = _clone(graph.childNodes(way)); if (isClosed) nodes.pop(); + if (vertexID !== undefined) { + 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); diff --git a/modules/operations/orthogonalize.js b/modules/operations/orthogonalize.js index 62b212c6e..908ca6071 100644 --- a/modules/operations/orthogonalize.js +++ b/modules/operations/orthogonalize.js @@ -6,30 +6,59 @@ import { behaviorOperation } from '../behavior/index'; export function operationOrthogonalize(selectedIDs, context) { - var entityID = selectedIDs[0]; - var entity = context.entity(entityID); - var extent = entity.extent(context.graph()); - var geometry = context.geometry(entityID); - var action = actionOrthogonalize(entityID, context.projection); + var _entityID; + var _entity; + var _geometry; + var action = chooseAction(); + + + function chooseAction() { + if (selectedIDs.length !== 1) return null; + + _entityID = selectedIDs[0]; + _entity = context.entity(_entityID); + _geometry = context.geometry(_entityID); + + // square a line/area + if (_entity.type === 'way' && _uniq(_entity.nodes).length > 2 ) { + return actionOrthogonalize(_entityID, context.projection); + + // square a single vertex + } else if (_geometry === 'vertex') { + var graph = context.graph(); + var parents = graph.parentWays(_entity); + if (parents.length === 1) { + var way = parents[0]; + if (way.nodes.indexOf(_entityID) !== -1) { + return actionOrthogonalize(way.id, context.projection, _entityID); + } + } + } + + return null; + } var operation = function() { + if (!action) return; context.perform(action, operation.annotation()); }; operation.available = function() { - return selectedIDs.length === 1 && - entity.type === 'way' && - _uniq(entity.nodes).length > 2; + return Boolean(action); }; operation.disabled = function() { + if (!action) return ''; + + var extent = _entity.extent(context.graph()); var reason; - if (extent.percentContainedIn(context.extent()) < 0.8) { + + if (_geometry !== 'vertex' && extent.percentContainedIn(context.extent()) < 0.8) { reason = 'too_large'; - } else if (context.hasHiddenConnections(entityID)) { + } else if (context.hasHiddenConnections(_entityID)) { reason = 'connected_to_hidden'; } return action.disabled(context.graph()) || reason; @@ -40,12 +69,12 @@ export function operationOrthogonalize(selectedIDs, context) { var disable = operation.disabled(); return disable ? t('operations.orthogonalize.' + disable) : - t('operations.orthogonalize.description.' + geometry); + t('operations.orthogonalize.description.' + _geometry); }; operation.annotation = function() { - return t('operations.orthogonalize.annotation.' + geometry); + return t('operations.orthogonalize.annotation.' + _geometry); }; diff --git a/test/spec/actions/orthogonalize.js b/test/spec/actions/orthogonalize.js index b656e4aae..1b487a3ed 100644 --- a/test/spec/actions/orthogonalize.js +++ b/test/spec/actions/orthogonalize.js @@ -127,11 +127,40 @@ describe('iD.actionOrthogonalize', function () { expect(diff.changes().d).to.be.undefined; expect(graph.hasEntity('d')).to.be.ok; }); + + it('preserves the shape of skinny quads', function () { + var projection = iD.d3.geoMercator(); + var tests = [[ + [-77.0339864831478, 38.8616391227204], + [-77.0209775298677, 38.8613609264884], + [-77.0210405781065, 38.8607390721519], + [-77.0339024188294, 38.8610663645859] + ], [ + [-89.4706683, 40.6261177], + [-89.4706664, 40.6260574], + [-89.4693973, 40.6260830], + [-89.4694012, 40.6261355] + ]]; + + for (var i = 0; i < tests.length; i++) { + var graph = iD.coreGraph([ + iD.osmNode({id: 'a', loc: tests[i][0]}), + iD.osmNode({id: 'b', loc: tests[i][1]}), + iD.osmNode({id: 'c', loc: tests[i][2]}), + iD.osmNode({id: 'd', loc: tests[i][3]}), + iD.osmWay({id: '-', nodes: ['a', 'b', 'c', 'd', 'a']}) + ]); + var initialWidth = iD.geoSphericalDistance(graph.entity('a').loc, graph.entity('b').loc); + graph = iD.actionOrthogonalize('-', projection)(graph); + var finalWidth = iD.geoSphericalDistance(graph.entity('a').loc, graph.entity('b').loc); + expect(finalWidth / initialWidth).within(0.90, 1.10); + } + }); }); describe('open paths', function () { - it('orthogonalizes a perfect quad', function () { + it('orthogonalizes a perfect quad path', function () { // d --- c // | // a --- b @@ -147,7 +176,7 @@ describe('iD.actionOrthogonalize', function () { expect(graph.entity('-').nodes).to.have.length(4); }); - it('orthogonalizes a quad', function () { + it('orthogonalizes a quad path', function () { // d --- c // | // a --- b @@ -254,33 +283,80 @@ describe('iD.actionOrthogonalize', function () { }); - it('preserves the shape of skinny quads', function () { - var projection = iD.d3.geoMercator(); - var tests = [[ - [-77.0339864831478, 38.8616391227204], - [-77.0209775298677, 38.8613609264884], - [-77.0210405781065, 38.8607390721519], - [-77.0339024188294, 38.8610663645859] - ], [ - [-89.4706683, 40.6261177], - [-89.4706664, 40.6260574], - [-89.4693973, 40.6260830], - [-89.4694012, 40.6261355] - ]]; - - for (var i = 0; i < tests.length; i++) { + describe('vertices', function () { + it('orthogonalizes a single vertex in a quad', function () { + // d --- c + // | | + // a --- b var graph = iD.coreGraph([ - iD.osmNode({id: 'a', loc: tests[i][0]}), - iD.osmNode({id: 'b', loc: tests[i][1]}), - iD.osmNode({id: 'c', loc: tests[i][2]}), - iD.osmNode({id: 'd', loc: tests[i][3]}), + iD.osmNode({id: 'a', loc: [0, 0]}), + iD.osmNode({id: 'b', loc: [2.1, 0]}), + iD.osmNode({id: 'c', loc: [2, 2]}), + iD.osmNode({id: 'd', loc: [0, 2]}), iD.osmWay({id: '-', nodes: ['a', 'b', 'c', 'd', 'a']}) ]); - var initialWidth = iD.geoSphericalDistance(graph.entity('a').loc, graph.entity('b').loc); - graph = iD.actionOrthogonalize('-', projection)(graph); - var finalWidth = iD.geoSphericalDistance(graph.entity('a').loc, graph.entity('b').loc); - expect(finalWidth / initialWidth).within(0.90, 1.10); - } + + var diff = iD.coreDifference(graph, iD.actionOrthogonalize('-', projection, 'b')(graph)); + expect(diff.changes().a).to.be.undefined; + expect(diff.changes().b).to.be.not.undefined; + expect(diff.changes().c).to.be.undefined; + expect(diff.changes().d).to.be.undefined; + }); + + it('orthogonalizes a single vertex in a triangle', function () { + // a + // | \ + // | \ + // b - c + var graph = iD.coreGraph([ + iD.osmNode({id: 'a', loc: [0, 3]}), + iD.osmNode({id: 'b', loc: [0.1, 0]}), + iD.osmNode({id: 'c', loc: [3, 0]}), + iD.osmWay({id: '-', nodes: ['a', 'b', 'c', 'a']}) + ]); + + var diff = iD.coreDifference(graph, iD.actionOrthogonalize('-', projection, 'b')(graph)); + expect(diff.changes().a).to.be.undefined; + expect(diff.changes().b).to.be.not.undefined; + expect(diff.changes().c).to.be.undefined; + }); + + it('orthogonalizes a single vertex in a quad path', function () { + // d --- c + // | + // a --- b + var graph = iD.coreGraph([ + iD.osmNode({id: 'a', loc: [0, 0]}), + iD.osmNode({id: 'b', loc: [2.1, 0]}), + iD.osmNode({id: 'c', loc: [2, 2]}), + iD.osmNode({id: 'd', loc: [0, 2]}), + iD.osmWay({id: '-', nodes: ['a', 'b', 'c', 'd']}) + ]); + + var diff = iD.coreDifference(graph, iD.actionOrthogonalize('-', projection, 'b')(graph)); + expect(diff.changes().a).to.be.undefined; + expect(diff.changes().b).to.be.not.undefined; + expect(diff.changes().c).to.be.undefined; + expect(diff.changes().d).to.be.undefined; + }); + + it('orthogonalizes a single vertex in a 3-point path', function () { + // a + // | + // | + // b - c + var graph = iD.coreGraph([ + iD.osmNode({id: 'a', loc: [0, 3]}), + iD.osmNode({id: 'b', loc: [0.1, 0]}), + iD.osmNode({id: 'c', loc: [3, 0]}), + iD.osmWay({id: '-', nodes: ['a', 'b', 'c']}) + ]); + + var diff = iD.coreDifference(graph, iD.actionOrthogonalize('-', projection, 'b')(graph)); + expect(diff.changes().a).to.be.undefined; + expect(diff.changes().b).to.be.not.undefined; + expect(diff.changes().c).to.be.undefined; + }); }); @@ -501,8 +577,79 @@ describe('iD.actionOrthogonalize', function () { expect(result).to.be.false; }); }); - }); + describe('vertex-only', function () { + + it('returns "square_enough" for a vertex in a perfect quad', function () { + // d ---- c + // | + // a ---- b + var graph = iD.coreGraph([ + iD.osmNode({id: 'a', loc: [0, 0]}), + iD.osmNode({id: 'b', loc: [2, 0]}), + iD.osmNode({id: 'c', loc: [2, 2]}), + iD.osmNode({id: 'd', loc: [0, 2]}), + iD.osmWay({id: '-', nodes: ['a', 'b', 'c', 'd']}) + ]); + + var result = iD.actionOrthogonalize('-', projection, 'b').disabled(graph); + expect(result).to.eql('square_enough'); + }); + + it('returns false for a vertex in an unsquared quad', function () { + // d --- c + // | + // a --- b + var graph = iD.coreGraph([ + iD.osmNode({id: 'a', loc: [0, 0]}), + iD.osmNode({id: 'b', loc: [2.1, 0]}), + iD.osmNode({id: 'c', loc: [2, 2]}), + iD.osmNode({id: 'd', loc: [0, 2]}), + iD.osmWay({id: '-', nodes: ['a', 'b', 'c', 'd']}) + ]); + + var result = iD.actionOrthogonalize('-', projection, 'b').disabled(graph); + expect(result).to.be.false; + }); + + it('returns false for a vertex in an unsquared 3-point path', function () { + // a + // | + // | + // b - c + var graph = iD.coreGraph([ + iD.osmNode({id: 'a', loc: [0, 3]}), + iD.osmNode({id: 'b', loc: [0, 0.1]}), + iD.osmNode({id: 'c', loc: [3, 0]}), + iD.osmWay({id: '-', nodes: ['a', 'b', 'c']}) + ]); + + var result = iD.actionOrthogonalize('-', projection, 'b').disabled(graph); + expect(result).to.be.false; + }); + + it('returns "not_squarish" for vertex that can not be squared', function () { + // e -- d + // / \ + // f c + // / + // a -- b + var graph = iD.coreGraph([ + iD.osmNode({id: 'a', loc: [1, 0]}), + iD.osmNode({id: 'b', loc: [3, 0]}), + iD.osmNode({id: 'c', loc: [4, 2]}), + iD.osmNode({id: 'd', loc: [3, 4]}), + iD.osmNode({id: 'e', loc: [1, 4]}), + iD.osmNode({id: 'f', loc: [0, 2]}), + iD.osmWay({id: '-', nodes: ['a', 'b', 'c', 'd', 'e', 'f']}) + ]); + + var result = iD.actionOrthogonalize('-', projection, 'b').disabled(graph); + expect(result).to.eql('not_squarish'); + }); + + }); + }); describe('transitions', function () { it('is transitionable', function() {