From 1a5293483fbfb8219a9c289875d1aec753ad0a55 Mon Sep 17 00:00:00 2001 From: simonpoole Date: Tue, 7 Jan 2025 16:06:04 +0100 Subject: [PATCH 1/4] Add support for "duplicate" function This adds support for duplication, that is creating a copy in the same location for nodes, ways and multipolygon relations, shallow duplication for ways and all relations and copying of multipolygon relations. Resolves: https://github.com/MarcusWolschon/osmeditor4android/issues/1832 --- src/main/java/de/blau/android/Logic.java | 27 +- .../ElementSelectionActionModeCallback.java | 55 ++-- .../de/blau/android/osm/ClipboardStorage.java | 13 +- .../de/blau/android/osm/StorageDelegator.java | 309 ++++++++++++------ .../drawable-xhdpi/content_duplicate_dark.png | Bin 0 -> 322 bytes .../content_duplicate_light.png | Bin 0 -> 720 bytes src/main/res/values/attrs.xml | 1 + src/main/res/values/strings.xml | 5 + src/main/res/values/styles.xml | 2 + svg/content_duplicate_dark.svg | 88 +++++ svg/content_duplicate_light.svg | 86 +++++ 11 files changed, 471 insertions(+), 115 deletions(-) create mode 100644 src/main/res/drawable-xhdpi/content_duplicate_dark.png create mode 100644 src/main/res/drawable-xhdpi/content_duplicate_light.png create mode 100644 svg/content_duplicate_dark.svg create mode 100644 svg/content_duplicate_light.svg diff --git a/src/main/java/de/blau/android/Logic.java b/src/main/java/de/blau/android/Logic.java index 2fe00424ba..8c8f1a6cba 100644 --- a/src/main/java/de/blau/android/Logic.java +++ b/src/main/java/de/blau/android/Logic.java @@ -5915,7 +5915,7 @@ private int[] calcCentroid(@NonNull List elements) { * @param activity the activity we were called from * @param x screen x to position the object at * @param y screen y to position the object at - * @return the pasted object or null if the clipboard was empty + * @return the pasted objects or null if the clipboard was empty */ @Nullable public List pasteFromClipboard(@Nullable Activity activity, float x, float y) { @@ -5925,6 +5925,31 @@ public List pasteFromClipboard(@Nullable Activity activity, float x, return getDelegator().pasteFromClipboard(lat, lon); } + /** + * Duplicate a list of elements + * + * If the the duplicated objects have a name tag, a compositie name will be generated + * + * @param activity the activity we were called from + * @param elements the List of OsmElement + * @param deep duplicate child elements if true + * @return the duplicated objects + */ + @Nullable + public List duplicate(@Nullable Activity activity, @NonNull List elements, boolean deep) { + createCheckpoint(activity, R.string.undo_action_duplicate); + List duplicated = getDelegator().duplicate(elements, deep); + for (OsmElement d : duplicated) { + String nameTag = d.getTagWithKey(Tags.KEY_NAME); + if (nameTag != null && activity != null) { + SortedMap tags = new TreeMap<>(d.getTags()); + tags.put(Tags.KEY_NAME, activity.getString(R.string.duplicated_name_template, nameTag)); + setTags(activity, d, tags, false); + } + } + return duplicated; + } + /** * Check if the clipboard is empty * diff --git a/src/main/java/de/blau/android/easyedit/ElementSelectionActionModeCallback.java b/src/main/java/de/blau/android/easyedit/ElementSelectionActionModeCallback.java index 9c3d6982e9..4552b86a24 100644 --- a/src/main/java/de/blau/android/easyedit/ElementSelectionActionModeCallback.java +++ b/src/main/java/de/blau/android/easyedit/ElementSelectionActionModeCallback.java @@ -95,24 +95,26 @@ public abstract class ElementSelectionActionModeCallback extends EasyEditActionM private static final int MENUITEM_HISTORY_WEB = 4; private static final int MENUITEM_HISTORY = 5; static final int MENUITEM_COPY = 6; - static final int MENUITEM_CUT = 7; - private static final int MENUITEM_PASTE_TAGS = 8; - private static final int MENUITEM_CREATE_RELATION = 9; - private static final int MENUITEM_ADD_RELATION_MEMBERS = 10; - private static final int MENUITEM_EXTEND_SELECTION = 11; - private static final int MENUITEM_ELEMENT_INFO = 12; + static final int MENUITEM_DUPLICATE = 7; + static final int MENUITEM_SHALLOW_DUPLICATE = 8; + static final int MENUITEM_CUT = 9; + private static final int MENUITEM_PASTE_TAGS = 10; + private static final int MENUITEM_CREATE_RELATION = 11; + private static final int MENUITEM_ADD_RELATION_MEMBERS = 12; + private static final int MENUITEM_EXTEND_SELECTION = 13; + private static final int MENUITEM_ELEMENT_INFO = 14; protected static final int LAST_REGULAR_MENUITEM = MENUITEM_ELEMENT_INFO; - static final int MENUITEM_UPLOAD = 31; - protected static final int MENUITEM_SHARE_POSITION = 32; - private static final int MENUITEM_TAG_LAST = 33; - static final int MENUITEM_ZOOM_TO_SELECTION = 34; - static final int MENUITEM_SEARCH_OBJECTS = 35; - private static final int MENUITEM_REPLACE_GEOMETRY = 36; - private static final int MENUITEM_CALIBRATE_BAROMETER = 37; - static final int MENUITEM_PREFERENCES = 38; - static final int MENUITEM_JS_CONSOLE = 39; - static final int MENUITEM_ADD_TO_TODO = 40; + static final int MENUITEM_UPLOAD = 40; + protected static final int MENUITEM_SHARE_POSITION = 41; + private static final int MENUITEM_TAG_LAST = 42; + static final int MENUITEM_ZOOM_TO_SELECTION = 43; + static final int MENUITEM_SEARCH_OBJECTS = 44; + private static final int MENUITEM_REPLACE_GEOMETRY = 45; + private static final int MENUITEM_CALIBRATE_BAROMETER = 46; + static final int MENUITEM_PREFERENCES = 47; + static final int MENUITEM_JS_CONSOLE = 48; + static final int MENUITEM_ADD_TO_TODO = 49; private static final int MENUITEM_TODO_CLOSE_AND_NEXT = 70; private static final int MENUITEM_TODO_SKIP_AND_NEXT = 71; @@ -178,10 +180,17 @@ public boolean onCreateActionMode(ActionMode mode, Menu menu) { menu.add(Menu.NONE, MENUITEM_DELETE, Menu.CATEGORY_SYSTEM, R.string.delete).setIcon(ThemeUtils.getResIdFromAttribute(main, R.attr.menu_delete)); final boolean isRelation = element instanceof Relation; - if (!isRelation) { + if (!isRelation || element.hasTag(Tags.KEY_TYPE, Tags.VALUE_MULTIPOLYGON)) { menu.add(Menu.NONE, MENUITEM_COPY, Menu.CATEGORY_SECONDARY, R.string.menu_copy).setIcon(ThemeUtils.getResIdFromAttribute(main, R.attr.menu_copy)); + menu.add(Menu.NONE, MENUITEM_DUPLICATE, Menu.CATEGORY_SECONDARY, R.string.menu_duplicate) + .setIcon(ThemeUtils.getResIdFromAttribute(main, R.attr.menu_duplicate)); + } + if (!isRelation) { menu.add(Menu.NONE, MENUITEM_CUT, Menu.CATEGORY_SECONDARY, R.string.menu_cut).setIcon(ThemeUtils.getResIdFromAttribute(main, R.attr.menu_cut)); } + if (!(element instanceof Node)) { + menu.add(Menu.NONE, MENUITEM_SHALLOW_DUPLICATE, Menu.CATEGORY_SECONDARY, R.string.menu_shallow_duplicate); + } pasteItem = menu.add(Menu.NONE, MENUITEM_PASTE_TAGS, Menu.CATEGORY_SECONDARY, R.string.menu_paste_tags); menu.add(GROUP_BASE, MENUITEM_EXTEND_SELECTION, Menu.CATEGORY_SYSTEM, R.string.menu_extend_selection) @@ -312,7 +321,8 @@ public static boolean setItemVisibility(boolean condition, @NonNull MenuItem ite public boolean onActionItemClicked(ActionMode mode, MenuItem item) { super.onActionItemClicked(mode, item); final TaskStorage taskStorage = App.getTaskStorage(); - switch (item.getItemId()) { + final int itemId = item.getItemId(); + switch (itemId) { case MENUITEM_TAG: main.performTagEdit(element, null, false, false); break; @@ -339,6 +349,13 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { logic.cutToClipboard(main, element); mode.finish(); break; + case MENUITEM_DUPLICATE: + case MENUITEM_SHALLOW_DUPLICATE: + List result = logic.duplicate(main, Util.wrapInList(element), itemId == MENUITEM_DUPLICATE); + mode.finish(); + App.getLogic().setSelection(result); + manager.editElements(); + break; case MENUITEM_PASTE_TAGS: main.performTagEdit(element, null, new HashMap<>(App.getTagClipboard(main).paste()), false); break; @@ -412,7 +429,7 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { case MENUITEM_TODO_CLOSE_AND_NEXT: case MENUITEM_TODO_SKIP_AND_NEXT: final List todos = taskStorage.getTodosForElement(element); - State newState = item.getItemId() == MENUITEM_TODO_CLOSE_AND_NEXT ? State.CLOSED : State.SKIPPED; + State newState = itemId == MENUITEM_TODO_CLOSE_AND_NEXT ? State.CLOSED : State.SKIPPED; Set listNames = new HashSet<>(); for (int i = 0; i < todos.size(); i++) { listNames.add(todos.get(i).getListName(main)); diff --git a/src/main/java/de/blau/android/osm/ClipboardStorage.java b/src/main/java/de/blau/android/osm/ClipboardStorage.java index 4e5d77cc2b..b58da19085 100644 --- a/src/main/java/de/blau/android/osm/ClipboardStorage.java +++ b/src/main/java/de/blau/android/osm/ClipboardStorage.java @@ -1,5 +1,7 @@ package de.blau.android.osm; +import static de.blau.android.contract.Constants.LOG_TAG_LEN; + import java.io.Serializable; import java.util.ArrayList; import java.util.List; @@ -9,7 +11,9 @@ import de.blau.android.exception.StorageException; public class ClipboardStorage implements Serializable { - static final String DEBUG_TAG = ClipboardStorage.class.getSimpleName().substring(0, Math.min(23, ClipboardStorage.class.getSimpleName().length())); + + private static final int TAG_LEN = Math.min(LOG_TAG_LEN, ClipboardStorage.class.getSimpleName().length()); + private static final String DEBUG_TAG = ClipboardStorage.class.getSimpleName().substring(0, TAG_LEN); /** * @@ -58,7 +62,6 @@ public void copyTo(@NonNull List elements, int latE7, int lonE7) { selectionLat = latE7; selectionLon = lonE7; mode = Mode.COPY; - try { for (OsmElement e : elements) { storage.insertElementUnsafe(e); @@ -99,6 +102,7 @@ public boolean isEmpty() { public List pasteFrom() { List ways = storage.getWays(); List nodes = storage.getNodes(); + List relations = storage.getRelations(); List result = new ArrayList<>(); if (mode == Mode.CUT) { reset(); // can only paste a cut way once @@ -113,6 +117,11 @@ public List pasteFrom() { result.add(w); } } + if (relations != null) { + for (Relation r : relations) { + result.add(r); + } + } return result; } diff --git a/src/main/java/de/blau/android/osm/StorageDelegator.java b/src/main/java/de/blau/android/osm/StorageDelegator.java index f0a50fc74f..efca9f6069 100755 --- a/src/main/java/de/blau/android/osm/StorageDelegator.java +++ b/src/main/java/de/blau/android/osm/StorageDelegator.java @@ -2524,29 +2524,16 @@ private void validateRelationMemberCount(@Nullable List relations, int public void copyToClipboard(@NonNull List elements, int lat, int lon) { dirty = true; // otherwise clipboard will not get saved without other changes List toCopy = new ArrayList<>(); - Map processedNodes = new HashMap<>(); + Map processed = new HashMap<>(); try { - lock(); for (OsmElement e : elements) { if (e instanceof Node) { - Node newNode = factory.createNodeWithNewId(((Node) e).getLat(), ((Node) e).getLon()); - newNode.setTags(e.getTags()); - toCopy.add(newNode); - processedNodes.put(e.getOsmId(), newNode); + toCopy.add(duplicateNode((Node) e, 0, 0, processed)); } else if (e instanceof Way) { - Way newWay = factory.createWayWithNewId(); - newWay.setTags(e.getTags()); - for (Node nd : ((Way) e).getNodes()) { - Node newNode = processedNodes.get(nd.getOsmId()); - if (newNode == null) { - newNode = factory.createNodeWithNewId(nd.getLat(), nd.getLon()); - newNode.setTags(nd.getTags()); - processedNodes.put(nd.getOsmId(), newNode); - } - newWay.addNode(newNode); - } - toCopy.add(newWay); + toCopy.add(duplicateWay((Way) e, 0, 0, processed, true)); + } else if (e instanceof Relation) { + toCopy.add(duplicateRelation((Relation) e, 0, 0, processed, true)); } } if (!toCopy.isEmpty()) { @@ -2569,9 +2556,11 @@ public void cutToClipboard(@NonNull List elements, int lat, int lon) List toCut = new ArrayList<>(); Map replacedNodes = new HashMap<>(); try { - lock(); for (OsmElement e : elements) { + if (e instanceof Relation) { + throw new IllegalArgumentException("Cutting of Relations not supported"); + } toCut.add(e); if (e instanceof Way) { undo.save(e); @@ -2579,24 +2568,26 @@ public void cutToClipboard(@NonNull List elements, int lat, int lon) List nodes = new ArrayList<>(((Way) e).getNodes()); for (Node nd : nodes) { List ways = currentStorage.getWays(nd); - if (ways.size() > 1) { // 1 is expected (our way will be deleted later) - Node newNode = replacedNodes.get(nd.getOsmId()); - if (newNode == null) { - // check if there is actually a Way we are not cutting - for (Way w : ways) { - if (!elements.contains(w)) { - newNode = factory.createNodeWithNewId(nd.getLat(), nd.getLon()); - newNode.setTags(nd.getTags()); - insertElementSafe(newNode); - replacedNodes.put(nd.getOsmId(), newNode); - break; - } + if (ways.size() <= 1) { // 1 is expected (our way will be deleted later) + continue; + } + Node newNode = replacedNodes.get(nd.getOsmId()); + if (newNode == null) { + // check if there is actually a Way we are not cutting + for (Way w : ways) { + if (!elements.contains(w)) { + newNode = factory.createNodeWithNewId(nd.getLat(), nd.getLon()); + newNode.setTags(nd.getTags()); + insertElementSafe(newNode); + replacedNodes.put(nd.getOsmId(), newNode); + break; } } } } } } + Set wayNodes = new HashSet<>(); for (OsmElement removeElement : toCut) { if (removeElement instanceof Node) { removeNode((Node) removeElement); @@ -2610,17 +2601,13 @@ public void cutToClipboard(@NonNull List elements, int lat, int lon) ((Way) removeElement).replaceNode(nd, replacement); } } + wayNodes.addAll(((Way) removeElement).getNodes()); removeWay((Way) removeElement); } } // way nodes have to wait till we have removed all the ways - for (OsmElement removeElement : toCut) { - if (removeElement instanceof Way) { - Set nodes = new HashSet<>(((Way) removeElement).getNodes()); - for (Node nd : nodes) { - removeNode(nd); // - } - } + for (Node nd : wayNodes) { + removeNode(nd); // } clipboard.cutTo(toCut, lat, lon); } finally { @@ -2648,68 +2635,204 @@ public List pasteFromClipboard(int lat, int lon) { boolean copy = !clipboard.isEmpty(); int deltaLat = lat - clipboard.getSelectionLat(); int deltaLon = lon - clipboard.getSelectionLon(); - Map newNodes = new HashMap<>(); // every node needs to only be transformed once - for (OsmElement e : elements) { + Map processed = new HashMap<>(); // every element only needs to be transformed once + for (OsmElement original : elements) { // if the clipboard isn't empty now we need to clone the element if (copy) { // paste from copy - if (e instanceof Node) { - Node newNode = factory.createNodeWithNewId(((Node) e).getLat() + deltaLat, ((Node) e).getLon() + deltaLon); - newNode.setTags(e.getTags()); - insertElementSafe(newNode); - newNodes.put((Node) e, newNode); - e = newNode; - } else if (e instanceof Way) { - Way newWay = factory.createWayWithNewId(); - undo.save(newWay); // do this before we create and add nodes - newWay.setTags(e.getTags()); - List nodeList = ((Way) e).getNodes(); - // this is slightly complicated because we need to handle cases with potentially broken geometry - // allocate and set the position of the new nodes - Set nodes = new HashSet<>(nodeList); - for (Node nd : nodes) { - if (!newNodes.containsKey(nd)) { - Node newNode = factory.createNodeWithNewId(nd.getLat() + deltaLat, nd.getLon() + deltaLon); - newNode.setTags(nd.getTags()); - insertElementSafe(newNode); - newNodes.put(nd, newNode); - } - } - // now add them to the new way - for (Node nd : nodeList) { - newWay.addNode(newNodes.get(nd)); - } - insertElementSafe(newWay); - e = newWay; - } + result.add(createDuplicate(original, deltaLat, deltaLon, processed, true)); } else { // paste from cut - if (currentStorage.contains(e)) { - Log.e(DEBUG_TAG, "Attempt to paste from cut, but element is already present"); - clipboard.reset(); - return null; + OsmElement e = pasteFromCut(original, deltaLat, deltaLon, processed); + if (e != null) { + result.add(e); } - undo.save(e); - if (e instanceof Node) { - ((Node) e).setLat(((Node) e).getLat() + deltaLat); - ((Node) e).setLon(((Node) e).getLon() + deltaLon); - newNodes.put((Node) e, null); - } else if (e instanceof Way) { - Set nodes = new HashSet<>(((Way) e).getNodes()); - for (Node nd : nodes) { - if (!newNodes.containsKey(nd)) { - undo.save(nd); - nd.setLat(nd.getLat() + deltaLat); - nd.setLon(nd.getLon() + deltaLon); - nd.updateState(nd.getOsmId() < 0 ? OsmElement.STATE_CREATED : OsmElement.STATE_MODIFIED); - insertElementSafe(nd); - newNodes.put(nd, null); - } + } + } + return result; + } + + /** + * Re-create an OsmElement from a cut at a specific position + * + * @param e the OsmElement to re-create + * @param deltaLat delta latitude (WGS84*1E7) + * @param deltaLon delta longitude (WGS84*1E7) + * @param processed bookkeeping which nodes have already been duplicated + * @return the re-created OsmElement + */ + @Nullable + private OsmElement pasteFromCut(@NonNull OsmElement e, int deltaLat, int deltaLon, @NonNull Map processed) { + if (currentStorage.contains(e)) { + Log.e(DEBUG_TAG, "Attempt to paste from cut, but element is already present"); + clipboard.reset(); + return null; + } + undo.save(e); + if (e instanceof Node) { + ((Node) e).setLat(((Node) e).getLat() + deltaLat); + ((Node) e).setLon(((Node) e).getLon() + deltaLon); + processed.put(e, null); + } else if (e instanceof Way) { + Set nodes = new HashSet<>(((Way) e).getNodes()); + for (Node nd : nodes) { + if (!processed.containsKey(nd)) { + undo.save(nd); + nd.setLat(nd.getLat() + deltaLat); + nd.setLon(nd.getLon() + deltaLon); + nd.updateState(nd.getOsmId() < 0 ? OsmElement.STATE_CREATED : OsmElement.STATE_MODIFIED); + insertElementSafe(nd); + processed.put(nd, null); + } + } + ((Way) e).invalidateBoundingBox(); + } + insertElementSafe(e); + e.updateState(e.getOsmId() < 0 ? OsmElement.STATE_CREATED : OsmElement.STATE_MODIFIED); + return e; + } + + /** + * Create a duplicate of an OsmElement at a specific position + * + * @param e the OsmElement to duplicate + * @param deltaLat delta latitude (WGS84*1E7) + * @param deltaLon delta longitude (WGS84*1E7) + * @param processed bookkeeping which elements have already been duplicated + * @param deep duplicate child elements if true + * @return the new, duplicated, OsmElement + */ + @NonNull + private OsmElement createDuplicate(@NonNull OsmElement e, int deltaLat, int deltaLon, @NonNull Map processed, boolean deep) { + if (e instanceof Node) { + return duplicateNode((Node) e, deltaLat, deltaLon, processed); + } + if (e instanceof Way) { + return duplicateWay((Way) e, deltaLat, deltaLon, processed, deep); + } + if (e instanceof Relation) { + return duplicateRelation((Relation) e, deltaLat, deltaLon, processed, deep); + } + throw new IllegalArgumentException("Unexpected element " + e); + } + + /** + * Duplicate a Relation + * + * @param r the Relation + * @param deltaLat delta latitude (WGS84*1E7) + * @param deltaLon delta longitude (WGS84*1E7) + * @param processed bookkeeping which elements have already been duplicated + * @param deep duplicate child elements if true + * @return a duplicate of r + */ + @NonNull + private Relation duplicateRelation(@NonNull Relation r, int deltaLat, int deltaLon, @NonNull Map processed, boolean deep) { + Relation newRelation = factory.createRelationWithNewId(); + undo.save(newRelation); // do this before we create and add members + newRelation.setTags(r.getTags()); + List memberList = r.getMembers(); + if (deep) { + if (!r.allDownloaded()) { + throw new IllegalArgumentException("Relation members not downloaded"); + } + Set members = new HashSet<>(memberList); + for (RelationMember rm : members) { + if (!processed.containsKey(rm.getElement())) { + switch (rm.type) { + case Node.NAME: + duplicateNode((Node) rm.getElement(), deltaLat, deltaLon, processed); + break; + case Way.NAME: + duplicateWay((Way) rm.getElement(), deltaLat, deltaLon, processed, true); + break; + case Relation.NAME: + duplicateRelation((Relation) rm.getElement(), deltaLat, deltaLon, processed, true); + break; + default: + throw new IllegalArgumentException("Unexpected member element " + rm); } - ((Way) e).invalidateBoundingBox(); } - insertElementSafe(e); - e.updateState(e.getOsmId() < 0 ? OsmElement.STATE_CREATED : OsmElement.STATE_MODIFIED); } - result.add(e); + for (RelationMember rm : memberList) { + final OsmElement memberElement = processed.get(rm.getElement()); + newRelation.addMember(new RelationMember(rm.getRole(), memberElement)); + memberElement.addParentRelation(newRelation); + } + } else { + newRelation.addMembers(memberList, true); + } + processed.put(r, newRelation); + return newRelation; + } + + /** + * Duplicate a way + * + * @param way the Way + * @param deltaLat delta latitude (WGS84*1E7) + * @param deltaLon delta longitude (WGS84*1E7) + * @param processed bookkeeping which elements have already been duplicated + * @param deep duplicate child elements if true + * @return a duplicate of way + */ + @NonNull + private Way duplicateWay(@NonNull Way way, int deltaLat, int deltaLon, @NonNull Map processed, boolean deep) { + Way newWay = factory.createWayWithNewId(); + undo.save(newWay); // do this before we create and add nodes + newWay.setTags(way.getTags()); + List nodeList = way.getNodes(); + if (deep) { + // this is slightly complicated because we need to handle cases with potentially broken geometry + // allocate and set the position of the new nodes + Set nodes = new HashSet<>(nodeList); + for (Node nd : nodes) { + if (!processed.containsKey(nd)) { + duplicateNode(nd, deltaLat, deltaLon, processed); + } + } + // now add them to the new way + for (Node nd : nodeList) { + newWay.addNode((Node) processed.get(nd)); + } + } else { + newWay.addNodes(nodeList, true); + } + insertElementSafe(newWay); + processed.put(way, newWay); + return newWay; + } + + /** + * Duplicate a Node + * + * @param node the Node + * @param deltaLat delta latitude (WGS84*1E7) + * @param deltaLon delta longitude (WGS84*1E7) + * @param processed bookkeeping which elements have already been duplicated + * @return a duplicate of node + */ + @NonNull + private Node duplicateNode(@NonNull Node node, int deltaLat, int deltaLon, @NonNull Map processed) { + Node newNode = factory.createNodeWithNewId(node.getLat() + deltaLat, node.getLon() + deltaLon); + newNode.setTags(node.getTags()); + insertElementSafe(newNode); + processed.put(node, newNode); + return newNode; + } + + /** + * Create duplicates of a list of elements + * + * @param elements the OsmElements + * @param deep duplicate child elements if true + * @return a List of the duplicated elements + */ + @NonNull + public List duplicate(@NonNull List elements, boolean deep) { + Collections.sort(elements, new NwrComparator()); // enforce NWR order + List result = new ArrayList<>(); + Map processed = new HashMap<>(); + for (OsmElement original : elements) { + result.add(createDuplicate(original, 0, 0, processed, deep)); } return result; } diff --git a/src/main/res/drawable-xhdpi/content_duplicate_dark.png b/src/main/res/drawable-xhdpi/content_duplicate_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..5748f30fb82ad34f7f0f491b22177482297ab65b GIT binary patch literal 322 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=EX7WqAsj$Z!;#Vf4nJ z@ErkR#;MwT(m+AU64!{5;QX|b^2DN4hVt@qz0ADq;^f4FRK5J7^x5xhq=1U{d%8G= zRLpsMdn4~52LacEri)m;3PrDQ`v!2i&Nv~oh=G67{gl<-^Xlr1Pp;Ixe|%f+_L-4^ zrvu|oZ`$+y@vqN2&YgLDW8UGr>Cbk`i%WbGmvUgw;9bDHh3x`k6!Qy)wTvYV*BGoE rvKeF-m^ScTpqlipm-c>Y>fagj9`Ymva823{^a_KgtDnm{r-UW|NRMs; literal 0 HcmV?d00001 diff --git a/src/main/res/drawable-xhdpi/content_duplicate_light.png b/src/main/res/drawable-xhdpi/content_duplicate_light.png new file mode 100644 index 0000000000000000000000000000000000000000..f007e0ac7a0a19ea987bc8dd1562059be51bebcb GIT binary patch literal 720 zcmV;>0x$iEP)LoK~#90?V7!B6G0Hg-|Pi}goFeXDe@0c&{3qKMWW`TLymk_ zVhU5!Q6XrIsIWO4Cr+p+(x#xI1c`zQkSL%)B9$cyfXl3saC(vJbr)aBVYz-3(!XH5d07B#P!R>d@WluyZ4)G ztvA!y`$QPvm={6R%+}=mQvg#jp!5+i1GEKbV+Lpo(8dhV7NCvzeL$npIQ{e2pV#?S zJpb>SZdKy?mAnp${PKE|e413BxB-K~AOhUg{Hmeds9l4S+?!3=oe% zk|ev;>fK9uHT%E~B7T^y3FtYdM|nNo?|uuRZyFCn-0ikL=M_|{D?5kJ0VeKuSKGNg zp(uR>%m8fx+7zDwgs)({JNy;X-fuBQQ>M^>UZ>pz&@8H(RV)J90sw#<`po#4t(l$J zJ3+&o2m{tT&$j?Dz+m*P!C^bKW3w}8&Lh>>=fAEoI zf|nxzi+~vrBm&O4GzIrV%nE^vqJ3=*hG7`SEbtea9)pJv@)Lys0000 + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 9c27b7d991..ac23fd7ad0 100755 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1066,6 +1066,8 @@ Join Unjoin Copy + Duplicate + Shallow duplicate Cut Paste Paste tags @@ -1241,6 +1243,8 @@ Tap the screen position Paste multiple times Tap the screen position + + Duplicate of %1$s Map background The tile based map background. @@ -1626,6 +1630,7 @@ Move tags to Relation Cut Paste + Duplicate Rotation Way squaring Arrange in circle diff --git a/src/main/res/values/styles.xml b/src/main/res/values/styles.xml index dfa40037d1..b984e05ac8 100644 --- a/src/main/res/values/styles.xml +++ b/src/main/res/values/styles.xml @@ -155,6 +155,7 @@ @drawable/ic_action_discard_holo_dark @drawable/ic_menu_cut_holo_dark @drawable/ic_action_copy_holo_dark + @drawable/content_duplicate_dark @drawable/ic_action_paste_holo_dark @drawable/ic_action_save_holo_dark @drawable/ic_action_rotate_right_holo_dark @@ -236,6 +237,7 @@ @drawable/ic_action_discard_holo_light @drawable/ic_menu_cut_holo_light @drawable/ic_action_copy_holo_light + @drawable/content_duplicate_light @drawable/ic_action_paste_holo_light @drawable/ic_action_save_holo_light @drawable/ic_action_rotate_right_holo_light diff --git a/svg/content_duplicate_dark.svg b/svg/content_duplicate_dark.svg new file mode 100644 index 0000000000..81f6b43915 --- /dev/null +++ b/svg/content_duplicate_dark.svg @@ -0,0 +1,88 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/svg/content_duplicate_light.svg b/svg/content_duplicate_light.svg new file mode 100644 index 0000000000..60a370f409 --- /dev/null +++ b/svg/content_duplicate_light.svg @@ -0,0 +1,86 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + From ddb42d90c1bc65f9a5328cef185f592f10d7745a Mon Sep 17 00:00:00 2001 From: simonpoole Date: Tue, 7 Jan 2025 16:29:21 +0100 Subject: [PATCH 2/4] Update documentation --- documentation/docs/help/en/Node selected.md | 4 ++++ documentation/docs/help/en/Relation selected.md | 12 ++++++++++++ documentation/docs/help/en/Way selected.md | 8 ++++++++ .../docs/help/images/content_duplicate_light.png | Bin 0 -> 555 bytes src/main/assets/help/en/Multiselect.html | 2 +- src/main/assets/help/en/Node selected.html | 2 ++ src/main/assets/help/en/Relation selected.html | 6 ++++++ src/main/assets/help/en/Way selected.html | 4 ++++ .../help/images/content_duplicate_light.png | Bin 0 -> 555 bytes 9 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 documentation/docs/help/images/content_duplicate_light.png create mode 100644 src/main/assets/help/images/content_duplicate_light.png diff --git a/documentation/docs/help/en/Node selected.md b/documentation/docs/help/en/Node selected.md index 8daa327b90..eb52944c39 100644 --- a/documentation/docs/help/en/Node selected.md +++ b/documentation/docs/help/en/Node selected.md @@ -65,6 +65,10 @@ If the node has a direction tag with a degree value, rotate the node by dragging Copy the node to the internal copy and paste buffer. +### ![Duplicate](../images/content_duplicate_light.png) Duplicate + +Create a copy of the selected node in the same location. This does not utilize the copy and paste buffer. + ### ![Cut](../images/ic_menu_cut_holo_light.png) Cut Move the node to the internal copy and paste buffer removing it from the data. diff --git a/documentation/docs/help/en/Relation selected.md b/documentation/docs/help/en/Relation selected.md index 7579c7d3a0..b32b2200fc 100644 --- a/documentation/docs/help/en/Relation selected.md +++ b/documentation/docs/help/en/Relation selected.md @@ -44,6 +44,18 @@ Start Multi-Select mode with all members of the current relation selected. The r If the relation is a multi-polygon, rotate it around its centroid by dragging the display roughly in a circle. The centroid position is marked with a cross. +### ![Copy](../images/ic_menu_copy_holo_light.png) Copy + +Copy the selected relation to the internal copy and paste buffer. Only available for multi-polygon relations. + +### ![Duplicate](../images/content_duplicate_light.png) Duplicate + +Create a copy of the selected relation in the same location. Only available for multi-polygon relations. This does not utilize the copy and paste buffer. + +### Shallow duplicate + +Create a copy of the selected relation with the same member elements. This does not utilize the copy and paste buffer. + ### ![Delete](../images/tag_menu_delete.png) Delete Remove the object from the data. diff --git a/documentation/docs/help/en/Way selected.md b/documentation/docs/help/en/Way selected.md index f1264e9633..8d4c97a627 100644 --- a/documentation/docs/help/en/Way selected.md +++ b/documentation/docs/help/en/Way selected.md @@ -114,6 +114,14 @@ Go to the end or start of the selected way. Copy the way to the internal copy and paste buffer. +### ![Duplicate](../images/content_duplicate_light.png) Duplicate + +Create a copy of the selected way in the same location. This does not utilize the copy and paste buffer. + +### Shallow duplicate + +Create a copy of the selected way with the same way nodes. This does not utilize the copy and paste buffer. + ### ![Cut](../images/ic_menu_cut_holo_light.png) Cut Move the way to the internal copy and paste buffer removing it from the data. diff --git a/documentation/docs/help/images/content_duplicate_light.png b/documentation/docs/help/images/content_duplicate_light.png new file mode 100644 index 0000000000000000000000000000000000000000..074fba1a610ddefd6c4f7fd018076170cf5e37c3 GIT binary patch literal 555 zcmV+`0@VG9P)yXiQ9~Af?)bflh3M*#yGSknk0pSPTQDY41I+gE6$Va?X{B-*(>f{O)t^ z$-QvXBmIk|Qh5XHesJ5^`C9wDc@>f#D$F7BFv0PTeMox5?5FFV-wSzuHkQ>fQ;+>h^`}uHF~E;9>6Zg-_;xdk!I%mHxdgp zcJJeqGy%l%43SruEtzr#;M9&ZmXX~c!PeG#3upl>7t41HaAUI9s8&z^FpH(KUmzkC zH{xfxP6h<^&9!ZBL*+!wK6X^wi3K64e_1;O4nujbKp;~n6mnip&{1Dfz#CXa$8r@v t2Ebp}_D_K*r)IYAKfpk%lZ?L!`2~C}fSOwe7mfe`002ovPDHLkV1h#*_H_UN literal 0 HcmV?d00001 diff --git a/src/main/assets/help/en/Multiselect.html b/src/main/assets/help/en/Multiselect.html index c631aa21dd..242b0e14f3 100644 --- a/src/main/assets/help/en/Multiselect.html +++ b/src/main/assets/help/en/Multiselect.html @@ -25,7 +25,7 @@

Merge Merge ways

Extract segment

If you have selected exactly two nodes on the same way, you can extract the segment of the way between the two nodes. If the way is closed the segment extracted will between the first and 2nd node selected in the winding direction (clockwise or counterclockwise) of the way.

If the way has highway or waterway tagging a number of shortcuts will be displayed, for example to change a footway in to steps.

-

Add node at intersection

+

Add node at intersectionn

If two or more ways are selected and they intersect without a common node, a new node will be added at the first intersection found.

Create circle

Creates a circle if at least three nodes are selected. If you select the nodes in clockwise/counterclockwise direction the resulting way will turn clockwise/counterclockwise. Nodes are spaced roughly 2 meters apart for larger circles. If additional nodes are too near to the original ones they will not be added. Note that if the selected nodes do not form a valid polygon, the behaviour of the function is undefined.

diff --git a/src/main/assets/help/en/Node selected.html b/src/main/assets/help/en/Node selected.html index 50edf2fb89..5fcfb5b307 100644 --- a/src/main/assets/help/en/Node selected.html +++ b/src/main/assets/help/en/Node selected.html @@ -38,6 +38,8 @@

Rotate Rotate

If the node has a direction tag with a degree value, rotate the node by dragging the display roughly in a circle.

Copy Copy

Copy the node to the internal copy and paste buffer.

+

Duplicate Duplicate

+

Create a copy of the selected node in the same location. This does not utilize the copy and paste buffer.

Cut Cut

Move the node to the internal copy and paste buffer removing it from the data.

Paste tags

diff --git a/src/main/assets/help/en/Relation selected.html b/src/main/assets/help/en/Relation selected.html index aa91aaddbd..5361f49a6f 100644 --- a/src/main/assets/help/en/Relation selected.html +++ b/src/main/assets/help/en/Relation selected.html @@ -28,6 +28,12 @@

RelationMembers Select re

Start Multi-Select mode with all members of the current relation selected. The relation itself will be deselected.

Rotate Rotate

If the relation is a multi-polygon, rotate it around its centroid by dragging the display roughly in a circle. The centroid position is marked with a cross.

+

Copy Copy

+

Copy the selected relation to the internal copy and paste buffer. Only available for multi-polygon relations.

+

Duplicate Duplicate

+

Create a copy of the selected relation in the same location. Only available for multi-polygon relations. This does not utilize the copy and paste buffer.

+

Shallow duplicate

+

Create a copy of the selected relation with the same member elements. This does not utilize the copy and paste buffer.

Delete Delete

Remove the object from the data.

Paste tags

diff --git a/src/main/assets/help/en/Way selected.html b/src/main/assets/help/en/Way selected.html index bdfaa7ac60..9d9c983873 100644 --- a/src/main/assets/help/en/Way selected.html +++ b/src/main/assets/help/en/Way selected.html @@ -67,6 +67,10 @@

Start/End of Way

Go to the end or start of the selected way.

Copy Copy

Copy the way to the internal copy and paste buffer.

+

Duplicate Duplicate

+

Create a copy of the selected way in the same location. This does not utilize the copy and paste buffer.

+

Shallow duplicate

+

Create a copy of the selected way with the same way nodes. This does not utilize the copy and paste buffer.

Cut Cut

Move the way to the internal copy and paste buffer removing it from the data.

Paste tags

diff --git a/src/main/assets/help/images/content_duplicate_light.png b/src/main/assets/help/images/content_duplicate_light.png new file mode 100644 index 0000000000000000000000000000000000000000..074fba1a610ddefd6c4f7fd018076170cf5e37c3 GIT binary patch literal 555 zcmV+`0@VG9P)yXiQ9~Af?)bflh3M*#yGSknk0pSPTQDY41I+gE6$Va?X{B-*(>f{O)t^ z$-QvXBmIk|Qh5XHesJ5^`C9wDc@>f#D$F7BFv0PTeMox5?5FFV-wSzuHkQ>fQ;+>h^`}uHF~E;9>6Zg-_;xdk!I%mHxdgp zcJJeqGy%l%43SruEtzr#;M9&ZmXX~c!PeG#3upl>7t41HaAUI9s8&z^FpH(KUmzkC zH{xfxP6h<^&9!ZBL*+!wK6X^wi3K64e_1;O4nujbKp;~n6mnip&{1Dfz#CXa$8r@v t2Ebp}_D_K*r)IYAKfpk%lZ?L!`2~C}fSOwe7mfe`002ovPDHLkV1h#*_H_UN literal 0 HcmV?d00001 From 755d2911f514f00ec4cab15cce265b78bb3e5e43 Mon Sep 17 00:00:00 2001 From: simonpoole Date: Tue, 7 Jan 2025 17:10:17 +0100 Subject: [PATCH 3/4] Add duplication unit tests --- .../android/osm/StorageDelegatorTest.java | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/src/test/java/de/blau/android/osm/StorageDelegatorTest.java b/src/test/java/de/blau/android/osm/StorageDelegatorTest.java index 4fa53157e6..31fbb46676 100644 --- a/src/test/java/de/blau/android/osm/StorageDelegatorTest.java +++ b/src/test/java/de/blau/android/osm/StorageDelegatorTest.java @@ -127,6 +127,105 @@ public void cutUndo() { assertTrue(d.clipboardIsEmpty()); } + /** + * Test duplication + */ + @Test + public void duplicateWay() { + StorageDelegator d = new StorageDelegator(); + Way w = DelegatorUtil.addWayToStorage(d, true); + + List duplicates = d.duplicate(Util.wrapInList(w), true); + assertNotNull(d.getOsmElement(Way.NAME, w.getOsmId())); + assertEquals(1, duplicates.size()); + assertTrue(duplicates.get(0) instanceof Way); + Way dup = (Way) duplicates.get(0); + assertNotEquals(w, dup); + assertEquals(w.nodeCount(), dup.nodeCount()); + List originalNodes = w.getNodes(); + List dupNodes = dup.getNodes(); + for (int i = 0; i < w.nodeCount(); i++) { + assertNotEquals(originalNodes.get(i), dupNodes.get(i)); + assertEquals(originalNodes.get(i).getLat(), dupNodes.get(i).getLat()); + assertEquals(originalNodes.get(i).getLon(), dupNodes.get(i).getLon()); + } + } + + /** + * Test shallow duplication + */ + @Test + public void shallowDuplicateWay() { + StorageDelegator d = new StorageDelegator(); + Way w = DelegatorUtil.addWayToStorage(d, true); + + List duplicates = d.duplicate(Util.wrapInList(w), false); + assertNotNull(d.getOsmElement(Way.NAME, w.getOsmId())); + assertEquals(1, duplicates.size()); + assertTrue(duplicates.get(0) instanceof Way); + Way dup = (Way) duplicates.get(0); + assertNotEquals(w, dup); + assertEquals(w.nodeCount(), dup.nodeCount()); + List originalNodes = w.getNodes(); + List dupNodes = dup.getNodes(); + for (int i = 0; i < w.nodeCount(); i++) { + assertEquals(originalNodes.get(i), dupNodes.get(i)); + } + } + + /** + * Test duplication + */ + @Test + public void duplicateRelation() { + StorageDelegator d = new StorageDelegator(); + Way w = DelegatorUtil.addWayToStorage(d, true); + + final Relation r = w.getParentRelations().get(0); + List duplicates = d.duplicate(Util.wrapInList(r), true); + assertNotNull(d.getOsmElement(Way.NAME, w.getOsmId())); + assertEquals(1, duplicates.size()); + assertTrue(duplicates.get(0) instanceof Relation); + Relation dup = (Relation) duplicates.get(0); + assertNotEquals(r, dup); + assertEquals(r.getMemberCount(), dup.getMemberCount()); + List originalMembers = r.getMembers(); + List dupMembers = dup.getMembers(); + final OsmElement dupElement = dupMembers.get(0).getElement(); + assertNotEquals(originalMembers.get(0).getElement(), dupElement); + assertTrue(dupElement instanceof Way); + List originalNodes = w.getNodes(); + List dupNodes = ((Way) dupElement).getNodes(); + for (int i = 0; i < w.nodeCount(); i++) { + assertNotEquals(originalNodes.get(i), dupNodes.get(i)); + assertEquals(originalNodes.get(i).getLat(), dupNodes.get(i).getLat()); + assertEquals(originalNodes.get(i).getLon(), dupNodes.get(i).getLon()); + } + } + + /** + * Test shallow duplication + */ + @Test + public void shallowDuplicateRelation() { + StorageDelegator d = new StorageDelegator(); + Way w = DelegatorUtil.addWayToStorage(d, true); + + final Relation r = w.getParentRelations().get(0); + List duplicates = d.duplicate(Util.wrapInList(r), false); + assertNotNull(d.getOsmElement(Way.NAME, w.getOsmId())); + assertEquals(1, duplicates.size()); + assertTrue(duplicates.get(0) instanceof Relation); + Relation dup = (Relation) duplicates.get(0); + assertNotEquals(r, dup); + assertEquals(r.getMemberCount(), dup.getMemberCount()); + List originalMembers = r.getMembers(); + List dupMembers = dup.getMembers(); + for (int i = 0; i < r.getMemberCount(); i++) { + assertEquals(originalMembers.get(i).getElement(), dupMembers.get(i).getElement()); + } + } + /** * Load some data modify a way and a node, then prune */ From 32e9ccd6b488e8acbec8c882100717e34c29e237 Mon Sep 17 00:00:00 2001 From: simonpoole Date: Wed, 8 Jan 2025 14:00:22 +0100 Subject: [PATCH 4/4] Segment is already selected --- src/androidTest/java/de/blau/android/easyedit/WayTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/androidTest/java/de/blau/android/easyedit/WayTest.java b/src/androidTest/java/de/blau/android/easyedit/WayTest.java index 9b12513aa3..61c1fe423f 100644 --- a/src/androidTest/java/de/blau/android/easyedit/WayTest.java +++ b/src/androidTest/java/de/blau/android/easyedit/WayTest.java @@ -175,8 +175,8 @@ public void splitAndMergeWay() { TestUtils.clickAtCoordinates(device, map, splitNode.getLon(), splitNode.getLat()); TestUtils.sleep(2000); assertTrue(TestUtils.textGone(device, context.getString(R.string.actionmode_wayselect), 5000)); - TestUtils.clickAtCoordinates(device, map, 8.3893820, 47.3895626, true); - assertTrue(TestUtils.clickText(device, false, "↗ Path", false, false)); + // TestUtils.clickAtCoordinates(device, map, 8.3893820, 47.3895626, true); + // assertTrue(TestUtils.clickText(device, false, "↗ Path", false, false)); assertTrue(TestUtils.findText(device, false, context.getString(R.string.actionmode_wayselect))); way = App.getLogic().getSelectedWay(); assertNotNull(way);